diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a22ee33..3d18911 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -53,6 +53,12 @@ "clearScreen": "Clear screen", "exitClearScreen": "Exit clear screen", "playbackSpeed": "Playback speed", + "speedBoosting": "{speed}x speed", + "@speedBoosting": { + "placeholders": { + "speed": {"type": "String"} + } + }, "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c4deb62..01110ff 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -53,6 +53,7 @@ "clearScreen": "Bersihkan layar", "exitClearScreen": "Keluar dari layar bersih", "playbackSpeed": "Kecepatan putar", + "speedBoosting": "{speed}x kecepatan", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 425f8b8..909b346 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -382,6 +382,12 @@ abstract class AppLocalizations { /// **'Playback speed'** String get playbackSpeed; + /// No description provided for @speedBoosting. + /// + /// In en, this message translates to: + /// **'{speed}x speed'** + String speedBoosting(String speed); + /// 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 f9512b8..e2f12e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -154,6 +154,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get playbackSpeed => 'Playback speed'; + @override + String speedBoosting(String speed) { + return '${speed}x 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 fad8f89..da33e71 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -154,6 +154,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get playbackSpeed => 'Kecepatan putar'; + @override + String speedBoosting(String speed) { + return '${speed}x kecepatan'; + } + @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 1fb5965..a8703df 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -151,6 +151,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get playbackSpeed => '倍速'; + @override + String speedBoosting(String speed) { + return '$speed 倍速快进中'; + } + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 65d93b3..a1ade16 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -53,6 +53,7 @@ "clearScreen": "清屏", "exitClearScreen": "退出清屏", "playbackSpeed": "倍速", + "speedBoosting": "{speed} 倍速快进中", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3eeb45d..d7f0652 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -36,6 +36,8 @@ import '../../core/data/repositories/playback_repository.dart'; part 'player_view_actions.dart'; +const double _boostPlaybackSpeed = 2.0; + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -82,6 +84,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; + bool _boosting = false; + double? _speedBeforeBoost; + bool? _chromeBeforeBoost; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -130,6 +135,34 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _chromeVisible = false); } + /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 + void _startBoost() { + if (_boosting) return; + setState(() { + _boosting = true; + _speedBeforeBoost = _playbackSpeed; + _chromeBeforeBoost = _chromeVisible; + _playbackSpeed = _boostPlaybackSpeed; + _chromeVisible = false; + }); + unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); + } + + /// 松手或手势取消:恢复长按前的倍速与清屏状态。 + void _endBoost() { + if (!_boosting) return; + final speed = _speedBeforeBoost ?? _playbackSpeed; + final chrome = _chromeBeforeBoost ?? true; + setState(() { + _boosting = false; + _speedBeforeBoost = null; + _chromeBeforeBoost = null; + _playbackSpeed = speed; + _chromeVisible = chrome; + }); + unawaited(logic.setPlaybackSpeed(speed)); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -397,6 +430,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, + boosting: _boosting, ), actions: PlayerOverlayActions( onBack: widget.embedded @@ -419,6 +453,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, + onLongPressCenter: () => _openMore(context, episode), + onBoostStart: _startBoost, + onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 6b404c8..dd64973 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -16,6 +16,8 @@ const double _topButtonSize = 36; const double _topBarHeight = 58; const double _railButtonSize = 34; const double _railGap = 18; +const double _boostHintIconSize = 16; +const double _boostHintFontSize = 13; const List _playerTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), ]; @@ -45,10 +47,16 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: actions.onTogglePlay, + child: LayoutBuilder( + builder: (context, constraints) => GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: actions.onTogglePlay, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => actions.onBoostEnd(), + onLongPressCancel: actions.onBoostEnd, + ), ), ), _top(context), @@ -120,6 +128,17 @@ class PlayerPlaybackOverlay extends StatelessWidget { ); } + /// 长按分区:中间 1/3 弹更多,左右各 1/3 开始加速。 + void _handleLongPressStart(LongPressStartDetails details, double width) { + final third = width / 3; + final dx = details.localPosition.dx; + if (dx < third || dx > width - third) { + actions.onBoostStart(); + return; + } + actions.onLongPressCenter(); + } + Widget _rail(BuildContext context) { final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( @@ -149,6 +168,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (controlsVisible) _bottomInfo(context), + if (data.boosting) _boostHint(), _bottomProgress(), _bottomActions(), ], @@ -156,6 +176,35 @@ class PlayerPlaybackOverlay extends StatelessWidget { ); } + /// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。 + Widget _boostHint() { + return Padding( + key: const Key('player-speed-boost-hint'), + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: _boostHintIconSize, + shadows: _playerTextShadows, + ), + const SizedBox(width: 4), + Text( + data.l10n.speedBoosting(data.playbackSpeed.toStringAsFixed(1)), + style: const TextStyle( + color: AppColors.blue, + fontSize: _boostHintFontSize, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ), + ), + ], + ), + ); + } + Widget _bottomInfo(BuildContext context) { final series = localizedDramaTitle(data.l10n, data.drama); final synopsis = localizedDramaSynopsis(data.l10n, data.drama); diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 4849fa3..0bdc23e 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -22,6 +22,7 @@ class PlayerOverlayData { required this.playing, required this.playbackSpeed, required this.totalEpisodes, + required this.boosting, this.authorNote = '', }); @@ -61,6 +62,9 @@ class PlayerOverlayData { /// 可见集数量。 final int totalEpisodes; + /// 是否处于长按加速态。 + final bool boosting; + /// 作者声明;没有后端字段时为空。 final String authorNote; } @@ -83,6 +87,9 @@ class PlayerOverlayActions { required this.onMore, required this.onScrubStart, required this.onSeek, + required this.onLongPressCenter, + required this.onBoostStart, + required this.onBoostEnd, }); /// 返回上一页。 @@ -126,4 +133,13 @@ class PlayerOverlayActions { /// 提交进度跳转。 final Future Function(Duration)? onSeek; + + /// 长按画面中间:弹出更多面板。 + final VoidCallback onLongPressCenter; + + /// 长按画面两侧:开始加速。 + final VoidCallback onBoostStart; + + /// 长按结束或取消:结束加速。 + final VoidCallback onBoostEnd; } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7ce34cd..f33334f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -152,6 +152,102 @@ void main() { expect(find.byTooltip('Clear screen'), findsOneWidget); }); + testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + final gesture = await tester.startGesture(const Offset(195, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-close'))); + await tester.pumpAndSettle(); + // 长按弹更多不改播放状态,仍处于播放中。 + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_boost_speed_on_side_long_press_and_restore', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + expect(find.byKey(const Key('player-progress')), findsOneWidget); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('2.0x'), + ), + findsOneWidget, + ); + // 加速与清屏都不改变播放状态。 + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('1.0x'), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + // 右侧 1/3 同样触发加速。 + final rightSide = await tester.startGesture(const Offset(330, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + await rightSide.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + }); + + testWidgets('should_keep_previous_clear_state_after_boost', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + + final gesture = await tester.startGesture(const Offset(60, 300)); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pump(); + expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + + // 本来已清屏,松手后仍保持清屏,只恢复倍速与提示。 + expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsNothing); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('player-speed-entry')), + matching: find.text('1.0x'), + ), + findsOneWidget, + ); + }); + testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); await _openSeriesPlayer(tester);