From 324ba10edf1c2b3e91c46d9c1b3eda4ebe2f97ce Mon Sep 17 00:00:00 2001 From: 22 <60903333+nini22P@users.noreply.github.com> Date: Sun, 23 Feb 2025 20:09:08 +0800 Subject: [PATCH] feat: add playback speed button --- CHANGELOG.md | 15 +++++ README.md | 2 + README_CN.md | 2 + lib/hooks/use_fvp_player.dart | 13 +++-- lib/hooks/use_media_kit_player.dart | 14 +++-- lib/l10n/app_en.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/models/player.dart | 8 --- lib/models/store/app_state.dart | 1 + lib/pages/dialog/show_rate_dialog.dart | 65 ++++++++++++++++++++++ lib/pages/player/control_bar.dart | 76 ++++++++++++++++++++++++++ lib/pages/player/iris_player.dart | 23 +++++--- lib/store/use_app_store.dart | 6 ++ lib/widgets/custom_menu.dart | 24 ++++++++ 14 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 lib/pages/dialog/show_rate_dialog.dart create mode 100644 lib/widgets/custom_menu.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6dcfb..481480d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v1.3.4 + +### Changelog + +* The Android version allows you to set the screen orientation. +* Add playback speed button. +* Add hotkeys: Step forward `+`, Step backward `-`. + +### 更新日志 + +* 安卓版本可以设置屏幕方向。 +* 添加播放速度按钮。 +* 添加快捷键:帧进 `+`,帧退 `-`。 + + ## v1.3.3 ### Changelog diff --git a/README.md b/README.md index 13e97a7..1310c3f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ English | [中文](./README_CN.md) | `Ctrl + C` | Close currently media file | | `Ctrl + H` | Play history | | `Ctrl + P` | Settings | +| `+` | Step forward | +| `-` | Step backward | | `Enter` | Enter full screen / Exit full screen / Select file | | `F11` | Enter full screen / Exit full screen | | `Esc` | Exit current Menu / Go back / Exit full screen | diff --git a/README_CN.md b/README_CN.md index 408e402..f1e4b9a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -52,6 +52,8 @@ | `Ctrl + C` | 关闭当前媒体文件 | | `Ctrl + H` | 播放历史 | | `Ctrl + P` | 设置 | +| `+` | 帧进 | +| `-` | 帧退 | | `Enter` | 进入全屏 / 退出全屏 / 选择文件 | | `F11` | 进入全屏 / 退出全屏 | | `Esc` | 退出当前菜单 / 返回上一级 / 关闭全屏 | diff --git a/lib/hooks/use_fvp_player.dart b/lib/hooks/use_fvp_player.dart index b1ffada..af0ded4 100644 --- a/lib/hooks/use_fvp_player.dart +++ b/lib/hooks/use_fvp_player.dart @@ -18,6 +18,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; FvpPlayer useFvpPlayer(BuildContext context) { final autoPlay = useAppStore().select(context, (state) => state.autoPlay); + final rate = useAppStore().select(context, (state) => state.rate); final volume = useAppStore().select(context, (state) => state.volume); final isMuted = useAppStore().select(context, (state) => state.isMuted); final repeat = useAppStore().select(context, (state) => state.repeat); @@ -91,6 +92,7 @@ FvpPlayer useFvpPlayer(BuildContext context) { try { await controller.initialize(); await controller.setLooping(repeat == Repeat.one ? true : false); + await controller.setPlaybackSpeed(rate); await controller.setVolume(isMuted ? 0 : volume / 100); } catch (e) { logger('Error initializing player: $e'); @@ -115,8 +117,6 @@ FvpPlayer useFvpPlayer(BuildContext context) { useListenableSelector(controller, () => controller.value.position); final buffered = useListenableSelector(controller, () => controller.value.buffered); - final playbackSpeed = - useListenableSelector(controller, () => controller.value.playbackSpeed); final size = useListenableSelector(controller, () => controller.value.size); final isCompleted = useListenableSelector(controller, () => controller.value.isCompleted); @@ -187,6 +187,13 @@ FvpPlayer useFvpPlayer(BuildContext context) { return; }, [isCompleted]); + useEffect(() { + if (controller.value.isInitialized) { + controller.setPlaybackSpeed(rate); + } + return; + }, [rate]); + useEffect(() { if (controller.value.isInitialized) { controller.setVolume(isMuted ? 0 : volume / 100); @@ -299,7 +306,6 @@ FvpPlayer useFvpPlayer(BuildContext context) { aspect: aspect, width: size.width, height: size.height, - rate: playbackSpeed, play: play, pause: pause, backward: (seconds) => @@ -308,7 +314,6 @@ FvpPlayer useFvpPlayer(BuildContext context) { seekTo(Duration(seconds: position.inSeconds + seconds)), stepBackward: stepBackward, stepForward: stepForward, - updateRate: (value) => controller.setPlaybackSpeed(value), seekTo: seekTo, saveProgress: saveProgress, seeking: seeking.value, diff --git a/lib/hooks/use_media_kit_player.dart b/lib/hooks/use_media_kit_player.dart index a2f8653..5ac1c1b 100644 --- a/lib/hooks/use_media_kit_player.dart +++ b/lib/hooks/use_media_kit_player.dart @@ -27,12 +27,14 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { final controller = useMemoized(() => VideoController(player)); + final rate = useAppStore().select(context, (state) => state.rate); final volume = useAppStore().select(context, (state) => state.volume); final isMuted = useAppStore().select(context, (state) => state.isMuted); useEffect(() { () async { player.setSubtitleTrack(SubtitleTrack.no()); + player.setRate(rate); player.setVolume(isMuted ? 0 : volume.toDouble()); if (Platform.isAndroid) { @@ -95,7 +97,7 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { Duration duration = useStream(player.stream.duration).data ?? Duration.zero; Duration buffer = useStream(player.stream.buffer).data ?? Duration.zero; bool completed = useStream(player.stream.completed).data ?? false; - double rate = useStream(player.stream.rate).data ?? 1.0; + // double rate = useStream(player.stream.rate).data ?? 1.0; Track? track = useStream(player.stream.track).data; AudioTrack audio = @@ -227,6 +229,11 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { return null; }, [completed, repeat]); + useEffect(() { + player.setRate(rate); + return; + }, [rate]); + useEffect(() { player.setVolume(isMuted ? 0 : volume.toDouble()); return; @@ -309,9 +316,6 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { } } - Future updateRate(double value) async => - player.state.rate == value ? null : await player.setRate(value); - return MediaKitPlayer( player: player, controller: controller, @@ -326,7 +330,6 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { duration: duration, buffer: duration == Duration.zero ? Duration.zero : buffer, seeking: seeking.value, - rate: rate, aspect: videoParams?.aspect, width: videoParams?.w?.toDouble(), height: videoParams?.h?.toDouble(), @@ -339,7 +342,6 @@ MediaKitPlayer useMediaKitPlayer(BuildContext context) { forward: forward, stepBackward: stepBackward, stepForward: stepForward, - updateRate: updateRate, seekTo: seekTo, ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bf5e750..178086d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -67,6 +67,7 @@ "pause": "Pause", "play": "Play", "play_queue": "Play queue", + "playback_speed": "Playback speed", "player_backend": "Player backend", "port": "Port", "portrait": "Portrait", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a95a682..be11dc9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -67,6 +67,7 @@ "pause": "暂停", "play": "播放", "play_queue": "播放队列", + "playback_speed": "播放速度", "player_backend": "播放器后端", "port": "端口", "portrait": "纵向", diff --git a/lib/models/player.dart b/lib/models/player.dart index b8b0fd3..5067ff5 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -12,7 +12,6 @@ class MediaPlayer { final Duration duration; final Duration buffer; final bool seeking; - final double rate; final double? aspect; final double? width; final double? height; @@ -25,7 +24,6 @@ class MediaPlayer { final Future Function(int) forward; final Future Function() stepBackward; final Future Function() stepForward; - final Future Function(double) updateRate; final Future Function(Duration) seekTo; MediaPlayer({ @@ -36,7 +34,6 @@ class MediaPlayer { required this.duration, required this.buffer, required this.seeking, - required this.rate, required this.aspect, required this.width, required this.height, @@ -49,7 +46,6 @@ class MediaPlayer { required this.forward, required this.stepBackward, required this.stepForward, - required this.updateRate, required this.seekTo, }); } @@ -76,7 +72,6 @@ class MediaKitPlayer extends MediaPlayer { required super.duration, required super.buffer, required super.seeking, - required super.rate, required super.aspect, required super.width, required super.height, @@ -89,7 +84,6 @@ class MediaKitPlayer extends MediaPlayer { required super.forward, required super.stepBackward, required super.stepForward, - required super.updateRate, required super.seekTo, }); } @@ -108,7 +102,6 @@ class FvpPlayer extends MediaPlayer { required super.duration, required super.buffer, required super.seeking, - required super.rate, required super.aspect, required super.width, required super.height, @@ -121,7 +114,6 @@ class FvpPlayer extends MediaPlayer { required super.forward, required super.stepBackward, required super.stepForward, - required super.updateRate, required super.seekTo, }); } diff --git a/lib/models/store/app_state.dart b/lib/models/store/app_state.dart index 81d3d79..2511ff5 100644 --- a/lib/models/store/app_state.dart +++ b/lib/models/store/app_state.dart @@ -39,6 +39,7 @@ class AppState with _$AppState { @Default(false) bool shuffle, @Default(Repeat.none) Repeat repeat, @Default(BoxFit.contain) BoxFit fit, + @Default(1) double rate, @Default(80) int volume, @Default(false) bool isMuted, @Default(ThemeMode.system) ThemeMode themeMode, diff --git a/lib/pages/dialog/show_rate_dialog.dart b/lib/pages/dialog/show_rate_dialog.dart new file mode 100644 index 0000000..aae7e7f --- /dev/null +++ b/lib/pages/dialog/show_rate_dialog.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_zustand/flutter_zustand.dart'; +import 'package:iris/store/use_app_store.dart'; +import 'package:iris/utils/get_localizations.dart'; + +Future showRateDialog(BuildContext context) async => + await showDialog( + context: context, + builder: (context) => const RateDialog(), + ); + +class RateDialog extends HookWidget { + const RateDialog({super.key}); + + @override + Widget build(BuildContext context) { + final t = getLocalizations(context); + final rate = useAppStore().select(context, (state) => state.rate); + + void updateRate(double rate) { + useAppStore().updateRate(rate); + Navigator.pop(context); + } + + return AlertDialog( + title: Text(t.playback_speed), + content: SingleChildScrollView( + child: Column( + children: [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 3.0, + 4.0, + 5.0, + ] + .map( + (item) => ListTile( + title: Text('${item}X'), + leading: Radio( + value: item, + groupValue: rate, + onChanged: (_) => updateRate(item), + ), + onTap: () => updateRate(item), + ), + ) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: Text(t.cancel), + ), + ], + ); + } +} diff --git a/lib/pages/player/control_bar.dart b/lib/pages/player/control_bar.dart index 0af74ff..489d505 100644 --- a/lib/pages/player/control_bar.dart +++ b/lib/pages/player/control_bar.dart @@ -6,6 +6,7 @@ import 'package:iris/models/player.dart'; import 'package:iris/models/storages/local.dart'; import 'package:iris/models/store/app_state.dart'; import 'package:iris/pages/dialog/show_open_link_dialog.dart'; +import 'package:iris/pages/dialog/show_rate_dialog.dart'; import 'package:iris/pages/player/control_bar_slider.dart'; import 'package:iris/pages/history.dart'; import 'package:iris/pages/show_open_link_bottom_sheet.dart'; @@ -21,6 +22,7 @@ import 'package:iris/utils/resize_window.dart'; import 'package:iris/widgets/dark_theme.dart'; import 'package:iris/widgets/popup.dart'; import 'package:iris/pages/storage/storages.dart'; +import 'package:iris/widgets/custom_menu.dart'; import 'package:window_manager/window_manager.dart'; class ControlBar extends HookWidget { @@ -39,6 +41,7 @@ class ControlBar extends HookWidget { Widget build(BuildContext context) { final t = getLocalizations(context); + final rate = useAppStore().select(context, (state) => state.rate); final volume = useAppStore().select(context, (state) => state.volume); final isMuted = useAppStore().select(context, (state) => state.isMuted); final int playQueueLength = @@ -219,6 +222,66 @@ class ControlBar extends HookWidget { }, ), ), + if (MediaQuery.of(context).size.width > 600) + Builder( + builder: (context) => DarkTheme( + child: Tooltip( + message: t.playback_speed, + child: TextButton( + child: Text( + '${rate}X', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).brightness == + Brightness.dark + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.surface, + ), + ), + onPressed: () => showControlForHover( + showCustomMenu( + context, + items: [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 3.0, + 4.0, + 5.0, + ] + .map( + (item) => PopupMenuItem( + child: Text( + '${item}X', + style: TextStyle( + color: item == rate + ? Theme.of(context) + .colorScheme + .primary + : null, + fontWeight: item == rate + ? FontWeight.bold + : FontWeight.w100, + ), + ), + onTap: () async { + showControl(); + useAppStore().updateRate(item); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ), + ), if (MediaQuery.of(context).size.width < 600) Builder( builder: (context) => DarkTheme( @@ -520,6 +583,19 @@ class ControlBar extends HookWidget { useAppStore().toggleFit(); }, ), + if (MediaQuery.of(context).size.width <= 460) + PopupMenuItem( + child: ListTile( + mouseCursor: SystemMouseCursors.click, + leading: const Icon( + Icons.speed_rounded, + size: 20, + ), + title: Text('${t.playback_speed}: ${rate}X'), + ), + onTap: () => + showControlForHover(showRateDialog(context)), + ), if (MediaQuery.of(context).size.width < 420) PopupMenuItem( child: ListTile( diff --git a/lib/pages/player/iris_player.dart b/lib/pages/player/iris_player.dart index 44ab555..9813773 100644 --- a/lib/pages/player/iris_player.dart +++ b/lib/pages/player/iris_player.dart @@ -79,6 +79,7 @@ class IrisPlayer extends HookWidget { final volume = useVolume(isRightGesture.value); final t = getLocalizations(context); + final rate = useAppStore().select(context, (state) => state.rate); final shuffle = useAppStore().select(context, (state) => state.shuffle); final fit = useAppStore().select(context, (state) => state.fit); final autoResize = @@ -604,29 +605,33 @@ class IrisPlayer extends HookWidget { onLongPressStart: (details) { if (isTouch.value && player.isPlaying == true) { isLongPress.value = true; - player.updateRate(2.0); + useAppStore().updateRate(2.0); } }, onLongPressMoveUpdate: (details) { int fast = (details.offsetFromOrigin.dx / 50).toInt(); if (fast >= 1) { - player + useAppStore() .updateRate(fast > 4 ? 5.0 : (1 + fast).toDouble()); } else if (fast <= -1) { - player.updateRate(fast < -3 + useAppStore().updateRate(fast < -3 ? 0.25 : (1 - 0.25 * fast.abs()).toDouble()); } }, onLongPressEnd: (details) { - player.updateRate(1.0); - isTouch.value = false; + if (isLongPress.value) { + useAppStore().updateRate(1.0); + } isLongPress.value = false; + isTouch.value = false; }, onLongPressCancel: () { - player.updateRate(1.0); - isTouch.value = false; + if (isLongPress.value) { + useAppStore().updateRate(1.0); + } isLongPress.value = false; + isTouch.value = false; }, onPanStart: (details) async { if (isDesktop && @@ -776,7 +781,7 @@ class IrisPlayer extends HookWidget { bottom: 0, child: Audio(cover: cover)), // 播放速度 - if (player.rate != 1.0) + if (rate != 1.0 && isLongPress.value) Positioned( left: 0, top: 0, @@ -803,7 +808,7 @@ class IrisPlayer extends HookWidget { ), const SizedBox(width: 10), Text( - player.rate.toString(), + rate.toString(), style: const TextStyle( color: Colors.white, fontSize: 20, diff --git a/lib/store/use_app_store.dart b/lib/store/use_app_store.dart index b388446..859ed4b 100644 --- a/lib/store/use_app_store.dart +++ b/lib/store/use_app_store.dart @@ -62,6 +62,12 @@ class AppStore extends PersistentStore { await save(state); } + Future updateRate(double value) async { + logger('updateRate: $value'); + set(state.copyWith(rate: value)); + await save(state); + } + Future updateVolume(int volume) async { set(state.copyWith( volume: volume < 0 diff --git a/lib/widgets/custom_menu.dart b/lib/widgets/custom_menu.dart new file mode 100644 index 0000000..336f6dd --- /dev/null +++ b/lib/widgets/custom_menu.dart @@ -0,0 +1,24 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:popover/popover.dart'; + +Future showCustomMenu( + BuildContext context, { + required List items, + double? width = 128, +}) async => + showPopover( + context: context, + bodyBuilder: (context) => SingleChildScrollView( + child: Column(children: items), + ), + width: width, + height: min(MediaQuery.of(context).size.height * 0.8, + items.length * kMinInteractiveDimension), + radius: 16, + arrowHeight: 0, + arrowWidth: 0, + backgroundColor: + Theme.of(context).colorScheme.surfaceDim.withValues(alpha: 0.9), + barrierColor: Colors.transparent, + );