mirror of
https://github.com/nini22P/iris.git
synced 2026-05-23 02:20:28 +08:00
feat: add playback speed button
This commit is contained in:
15
CHANGELOG.md
15
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
| `Ctrl + C` | 关闭当前媒体文件 |
|
||||
| `Ctrl + H` | 播放历史 |
|
||||
| `Ctrl + P` | 设置 |
|
||||
| `+` | 帧进 |
|
||||
| `-` | 帧退 |
|
||||
| `Enter` | 进入全屏 / 退出全屏 / 选择文件 |
|
||||
| `F11` | 进入全屏 / 退出全屏 |
|
||||
| `Esc` | 退出当前菜单 / 返回上一级 / 关闭全屏 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"pause": "Pause",
|
||||
"play": "Play",
|
||||
"play_queue": "Play queue",
|
||||
"playback_speed": "Playback speed",
|
||||
"player_backend": "Player backend",
|
||||
"port": "Port",
|
||||
"portrait": "Portrait",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"pause": "暂停",
|
||||
"play": "播放",
|
||||
"play_queue": "播放队列",
|
||||
"playback_speed": "播放速度",
|
||||
"player_backend": "播放器后端",
|
||||
"port": "端口",
|
||||
"portrait": "纵向",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
lib/pages/dialog/show_rate_dialog.dart
Normal file
65
lib/pages/dialog/show_rate_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
24
lib/widgets/custom_menu.dart
Normal file
24
lib/widgets/custom_menu.dart
Normal 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,
|
||||
);
|
||||
Reference in New Issue
Block a user