feat: add playback speed button

This commit is contained in:
22
2025-02-23 20:09:08 +08:00
parent 34457db0d8
commit 324ba10edf
14 changed files with 224 additions and 27 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -52,6 +52,8 @@
| `Ctrl + C` | 关闭当前媒体文件 |
| `Ctrl + H` | 播放历史 |
| `Ctrl + P` | 设置 |
| `+` | 帧进 |
| `-` | 帧退 |
| `Enter` | 进入全屏 / 退出全屏 / 选择文件 |
| `F11` | 进入全屏 / 退出全屏 |
| `Esc` | 退出当前菜单 / 返回上一级 / 关闭全屏 |

View File

@@ -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,

View File

@@ -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<void> 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,
);
}

View File

@@ -67,6 +67,7 @@
"pause": "Pause",
"play": "Play",
"play_queue": "Play queue",
"playback_speed": "Playback speed",
"player_backend": "Player backend",
"port": "Port",
"portrait": "Portrait",

View File

@@ -67,6 +67,7 @@
"pause": "暂停",
"play": "播放",
"play_queue": "播放队列",
"playback_speed": "播放速度",
"player_backend": "播放器后端",
"port": "端口",
"portrait": "纵向",

View File

@@ -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<void> Function(int) forward;
final Future<void> Function() stepBackward;
final Future<void> Function() stepForward;
final Future<void> Function(double) updateRate;
final Future<void> 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,
});
}

View File

@@ -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,

View File

@@ -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<void> showRateDialog(BuildContext context) async =>
await showDialog<void>(
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: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'Cancel'),
child: Text(t.cancel),
),
],
);
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -62,6 +62,12 @@ class AppStore extends PersistentStore<AppState> {
await save(state);
}
Future<void> updateRate(double value) async {
logger('updateRate: $value');
set(state.copyWith(rate: value));
await save(state);
}
Future<void> updateVolume(int volume) async {
set(state.copyWith(
volume: volume < 0

View File

@@ -0,0 +1,24 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:popover/popover.dart';
Future<void> showCustomMenu(
BuildContext context, {
required List<Widget> 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,
);