diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d4ceef..b710ee7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -48,6 +48,7 @@ }, "clearScreen": "Clear screen", "exitClearScreen": "Exit clear screen", + "playbackSpeed": "Playback speed", "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6876854..6071753 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -48,6 +48,7 @@ }, "clearScreen": "Bersihkan layar", "exitClearScreen": "Keluar dari layar bersih", + "playbackSpeed": "Kecepatan putar", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 42042f4..63446d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a09195b..f777db2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a1fadb8..a417c57 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c58a9a5..ea78f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -136,6 +136,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get exitClearScreen => '退出清屏'; + @override + String get playbackSpeed => '倍速'; + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 42ebcaf..5267de7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -48,6 +48,7 @@ }, "clearScreen": "清屏", "exitClearScreen": "退出清屏", + "playbackSpeed": "倍速", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 7362e1d..321a3b6 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -68,6 +68,7 @@ class PlayerLogic extends ChangeNotifier { List 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 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 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 showComments(BuildContext context, Drama drama) async { session.setOverlayOpen(true); await session.playbackSettled; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index aae3c20..a333628 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -72,6 +72,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; + double _playbackSpeed = 1.0; @override void initState() { @@ -384,6 +385,8 @@ class _PlayerPageState extends State 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 with WidgetsBindingObserver { ], ); } + + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + 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 _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, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b27f2a1..69c5a66 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -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')));