Files
PiliPlus/lib/plugin/pl_player/view.dart
dom 2bdab71138 opt ui
Signed-off-by: dom <githubaccount56556@proton.me>
2026-02-25 18:02:36 +08:00

2941 lines
97 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/cropped_image.dart';
import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/disabled_icon.dart';
import 'package:PiliPlus/common/widgets/gesture/immediate_tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/gesture/mouse_interactive_viewer.dart';
import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/pair.dart';
import 'package:PiliPlus/common/widgets/player_bar.dart';
import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.dart';
import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart';
import 'package:PiliPlus/common/widgets/view_safe_area.dart';
import 'package:PiliPlus/models/common/sponsor_block/action_type.dart';
import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart';
import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart';
import 'package:PiliPlus/models/common/super_resolution_type.dart';
import 'package:PiliPlus/models/common/video/video_quality.dart';
import 'package:PiliPlus/models/video/play/url.dart';
import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc;
import 'package:PiliPlus/models_new/video/video_detail/episode.dart';
import 'package:PiliPlus/models_new/video/video_detail/section.dart';
import 'package:PiliPlus/models_new/video/video_detail/ugc_season.dart';
import 'package:PiliPlus/pages/common/common_intro_controller.dart';
import 'package:PiliPlus/pages/danmaku/danmaku_model.dart';
import 'package:PiliPlus/pages/live_room/widgets/bottom_control.dart'
as live_bottom;
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/pages/video/post_panel/popup_menu_text.dart';
import 'package:PiliPlus/pages/video/post_panel/view.dart';
import 'package:PiliPlus/pages/video/widgets/header_control.dart';
import 'package:PiliPlus/plugin/pl_player/controller.dart';
import 'package:PiliPlus/plugin/pl_player/models/bottom_control_type.dart';
import 'package:PiliPlus/plugin/pl_player/models/bottom_progress_behavior.dart';
import 'package:PiliPlus/plugin/pl_player/models/data_status.dart';
import 'package:PiliPlus/plugin/pl_player/models/double_tap_type.dart';
import 'package:PiliPlus/plugin/pl_player/models/fullscreen_mode.dart';
import 'package:PiliPlus/plugin/pl_player/models/gesture_type.dart';
import 'package:PiliPlus/plugin/pl_player/models/play_status.dart';
import 'package:PiliPlus/plugin/pl_player/models/video_fit_type.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/app_bar_ani.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/backward_seek.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/bottom_control.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/forward_seek.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/mpv_convert_webp.dart';
import 'package:PiliPlus/plugin/pl_player/widgets/play_pause_btn.dart';
import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/theme_ext.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/path_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/storage_key.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:collection/collection.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show RenderProxyBox, SemanticsConfiguration;
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:screen_brightness_platform_interface/screen_brightness_platform_interface.dart';
import 'package:window_manager/window_manager.dart';
class PLVideoPlayer extends StatefulWidget {
const PLVideoPlayer({
required this.maxWidth,
required this.maxHeight,
required this.plPlayerController,
this.videoDetailController,
this.introController,
required this.headerControl,
this.bottomControl,
this.danmuWidget,
this.showEpisodes,
this.showViewPoints,
this.fill = Colors.black,
this.alignment = Alignment.center,
super.key,
});
final double maxWidth;
final double maxHeight;
final PlPlayerController plPlayerController;
final VideoDetailController? videoDetailController;
final CommonIntroController? introController;
final Widget headerControl;
final Widget? bottomControl;
final Widget? danmuWidget;
final void Function([
int?,
UgcSeason?,
List<ugc.BaseEpisodeItem>?,
String?,
int?,
int?,
])?
showEpisodes;
final VoidCallback? showViewPoints;
final Color fill;
final Alignment alignment;
@override
State<PLVideoPlayer> createState() => _PLVideoPlayerState();
}
class _PLVideoPlayerState extends State<PLVideoPlayer>
with WidgetsBindingObserver, TickerProviderStateMixin {
late AnimationController animationController;
late VideoController videoController;
late final CommonIntroController introController = widget.introController!;
late final VideoDetailController videoDetailController =
widget.videoDetailController!;
final _playerKey = GlobalKey();
final _videoKey = GlobalKey();
final RxDouble _brightnessValue = 0.0.obs;
final RxBool _brightnessIndicator = false.obs;
Timer? _brightnessTimer;
late FullScreenMode mode;
late final RxBool showRestoreScaleBtn = false.obs;
GestureType? _gestureType;
Offset initialFocalPoint = Offset.zero;
//播放器放缩
bool interacting = false;
// 阅读器限制
// Timer? _accessibilityDebounce;
// double _lastAnnouncedValue = -1;
bool _pauseDueToPauseUponEnteringBackgroundMode = false;
StreamSubscription? _brightnessListener;
int? tmpSubtitlePaddingB;
StreamSubscription? _controlsListener;
void _controlListener(bool val) {
final visible = val && !plPlayerController.controlsLock.value;
if ((widget.headerControl.key as GlobalKey<TimeBatteryMixin>).currentState
case final state?) {
if (state.mounted) {
state.getBatteryLevelIfNeeded();
state.provider
?..startIfNeeded()
..muted = !visible;
if (visible) {
state.startClock();
} else {
state.stopClock();
}
}
}
if (visible) {
animationController.forward();
} else {
animationController.reverse();
}
if (widget.videoDetailController case final controller?) {
if (controller.vttSubtitlesIndex.value != 0) {
if (visible) {
const int minPadding = 70;
if (plPlayerController.subtitlePaddingB < minPadding) {
tmpSubtitlePaddingB = plPlayerController.subtitlePaddingB;
plPlayerController
..subtitlePaddingB = minPadding
..subtitleConfig.value = plPlayerController.getSubConfig;
}
} else {
if (tmpSubtitlePaddingB != null) {
plPlayerController
..subtitlePaddingB = tmpSubtitlePaddingB!
..subtitleConfig.value = plPlayerController.getSubConfig;
tmpSubtitlePaddingB = null;
}
}
}
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controlsListener = plPlayerController.showControls.listen(
_controlListener,
);
transformationController = TransformationController();
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 100),
);
videoController = plPlayerController.videoController!;
if (PlatformUtils.isMobile) {
Future.microtask(() async {
try {
FlutterVolumeController.updateShowSystemUI(true);
plPlayerController.volume.value =
(await FlutterVolumeController.getVolume())!;
FlutterVolumeController.addListener((double value) {
if (mounted &&
!plPlayerController.volumeInterceptEventStream.value) {
plPlayerController.volume.value = value;
if (Platform.isIOS && !FlutterVolumeController.showSystemUI) {
plPlayerController
..volumeIndicator.value = true
..volumeTimer?.cancel()
..volumeTimer = Timer(const Duration(milliseconds: 800), () {
if (mounted) {
plPlayerController.volumeIndicator.value = false;
}
});
}
}
});
} catch (_) {}
});
Future.microtask(() async {
try {
_brightnessValue.value =
await ScreenBrightnessPlatform.instance.application;
void listener(double value) {
if (mounted) {
_brightnessValue.value = value;
}
}
_brightnessListener =
Platform.isIOS || plPlayerController.setSystemBrightness
? ScreenBrightnessPlatform
.instance
.onSystemScreenBrightnessChanged
.listen(listener)
: ScreenBrightnessPlatform
.instance
.onApplicationScreenBrightnessChanged
.listen(listener);
} catch (_) {}
});
}
if (plPlayerController.enableTapDm) {
_tapGestureRecognizer = ImmediateTapGestureRecognizer(
onTapDown: plPlayerController.enableShowDanmaku.value
? _onTapDown
: null,
onTapUp: _onTapUp,
onTapCancel: _removeDmAction,
);
_danmakuListener = plPlayerController.enableShowDanmaku.listen((value) {
if (!value) _removeDmAction();
_tapGestureRecognizer.onTapDown = value ? _onTapDown : null;
});
} else {
_tapGestureRecognizer = ImmediateTapGestureRecognizer(onTapUp: _onTapUp);
}
_doubleTapGestureRecognizer = DoubleTapGestureRecognizer()
..onDoubleTapDown = _onDoubleTapDown;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!plPlayerController.continuePlayInBackground.value) {
late final player = plPlayerController.videoController?.player;
if (const [
AppLifecycleState.paused,
AppLifecycleState.detached,
].contains(state)) {
if (player != null && player.state.playing) {
_pauseDueToPauseUponEnteringBackgroundMode = true;
player.pause();
}
} else {
if (_pauseDueToPauseUponEnteringBackgroundMode) {
_pauseDueToPauseUponEnteringBackgroundMode = false;
player?.play();
}
}
}
}
Future<void> setBrightness(double value) async {
try {
if (Platform.isIOS || plPlayerController.setSystemBrightness) {
await ScreenBrightnessPlatform.instance.setSystemScreenBrightness(
value,
);
} else {
await ScreenBrightnessPlatform.instance.setApplicationScreenBrightness(
value,
);
}
} catch (_) {}
_brightnessIndicator.value = true;
_brightnessTimer?.cancel();
_brightnessTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) {
_brightnessIndicator.value = false;
}
});
plPlayerController.brightness.value = value;
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_danmakuListener?.cancel();
_tapGestureRecognizer.dispose();
_longPressRecognizer?.dispose();
_doubleTapGestureRecognizer.dispose();
_brightnessListener?.cancel();
_controlsListener?.cancel();
animationController.dispose();
if (PlatformUtils.isMobile) {
FlutterVolumeController.removeListener();
}
transformationController.dispose();
_removeDmAction();
super.dispose();
}
// 动态构建底部控制条
Widget buildBottomControl(
VideoDetailController videoDetailController,
bool isLandscape,
) {
final videoDetail = introController.videoDetail.value;
final isSeason = videoDetail.ugcSeason != null;
final isPart = videoDetail.pages != null && videoDetail.pages!.length > 1;
final isPgc = !videoDetailController.isUgc;
final isPlayAll = videoDetailController.isPlayAll;
final anySeason = isSeason || isPart || isPgc || isPlayAll;
final isFullScreen = this.isFullScreen;
final double widgetWidth = isLandscape && isFullScreen ? 42 : 35;
Widget progressWidget(
BottomControlType bottomControl,
) => switch (bottomControl) {
/// 播放暂停
BottomControlType.playOrPause => PlayOrPauseButton(
plPlayerController: plPlayerController,
),
/// 上一集
BottomControlType.pre => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '上一集',
icon: const Icon(
Icons.skip_previous,
size: 22,
color: Colors.white,
),
onTap: () {
if (!introController.prevPlay()) {
SmartDialog.showToast('已经是第一集了');
}
},
),
/// 下一集
BottomControlType.next => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '下一集',
icon: const Icon(
Icons.skip_next,
size: 22,
color: Colors.white,
),
onTap: () {
if (!introController.nextPlay()) {
SmartDialog.showToast('已经是最后一集了');
}
},
),
/// 时间进度
BottomControlType.time => Obx(
() => _VideoTime(
position: DurationUtils.formatDuration(
plPlayerController.positionSeconds.value,
),
duration: DurationUtils.formatDuration(
plPlayerController.duration.value.inSeconds,
),
),
),
/// 高能进度条
BottomControlType.dmChart => Obx(
() {
final list = videoDetailController.dmTrend.value?.dataOrNull;
if (list != null && list.isNotEmpty) {
final show = videoDetailController.showDmTrendChart.value;
return ComBtn(
width: widgetWidth,
height: 30,
tooltip: '高能进度条',
icon: DisabledIcon(
disable: !show,
child: const Icon(
Icons.show_chart,
size: 22,
color: Colors.white,
),
),
onTap: () => videoDetailController.showDmTrendChart.value = !show,
);
}
return const SizedBox.shrink();
},
),
/// 超分辨率
BottomControlType.superResolution => Obx(
() {
final type = plPlayerController.superResolutionType.value;
return PopupMenuButton<SuperResolutionType>(
tooltip: '超分辨率',
requestFocus: false,
initialValue: type,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return SuperResolutionType.values
.map(
(type) => PopupMenuItem<SuperResolutionType>(
height: 35,
padding: const EdgeInsets.only(left: 30),
value: type,
onTap: () => plPlayerController.setShader(type),
child: Text(
type.label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
),
)
.toList();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
type.label,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
);
},
),
/// 分段信息
BottomControlType.viewPoints => Obx(
() {
if (videoDetailController.viewPointList.isNotEmpty) {
final show = videoDetailController.showVP.value;
return ComBtn(
width: widgetWidth,
height: 30,
tooltip: '分段信息',
icon: DisabledIcon(
iconSize: 22,
color: Colors.white,
disable: !show,
child: Transform.rotate(
angle: math.pi / 2,
child: const Icon(
Icons.reorder,
size: 22,
color: Colors.white,
),
),
),
onTap: widget.showViewPoints,
onLongPress: () {
Feedback.forLongPress(context);
videoDetailController.showVP.value = !show;
},
onSecondaryTap: PlatformUtils.isMobile
? null
: () => videoDetailController.showVP.value = !show,
);
}
return const SizedBox.shrink();
},
),
/// 选集
BottomControlType.episode => ComBtn(
width: widgetWidth,
height: 30,
tooltip: '选集',
icon: const Icon(
Icons.list,
size: 22,
color: Colors.white,
),
onTap: () {
if (videoDetailController.isFileSource) {
// TODO
return;
}
// part -> playAll -> season(pgc)
if (isPlayAll && !isPart) {
widget.showEpisodes?.call();
return;
}
int? index;
int currentCid = plPlayerController.cid!;
String bvid = plPlayerController.bvid;
List<ugc.BaseEpisodeItem> episodes = [];
if (isSeason) {
final List<SectionItem> sections = videoDetail.ugcSeason!.sections!;
for (int i = 0; i < sections.length; i++) {
final List<EpisodeItem> episodesList = sections[i].episodes!;
for (final item in episodesList) {
if (item.cid == currentCid) {
index = i;
episodes = episodesList;
break;
}
}
}
} else if (isPart) {
episodes = videoDetail.pages!;
} else if (isPgc) {
episodes =
(introController as PgcIntroController).pgcItem.episodes!;
}
widget.showEpisodes?.call(
index,
isSeason ? videoDetail.ugcSeason! : null,
isSeason ? null : episodes,
bvid,
IdUtils.bv2av(bvid),
isSeason && isPart
? videoDetailController.seasonCid ?? currentCid
: currentCid,
);
},
),
/// 画面比例
BottomControlType.fit => Obx(
() {
final fit = plPlayerController.videoFit.value;
return PopupMenuButton<VideoFitType>(
tooltip: '画面比例',
requestFocus: false,
initialValue: fit,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return VideoFitType.values
.map(
(boxFit) => PopupMenuItem<VideoFitType>(
height: 35,
padding: const EdgeInsets.only(left: 30),
value: boxFit,
onTap: () => plPlayerController.toggleVideoFit(boxFit),
child: Text(
boxFit.desc,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
),
)
.toList();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
fit.desc,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
);
},
),
BottomControlType.aiTranslate => Obx(
() {
final list = videoDetailController.languages.value;
if (list != null && list.isNotEmpty) {
return PopupMenuButton<String>(
tooltip: '翻译',
requestFocus: false,
initialValue: videoDetailController.currLang.value,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return [
PopupMenuItem<String>(
height: 35,
value: '',
onTap: () => videoDetailController.setLanguage(''),
child: const Text(
"关闭翻译",
style: TextStyle(
color: Colors.white,
fontSize: 13,
),
),
),
...list.map((e) {
return PopupMenuItem<String>(
height: 35,
value: e.lang,
onTap: () => videoDetailController.setLanguage(e.lang!),
child: Text(
e.title!,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
);
}),
];
},
child: SizedBox(
width: widgetWidth,
height: 30,
child: const Icon(
Icons.translate,
size: 18,
color: Colors.white,
),
),
);
}
return const SizedBox.shrink();
},
),
/// 字幕
BottomControlType.subtitle => Obx(
() {
if (videoDetailController.subtitles.isNotEmpty) {
final val = videoDetailController.vttSubtitlesIndex.value;
return PopupMenuButton<int>(
tooltip: '字幕',
requestFocus: false,
initialValue: val,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return [
PopupMenuItem<int>(
value: 0,
height: 35,
onTap: () => videoDetailController.setSubtitle(0),
child: const Text(
"关闭字幕",
style: TextStyle(
color: Colors.white,
fontSize: 13,
),
),
),
...videoDetailController.subtitles.indexed.map((e) {
return PopupMenuItem<int>(
value: e.$1 + 1,
height: 35,
onTap: () => videoDetailController.setSubtitle(e.$1 + 1),
child: Text(
"${e.$2.lanDoc}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
);
}),
];
},
child: SizedBox(
width: widgetWidth,
height: 30,
child: val == 0
? const Icon(
Icons.closed_caption_off_outlined,
size: 22,
color: Colors.white,
)
: const Icon(
Icons.closed_caption_off_rounded,
size: 22,
color: Colors.white,
),
),
);
}
return const SizedBox.shrink();
},
),
/// 播放速度
BottomControlType.speed => Obx(
() => PopupMenuButton<double>(
tooltip: '倍速',
requestFocus: false,
initialValue: plPlayerController.playbackSpeed,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return plPlayerController.speedList
.map(
(double speed) => PopupMenuItem<double>(
height: 35,
padding: const EdgeInsets.only(left: 30),
value: speed,
onTap: () => plPlayerController.setPlaybackSpeed(speed),
child: Text(
"${speed}X",
style: const TextStyle(color: Colors.white, fontSize: 13),
semanticsLabel: "$speed倍速",
),
),
)
.toList();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
"${plPlayerController.playbackSpeed}X",
style: const TextStyle(color: Colors.white, fontSize: 13),
semanticsLabel: "${plPlayerController.playbackSpeed}倍速",
),
),
),
),
BottomControlType.qa => Obx(
() {
final VideoQuality? currentVideoQa =
videoDetailController.currentVideoQa.value;
if (currentVideoQa == null) {
return const SizedBox.shrink();
}
final PlayUrlModel videoInfo = videoDetailController.data;
if (videoInfo.dash == null) {
return const SizedBox.shrink();
}
final List<FormatItem> videoFormat = videoInfo.supportFormats!;
final int totalQaSam = videoFormat.length;
int usefulQaSam = 0;
final List<VideoItem> video = videoInfo.dash!.video!;
final Set<int> idSet = {};
for (final VideoItem item in video) {
final int id = item.id!;
if (!idSet.contains(id)) {
idSet.add(id);
usefulQaSam++;
}
}
return PopupMenuButton<int>(
tooltip: '画质',
requestFocus: false,
initialValue: currentVideoQa.code,
color: Colors.black.withValues(alpha: 0.8),
itemBuilder: (context) {
return List.generate(
totalQaSam,
(index) {
final item = videoFormat[index];
final enabled = index >= totalQaSam - usefulQaSam;
return PopupMenuItem<int>(
enabled: enabled,
height: 35,
padding: const EdgeInsets.only(left: 15, right: 10),
value: item.quality,
onTap: () async {
if (currentVideoQa.code == item.quality) {
return;
}
final int quality = item.quality!;
final newQa = VideoQuality.fromCode(quality);
videoDetailController
..plPlayerController.cacheVideoQa = newQa.code
..currentVideoQa.value = newQa
..updatePlayer();
SmartDialog.showToast("画质已变为:${newQa.desc}");
// update
if (!plPlayerController.tempPlayerConf) {
GStorage.setting.put(
await Utils.isWiFi
? SettingBoxKey.defaultVideoQa
: SettingBoxKey.defaultVideoQaCellular,
quality,
);
}
},
child: Text(
item.newDesc ?? '',
style: enabled
? const TextStyle(color: Colors.white, fontSize: 13)
: const TextStyle(
color: Color(0x62FFFFFF),
fontSize: 13,
),
),
);
},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
currentVideoQa.shortDesc,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
);
},
),
/// 全屏
BottomControlType.fullscreen => ComBtn(
width: widgetWidth,
height: 30,
tooltip: isFullScreen ? '退出全屏' : '全屏',
icon: isFullScreen
? const Icon(
Icons.fullscreen_exit,
size: 24,
color: Colors.white,
)
: const Icon(
Icons.fullscreen,
size: 24,
color: Colors.white,
),
onTap: () =>
plPlayerController.triggerFullScreen(status: !isFullScreen),
onSecondaryTap: () => plPlayerController.triggerFullScreen(
status: !isFullScreen,
inAppFullScreen: true,
),
),
};
final isNotFileSource = !plPlayerController.isFileSource;
List<BottomControlType> userSpecifyItemLeft = [
BottomControlType.playOrPause,
BottomControlType.time,
if (!isNotFileSource || anySeason) ...[
BottomControlType.pre,
BottomControlType.next,
],
];
final flag =
isFullScreen || plPlayerController.isDesktopPip || maxWidth >= 500;
List<BottomControlType> userSpecifyItemRight = [
if (isNotFileSource && plPlayerController.showDmChart)
BottomControlType.dmChart,
if (plPlayerController.isAnim) BottomControlType.superResolution,
if (isNotFileSource && plPlayerController.showViewPoints)
BottomControlType.viewPoints,
if (isNotFileSource && anySeason) BottomControlType.episode,
if (flag) BottomControlType.fit,
if (isNotFileSource) BottomControlType.aiTranslate,
BottomControlType.subtitle,
BottomControlType.speed,
if (isNotFileSource && flag) BottomControlType.qa,
if (!plPlayerController.isDesktopPip) BottomControlType.fullscreen,
];
return PlayerBar(
children: [
Row(
mainAxisSize: .min,
children: userSpecifyItemLeft.map(progressWidget).toList(),
),
Row(
mainAxisSize: .min,
children: userSpecifyItemRight.map(progressWidget).toList(),
),
],
);
}
PlPlayerController get plPlayerController => widget.plPlayerController;
bool get isFullScreen => plPlayerController.isFullScreen.value;
late final TransformationController transformationController;
late ColorScheme colorScheme;
late double maxWidth;
late double maxHeight;
@override
void didChangeDependencies() {
super.didChangeDependencies();
colorScheme = ColorScheme.of(context);
}
void _onInteractionStart(ScaleStartDetails details) {
if (plPlayerController.controlsLock.value) return;
// 如果起点太靠上则屏蔽
final localFocalPoint = details.localFocalPoint;
final dx = localFocalPoint.dx;
final dy = localFocalPoint.dy;
if (dx < 40 || dy < 40) return;
if (dx > maxWidth - 40 || dy > maxHeight - 40) return;
if (details.pointerCount > 1) {
interacting = true;
}
initialFocalPoint = localFocalPoint;
// if (kDebugMode) {
// debugPrint("_initialFocalPoint$_initialFocalPoint");
// }
_gestureType = null;
}
void _onInteractionUpdate(ScaleUpdateDetails details) {
showRestoreScaleBtn.value =
transformationController.value.storage[0] != 1.0;
if (interacting || initialFocalPoint == Offset.zero) {
return;
}
Offset cumulativeDelta = details.localFocalPoint - initialFocalPoint;
if (details.pointerCount > 1 && cumulativeDelta.distanceSquared < 2.25) {
interacting = true;
_gestureType = null;
return;
}
/// 锁定时禁用
if (plPlayerController.controlsLock.value) return;
if (_gestureType == null) {
if (cumulativeDelta.distanceSquared < 1) return;
final dx = cumulativeDelta.dx.abs();
final dy = cumulativeDelta.dy.abs();
if (dx > 3 * dy) {
_gestureType = GestureType.horizontal;
_showControlsIfNeeded();
} else if (dy > 3 * dx) {
if (!plPlayerController.enableSlideVolumeBrightness &&
!plPlayerController.enableSlideFS) {
return;
}
// _gestureType = 'vertical';
final double tapPosition = details.localFocalPoint.dx;
final double sectionWidth = maxWidth / 3;
if (tapPosition < sectionWidth) {
if (PlatformUtils.isDesktop ||
!plPlayerController.enableSlideVolumeBrightness) {
return;
}
// 左边区域
_gestureType = GestureType.left;
} else if (tapPosition < sectionWidth * 2) {
if (!plPlayerController.enableSlideFS) {
return;
}
// 全屏
_gestureType = GestureType.center;
} else {
if (!plPlayerController.enableSlideVolumeBrightness) {
return;
}
// 右边区域
_gestureType = GestureType.right;
}
} else {
return;
}
}
Offset delta = details.focalPointDelta;
if (_gestureType == GestureType.horizontal) {
// live模式下禁用
if (plPlayerController.isLive) return;
final int curSliderPosition =
plPlayerController.sliderPosition.inMilliseconds;
final int newPos =
(curSliderPosition +
(plPlayerController.sliderScale * delta.dx / maxWidth)
.round())
.clamp(0, plPlayerController.duration.value.inMilliseconds);
final Duration result = Duration(milliseconds: newPos);
final height = maxHeight * 0.125;
if (details.localFocalPoint.dy <= height &&
(details.localFocalPoint.dx >= maxWidth * 0.875 ||
details.localFocalPoint.dx <= maxWidth * 0.125)) {
plPlayerController.cancelSeek = true;
plPlayerController.showPreview.value = false;
if (plPlayerController.hasToast != true) {
plPlayerController.hasToast = true;
SmartDialog.showAttach(
targetContext: context,
alignment: Alignment.center,
animationTime: const Duration(milliseconds: 200),
animationType: SmartAnimationType.fade,
displayTime: const Duration(milliseconds: 1500),
maskColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(6),
),
color: colorScheme.secondaryContainer,
),
child: Text(
'松开手指,取消进退',
style: TextStyle(
color: colorScheme.onSecondaryContainer,
),
),
),
);
}
} else {
if (plPlayerController.cancelSeek == true) {
plPlayerController
..cancelSeek = null
..hasToast = null;
}
}
plPlayerController
..onUpdatedSliderProgress(result)
..onChangedSliderStart();
if (!plPlayerController.isFileSource &&
plPlayerController.showSeekPreview &&
plPlayerController.cancelSeek != true) {
plPlayerController.updatePreviewIndex(newPos ~/ 1000);
}
} else if (_gestureType == GestureType.left) {
// 左边区域 👈
final double level = maxHeight * 3;
final double brightness = _brightnessValue.value - delta.dy / level;
final double result = brightness.clamp(0.0, 1.0);
setBrightness(result);
} else if (_gestureType == GestureType.center) {
// 全屏
const double threshold = 2.5; // 滑动阈值
double cumulativeDy = details.localFocalPoint.dy - initialFocalPoint.dy;
void fullScreenTrigger(bool status) {
plPlayerController.triggerFullScreen(status: status);
}
if (cumulativeDy > threshold) {
_gestureType = GestureType.center_down;
if (isFullScreen ^ plPlayerController.fullScreenGestureReverse) {
fullScreenTrigger(
plPlayerController.fullScreenGestureReverse,
);
}
// if (kDebugMode) debugPrint('center_down:$cumulativeDy');
} else if (cumulativeDy < -threshold) {
_gestureType = GestureType.center_up;
if (!isFullScreen ^ plPlayerController.fullScreenGestureReverse) {
fullScreenTrigger(
!plPlayerController.fullScreenGestureReverse,
);
}
// if (kDebugMode) debugPrint('center_up:$cumulativeDy');
}
} else if (_gestureType == GestureType.right) {
// 右边区域
final double level = maxHeight * 0.5;
EasyThrottle.throttle(
'setVolume',
const Duration(milliseconds: 20),
() {
final double volume = clampDouble(
plPlayerController.volume.value - delta.dy / level,
0.0,
PlPlayerController.maxVolume,
);
plPlayerController.setVolume(volume);
},
);
}
}
void _onInteractionEnd(ScaleEndDetails details) {
if (plPlayerController.showSeekPreview) {
plPlayerController.showPreview.value = false;
}
if (plPlayerController.isSliderMoving.value) {
if (plPlayerController.cancelSeek == true) {
plPlayerController.onUpdatedSliderProgress(
plPlayerController.position,
);
} else {
plPlayerController.seekTo(
plPlayerController.sliderPosition,
isSeek: false,
);
}
plPlayerController.onChangedSliderEnd();
}
interacting = false;
initialFocalPoint = Offset.zero;
_gestureType = null;
}
void onDoubleTapDownMobile(TapDownDetails details) {
if (plPlayerController.isLive || plPlayerController.controlsLock.value) {
return;
}
final double tapPosition = details.localPosition.dx;
final double sectionWidth = maxWidth / 4;
DoubleTapType type;
if (tapPosition < sectionWidth) {
type = DoubleTapType.left;
} else if (tapPosition < sectionWidth * 3) {
type = DoubleTapType.center;
} else {
type = DoubleTapType.right;
}
plPlayerController.doubleTapFuc(type);
}
void onTapDesktop() {
if (plPlayerController.isLive || plPlayerController.controlsLock.value) {
return;
}
plPlayerController.onDoubleTapCenter();
}
void onDoubleTapDesktop() {
if (plPlayerController.controlsLock.value) {
return;
}
plPlayerController.triggerFullScreen(status: !isFullScreen);
}
void _onTapUp(TapUpDetails details) {
switch (details.kind) {
case ui.PointerDeviceKind.mouse when PlatformUtils.isDesktop:
onTapDesktop();
break;
default:
if (_suspendedDm == null) {
plPlayerController.controls = !plPlayerController.showControls.value;
} else if (_suspendedDm!.suspend) {
_dmOffset.value = details.localPosition;
} else {
_suspendedDm = null;
}
break;
}
}
void _onTapDown(TapDownDetails details) {
final ctr = plPlayerController.danmakuController;
if (ctr != null) {
final pos = details.localPosition;
final res = ctr.findSingleDanmaku(pos);
if (res != null) {
final (dy, item) = res;
if (item != _suspendedDm) {
_suspendedDm?.suspend = false;
if (item.content.extra == null) {
_dmOffset.value = null;
return;
}
_suspendedDm = item..suspend = true;
this.dy = dy;
}
} else {
_suspendedDm?.suspend = false;
_dmOffset.value = null;
}
}
}
void _onDoubleTapDown(TapDownDetails details) {
switch (details.kind) {
case ui.PointerDeviceKind.mouse when PlatformUtils.isDesktop:
onDoubleTapDesktop();
break;
default:
onDoubleTapDownMobile(details);
break;
}
}
LongPressGestureRecognizer? _longPressRecognizer;
LongPressGestureRecognizer get longPressRecognizer => _longPressRecognizer ??=
LongPressGestureRecognizer(
duration: plPlayerController.enableTapDm
? const Duration(milliseconds: 300)
: null,
)
..onLongPressStart = ((_) =>
plPlayerController.setLongPressStatus(true))
..onLongPressEnd = ((_) => plPlayerController.setLongPressStatus(false))
..onLongPressCancel = (() =>
plPlayerController.setLongPressStatus(false));
late final ImmediateTapGestureRecognizer _tapGestureRecognizer;
late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer;
StreamSubscription<bool>? _danmakuListener;
void _onPointerDown(PointerDownEvent event) {
if (PlatformUtils.isDesktop) {
final buttons = event.buttons;
final isSecondaryBtn = buttons == kSecondaryMouseButton;
if (isSecondaryBtn || buttons == kMiddleMouseButton) {
final isFullScreen = this.isFullScreen;
if (isFullScreen && plPlayerController.controlsLock.value) {
plPlayerController
..controlsLock.value = false
..showControls.value = false;
}
plPlayerController
.triggerFullScreen(
status: !isFullScreen,
inAppFullScreen: isSecondaryBtn,
)
.whenComplete(() => initialFocalPoint = Offset.zero);
return;
}
}
_tapGestureRecognizer.addPointer(event);
_doubleTapGestureRecognizer.addPointer(event);
if (!plPlayerController.isLive) {
longPressRecognizer.addPointer(event);
}
}
void _showControlsIfNeeded() {
if (plPlayerController.isLive) return;
late final isFullScreen = this.isFullScreen;
final progressType = plPlayerController.progressType;
if (progressType == BtmProgressBehavior.alwaysHide ||
(isFullScreen &&
progressType == BtmProgressBehavior.onlyHideFullScreen) ||
(!isFullScreen &&
progressType == BtmProgressBehavior.onlyShowFullScreen)) {
plPlayerController.controls = true;
}
}
void _onPointerPanZoomUpdate(PointerPanZoomUpdateEvent event) {
if (plPlayerController.controlsLock.value) return;
if (_gestureType == null) {
final pan = event.pan;
if (pan.distanceSquared < 1) return;
final dx = pan.dx.abs();
final dy = pan.dy.abs();
if (dx > 3 * dy) {
_gestureType = GestureType.horizontal;
_showControlsIfNeeded();
} else if (dy > 3 * dx) {
_gestureType = GestureType.right;
}
return;
}
if (_gestureType == GestureType.horizontal) {
if (plPlayerController.isLive) return;
Offset delta = event.localPanDelta;
final int curSliderPosition =
plPlayerController.sliderPosition.inMilliseconds;
final int newPos =
(curSliderPosition +
(plPlayerController.sliderScale * delta.dx / maxWidth)
.round())
.clamp(0, plPlayerController.duration.value.inMilliseconds);
final Duration result = Duration(milliseconds: newPos);
if (plPlayerController.cancelSeek == true) {
plPlayerController
..cancelSeek = null
..hasToast = null;
}
plPlayerController
..onUpdatedSliderProgress(result)
..onChangedSliderStart();
if (!plPlayerController.isFileSource &&
plPlayerController.showSeekPreview &&
plPlayerController.cancelSeek != true) {
plPlayerController.updatePreviewIndex(newPos ~/ 1000);
}
} else if (_gestureType == GestureType.right) {
final double level = maxHeight * 0.5;
EasyThrottle.throttle(
'setVolume',
const Duration(milliseconds: 20),
() {
final double volume = clampDouble(
plPlayerController.volume.value - event.localPanDelta.dy / level,
0.0,
PlPlayerController.maxVolume,
);
plPlayerController.setVolume(volume);
},
);
}
}
void _onPointerPanZoomEnd(PointerPanZoomEndEvent event) {
_gestureType = null;
}
void _onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
final offset = -event.scrollDelta.dy / 4000;
final volume = clampDouble(
plPlayerController.volume.value + offset,
0.0,
PlPlayerController.maxVolume,
);
plPlayerController.setVolume(volume);
}
}
@override
Widget build(BuildContext context) {
maxWidth = widget.maxWidth;
maxHeight = widget.maxHeight;
final primary = colorScheme.isLight
? colorScheme.inversePrimary
: colorScheme.primary;
late final thumbGlowColor = primary.withAlpha(80);
late final bufferedBarColor = primary.withValues(alpha: 0.4);
const TextStyle textStyle = TextStyle(
color: Colors.white,
fontSize: 12,
);
final isFullScreen = this.isFullScreen;
final isLive = plPlayerController.isLive;
final child = Stack(
fit: StackFit.passthrough,
key: _playerKey,
children: <Widget>[
_videoWidget,
if (widget.danmuWidget case final danmaku?)
Positioned.fill(top: 4, child: danmaku),
if (!isLive)
Positioned.fill(
child: IgnorePointer(
ignoring: !plPlayerController.enableDragSubtitle,
child: Obx(
() => SubtitleView(
controller: videoController,
configuration: plPlayerController.subtitleConfig.value,
enableDragSubtitle: plPlayerController.enableDragSubtitle,
onUpdatePadding: plPlayerController.onUpdatePadding,
),
),
),
),
if (plPlayerController.enableTapDm)
Obx(
() {
if (!plPlayerController.enableShowDanmaku.value) {
return const SizedBox.shrink();
}
final dmOffset = _dmOffset.value;
if (dmOffset != null && _suspendedDm != null) {
return _buildDmAction(_suspendedDm!, dmOffset);
}
return const SizedBox.shrink();
},
),
/// 长按倍速 toast
if (!isLive)
IgnorePointer(
ignoring: true,
child: Align(
alignment: Alignment.topCenter,
child: FractionalTranslation(
translation: isFullScreen
? const Offset(0.0, 1.2)
: const Offset(0.0, 0.8),
child: Obx(
() => AnimatedOpacity(
curve: Curves.easeInOut,
opacity: plPlayerController.longPressStatus.value
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
child: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: Color(0x88000000),
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Obx(
() => Text(
'${plPlayerController.enableAutoLongPressSpeed ? (plPlayerController.longPressStatus.value ? plPlayerController.lastPlaybackSpeed : plPlayerController.playbackSpeed) * 2 : plPlayerController.longPressSpeed}倍速中',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
),
),
),
),
),
),
),
/// 时间进度 toast
if (!isLive)
IgnorePointer(
ignoring: true,
child: Align(
alignment: Alignment.topCenter,
child: FractionalTranslation(
translation: isFullScreen
? const Offset(0.0, 1.2)
: const Offset(0.0, 0.8),
child: Obx(
() => AnimatedOpacity(
curve: Curves.easeInOut,
opacity: plPlayerController.isSliderMoving.value
? 1.0
: 0.0,
duration: const Duration(milliseconds: 150),
child: Container(
decoration: const BoxDecoration(
color: Color(0x88000000),
borderRadius: BorderRadius.all(Radius.circular(64)),
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
child: Row(
spacing: 2,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Obx(() {
return Text(
DurationUtils.formatDuration(
plPlayerController
.sliderTempPosition
.value
.inSeconds,
),
style: textStyle,
);
}),
const Text('/', style: textStyle),
Obx(
() {
return Text(
DurationUtils.formatDuration(
plPlayerController.duration.value.inSeconds,
),
style: textStyle,
);
},
),
],
),
),
),
),
),
),
),
/// 音量🔊 控制条展示
IgnorePointer(
ignoring: true,
child: Align(
alignment: Alignment.center,
child: Obx(
() {
final volume = plPlayerController.volume.value;
return AnimatedOpacity(
curve: Curves.easeInOut,
opacity: plPlayerController.volumeIndicator.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 5,
),
decoration: const BoxDecoration(
color: Color(0x88000000),
borderRadius: BorderRadius.all(Radius.circular(64)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
volume == 0.0
? Icons.volume_off
: volume < 0.5
? Icons.volume_down
: Icons.volume_up,
color: Colors.white,
size: 20.0,
),
const SizedBox(width: 2.0),
Text(
'${(volume * 100.0).round()}%',
style: const TextStyle(
fontSize: 13.0,
color: Colors.white,
),
),
],
),
),
);
},
),
),
),
/// 亮度🌞 控制条展示
IgnorePointer(
ignoring: true,
child: Align(
alignment: Alignment.center,
child: Obx(
() => AnimatedOpacity(
curve: Curves.easeInOut,
opacity: _brightnessIndicator.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 5,
),
decoration: const BoxDecoration(
color: Color(0x88000000),
borderRadius: BorderRadius.all(Radius.circular(64)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
_brightnessValue.value < 1.0 / 3.0
? Icons.brightness_low
: _brightnessValue.value < 2.0 / 3.0
? Icons.brightness_medium
: Icons.brightness_high,
color: Colors.white,
size: 18.0,
),
const SizedBox(width: 2.0),
Text(
'${(_brightnessValue.value * 100.0).round()}%',
style: const TextStyle(
fontSize: 13.0,
color: Colors.white,
),
),
],
),
),
),
),
),
),
// 头部、底部控制条
Positioned.fill(
top: -1,
bottom: -1,
child: ClipRect(
child: RepaintBoundary(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AppBarAni(
isTop: true,
controller: animationController,
isFullScreen: isFullScreen,
child: plPlayerController.isDesktopPip
? GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (_) => windowManager.startDragging(),
child: widget.headerControl,
)
: widget.headerControl,
),
AppBarAni(
isTop: false,
controller: animationController,
isFullScreen: isFullScreen,
child:
widget.bottomControl ??
BottomControl(
maxWidth: maxWidth,
isFullScreen: isFullScreen,
controller: plPlayerController,
videoDetailController: videoDetailController,
buildBottomControl: () => buildBottomControl(
videoDetailController,
maxWidth > maxHeight,
),
),
),
],
),
),
),
),
// Positioned(
// right: 25,
// top: 125,
// child: FilledButton.tonal(
// onPressed: () {
// transformationController.value = Matrix4.identity()
// ..translate(0.5, 0.5)
// ..scale(0.5)
// ..translate(-0.5, -0.5);
// showRestoreScaleBtn.value = true;
// },
// child: const Text('scale'),
// ),
// ),
Obx(
() =>
showRestoreScaleBtn.value && plPlayerController.showControls.value
? Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 95),
child: FilledButton.tonal(
style: FilledButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: colorScheme.secondaryContainer
.withValues(alpha: 0.8),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(15),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(6),
),
),
),
onPressed: () async {
showRestoreScaleBtn.value = false;
final animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 255),
);
final anim = animController.drive(
Matrix4Tween(
begin: transformationController.value,
end: Matrix4.identity(),
).chain(CurveTween(curve: Curves.easeOut)),
);
void listener() {
transformationController.value = anim.value;
}
animController.addListener(listener);
await animController.forward(from: 0);
animController
..removeListener(listener)
..dispose();
},
child: const Text('还原屏幕'),
),
),
)
: const SizedBox.shrink(),
),
/// 进度条 live模式下禁用
if (!isLive &&
plPlayerController.progressType != BtmProgressBehavior.alwaysHide)
Positioned(
bottom: -2.2,
left: 0,
right: 0,
child: Obx(
() {
final showControls = plPlayerController.showControls.value;
final offstage = switch (plPlayerController.progressType) {
BtmProgressBehavior.onlyShowFullScreen =>
showControls || !isFullScreen,
BtmProgressBehavior.onlyHideFullScreen =>
showControls || isFullScreen,
_ => showControls,
};
return Offstage(
offstage: offstage,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Obx(() {
final int value =
plPlayerController.sliderPositionSeconds.value;
final int max =
plPlayerController.duration.value.inSeconds;
final int buffer =
plPlayerController.bufferedSeconds.value;
return ProgressBar(
progress: Duration(seconds: value),
buffered: Duration(seconds: buffer),
total: Duration(seconds: max),
progressBarColor: primary,
baseBarColor: const Color(0x33FFFFFF),
bufferedBarColor: bufferedBarColor,
thumbColor: primary,
thumbGlowColor: thumbGlowColor,
barHeight: 3.5,
thumbRadius: 2.5,
);
}),
if (plPlayerController.enableBlock &&
videoDetailController.segmentProgressList.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0.75,
child: SegmentProgressBar(
segments: videoDetailController.segmentProgressList,
),
),
if (plPlayerController.showViewPoints &&
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value)
Padding(
padding: const .only(bottom: 4.25),
child: ViewPointSegmentProgressBar(
segments: videoDetailController.viewPointList,
onSeek: PlatformUtils.isMobile
? (position) => plPlayerController.seekTo(
position,
isSeek: false,
)
: null,
),
),
if (plPlayerController.showDmChart &&
videoDetailController.showDmTrendChart.value)
if (videoDetailController.dmTrend.value?.dataOrNull
case final list?)
buildDmChart(primary, list, videoDetailController),
],
),
);
},
),
),
if (!isLive && plPlayerController.showSeekPreview)
buildSeekPreviewWidget(
plPlayerController,
maxWidth,
maxHeight,
() => mounted,
),
if (isFullScreen || plPlayerController.isDesktopPip) ...[
// 锁
if (plPlayerController.showFsLockBtn)
ViewSafeArea(
right: false,
child: Align(
alignment: Alignment.centerLeft,
child: FractionalTranslation(
translation: const Offset(1, -0.4),
child: Obx(
() => Offstage(
offstage: !plPlayerController.showControls.value,
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0x45000000),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Obx(() {
final controlsLock =
plPlayerController.controlsLock.value;
return ComBtn(
tooltip: controlsLock ? '解锁' : '锁定',
icon: controlsLock
? const Icon(
FontAwesomeIcons.lock,
size: 15,
color: Colors.white,
)
: const Icon(
FontAwesomeIcons.lockOpen,
size: 15,
color: Colors.white,
),
onTap: () =>
plPlayerController.onLockControl(!controlsLock),
);
}),
),
),
),
),
),
),
// 截图
if (plPlayerController.showFsScreenshotBtn)
ViewSafeArea(
left: false,
child: Obx(
() => Align(
alignment: Alignment.centerRight,
child: FractionalTranslation(
translation: const Offset(-1, -0.4),
child: Offstage(
offstage: !plPlayerController.showControls.value,
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0x45000000),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: ComBtn(
tooltip: '截图',
icon: const Icon(
Icons.photo_camera,
size: 20,
color: Colors.white,
),
onLongPress:
(Platform.isAndroid || kDebugMode) && !isLive
? screenshotWebp
: null,
onTap: plPlayerController.takeScreenshot,
),
),
),
),
),
),
),
],
Obx(() {
if (plPlayerController.dataStatus.loading ||
(plPlayerController.isBuffering.value &&
plPlayerController.playerStatus.isPlaying)) {
return Center(
child: GestureDetector(
onTap: plPlayerController.refreshPlayer,
child: Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [Colors.black26, Colors.transparent],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/loading.webp',
height: 25,
cacheHeight: 25.cacheSize(context),
semanticLabel: "加载中",
color: Colors.white,
),
if (plPlayerController.isBuffering.value)
Obx(() {
if (plPlayerController.bufferedSeconds.value == 0) {
return const Text(
'加载中...',
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
);
}
String bufferStr = plPlayerController.buffered
.toString();
return Text(
bufferStr.substring(0, bufferStr.length - 3),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
);
}),
],
),
),
),
);
} else {
return const SizedBox.shrink();
}
}),
/// 点击 快进/快退
if (!isLive)
Obx(() {
final mountSeekBackwardButton =
plPlayerController.mountSeekBackwardButton.value;
final mountSeekForwardButton =
plPlayerController.mountSeekForwardButton.value;
return mountSeekBackwardButton || mountSeekForwardButton
? Positioned.fill(
child: Row(
children: [
if (mountSeekBackwardButton)
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) => Opacity(
opacity: value,
child: child,
),
child: BackwardSeekIndicator(
duration:
plPlayerController.fastForBackwardDuration,
onSubmitted: (Duration value) {
plPlayerController
..mountSeekBackwardButton.value = false
..onBackward(value);
},
),
),
),
const Spacer(flex: 2),
if (mountSeekForwardButton)
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) => Opacity(
opacity: value,
child: child,
),
child: ForwardSeekIndicator(
duration:
plPlayerController.fastForBackwardDuration,
onSubmitted: (Duration value) {
plPlayerController
..mountSeekForwardButton.value = false
..onForward(value);
},
),
),
),
],
),
)
: const SizedBox.shrink();
}),
],
);
if (PlatformUtils.isDesktop) {
return Obx(
() => MouseRegion(
cursor: !plPlayerController.showControls.value && isFullScreen
? SystemMouseCursors.none
: MouseCursor.defer,
onEnter: (_) => plPlayerController.controls = true,
onHover: (_) => plPlayerController.controls = true,
onExit: (_) => plPlayerController.controls =
widget.videoDetailController?.showSteinEdgeInfo.value ?? false,
child: child,
),
);
}
return child;
}
Widget get _videoWidget {
return Container(
clipBehavior: Clip.none,
width: maxWidth,
height: maxHeight,
color: widget.fill,
child: Obx(
() => MouseInteractiveViewer(
scaleEnabled: !plPlayerController.controlsLock.value,
pointerSignalFallback: _onPointerSignal,
onPointerPanZoomUpdate: _onPointerPanZoomUpdate,
onPointerPanZoomEnd: _onPointerPanZoomEnd,
onPointerDown: _onPointerDown,
onInteractionStart: _onInteractionStart,
onInteractionUpdate: _onInteractionUpdate,
onInteractionEnd: _onInteractionEnd,
panEnabled: false,
minScale: plPlayerController.enableShrinkVideoSize ? 0.75 : 1,
maxScale: 2.0,
boundaryMargin: plPlayerController.enableShrinkVideoSize
? const EdgeInsets.all(double.infinity)
: EdgeInsets.zero,
panAxis: PanAxis.aligned,
transformationController: transformationController,
onTranslate: () {
final storage = transformationController.value.storage;
showRestoreScaleBtn.value =
storage[12].abs() > 2.0 ||
storage[13].abs() > 2.0 ||
storage[0] != 1.0;
},
childKey: _videoKey,
child: RepaintBoundary(
key: _videoKey,
child: Obx(
() {
final videoFit = plPlayerController.videoFit.value;
return Transform.flip(
flipX: plPlayerController.flipX.value,
flipY: plPlayerController.flipY.value,
child: FittedBox(
fit: videoFit.boxFit,
alignment: widget.alignment,
child: SimpleVideo(
controller: plPlayerController.videoController!,
fill: widget.fill,
aspectRatio: videoFit.aspectRatio,
),
),
);
},
),
),
),
),
);
}
late final segment = Pair(
first: plPlayerController.position.inMilliseconds / 1000.0,
second: plPlayerController.position.inMilliseconds / 1000.0,
);
Future<void> screenshotWebp() async {
final videoInfo = videoDetailController.data;
final ids = videoInfo.dash!.video!.map((i) => i.id!).toSet();
final video = videoDetailController.findVideoByQa(ids.min);
VideoQuality qa = video.quality;
String? url = video.baseUrl;
if (url == null) return;
final ctr = plPlayerController;
final theme = Theme.of(context);
final currentPos = ctr.position.inMilliseconds / 1000.0;
final duration = ctr.duration.value.inMilliseconds / 1000.0;
final model = PostSegmentModel(
segment: segment,
category: SegmentType.sponsor,
actionType: ActionType.skip,
);
final isPlay = ctr.playerStatus.isPlaying;
if (isPlay) ctr.pause();
WebpPreset preset = WebpPreset.def;
final success =
await showDialog<bool>(
context: Get.context!,
builder: (context) => AlertDialog(
title: const Text('动态截图'),
content: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
PostPanel.segmentWidget(
theme,
item: model,
currentPos: () => currentPos,
videoDuration: duration,
),
PopupMenuText(
title: '选择画质',
value: () => qa.code,
onSelected: (value) {
final video = videoDetailController.findVideoByQa(value);
url = video.baseUrl;
qa = video.quality;
return false;
},
itemBuilder: (context) => videoInfo.supportFormats!
.map(
(i) => PopupMenuItem(
enabled: ids.contains(i.quality),
value: i.quality,
child: Text(i.newDesc ?? ''),
),
)
.toList(),
getSelectTitle: (_) => qa.shortDesc,
),
PopupMenuText(
title: 'webp预设',
value: () => preset,
onSelected: (value) {
preset = value;
return false;
},
itemBuilder: (context) => WebpPreset.values
.map((i) => PopupMenuItem(value: i, child: Text(i.name)))
.toList(),
getSelectTitle: (i) => '${i.name}(${i.desc})',
),
Text(
'*转码使用CPU速度可能慢于播放请不要选择过长的时间段或过高画质',
style: theme.textTheme.bodySmall,
),
],
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: theme.colorScheme.outline,
),
),
),
TextButton(
onPressed: () {
if (segment.first < segment.second) {
Get.back(result: true);
}
},
child: const Text('确定'),
),
],
),
) ??
false;
if (!success) return;
final progress = 0.0.obs;
final name =
'${ctr.cid}-${segment.first.toStringAsFixed(3)}_${segment.second.toStringAsFixed(3)}.webp';
final file = '$tmpDirPath/$name';
final mpv = MpvConvertWebp(
url!,
file,
segment.first,
segment.second,
progress: progress,
preset: preset,
);
final future = mpv.convert().whenComplete(
() => SmartDialog.dismiss(status: SmartStatus.loading),
);
SmartDialog.showLoading(
backType: SmartBackType.normal,
builder: (_) => LoadingWidget(progress: progress, msg: '正在保存,可能需要较长时间'),
onDismiss: () async {
if (progress.value < 1.0) {
mpv.dispose();
}
if (await future) {
await ImageUtils.saveFileImg(
filePath: file,
fileName: name,
needToast: true,
);
} else {
SmartDialog.showToast('转码出现错误或已取消');
}
if (isPlay) ctr.play();
},
);
}
static const _overlaySpacing = 5.0;
static const _actionItemWidth = 40.0;
static const _actionItemHeight = 35.0 - _triangleHeight;
DanmakuItem<DanmakuExtra>? _suspendedDm;
late double dy = 0;
late final Rxn<Offset> _dmOffset = Rxn<Offset>();
void _removeDmAction() {
if (_suspendedDm != null) {
_suspendedDm?.suspend = false;
_suspendedDm = null;
_dmOffset.value = null;
}
}
Widget _dmActionItem(
Widget child, {
required Future<void>? Function() onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await onTap();
_removeDmAction();
},
child: SizedBox(
width: _actionItemWidth,
height: _actionItemHeight,
child: Center(
child: child,
),
),
);
}
static final _timeRegExp = RegExp(r'(?:\d+[:])?\d+[:][0-5]?\d(?!\d)');
int? _getValidOffset(String data) {
if (_timeRegExp.firstMatch(data) case final timeStr?) {
final offset = DurationUtils.parseDuration(timeStr.group(0));
if (0 < offset &&
offset * 1000 < videoDetailController.data.timeLength!) {
return offset;
}
}
return null;
}
Widget _buildDmAction(
DanmakuItem<DanmakuExtra> item,
Offset offset,
) {
final dx = offset.dx;
// fullscreen
if (dx > maxWidth) {
_removeDmAction();
return const SizedBox.shrink();
}
final seekOffset = _getValidOffset(item.content.text);
final overlayWidth = _actionItemWidth * (seekOffset == null ? 3 : 4);
final top = dy + item.height + _triangleHeight + 2;
final realLeft = dx + overlayWidth / 2;
final left = realLeft.clamp(
_overlaySpacing + overlayWidth,
maxWidth - _overlaySpacing,
);
final right = maxWidth - left;
final triangleOffset = realLeft - left;
if (right > (maxWidth - item.xPosition)) {
_removeDmAction();
return const SizedBox.shrink();
}
final extra = item.content.extra;
return Positioned(
right: right,
top: top,
child: _DanmakuTip(
offset: triangleOffset,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: switch (extra) {
null => throw UnimplementedError(),
VideoDanmaku() => [
Stack(
clipBehavior: Clip.none,
children: [
_dmActionItem(
extra.isLike
? const Icon(
size: 20,
CustomIcons.player_dm_tip_like_solid,
color: Colors.white,
)
: const Icon(
size: 20,
CustomIcons.player_dm_tip_like,
color: Colors.white,
),
onTap: () => HeaderControl.likeDanmaku(
extra,
plPlayerController.cid!,
),
),
if (extra.like > 0)
Positioned(
left: _actionItemWidth - 10.5,
top: 0,
child: Text(
extra.like.toString(),
style: const TextStyle(
fontSize: 10.5,
color: Colors.white,
),
),
),
],
),
_dmActionItem(
const Icon(
size: 19,
CustomIcons.player_dm_tip_copy,
color: Colors.white,
),
onTap: () => Utils.copyText(item.content.text),
),
if (item.content.selfSend)
_dmActionItem(
const Icon(
size: 20,
CustomIcons.player_dm_tip_recall,
color: Colors.white,
),
onTap: () => HeaderControl.deleteDanmaku(
extra.id,
plPlayerController.cid!,
),
)
else
_dmActionItem(
const Icon(
size: 20,
CustomIcons.player_dm_tip_back,
color: Colors.white,
),
onTap: () => HeaderControl.reportDanmaku(
context,
extra: extra,
ctr: plPlayerController,
),
),
if (seekOffset != null)
_dmActionItem(
const Icon(
size: 18,
Icons.gps_fixed_outlined,
color: Colors.white,
),
onTap: () => plPlayerController.seekTo(
Duration(seconds: seekOffset),
isSeek: false,
),
),
],
LiveDanmaku() => [
_dmActionItem(
const Icon(
size: 20,
MdiIcons.accountOutline,
color: Colors.white,
),
onTap: () => Get.toNamed('/member?mid=${extra.mid}'),
),
_dmActionItem(
const Icon(
size: 19,
CustomIcons.player_dm_tip_copy,
color: Colors.white,
),
onTap: () => Utils.copyText(item.content.text),
),
_dmActionItem(
const Icon(
size: 20,
CustomIcons.player_dm_tip_back,
color: Colors.white,
),
onTap: () => HeaderControl.reportLiveDanmaku(
context,
roomId: (widget.bottomControl as live_bottom.BottomControl)
.liveRoomCtr
.roomId,
msg: item.content.text,
extra: extra,
),
),
],
},
),
),
);
}
}
Widget buildDmChart(
Color color,
List<double> dmTrend,
VideoDetailController videoDetailController, [
double offset = 0,
]) {
return IgnorePointer(
child: Container(
height: 12,
margin: EdgeInsets.only(
bottom:
videoDetailController.viewPointList.isNotEmpty &&
videoDetailController.showVP.value
? 19.25 + offset
: 4.25 + offset,
),
child: LineChart(
LineChartData(
titlesData: const FlTitlesData(show: false),
lineTouchData: const LineTouchData(enabled: false),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
minX: 0,
maxX: (dmTrend.length - 1).toDouble(),
minY: 0,
maxY: dmTrend.max,
lineBarsData: [
LineChartBarData(
spots: List.generate(
dmTrend.length,
(index) => FlSpot(
index.toDouble(),
dmTrend[index],
),
),
isCurved: true,
barWidth: 1,
color: color,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: color.withValues(alpha: 0.4),
),
),
],
),
),
),
);
}
Widget buildSeekPreviewWidget(
PlPlayerController plPlayerController,
double maxWidth,
double maxHeight,
ValueGetter<bool> isMounted,
) {
return Obx(
() {
if (!plPlayerController.showPreview.value) {
return const SizedBox.shrink();
}
try {
final data = plPlayerController.videoShot!.data;
final double scale =
plPlayerController.isFullScreen.value &&
(PlatformUtils.isDesktop || !plPlayerController.isVertical)
? 4
: 3;
double height = 27 * scale;
final compatHeight = maxHeight - 140;
if (compatHeight > 50) {
height = math.min(height, compatHeight);
}
final int imgXLen = data.imgXLen;
final int imgYLen = data.imgYLen;
final int totalPerImage = data.totalPerImage;
double imgXSize = data.imgXSize;
double imgYSize = data.imgYSize;
return Align(
alignment: Alignment.center,
child: Obx(
() {
final index = plPlayerController.previewIndex.value!;
int pageIndex = (index ~/ totalPerImage).clamp(
0,
data.image.length - 1,
);
int align = index % totalPerImage;
int x = align % imgXLen;
int y = align ~/ imgYLen;
final url = data.image[pageIndex];
return ClipRRect(
borderRadius: StyleString.mdRadius,
child: VideoShotImage(
url: url,
x: x,
y: y,
imgXSize: imgXSize,
imgYSize: imgYSize,
height: height,
imageCache: plPlayerController.previewCache,
onSetSize: (xSize, ySize) => data
..imgXSize = imgXSize = xSize
..imgYSize = imgYSize = ySize,
isMounted: isMounted,
),
);
},
),
);
} catch (e) {
if (kDebugMode) rethrow;
return const SizedBox.shrink();
}
},
);
}
class VideoShotImage extends StatefulWidget {
const VideoShotImage({
super.key,
required this.imageCache,
required this.url,
required this.x,
required this.y,
required this.imgXSize,
required this.imgYSize,
required this.height,
required this.onSetSize,
required this.isMounted,
});
final Map<String, ui.Image?> imageCache;
final String url;
final int x;
final int y;
final double imgXSize;
final double imgYSize;
final double height;
final Function(double imgXSize, double imgYSize) onSetSize;
final ValueGetter<bool> isMounted;
@override
State<VideoShotImage> createState() => _VideoShotImageState();
}
Future<ui.Image?> _getImg(String url) async {
final cacheManager = DefaultCacheManager();
final cacheKey = Utils.getFileName(url, fileExt: false);
try {
final fileInfo = await cacheManager.getSingleFile(
ImageUtils.safeThumbnailUrl(url),
key: cacheKey,
headers: Constants.baseHeaders,
);
return _loadImg(fileInfo.path);
} catch (_) {
return null;
}
}
Future<ui.Image?> _loadImg(String path) async {
final codec = await ui.instantiateImageCodecFromBuffer(
await ImmutableBuffer.fromFilePath(path),
);
final frame = await codec.getNextFrame();
codec.dispose();
return frame.image;
}
class _VideoShotImageState extends State<VideoShotImage> {
late Size _size;
late Rect _srcRect;
late Rect _dstRect;
late RRect _rrect;
ui.Image? _image;
@override
void initState() {
super.initState();
_initSize();
_loadImg();
}
void _initSizeIfNeeded() {
if (_size.width.isNaN) {
_initSize();
}
}
void _initSize() {
if (widget.imgXSize == 0) {
if (_image != null) {
final imgXSize = _image!.width / 10;
final imgYSize = _image!.height / 10;
final height = widget.height;
final width = height * imgXSize / imgYSize;
_setRect(width, height);
_setSrcRect(imgXSize, imgYSize);
widget.onSetSize(imgXSize, imgYSize);
} else {
_setRect(double.nan, double.nan);
_setSrcRect(widget.imgXSize, widget.imgYSize);
}
} else {
final height = widget.height;
final width = height * widget.imgXSize / widget.imgYSize;
_setRect(width, height);
_setSrcRect(widget.imgXSize, widget.imgYSize);
}
}
void _setRect(double width, double height) {
_size = Size(width, height);
_dstRect = Rect.fromLTRB(0, 0, width, height);
_rrect = RRect.fromRectAndRadius(_dstRect, const Radius.circular(10));
}
void _setSrcRect(double imgXSize, double imgYSize) {
_srcRect = Rect.fromLTWH(
widget.x * imgXSize,
widget.y * imgYSize,
imgXSize,
imgYSize,
);
}
void _loadImg() {
final url = widget.url;
_image = widget.imageCache[url];
if (_image != null) {
_initSizeIfNeeded();
} else if (!widget.imageCache.containsKey(url)) {
widget.imageCache[url] = null;
_getImg(url).then((image) {
if (image != null) {
if (widget.isMounted()) {
widget.imageCache[url] = image;
}
if (mounted) {
_image = image;
_initSizeIfNeeded();
setState(() {});
}
} else {
widget.imageCache.remove(url);
}
});
}
}
@override
void didUpdateWidget(VideoShotImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.url != widget.url) {
_loadImg();
}
if (oldWidget.x != widget.x || oldWidget.y != widget.y) {
_setSrcRect(widget.imgXSize, widget.imgYSize);
}
}
late final _imgPaint = Paint()..filterQuality = FilterQuality.medium;
late final _borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
@override
Widget build(BuildContext context) {
if (_image != null) {
return CroppedImage(
size: _size,
image: _image!,
srcRect: _srcRect,
dstRect: _dstRect,
rrect: _rrect,
imgPaint: _imgPaint,
borderPaint: _borderPaint,
);
}
return const SizedBox.shrink();
}
}
const double _triangleHeight = 5.6;
class _DanmakuTip extends SingleChildRenderObjectWidget {
const _DanmakuTip({
this.offset = 0,
super.child,
});
final double offset;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderDanmakuTip(offset: offset);
}
@override
void updateRenderObject(
BuildContext context,
_RenderDanmakuTip renderObject,
) {
renderObject.offset = offset;
}
}
class _RenderDanmakuTip extends RenderProxyBox {
_RenderDanmakuTip({
required double offset,
}) : _offset = offset;
double _offset;
double get offset => _offset;
set offset(double value) {
if (_offset == value) return;
_offset = value;
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
final paint = Paint()
..color = const Color(0xB3000000)
..style = .fill;
final radius = size.height / 2;
const triangleBase = _triangleHeight * 2 / 3;
final triangleCenterX = (size.width / 2 + _offset).clamp(
radius + triangleBase,
size.width - radius - triangleBase,
);
final path = Path()
// triangle (exceed)
..moveTo(triangleCenterX - triangleBase, 0)
..lineTo(triangleCenterX, -_triangleHeight)
..lineTo(triangleCenterX + triangleBase, 0)
// top
..lineTo(size.width - radius, 0)
// right
..arcToPoint(
Offset(size.width - radius, size.height),
radius: Radius.circular(radius),
)
// bottom
..lineTo(radius, size.height)
// left
..arcToPoint(
Offset(radius, 0),
radius: Radius.circular(radius),
)
..close();
context.canvas
..save()
..translate(offset.dx, offset.dy)
..drawPath(path, paint)
..drawPath(
path,
paint
..color = const Color(0x7EFFFFFF)
..style = PaintingStyle.stroke
..strokeWidth = 1.25,
)
..restore();
super.paint(context, offset);
}
}
class _VideoTime extends LeafRenderObjectWidget {
const _VideoTime({
required this.position,
required this.duration,
});
final String position;
final String duration;
@override
_RenderVideoTime createRenderObject(BuildContext context) => _RenderVideoTime(
position: position,
duration: duration,
);
@override
void updateRenderObject(
BuildContext context,
covariant _RenderVideoTime renderObject,
) {
renderObject
..position = position
..duration = duration;
}
}
class _RenderVideoTime extends RenderBox {
_RenderVideoTime({
required String position,
required String duration,
}) : _position = position,
_duration = duration;
String _duration;
set duration(String value) {
_duration = value;
final paragraph = _buildParagraph(const Color(0xFFD0D0D0), _duration);
if (paragraph.maxIntrinsicWidth != _cache?.maxIntrinsicWidth) {
markNeedsLayout();
}
_cache?.dispose();
_cache = paragraph;
markNeedsSemanticsUpdate();
}
String _position;
set position(String value) {
_position = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
ui.Paragraph? _cache;
ui.Paragraph _buildParagraph(Color color, String time) {
final builder =
ui.ParagraphBuilder(
ui.ParagraphStyle(
fontSize: 10,
height: 1.4,
fontFamily: 'Monospace',
),
)
..pushStyle(
ui.TextStyle(
color: color,
fontSize: 10,
height: 1.4,
fontFamily: 'Monospace',
fontFeatures: const [FontFeature.tabularFigures()],
),
)
..addText(time);
return builder.build()
..layout(const ui.ParagraphConstraints(width: .infinity));
}
@override
ui.Size computeDryLayout(covariant BoxConstraints constraints) {
final paragraph = _cache ??= _buildParagraph(
const Color(0xFFD0D0D0),
_duration,
);
return Size(paragraph.maxIntrinsicWidth, paragraph.height * 2);
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.label = 'position:$_position\nduration:$_duration';
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
void paint(PaintingContext context, ui.Offset offset) {
final para = _buildParagraph(Colors.white, _position);
context.canvas
..drawParagraph(
para,
Offset(
offset.dx + _cache!.maxIntrinsicWidth - para.maxIntrinsicWidth,
offset.dy,
),
)
..drawParagraph(_cache!, Offset(offset.dx, offset.dy + para.height));
para.dispose();
}
@override
void dispose() {
_cache?.dispose();
_cache = null;
super.dispose();
}
@override
bool get isRepaintBoundary => true;
}