feat(player): 倍速改为底部弹窗选择

This commit is contained in:
Zhou Weidong committed 2026-10-19 17:32:17 +08:00
1 parent 5ca5846a26
commit 3a7e0bc4b7
10 files changed
+194 -6

No files matched your search

+1
View File
@@ -48,6 +48,7 @@
},
"clearScreen": "Clear screen",
"exitClearScreen": "Exit clear screen",
"playbackSpeed": "Playback speed",
"minutesAgo": "{count} min ago",
"@minutesAgo": {
"placeholders": {
+1
View File
@@ -48,6 +48,7 @@
},
"clearScreen": "Bersihkan layar",
"exitClearScreen": "Keluar dari layar bersih",
"playbackSpeed": "Kecepatan putar",
"minutesAgo": "{count} mnt lalu",
"@minutesAgo": {
"placeholders": {
+6
View File
@@ -352,6 +352,12 @@ abstract class AppLocalizations {
/// **'Exit clear screen'**
String get exitClearScreen;
/// No description provided for @playbackSpeed.
///
/// In en, this message translates to:
/// **'Playback speed'**
String get playbackSpeed;
/// No description provided for @minutesAgo.
///
/// In en, this message translates to:
+3
View File
@@ -139,6 +139,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get exitClearScreen => 'Exit clear screen';
@override
String get playbackSpeed => 'Playback speed';
@override
String minutesAgo(int count) {
return '$count min ago';
+3
View File
@@ -139,6 +139,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get exitClearScreen => 'Keluar dari layar bersih';
@override
String get playbackSpeed => 'Kecepatan putar';
@override
String minutesAgo(int count) {
return '$count mnt lalu';
+3
View File
@@ -136,6 +136,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get exitClearScreen => '退出清屏';
@override
String get playbackSpeed => '倍速';
@override
String minutesAgo(int count) {
return '$count 分钟前';
+1
View File
@@ -48,6 +48,7 @@
},
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"playbackSpeed": "倍速",
"minutesAgo": "{count} 分钟前",
"@minutesAgo": {
"placeholders": {
+12
View File
@@ -68,6 +68,7 @@ class PlayerLogic extends ChangeNotifier {
List<Episode> sessionEpisodes = const [];
int pageRequest = 0;
bool muted = false;
double playbackSpeed = 1.0;
bool stoppedAtFeedEnd = false;
VideoPlayerPort? scrubbingPlayer;
bool pip = false;
@@ -341,6 +342,7 @@ class PlayerLogic extends ChangeNotifier {
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
try {
await player.controller.setPlaybackSpeed(playbackSpeed);
await pendingWrite;
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
@@ -360,6 +362,7 @@ class PlayerLogic extends ChangeNotifier {
final index = session.currentIndex;
final drama = queue.entryAt(index).drama;
await player.controller.setVolume(muted ? 0 : 1);
await player.controller.setPlaybackSpeed(playbackSpeed);
if (session.active != player) return;
trackedPlayer = player;
trackedEpisode = episode;
@@ -787,6 +790,15 @@ class PlayerLogic extends ChangeNotifier {
notify();
}
Future<void> setPlaybackSpeed(double value) async {
playbackSpeed = value;
final active = session.active;
if (active is VideoPlayerPort && active.controller.value.isInitialized) {
await active.controller.setPlaybackSpeed(value);
}
notify();
}
Future<void> showComments(BuildContext context, Drama drama) async {
session.setOverlayOpen(true);
await session.playbackSettled;
+102 -6
View File
@@ -72,6 +72,7 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
@override
void initState() {
@@ -384,6 +385,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
? () => unawaited(logic.playNextEpisode())
: null,
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
controlsVisible: _chromeVisible,
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
@@ -469,6 +472,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
],
);
}
Future<void> _openSpeedSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
);
if (selected == null || !mounted) return;
setState(() => _playbackSpeed = selected);
await logic.setPlaybackSpeed(selected);
}
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
@@ -602,6 +617,8 @@ class _PlaybackOverlay extends StatefulWidget {
required this.liked,
required this.saved,
required this.playing,
required this.playbackSpeed,
required this.onSpeedTap,
required this.controlsVisible,
required this.onTogglePlay,
required this.onClear,
@@ -626,6 +643,8 @@ class _PlaybackOverlay extends StatefulWidget {
final bool liked;
final bool saved;
final bool playing;
final double playbackSpeed;
final VoidCallback onSpeedTap;
final bool controlsVisible;
final VoidCallback onTogglePlay;
final VoidCallback onClear;
@@ -667,14 +686,28 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
Expanded(
child: Stack(
children: [
const Positioned(
Positioned(
right: 10,
bottom: 14,
child: Text(
'1.0x',
style: TextStyle(
color: Color(0xFFDDDDDD),
fontSize: 9,
child: Material(
color: Colors.transparent,
child: InkWell(
key: const Key('player-speed'),
onTap: widget.onSpeedTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Text(
_speedLabel(widget.playbackSpeed),
style: const TextStyle(
color: Color(0xFFDDDDDD),
fontSize: 9,
),
),
),
),
),
),
@@ -920,6 +953,69 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
}
}
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
String _speedLabel(double value) => '${value.toString()}x';
class _PlaybackSpeedSheet extends StatelessWidget {
const _PlaybackSpeedSheet({required this.selected});
final double selected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SafeArea(
child: Column(
key: const Key('player-speed-sheet'),
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.playbackSpeed,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
for (final value in _speedOptions)
ListTile(
key: Key('player-speed-${value.toString()}'),
dense: true,
tileColor: value == selected
? AppColors.blue.withValues(alpha: 0.12)
: null,
title: Text(
_speedLabel(value),
style: TextStyle(
color: value == selected ? AppColors.blue : AppColors.cream,
fontSize: 13,
fontWeight: value == selected
? FontWeight.w700
: FontWeight.w500,
),
),
trailing: value == selected
? const Icon(
Icons.check_rounded,
color: AppColors.blue,
size: 18,
)
: null,
onTap: () => Navigator.pop(context, value),
),
],
),
);
}
}
class _TopAction extends StatelessWidget {
const _TopAction({
super.key,
+62
View File
@@ -247,6 +247,68 @@ void main() {
expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing);
});
testWidgets('should_open_speed_sheet_and_apply_selected_speed', (
tester,
) async {
await _pumpApp(tester);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail-cta')));
await tester.pumpAndSettle();
expect(find.text('1.0x'), findsOneWidget);
await tester.tap(find.byKey(const Key('player-speed')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget);
for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) {
expect(find.byKey(Key('player-speed-$value')), findsOneWidget);
}
await tester.tap(find.byKey(const Key('player-speed-1.5')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-sheet')), findsNothing);
expect(find.text('1.5x'), findsOneWidget);
});
testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', (
tester,
) async {
await _pumpApp(tester);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail-cta')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-speed')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-speed-1.5')));
await tester.pumpAndSettle();
expect(find.text('1.5x'), findsOneWidget);
await tester.tap(find.byKey(const Key('player-clear-screen')));
await tester.pump();
expect(find.byKey(const Key('player-speed')), findsNothing);
await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
await tester.pump();
expect(find.byKey(const Key('player-speed')), findsOneWidget);
expect(find.text('1.5x'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle();
expect(find.text('1.5x'), findsOneWidget);
});
testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-home')));