import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:PiliPlus/common/assets.dart'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/style.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/mouse_interactive_viewer.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/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/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/controller.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/data_status.dart'; import 'package:PiliPlus/plugin/pl_player/models/double_tap_type.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/view/simple_video_texture.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/play_pause_btn.dart'; import 'package:PiliPlus/utils/connectivity_utils.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/mobile_observer.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:easy_debounce/easy_throttle.dart'; import 'package:flutter/foundation.dart' show clampDouble, kDebugMode; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; 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: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'; part 'widgets.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?, String?, int?, int?, ])? showEpisodes; final VoidCallback? showViewPoints; final Color fill; final Alignment alignment; @override State createState() => _PLVideoPlayerState(); } class _PLVideoPlayerState extends State 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 final RxBool showRestoreScaleBtn = false.obs; GestureType? _gestureType; Offset? _initialFocalPoint; bool _pauseDueToPauseUponEnteringBackgroundMode = false; StreamSubscription? _brightnessListener; int? tmpSubtitlePaddingB; StreamSubscription? _controlsListener; void _onControlChanged(bool val) { final visible = val && !plPlayerController.controlsLock.value; if ((widget.headerControl.key as GlobalKey).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(); addObserverMobile(this); _controlsListener = plPlayerController.showControls.listen( _onControlChanged, ); _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) { 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; } }); } } }, emitOnStart: false); } catch (_) {} }); Future.microtask(() async { try { void listener(double value) { if (mounted) { _brightnessValue.value = value; } } _brightnessValue.value = await ScreenBrightnessPlatform.instance.system; _brightnessListener = ScreenBrightnessPlatform .instance .onSystemScreenBrightnessChanged .listen(listener); } catch (_) {} }); } _tapGestureRecognizer = TapGestureRecognizer()..onTapUp = _onTapUp; _doubleTapGestureRecognizer = DoubleTapGestureRecognizer() ..onDoubleTapDown = _onDoubleTapDown; _scaleGestureRecognizer = ScaleGestureRecognizer( debugOwner: this, dragStartBehavior: .start, allowedButtonsFilter: (buttons) => buttons == kPrimaryButton, trackpadScrollToScaleFactor: const Offset( 0, -1 / kDefaultMouseScrollToScaleFactor, ), trackpadScrollCausesScale: false, ); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (!plPlayerController.continuePlayInBackground.value) { late final player = plPlayerController.videoPlayerController; if (const [.paused, .detached].contains(state)) { if (player != null && player.state.playing) { _pauseDueToPauseUponEnteringBackgroundMode = true; player.pause(); } } else { if (_pauseDueToPauseUponEnteringBackgroundMode) { _pauseDueToPauseUponEnteringBackgroundMode = false; player?.play(); } } } } Future setBrightness(double value) async { try { await ScreenBrightnessPlatform.instance.setSystemScreenBrightness( 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() { removeObserverMobile(this); _tapGestureRecognizer.dispose(); _longPressRecognizer?.dispose(); _doubleTapGestureRecognizer.dispose(); _scaleGestureRecognizer.dispose(); _brightnessListener?.cancel(); _controlsListener?.cancel(); _animationController.dispose(); _transformationController.dispose(); if (PlatformUtils.isMobile) { FlutterVolumeController.removeListener(); } 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.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: const Icon( CustomIcons.view_headline_rotate_90, 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 episodes = []; if (isSeason) { final List sections = videoDetail.ugcSeason!.sections!; for (int i = 0; i < sections.length; i++) { final List 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( tooltip: '画面比例', requestFocus: false, initialValue: fit, color: Colors.black.withValues(alpha: 0.8), itemBuilder: (context) { return VideoFitType.values .map( (boxFit) => PopupMenuItem( 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( tooltip: '翻译', requestFocus: false, initialValue: videoDetailController.currLang.value, color: Colors.black.withValues(alpha: 0.8), itemBuilder: (context) { return [ PopupMenuItem( height: 35, value: '', onTap: () => videoDetailController.setLanguage(''), child: const Text( "关闭翻译", style: TextStyle( color: Colors.white, fontSize: 13, ), ), ), ...list.map((e) { return PopupMenuItem( 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( tooltip: '字幕', requestFocus: false, initialValue: val, color: Colors.black.withValues(alpha: 0.8), itemBuilder: (context) { return [ PopupMenuItem( 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( 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( tooltip: '倍速', requestFocus: false, initialValue: plPlayerController.playbackSpeed, color: Colors.black.withValues(alpha: 0.8), itemBuilder: (context) { return plPlayerController.speedList .map( (double speed) => PopupMenuItem( 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 videoFormat = videoInfo.supportFormats!; final int totalQaSam = videoFormat.length; int usefulQaSam = 0; final List video = videoInfo.dash!.video!; final Set idSet = {}; for (final VideoItem item in video) { final int id = item.id!; if (!idSet.contains(id)) { idSet.add(id); usefulQaSam++; } } return PopupMenuButton( 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( 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 GStorage.setting.put( await ConnectivityUtils.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 userSpecifyItemLeft = [ BottomControlType.playOrPause, BottomControlType.time, if (!isNotFileSource || anySeason) ...[ BottomControlType.pre, BottomControlType.next, ], ]; final flag = isFullScreen || plPlayerController.isDesktopPip || maxWidth >= 500; List userSpecifyItemRight = [ if (isNotFileSource) 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 _onPanStart(ScaleStartDetails details) { _gestureType = null; _initialFocalPoint = details.localFocalPoint; } void _onScaleUpdate(double scale) { showRestoreScaleBtn.value = scale != 1.0; } void _onPanUpdate(ScaleUpdateDetails details) { if (_gestureType == null) { final cumulativeDelta = details.localFocalPoint - _initialFocalPoint!; if (cumulativeDelta.distanceSquared < 1) return; final dx = cumulativeDelta.dx.abs(); final dy = cumulativeDelta.dy.abs(); if (dx > 3 * dy) { _gestureType = .horizontal; } else if (dy > 3 * dx) { final double tapPosition = details.localFocalPoint.dx; final double sectionWidth = maxWidth / 3; if (tapPosition < sectionWidth) { // 左边区域 if (PlatformUtils.isDesktop) { _gestureType = .right; } else { _gestureType = .left; } } else if (tapPosition < sectionWidth * 2) { // 全屏 _gestureType = .center; } else { // 右边区域 _gestureType = .right; } } return; } final delta = details.focalPointDelta; if (_gestureType == .horizontal) { // live模式下禁用 if (plPlayerController.isLive) return; final curSliderPosition = plPlayerController.sliderPosition.inMilliseconds; final 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.cancelSeek != true) { plPlayerController.updatePreviewIndex(newPos ~/ 1000); } } else if (_gestureType == .left) { // 左边区域 👈 final double level = maxHeight * 3; final double brightness = (_brightnessValue.value - delta.dy / level) .clamp(0.0, 1.0); setBrightness(brightness); } else if (_gestureType == .center) { // 全屏 const double threshold = 2.5; // 滑动阈值 double cumulativeDy = details.localFocalPoint.dy - _initialFocalPoint!.dy; if (cumulativeDy > threshold) { _gestureType = .center_down; plPlayerController.triggerFullScreen(status: false); } else if (cumulativeDy < -threshold) { _gestureType = .center_up; plPlayerController.triggerFullScreen(status: true); } } else if (_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 _onPanEnd(ScaleEndDetails details) { if (Platform.isAndroid && _gestureType == .left) { ScreenBrightnessPlatform.instance.restoreBrightnessMode(); } 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(); } _initialFocalPoint = null; _gestureType = null; } void onDoubleTapDownMobile(TapDownDetails details) { 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 _onTapUp(TapUpDetails details) { switch (details.kind) { case ui.PointerDeviceKind.mouse when PlatformUtils.isDesktop: plPlayerController.onDoubleTapCenter(); default: plPlayerController.controls = !plPlayerController.showControls.value; } } void _onDoubleTapDown(TapDownDetails details) { switch (details.kind) { case ui.PointerDeviceKind.mouse when PlatformUtils.isDesktop: plPlayerController.triggerFullScreen(status: !isFullScreen); default: onDoubleTapDownMobile(details); } } LongPressGestureRecognizer? _longPressRecognizer; LongPressGestureRecognizer get longPressRecognizer => _longPressRecognizer ??= LongPressGestureRecognizer() ..onLongPressStart = ((_) => plPlayerController.setLongPressStatus(true)) ..onLongPressEnd = ((_) => plPlayerController.setLongPressStatus(false)) ..onLongPressCancel = (() => plPlayerController.setLongPressStatus(false)); late final TapGestureRecognizer _tapGestureRecognizer; late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer; late final ScaleGestureRecognizer _scaleGestureRecognizer; static const _kOffsetThreshold = 30.0; bool _isPositionAllowed(Offset offset) { if (offset.dx < _kOffsetThreshold || offset.dy < _kOffsetThreshold || offset.dx > maxWidth - _kOffsetThreshold || offset.dy > maxHeight - _kOffsetThreshold) { return false; } return true; } 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, ); return; } } final controlsUnlock = !plPlayerController.controlsLock.value; if (PlatformUtils.isMobile) { if (_isPositionAllowed(event.localPosition)) { _tapGestureRecognizer.addPointer(event); if (controlsUnlock) { if (!plPlayerController.isLive) { _doubleTapGestureRecognizer.addPointer(event); longPressRecognizer.addPointer(event); } _scaleGestureRecognizer.addPointer(event); } } } else if (controlsUnlock) { if (plPlayerController.isLive) { _doubleTapGestureRecognizer.addPointer(event); } else { _tapGestureRecognizer.addPointer(event); _doubleTapGestureRecognizer.addPointer(event); longPressRecognizer.addPointer(event); } _scaleGestureRecognizer.addPointer(event); } } 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 = .horizontal; } else if (dy > 3 * dx) { _gestureType = .right; } return; } if (_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.cancelSeek != true) { plPlayerController.updatePreviewIndex(newPos ~/ 1000); } } else if (_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) { plPlayerController.showPreview.value = false; if (plPlayerController.isSliderMoving.value) { plPlayerController ..seekTo(plPlayerController.sliderPosition, isSeek: false) ..onChangedSliderEnd(); } _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 isFullScreen = this.isFullScreen; final primary = isFullScreen && 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 isLive = plPlayerController.isLive; final child = Stack( key: _playerKey, fit: .passthrough, children: [ _videoWidget, if (widget.danmuWidget case final danmaku?) Positioned.fill(top: 4, child: danmaku), if (!isLive) Positioned.fill( child: IgnorePointer( child: Obx( () => SubtitleView( controller: videoController, configuration: plPlayerController.subtitleConfig.value, onUpdatePadding: plPlayerController.onUpdatePadding, ), ), ), ), /// 长按倍速 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.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: [ 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: [ 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) Positioned( bottom: -2.2, left: 0, right: 0, child: Obx( () { return Offstage( offstage: plPlayerController.showControls.value, 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 (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 (!isLive) buildSeekPreviewWidget( plPlayerController, maxWidth, maxHeight, () => mounted, ), if (isFullScreen || plPlayerController.isDesktopPip) ...[ // 锁 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), ); }), ), ), ), ), ), ), // 截图 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, ), 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.buffering, 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( tween: Tween(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( tween: Tween(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: .none, width: maxWidth, height: maxHeight, color: widget.fill, child: Obx( () => MouseInteractiveViewer( scaleEnabled: !plPlayerController.controlsLock.value, pointerSignalFallback: _onPointerSignal, onPointerPanZoomUpdate: _onPointerPanZoomUpdate, onPointerPanZoomEnd: _onPointerPanZoomEnd, onPointerDown: _onPointerDown, onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, onScaleUpdate: _onScaleUpdate, scaleGestureRecognizer: _scaleGestureRecognizer, panEnabled: false, minScale: 1.0, maxScale: 2.0, panAxis: .aligned, transformationController: _transformationController, 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: SimpleVideoTexture( 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, ); }