diff --git a/lib/pages/video/widgets/player_focus.dart b/lib/pages/video/widgets/player_focus.dart index b75ca1fdd9..0a114390e7 100644 --- a/lib/pages/video/widgets/player_focus.dart +++ b/lib/pages/video/widgets/player_focus.dart @@ -13,7 +13,7 @@ import 'package:flutter/services.dart' import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class PlayerFocus extends StatelessWidget { +class PlayerFocus extends StatefulWidget { const PlayerFocus({ super.key, required this.child, @@ -33,6 +33,23 @@ class PlayerFocus extends StatelessWidget { final bool Function()? canPlay; final bool Function()? onSkipSegment; + @override + State createState() => _PlayerFocusState(); +} + +class _PlayerFocusState extends State { + Timer? _longPressTimer; + Timer? _scrubbingTimer; + + @override + void dispose() { + _longPressTimer?.cancel(); + _longPressTimer = null; + _scrubbingTimer?.cancel(); + _scrubbingTimer = null; + super.dispose(); + } + static bool _shouldHandle(LogicalKeyboardKey logicalKey) { return logicalKey == LogicalKeyboardKey.tab || logicalKey == LogicalKeyboardKey.arrowLeft || @@ -52,12 +69,12 @@ class PlayerFocus extends StatelessWidget { } return KeyEventResult.ignored; }, - child: child, + child: widget.child, ); } - bool get isFullScreen => plPlayerController.isFullScreen.value; - bool get hasPlayer => plPlayerController.videoPlayerController != null; + bool get isFullScreen => widget.plPlayerController.isFullScreen.value; + bool get hasPlayer => widget.plPlayerController.videoPlayerController != null; bool _handleKey(KeyEvent event) { final key = event.logicalKey; @@ -67,11 +84,11 @@ class PlayerFocus extends StatelessWidget { if (HardwareKeyboard.instance.isMetaPressed) { return true; } - if (!plPlayerController.isLive) { + if (!widget.plPlayerController.isLive) { if (event is KeyDownEvent) { - introController!.onStartTriple(); + widget.introController!.onStartTriple(); } else if (event is KeyUpEvent) { - introController!.onCancelTriple(isKeyQ); + widget.introController!.onCancelTriple(isKeyQ); } } return true; @@ -81,13 +98,13 @@ class PlayerFocus extends StatelessWidget { if (isArrowUp || key == LogicalKeyboardKey.arrowDown) { if (event is! KeyDownEvent) return true; if (isArrowUp) { - if (introController case final introController?) { + if (widget.introController case final introController?) { if (!introController.prevPlay()) { SmartDialog.showToast('已经是第一集了'); } } } else { - if (introController case final introController?) { + if (widget.introController case final introController?) { if (!introController.nextPlay()) { SmartDialog.showToast('已经是最后一集了'); } @@ -96,28 +113,40 @@ class PlayerFocus extends StatelessWidget { return true; } - if (key == LogicalKeyboardKey.arrowRight) { - if (!plPlayerController.isLive) { - if (event is KeyDownEvent) { - if (hasPlayer && !plPlayerController.longPressStatus.value) { - plPlayerController - ..cancelLongPressTimer() - ..longPressTimer ??= Timer( - const Duration(milliseconds: 200), - () => plPlayerController - ..cancelLongPressTimer() - ..setLongPressStatus(true), - ); - } - } else if (event is KeyUpEvent) { - plPlayerController.cancelLongPressTimer(); - if (hasPlayer) { - if (plPlayerController.longPressStatus.value) { - plPlayerController.setLongPressStatus(false); + final isForward = key == LogicalKeyboardKey.arrowRight; + final isBackward = key == LogicalKeyboardKey.arrowLeft; + + if (isForward || isBackward) { + if (widget.plPlayerController.isLive) return true; + + if (event is KeyDownEvent) { + if (hasPlayer && _longPressTimer == null) { + _longPressTimer = Timer(const Duration(milliseconds: 300), () { + widget.plPlayerController.startScrubbing(); + _scrubbingTimer?.cancel(); + _scrubbingTimer = + Timer.periodic(const Duration(milliseconds: 200), (_) { + widget.plPlayerController.updateScrubbing(isForward); + }); + }); + } + } else if (event is KeyUpEvent) { + _longPressTimer?.cancel(); + _longPressTimer = null; + _scrubbingTimer?.cancel(); + _scrubbingTimer = null; + + if (hasPlayer) { + if (widget.plPlayerController.isScrubbing.value) { + widget.plPlayerController.endScrubbing(); + } else { + if (isForward) { + widget.plPlayerController + .onForward(widget.plPlayerController.fastForBackwardDuration); } else { - plPlayerController.onForward( - plPlayerController.fastForBackwardDuration, - ); + // isBackward + widget.plPlayerController.onBackward( + widget.plPlayerController.fastForBackwardDuration); } } } @@ -130,8 +159,8 @@ class PlayerFocus extends StatelessWidget { if (isDigit1 || key == LogicalKeyboardKey.digit2) { if (HardwareKeyboard.instance.isShiftPressed && hasPlayer) { final speed = isDigit1 ? 1.0 : 2.0; - if (speed != plPlayerController.playbackSpeed) { - plPlayerController.setPlaybackSpeed(speed); + if (speed != widget.plPlayerController.playbackSpeed) { + widget.plPlayerController.setPlaybackSpeed(speed); } SmartDialog.showToast('${speed}x播放'); } @@ -140,32 +169,32 @@ class PlayerFocus extends StatelessWidget { switch (key) { case LogicalKeyboardKey.space: - if (plPlayerController.isLive || canPlay!()) { + if (widget.plPlayerController.isLive || widget.canPlay!()) { if (hasPlayer) { - plPlayerController.onDoubleTapCenter(); + widget.plPlayerController.onDoubleTapCenter(); } } return true; case LogicalKeyboardKey.keyF: final isFullScreen = this.isFullScreen; - if (isFullScreen && plPlayerController.controlsLock.value) { - plPlayerController + if (isFullScreen && widget.plPlayerController.controlsLock.value) { + widget.plPlayerController ..controlsLock.value = false ..showControls.value = false; } - plPlayerController.triggerFullScreen( + widget.plPlayerController.triggerFullScreen( status: !isFullScreen, inAppFullScreen: HardwareKeyboard.instance.isShiftPressed, ); return true; case LogicalKeyboardKey.keyD: - final newVal = !plPlayerController.enableShowDanmaku.value; - plPlayerController.enableShowDanmaku.value = newVal; - if (!plPlayerController.tempPlayerConf) { + final newVal = !widget.plPlayerController.enableShowDanmaku.value; + widget.plPlayerController.enableShowDanmaku.value = newVal; + if (!widget.plPlayerController.tempPlayerConf) { GStorage.setting.put( - plPlayerController.isLive + widget.plPlayerController.isLive ? SettingBoxKey.enableShowLiveDanmaku : SettingBoxKey.enableShowDanmaku, newVal, @@ -175,7 +204,7 @@ class PlayerFocus extends StatelessWidget { case LogicalKeyboardKey.keyP: if (Utils.isDesktop && hasPlayer && !isFullScreen) { - plPlayerController + widget.plPlayerController ..toggleDesktopPip() ..controlsLock.value = false ..showControls.value = false; @@ -184,82 +213,75 @@ class PlayerFocus extends StatelessWidget { case LogicalKeyboardKey.keyM: if (hasPlayer) { - final isMuted = !plPlayerController.isMuted; - plPlayerController.videoPlayerController!.setVolume( - isMuted ? 0 : plPlayerController.volume.value * 100, + final isMuted = !widget.plPlayerController.isMuted; + widget.plPlayerController.videoPlayerController!.setVolume( + isMuted ? 0 : widget.plPlayerController.volume.value * 100, ); - plPlayerController.isMuted = isMuted; + widget.plPlayerController.isMuted = isMuted; SmartDialog.showToast('${isMuted ? '' : '取消'}静音'); } return true; case LogicalKeyboardKey.keyS: if (hasPlayer && isFullScreen) { - plPlayerController.takeScreenshot(); + widget.plPlayerController.takeScreenshot(); } return true; case LogicalKeyboardKey.keyL: - if (isFullScreen || plPlayerController.isDesktopPip) { - plPlayerController.onLockControl( - !plPlayerController.controlsLock.value, + if (isFullScreen || widget.plPlayerController.isDesktopPip) { + widget.plPlayerController.onLockControl( + !widget.plPlayerController.controlsLock.value, ); } return true; case LogicalKeyboardKey.enter: case LogicalKeyboardKey.select: - if (onSkipSegment?.call() ?? false) { + if (widget.onSkipSegment?.call() ?? false) { return true; } - if (plPlayerController.isLive || canPlay!()) { + if (widget.plPlayerController.isLive || widget.canPlay!()) { if (hasPlayer) { - plPlayerController.onDoubleTapCenter(); + widget.plPlayerController.onDoubleTapCenter(); } } return true; case LogicalKeyboardKey.contextMenu: - if (plPlayerController.isLive || (canPlay?.call() ?? false)) { + if (widget.plPlayerController.isLive || + (widget.canPlay?.call() ?? false)) { if (hasPlayer) { - onShowMenu?.call(); + widget.onShowMenu?.call(); } } return true; } - if (!plPlayerController.isLive) { + if (!widget.plPlayerController.isLive) { switch (key) { - case LogicalKeyboardKey.arrowLeft: - if (hasPlayer) { - plPlayerController.onBackward( - plPlayerController.fastForBackwardDuration, - ); - } - return true; - case LogicalKeyboardKey.keyW: if (HardwareKeyboard.instance.isMetaPressed) { return true; } - introController?.actionCoinVideo(); + widget.introController?.actionCoinVideo(); return true; case LogicalKeyboardKey.keyE: - introController?.actionFavVideo(isQuick: true); + widget.introController?.actionFavVideo(isQuick: true); return true; case LogicalKeyboardKey.keyT || LogicalKeyboardKey.keyV: - introController?.viewLater(); + widget.introController?.viewLater(); return true; case LogicalKeyboardKey.keyG: - if (introController case UgcIntroController ugcCtr) { + if (widget.introController case UgcIntroController ugcCtr) { ugcCtr.actionRelationMod(Get.context!); } return true; case LogicalKeyboardKey.bracketLeft: - if (introController case final introController?) { + if (widget.introController case final introController?) { if (!introController.prevPlay()) { SmartDialog.showToast('已经是第一集了'); } @@ -267,7 +289,7 @@ class PlayerFocus extends StatelessWidget { return true; case LogicalKeyboardKey.bracketRight: - if (introController case final introController?) { + if (widget.introController case final introController?) { if (!introController.nextPlay()) { SmartDialog.showToast('已经是最后一集了'); } diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 0d6a265ee7..76a32fc483 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -124,6 +124,11 @@ class PlPlayerController { /// 是否长按倍速 final RxBool longPressStatus = false.obs; + /// 是否正在拖拽 + final RxBool isScrubbing = false.obs; + final Rx scrubbingPosition = Rx(Duration.zero); + int scrubbingSpeed = 1; + /// 屏幕锁 为true时,关闭控制栏 final RxBool controlsLock = false.obs; @@ -1896,4 +1901,36 @@ class PlPlayerController { } }); } + + void startScrubbing() { + if (isLive) return; + isScrubbing.value = true; + scrubbingPosition.value = position.value; + if (showSeekPreview && videoShot == null) { + getVideoShot(); + } + videoPlayerController?.pause(); + } + + void updateScrubbing(bool forward) { + if (!isScrubbing.value) return; + + final newPosition = scrubbingPosition.value + + Duration(seconds: forward ? scrubbingSpeed : -scrubbingSpeed); + scrubbingPosition.value = newPosition.clamp(Duration.zero, duration.value); + + if (scrubbingSpeed < 64) { + scrubbingSpeed *= 2; + } + } + + void endScrubbing() { + if (!isScrubbing.value) return; + + seekTo(scrubbingPosition.value).then((_) { + videoPlayerController?.play(); + }); + isScrubbing.value = false; + scrubbingSpeed = 1; + } } diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index f9c86efee1..dd00660a28 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -42,6 +42,7 @@ 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/scrubbing_overlay.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'; @@ -119,6 +120,7 @@ class PLVideoPlayer extends StatefulWidget { class _PLVideoPlayerState extends State with WidgetsBindingObserver, TickerProviderStateMixin { + PlPlayerController get controller => widget.plPlayerController; void onDoubleTapCenter() {} void onDoubleTapSeekBackward() {} void onDoubleTapSeekForward() {} @@ -1662,6 +1664,7 @@ class _PLVideoPlayerState extends State ), ), ), + ScrubbingOverlay(controller: controller), ], ), ), diff --git a/lib/plugin/pl_player/widgets/scrubbing_overlay.dart b/lib/plugin/pl_player/widgets/scrubbing_overlay.dart new file mode 100644 index 0000000000..af730cdcf4 --- /dev/null +++ b/lib/plugin/pl_player/widgets/scrubbing_overlay.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:PiliPlus/plugin/pl_player/controller.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/utils/duration_utils.dart'; +import 'package:PiliPlus/models_new/video/video_shot/data.dart'; +import 'package:PiliPlus/http/loading_state.dart'; + +class ScrubbingOverlay extends StatelessWidget { + final PlPlayerController controller; + + const ScrubbingOverlay({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Obx(() { + if (!controller.isScrubbing.value) { + return const SizedBox.shrink(); + } + + return Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildThumbnail(), + const SizedBox(height: 16), + Text( + '${DurationUtils.formatDuration( + controller.scrubbingPosition.value.inSeconds.toDouble(), + )} / ${DurationUtils.formatDuration( + controller.duration.value.inSeconds.toDouble(), + )}', + style: const TextStyle(color: Colors.white, fontSize: 24), + ), + const SizedBox(height: 8), + SizedBox( + width: 300, + child: LinearProgressIndicator( + value: controller.duration.value.inSeconds == 0 + ? 0 + : controller.scrubbingPosition.value.inSeconds / + controller.duration.value.inSeconds, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildThumbnail() { + return Obx(() { + final videoShot = controller.videoShot; + if (videoShot == null || videoShot is! Success) { + return const SizedBox( + width: 160, + height: 90, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + final data = (videoShot as Success).response; + final imageUrl = _getThumbnailUrl(data); + if (imageUrl == null) { + return const SizedBox(width: 160, height: 90); + } + return NetworkImgLayer( + src: imageUrl, + width: 160, + height: 90, + ); + }); + } + + String? _getThumbnailUrl(VideoShotData data) { + if (data.image.isEmpty) return null; + + final seconds = controller.scrubbingPosition.value.inSeconds; + int index = -1; + for (var i = 0; i < data.index.length; i++) { + if (data.index[i] >= seconds) { + index = i; + break; + } + } + if (index == -1) return null; + + final row = (index / data.imgXLen).floor(); + final col = index % data.imgXLen; + + final x = col * data.imgXSize; + final y = row * data.imgYSize; + + final url = data.image.first; + return '$url?crop=${x.toInt()}_${y.toInt()}_${data.imgXSize.toInt()}_${data.imgYSize.toInt()}'; + } +}