diff --git a/.maestro/regression_flows/04_tv_player_keys.yaml b/.maestro/regression_flows/04_tv_player_keys.yaml index 76227c2a..4187f227 100644 --- a/.maestro/regression_flows/04_tv_player_keys.yaml +++ b/.maestro/regression_flows/04_tv_player_keys.yaml @@ -23,17 +23,40 @@ tags: - extendedWaitUntil: notVisible: "Pause" timeout: 10000 +# Hardware transport keys drive playback without raising the chrome (#1676): +# the player answers with a centred disc (pause) or a side readout (seek), never the +# full chrome, so subtitles stay readable. +# Pause first — it also freezes the clip, so the seek assertions below cannot +# race the end of a short fixture item. - pressKey: "Remote Media Play Pause" - extendedWaitUntil: - visible: "Play" + visible: "Paused" + timeout: 10000 +- assertNotVisible: "(?s)^Play$" +# D-pad seeking reports through the skip badge, never the scrub bar, and +# consecutive presses in one direction stack into a running total. +- pressKey: "Remote Dpad Right" +- extendedWaitUntil: + visible: "(?s)^Seek forward 10 seconds$" + timeout: 10000 +- assertNotVisible: "(?s)^Play$" +- pressKey: "Remote Dpad Right" +- extendedWaitUntil: + visible: "(?s)^Seek forward 20 seconds$" timeout: 10000 - pressKey: "Remote Media Play Pause" - extendedWaitUntil: - visible: "Pause" + visible: "Playing" + timeout: 10000 +- assertNotVisible: "(?s)^Pause$" +# Select stays the deliberate way to bring the chrome back. +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "(?s)^(Play|Pause)$" timeout: 10000 - pressKey: "back" - extendedWaitUntil: - notVisible: "Pause" + notVisible: "(?s)^Pause$" timeout: 10000 - assertNotVisible: "Overview" - pressKey: "back" diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 3510dcb9..f5079534 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -493,6 +493,8 @@ "playNext": "Növbətini oynat", "playButton": "Oynat", "pauseButton": "Fasilə", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Oynatma idarəetmələrini göstər", "hidePlaybackControls": "Oynatma idarəetmələrini gizlət", "seekBackwardButton": "${seconds} saniyə geri sar", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 119fbeb5..bea3776a 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -493,6 +493,8 @@ "playNext": "Пусни следващото", "playButton": "Пусни", "pauseButton": "Пауза", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Покажи контролите за възпроизвеждане", "hidePlaybackControls": "Скрий контролите за възпроизвеждане", "seekBackwardButton": "Превърти назад ${seconds} секунди", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 76ae2945..2ad9c712 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -493,6 +493,8 @@ "playNext": "Afspil næste", "playButton": "Afspil", "pauseButton": "Pause", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Vis afspilningsknapper", "hidePlaybackControls": "Skjul afspilningsknapper", "seekBackwardButton": "Spol ${seconds} sekunder tilbage", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4aa462d0..c8fd7993 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -493,6 +493,8 @@ "playNext": "Nächstes abspielen", "playButton": "Abspielen", "pauseButton": "Pause", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Wiedergabesteuerung anzeigen", "hidePlaybackControls": "Wiedergabesteuerung ausblenden", "seekBackwardButton": "${seconds} Sekunden zurück", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 9e483524..fc0c74f2 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -493,6 +493,8 @@ "playNext": "Play Next", "playButton": "Play", "pauseButton": "Pause", + "playbackPaused": "Paused", + "playbackResumed": "Playing", "showPlaybackControls": "Show playback controls", "hidePlaybackControls": "Hide playback controls", "seekBackwardButton": "Seek backward ${seconds} seconds", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 232feeb1..2ae0cf37 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -493,6 +493,8 @@ "playNext": "Reproducir siguiente", "playButton": "Reproducir", "pauseButton": "Pausar", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Mostrar controles de reproducción", "hidePlaybackControls": "Ocultar controles de reproducción", "seekBackwardButton": "Retroceder ${seconds} segundos", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 3dc10b61..d31be3fa 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -493,6 +493,8 @@ "playNext": "Lire l'épisode suivant", "playButton": "Lire", "pauseButton": "Pause", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Afficher les commandes de lecture", "hidePlaybackControls": "Masquer les commandes de lecture", "seekBackwardButton": "Reculer de ${seconds} secondes", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 9b0dc6ee..da0534c5 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -493,6 +493,8 @@ "playNext": "Következő lejátszása", "playButton": "Lejátszás", "pauseButton": "Szünet", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Lejátszásvezérlők megjelenítése", "hidePlaybackControls": "Lejátszásvezérlők elrejtése", "seekBackwardButton": "Tekerés hátra ${seconds} másodperccel", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f0170790..08b928bb 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -493,6 +493,8 @@ "playNext": "Riproduci il successivo", "playButton": "Riproduci", "pauseButton": "Pausa", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Mostra i controlli di riproduzione", "hidePlaybackControls": "Nascondi i controlli di riproduzione", "seekBackwardButton": "Riavvolgi di ${seconds} secondi", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 8bf0c0d1..0092e280 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -493,6 +493,8 @@ "playNext": "次を再生", "playButton": "再生", "pauseButton": "一時停止", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "再生コントロールを表示", "hidePlaybackControls": "再生コントロールを非表示", "seekBackwardButton": "${seconds}秒戻る", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index a013d1ac..02366826 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -493,6 +493,8 @@ "playNext": "Келесіні ойнату", "playButton": "Ойнату", "pauseButton": "Кідірту", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Басқару элементтерін көрсету", "hidePlaybackControls": "Басқару элементтерін жасыру", "seekBackwardButton": "${seconds} секунд артқа айналдыру", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 91bcb7b6..e89bd2ee 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -493,6 +493,8 @@ "playNext": "다음 재생", "playButton": "재생", "pauseButton": "일시정지", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "재생 컨트롤 표시", "hidePlaybackControls": "재생 컨트롤 숨기기", "seekBackwardButton": "${seconds}초 뒤로 이동", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index f8332670..6edef1b8 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -493,6 +493,8 @@ "playNext": "Spill neste", "playButton": "Spill av", "pauseButton": "Pause", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Vis avspillingskontroller", "hidePlaybackControls": "Skjul avspillingskontroller", "seekBackwardButton": "Spol tilbake ${seconds} sekunder", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index c2202230..f93f64a6 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -493,6 +493,8 @@ "playNext": "Volgende afspelen", "playButton": "Afspelen", "pauseButton": "Pauzeren", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Afspeelbediening tonen", "hidePlaybackControls": "Afspeelbediening verbergen", "seekBackwardButton": "${seconds} seconden terugspoelen", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 6a407864..0a7230ea 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -493,6 +493,8 @@ "playNext": "Odtwórz następny", "playButton": "Odtwórz", "pauseButton": "Pauza", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Pokaż elementy sterujące odtwarzaniem", "hidePlaybackControls": "Ukryj elementy sterujące odtwarzaniem", "seekBackwardButton": "Przewiń do tyłu o ${seconds} sekund", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index eca4f292..d40fb056 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -493,6 +493,8 @@ "playNext": "Reproduzir Próximo", "playButton": "Reproduzir", "pauseButton": "Pausar", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Mostrar controles de reprodução", "hidePlaybackControls": "Ocultar controles de reprodução", "seekBackwardButton": "Retroceder ${seconds} segundos", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 4a443abd..0c9187ef 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -493,6 +493,8 @@ "playNext": "Следующее", "playButton": "Воспроизвести", "pauseButton": "Пауза", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Показать элементы управления воспроизведением", "hidePlaybackControls": "Скрыть элементы управления воспроизведением", "seekBackwardButton": "Перемотать назад на ${seconds} с", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index bd8a7b87..e1d56281 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32681 (1485 per locale) +/// Strings: 32683 (1485 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b893be2a..4a1cd731 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1541,6 +1541,12 @@ class Translations$videoControls$en { /// en: 'Pause' String get pauseButton => 'Pause'; + /// en: 'Paused' + String get playbackPaused => 'Paused'; + + /// en: 'Playing' + String get playbackResumed => 'Playing'; + /// en: 'Show playback controls' String get showPlaybackControls => 'Show playback controls'; @@ -5662,6 +5668,8 @@ extension on Translations { 'videoControls.playNext' => 'Play Next', 'videoControls.playButton' => 'Play', 'videoControls.pauseButton' => 'Pause', + 'videoControls.playbackPaused' => 'Paused', + 'videoControls.playbackResumed' => 'Playing', 'videoControls.showPlaybackControls' => 'Show playback controls', 'videoControls.hidePlaybackControls' => 'Hide playback controls', 'videoControls.seekBackwardButton' => ({required Object seconds}) => 'Seek backward ${seconds} seconds', @@ -5709,10 +5717,10 @@ extension on Translations { 'videoControls.chapters' => 'Chapters', 'videoControls.noChaptersAvailable' => 'No chapters available', 'videoControls.queue' => 'Queue', - 'videoControls.noQueueItems' => 'No items in queue', - 'videoControls.searchSubtitles' => 'Search Subtitles', _ => null, } ?? switch (path) { + 'videoControls.noQueueItems' => 'No items in queue', + 'videoControls.searchSubtitles' => 'Search Subtitles', 'videoControls.language' => 'Language', 'videoControls.noSubtitlesFound' => 'No subtitles found', 'videoControls.subtitleDownloaded' => 'Subtitle downloaded', @@ -6223,10 +6231,10 @@ extension on Translations { 'watchTogether.joinWatchSession' => 'Join Watch Session', 'watchTogether.enterCodeHint' => 'Enter 5-character code', 'watchTogether.pasteFromClipboard' => 'Paste from clipboard', - 'watchTogether.pleaseEnterCode' => 'Please enter a session code', - 'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters', _ => null, } ?? switch (path) { + 'watchTogether.pleaseEnterCode' => 'Please enter a session code', + 'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters', 'watchTogether.joinInstructions' => 'Enter the host\'s session code to join.', 'watchTogether.failedToCreate' => 'Failed to create session', 'watchTogether.failedToJoin' => 'Failed to join session', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 1073c543..5bb65aed 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -493,6 +493,8 @@ "playNext": "Spela nästa", "playButton": "Spela", "pauseButton": "Pausa", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Visa uppspelningskontroller", "hidePlaybackControls": "Dölj uppspelningskontroller", "seekBackwardButton": "Spola bakåt ${seconds} sekunder", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 34c849da..cf814789 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -493,6 +493,8 @@ "playNext": "Sonrakini Oynat", "playButton": "Oynat", "pauseButton": "Duraklat", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Oynatma kontrollerini göster", "hidePlaybackControls": "Oynatma kontrollerini gizle", "seekBackwardButton": "${seconds} saniye geri sar", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index f23724d1..77669650 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -493,6 +493,8 @@ "playNext": "Keyingisini ijro etish", "playButton": "Ijro etish", "pauseButton": "Pauza", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "Boshqaruv tugmalarini koʻrsatish", "hidePlaybackControls": "Boshqaruv tugmalarini yashirish", "seekBackwardButton": "${seconds} soniya orqaga oʻtkazish", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 60c5be92..85b1f730 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -493,6 +493,8 @@ "playNext": "播放下一集", "playButton": "播放", "pauseButton": "暫停", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "顯示播放控制項", "hidePlaybackControls": "隱藏播放控制項", "seekBackwardButton": "後退 ${seconds} 秒", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index ea34f518..694be396 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -493,6 +493,8 @@ "playNext": "播放下一集", "playButton": "播放", "pauseButton": "暂停", + "playbackPaused": "", + "playbackResumed": "", "showPlaybackControls": "显示播放控制项", "hidePlaybackControls": "隐藏播放控制项", "seekBackwardButton": "快退 ${seconds} 秒", diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 303b454f..b694d3bd 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -308,7 +308,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, onSeekRequested: _seekPlayback, - onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!), + onPlayPauseRequested: _handleControlsTransport, onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, onReachedEnd: ({skipAutoPlayCountdown = false}) => diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 85358644..a50c4068 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -479,6 +479,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { unawaited(_seekBackForRewind(currentPlayer)); unawaited(_playWithPlaybackIntent(currentPlayer)); _wasPlayingBeforeInactive = false; + _announceTransportCommand(willPlay: true); _updateMediaControlsPlaybackState(); }, onPause: () { @@ -489,6 +490,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } unawaited(_pauseWithPlaybackIntent(currentPlayer)); + _announceTransportCommand(willPlay: false); _updateMediaControlsPlaybackState(); }, onTogglePlayPause: () { @@ -496,10 +498,12 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (currentPlayer == null) return; if (currentPlayer.state.isActive) { unawaited(_pauseWithPlaybackIntent(currentPlayer)); + _announceTransportCommand(willPlay: false); } else { unawaited(_seekBackForRewind(currentPlayer)); unawaited(_playWithPlaybackIntent(currentPlayer)); _wasPlayingBeforeInactive = false; + _announceTransportCommand(willPlay: true); } _updateMediaControlsPlaybackState(); }, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 84fbde0f..777d9645 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1705,13 +1705,30 @@ class VideoPlayerScreenState extends State with WidgetsBindin 'Apple TV remote play/pause received source=${action.source}' '${action.detail == null ? '' : ' detail=${action.detail}'}', ); - await _toggleRemotePlayPause(source: 'Apple TV remote'); + await _remoteTransport(TransportCommand.toggle, source: 'Apple TV remote'); } - /// Toggle play/pause on behalf of a hardware remote (Apple TV bridge or a - /// hardware media key). Mirrors the controls path: rewind-on-resume, then - /// play/pause with playback intent. - Future _toggleRemotePlayPause({required String source}) async { + /// Announce an *accepted user transport command* with a centred transient disc. + /// + /// Deliberately not driven from `player.streams.playing` or the + /// `_*WithPlaybackIntent` helpers: those also fire for the sleep timer, + /// lifecycle and audio-session changes, frame-rate re-opens, still-watching + /// prompts, route exit and episode reloads — none of which are user + /// commands. Each accepted command site opts in explicitly instead (#1676). + void _announceTransportCommand({required bool willPlay}) { + if (!mounted) return; + // Visible chrome already renders the play/pause state. + if (_chromeController.controlsVisible) return; + _toastController.showTransport( + willPlay ? Symbols.play_arrow_rounded : Symbols.pause_rounded, + willPlay ? t.videoControls.playbackResumed : t.videoControls.playbackPaused, + ); + } + + /// Apply a transport command on behalf of a hardware remote (Apple TV bridge + /// or a hardware media key). Mirrors the controls path: rewind-on-resume, + /// then play/pause with playback intent, then announce. + Future _remoteTransport(TransportCommand command, {required String source}) async { if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; final currentPlayer = player; @@ -1725,23 +1742,43 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } + // Rewind-on-resume follows the resolved intent, never the current state: a + // directed pause on an already-paused video must not jump backwards. + final resumes = switch (command) { + TransportCommand.play => !currentPlayer.state.playing, + TransportCommand.pause => false, + TransportCommand.toggle => !currentPlayer.state.playing, + }; + try { - if (!currentPlayer.state.playing) { + if (resumes) { await _seekBackForRewind(currentPlayer); if (!mounted || player != currentPlayer) return; } - await _playOrPauseWithPlaybackIntent(currentPlayer); + await switch (command) { + TransportCommand.play => _playWithPlaybackIntent(currentPlayer), + TransportCommand.pause => _pauseWithPlaybackIntent(currentPlayer), + TransportCommand.toggle => _playOrPauseWithPlaybackIntent(currentPlayer), + }; + _announceTransportCommand(willPlay: _playbackIntentShouldPlay); } catch (e, st) { appLogger.w('$source play/pause failed', error: e, stackTrace: st); } } - /// Hardware media play/pause keys (Android TV remotes). Deliberately not - /// space/configured hotkeys — text fields must still receive those. - static bool _isHardwarePlayPauseKey(LogicalKeyboardKey key) => - key == LogicalKeyboardKey.mediaPlayPause || - key == LogicalKeyboardKey.mediaPlay || - key == LogicalKeyboardKey.mediaPause; + /// Transport requested from the player controls (keyboard hotkey, companion + /// remote, on-screen button, click-to-toggle, D-pad Select). Authorization + /// and rewind-on-resume already ran in the controls layer. + Future _handleControlsTransport(TransportCommand command) async { + final currentPlayer = player; + if (currentPlayer == null) return; + await switch (command) { + TransportCommand.play => _playWithPlaybackIntent(currentPlayer), + TransportCommand.pause => _pauseWithPlaybackIntent(currentPlayer), + TransportCommand.toggle => _playOrPauseWithPlaybackIntent(currentPlayer), + }; + _announceTransportCommand(willPlay: _playbackIntentShouldPlay); + } String? _lastLogError; bool _sawServer500 = false; @@ -1923,20 +1960,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin () => _handleScreenPlayerNavigation(navigationKey), ); } - // Hardware media play/pause must act even when focus rests on this + // Hardware media transport must act even when focus rests on this // node or a sibling overlay — otherwise the key only reveals the // chrome and leaks to the (possibly stale/suspended) Android // MediaSession (#1375). Gated to TV-style nav: on desktop the global // HardwareKeyboard handler already acts (handlers don't stop focus // dispatch), and Apple TV delivers play/pause via its native bridge. - if (_videoPlayerNavigationEnabled && - !PlatformDetector.isAppleTV() && - _isHardwarePlayPauseKey(event.logicalKey)) { + // The chrome deliberately stays down; _remoteTransport announces the + // accepted command with a centred transient disc instead (#1676). + final transportCommand = classifyTransportKey(event.logicalKey); + if (_videoPlayerNavigationEnabled && !PlatformDetector.isAppleTV() && transportCommand != null) { if (event is KeyDownEvent) { - unawaited(_toggleRemotePlayPause(source: 'Hardware media key')); - if (node.hasPrimaryFocus) { - _chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause); - } + unawaited(_remoteTransport(transportCommand, source: 'Hardware media key')); } return KeyEventResult.handled; // consume down, repeat, and up } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 8c6a2be9..eca8cd91 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -206,6 +206,12 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onToggleMute, ValueChanged? onLiveSeekBy, Future Function(Duration position)? onSeekRequested, + + /// Takes over relative seeking entirely when supplied, so the caller can + /// coalesce a burst of presses and report the accepted offset. Without it + /// each press rebases off `player.state.position`, which a slow backend + /// has not applied yet. + ValueChanged? onSeekBy, }) { final isRepeat = event is KeyRepeatEvent; if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored; @@ -278,6 +284,10 @@ class KeyboardShortcutsService extends ChangeNotifier { } void performSeek(int offsetSeconds) { + if (onSeekBy != null) { + onSeekBy(offsetSeconds); + return; + } // Relative live-TV skip: route through the parent accumulator, which // coalesces a rapid burst into one transcode re-open (#1253). if (onLiveSeekBy != null) { diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 9b3851b3..4bc1d20c 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -16,6 +16,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { key == LogicalKeyboardKey.arrowRight; } + bool _isHorizontalKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; + } + bool _isSelectKey(LogicalKeyboardKey key) { return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || @@ -23,27 +27,28 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { key == LogicalKeyboardKey.gameButtonA; } - /// Determine if the key event should toggle play/pause based on configured hotkeys. - bool _isPlayPauseKey(KeyEvent event) { - final logicalKey = event.logicalKey; - final physicalKey = event.physicalKey; + /// Resolve the transport intent for a key event, or null when the key is not + /// a transport key. Hardware `mediaPlay`/`mediaPause` stay *directed*; the + /// configured hotkey is always a toggle. + TransportCommand? _transportCommandFor(KeyEvent event) { + // Always accept hardware media transport keys (Android TV remotes) + final hardware = classifyTransportKey(event.logicalKey); + if (hardware != null) return hardware; - // Always accept hardware media play/pause keys (Android TV remotes) - if (logicalKey == LogicalKeyboardKey.mediaPlayPause || - logicalKey == LogicalKeyboardKey.mediaPlay || - logicalKey == LogicalKeyboardKey.mediaPause) { - return true; - } + final physicalKey = event.physicalKey; // When the shortcuts service is available, respect the configured play/pause hotkey if (_keyboardService != null) { final hotkey = _keyboardService!.hotkeys['play_pause']; - if (hotkey == null) return false; - return hotkey.key == physicalKey; + if (hotkey == null) return null; + return hotkey.key == physicalKey ? TransportCommand.toggle : null; } // Fallback to defaults while the service is loading - return physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause; + if (physicalKey == PhysicalKeyboardKey.space || physicalKey == PhysicalKeyboardKey.mediaPlayPause) { + return TransportCommand.toggle; + } + return null; } bool _isMediaSeekKey(LogicalKeyboardKey key) { @@ -57,8 +62,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return key == LogicalKeyboardKey.mediaTrackNext || key == LogicalKeyboardKey.mediaTrackPrevious; } - bool _isPlayPauseActivation(KeyEvent event) { - return event is KeyDownEvent && _isPlayPauseKey(event); + TransportCommand? _playPauseActivation(KeyEvent event) { + return event is KeyDownEvent ? _transportCommandFor(event) : null; } void _activateHiddenControlsPrimaryAction() { @@ -70,8 +75,11 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _activateSkipMarker(); return; } - _playOrPause(); + // Raise the chrome *before* toggling: Select is the deliberate "show me the + // controls" affordance, and the visible chrome suppresses the transient + // transport disc that would otherwise flash underneath it. _showControlsWithFocus(); + unawaited(_playOrPause()); } KeyEventResult _handleLocalPlayerNavigationKeyEvent(KeyEvent event, PlayerNavigationKey navigationKey) { @@ -126,6 +134,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onToggleMute: widget.volumeController.toggleMute, onLiveSeekBy: widget.onLiveSeekBy, onSeekRequested: widget.onSeekRequested, + onSeekBy: _keyboardSeekBy, ); } @@ -158,10 +167,12 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); if (isMobile) return false; - // Handle play/pause globally - works regardless of focus - if (_isPlayPauseActivation(event)) { - _playOrPause(); - _showControlsWithFocus(requestFocus: false); + // Handle play/pause globally - works regardless of focus. The screen + // announces the accepted command with a transient disc, so the chrome + // stays down and subtitles stay readable (#1676). + final globalCommand = _playPauseActivation(event); + if (globalCommand != null) { + unawaited(_playOrPause(command: globalCommand)); return true; // Event handled, stop propagation } @@ -187,6 +198,18 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { } if (navigationKey != PlayerNavigationKey.none) return KeyEventResult.ignored; + // Releasing a key ends its seek burst, before the KeyUp is consumed below. + // Two independent reasons to fire: + // - a released hidden-chrome arrow must reset the acceleration tier even + // when nothing is pending, because live TV (and a zero-duration item) + // seeks straight through onLiveSeekBy without touching the accumulator; + // - any key holding a pending target commits it now, so rebound shortcuts + // and Shift+arrow large seeks land promptly rather than on the debounce. + if (event is KeyUpEvent && + ((!_showControls && _isHorizontalKey(event.logicalKey)) || _hiddenSeek.pendingPosition != null)) { + _flushHiddenDirectionalSeek(); + } + // Only handle KeyDown and KeyRepeat events. // Consume KeyUp events for navigation keys to prevent leaking to previous routes. // Let non-navigation keys (volume, etc.) pass through to the OS. @@ -201,16 +224,15 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { } final key = event.logicalKey; - final isPlayPauseKey = _isPlayPauseKey(event); + final transportCommand = _transportCommandFor(event); - // Always consume play/pause keys to prevent propagation to background routes. - // On TV/mobile, handle play/pause here; on desktop, the global handler does it. - if (isPlayPauseKey) { - if (_videoPlayerNavigationEnabled || isMobile) { - if (_isPlayPauseActivation(event)) { - _playOrPause(); - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); - } + // Always consume transport keys to prevent propagation to background routes. + // On TV/mobile, handle them here; on desktop, the global handler does it. + // The chrome deliberately stays down — the screen announces the accepted + // command with a centred transient disc instead (#1676). + if (transportCommand != null) { + if ((_videoPlayerNavigationEnabled || isMobile) && event is KeyDownEvent) { + unawaited(_playOrPause(command: transportCommand)); } return KeyEventResult.handled; } @@ -220,9 +242,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { if (event is KeyDownEvent && _isMediaSeekKey(key)) { if (widget.canControl) { final isForward = key == LogicalKeyboardKey.mediaFastForward || key == LogicalKeyboardKey.mediaSkipForward; - unawaited(_seekToChapter(forward: isForward)); + _seekToChapterWithFeedback(forward: isForward); } - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); return KeyEventResult.handled; } @@ -230,9 +251,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // Uses same behavior as seek keys: chapter navigation or time-based seek. if (event is KeyDownEvent && _isMediaTrackKey(key)) { if (widget.canControl) { - unawaited(_seekToChapter(forward: key == LogicalKeyboardKey.mediaTrackNext)); + _seekToChapterWithFeedback(forward: key == LogicalKeyboardKey.mediaTrackNext); } - _showControlsWithFocus(requestFocus: _videoPlayerNavigationEnabled); return KeyEventResult.handled; } @@ -244,21 +264,17 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return handleOneShotSelect(event, _activateHiddenControlsPrimaryAction); } - // On desktop/TV, show controls on directional input. - // LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause. + // On desktop/TV, directional input drives the player without the chrome. + // LEFT/RIGHT seeks in place with a transient badge; UP/DOWN is the + // deliberate "show me the controls" gesture. if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) { if (!_showControls) { - final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; - if (isHorizontal) { - _showControlsWithTimelineFocus(); - // A repeat may arrive before the post-frame focus handoff reaches - // the timeline. Consume it here without adding another seek step; - // once focused, the timeline owns intentional held-key repeats. - if (shouldStartHiddenDirectionalSeek(event) && widget.canControl) { - final forward = key == LogicalKeyboardKey.arrowRight; - unawaited(_seekByTime(forward: forward)); + if (_isHorizontalKey(key)) { + if (shouldStartHiddenDirectionalSeek(event)) { + _hiddenDirectionalSeek(forward: key == LogicalKeyboardKey.arrowRight, isRepeat: event is KeyRepeatEvent); } } else { + _flushHiddenDirectionalSeek(); _showControlsWithFocus(); } return KeyEventResult.handled; diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index d64e0a30..41ae8a3a 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -25,20 +25,128 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { await _seekByOffset(delta); } - Future _seekToChapter({required bool forward}) async { - if (_chapters.isEmpty) { - // No chapters - seek by configured amount - final delta = Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall); - await _seekByOffset(delta); + /// Relative seek reported through the transient skip badge instead of the + /// scrub bar, so the picture and its subtitles stay uncovered (#1676). + /// + /// Steps are coalesced into one absolute seek pinned to the pending target, + /// so a burst of presses cannot rebase off a position a slow backend has not + /// applied yet — without that the badge would report a total the player + /// never actually seeks. + void _seekByWithFeedback(Duration delta) { + if (!widget.canControl || delta == Duration.zero) return; + final forward = !delta.isNegative; + + // Live TV: relative epoch-based skips go through the parent accumulator — + // an absolute target is meaningless against a moving live edge (#1253). + if (widget.isLive && widget.onLiveSeekBy != null) { + final stepSeconds = (delta.inMilliseconds.abs() / 1000).round().clamp(1, 300); + widget.onLiveSeekBy!(forward ? stepSeconds : -stepSeconds); + _registerSkipFeedback(isForward: forward, seconds: stepSeconds); return; } - final targetIndex = MediaChapter.seekTargetIndex(widget.player.state.position, _chapters, forward: forward); - if (targetIndex != null) { - await _seekToPosition(_chapters[targetIndex].startTime); - } else if (!forward) { - await _seekToPosition(Duration.zero); + if (widget.player.state.duration.inMilliseconds <= 0) return; + + _hiddenSeek.seekBy(delta); + _registerSkipFeedback(isForward: forward, seconds: (delta.inMilliseconds.abs() / 1000).round()); + } + + /// Seek requested by a configured keyboard shortcut (the default Left/Right + /// and Shift+Left/Right bindings, plus any rebinding of them). Desktop never + /// reaches the D-pad path below, so this is its route to the same badge. + void _keyboardSeekBy(int offsetSeconds) => _seekByWithFeedback(Duration(seconds: offsetSeconds)); + + /// Directional D-pad seek with the chrome hidden. Mirrors the focused + /// timeline's held-key behaviour — progressive acceleration plus one + /// coalesced seek — without raising the timeline. + void _hiddenDirectionalSeek({required bool forward, required bool isRepeat}) { + if (!widget.canControl) return; + + if (_hiddenSeekForward != forward) { + _hiddenSeekForward = forward; + _hiddenSeekRepeatCount = 0; } + if (isRepeat) _hiddenSeekRepeatCount++; + final multiplier = isRepeat ? steppedSeekMultiplier(_hiddenSeekRepeatCount) : 1.0; + + final stepMs = (_seekTimeSmall * 1000 * multiplier).clamp(500, 120_000).toInt(); + _seekByWithFeedback(Duration(milliseconds: forward ? stepMs : -stepMs)); + } + + /// Commit the pending coalesced seek — the key was released, or the chrome + /// took over. A no-op when nothing is pending. + void _flushHiddenDirectionalSeek() { + _hiddenSeekForward = null; + _hiddenSeekRepeatCount = 0; + _hiddenSeek.flush(); + } + + /// Tolerance for "already at the start", so a previous-chapter press at the + /// very beginning is recognised as a no-op rather than a rewind to zero. + static const Duration _startOfMediaTolerance = Duration(milliseconds: 500); + + /// What an adjacent-chapter seek would do from the current position, without + /// performing it. Resolving separately lets a caller show feedback on key + /// down rather than after a potentially slow transcode re-open. + /// + /// A null [target] with chapters present means there is nowhere to go — past + /// the last chapter going forward, or already at the start going back — so + /// callers must neither seek nor announce. + ({bool usedChapters, MediaChapter? chapter, Duration? target}) _resolveChapterSeek({required bool forward}) { + if (_chapters.isEmpty) return (usedChapters: false, chapter: null, target: null); + + final position = widget.player.state.position; + final targetIndex = MediaChapter.seekTargetIndex(position, _chapters, forward: forward); + if (targetIndex != null) { + final chapter = _chapters[targetIndex]; + return (usedChapters: true, chapter: chapter, target: chapter.startTime); + } + if (!forward && position > _startOfMediaTolerance) { + return (usedChapters: true, chapter: null, target: Duration.zero); + } + return (usedChapters: true, chapter: null, target: null); + } + + Future _seekToChapter({required bool forward}) { + return _applyChapterSeek(_resolveChapterSeek(forward: forward), forward: forward); + } + + Future _applyChapterSeek( + ({bool usedChapters, MediaChapter? chapter, Duration? target}) resolved, { + required bool forward, + }) async { + if (!resolved.usedChapters) { + // No chapters - seek by configured amount + await _seekByOffset(Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall)); + return; + } + final target = resolved.target; + if (target != null) await _seekToPosition(target); + } + + /// Chapter-aware seek driven by a remote's transport keys. Shows a transient + /// badge instead of raising the chrome (#1676). + void _seekToChapterWithFeedback({required bool forward}) { + final resolved = _resolveChapterSeek(forward: forward); + if (!resolved.usedChapters) { + // No chapters: take the same coalesced path as every other badged seek. + // Going through _applyChapterSeek here would rebase each press off + // player.state.position, so a burst would report a total it never + // commits. + _seekByWithFeedback(Duration(seconds: forward ? _seekTimeSmall : -_seekTimeSmall)); + return; + } + if (resolved.target != null) { + // Only announce a jump that actually happens. + final title = resolved.chapter?.title?.trim(); + widget.toastController.show( + forward ? Symbols.skip_next_rounded : Symbols.skip_previous_rounded, + title != null && title.isNotEmpty + ? title + : (forward ? t.videoControls.nextChapterButton : t.videoControls.previousChapterButton), + ); + } + unawaited(_applyChapterSeek(resolved, forward: forward)); } Future _seekToPosition(Duration position, {bool notifyCompletion = true}) async { @@ -73,14 +181,31 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { return seekFuture; } - Future _playOrPause() async { + Future _playOrPause({TransportCommand command = TransportCommand.toggle}) async { if (!widget.canControl) return; - if (!widget.player.state.playing && _rewindOnResume > 0) { + // Rewind-on-resume keys off the *resolved* intent, not the current state: + // a directed pause on an already-paused video must leave the position + // untouched instead of jumping backwards. + final willPlay = switch (command) { + TransportCommand.play => true, + TransportCommand.pause => false, + TransportCommand.toggle => !widget.player.state.playing, + }; + if (willPlay && !widget.player.state.playing && _rewindOnResume > 0) { final target = widget.player.state.position - Duration(seconds: _rewindOnResume); final clamped = clampSeekPosition(widget.player, target); await (widget.onSeekRequested ?? widget.player.seek)(clamped); } - await (widget.onPlayPauseRequested ?? widget.player.playOrPause)(); + final requested = widget.onPlayPauseRequested; + if (requested != null) { + await requested(command); + return; + } + await switch (command) { + TransportCommand.play => widget.player.play(), + TransportCommand.pause => widget.player.pause(), + TransportCommand.toggle => widget.player.playOrPause(), + }; } /// Throttled seek for timeline slider - executes immediately then throttles to 200ms. @@ -442,11 +567,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { _singleTapTimer?.cancel(); _singleTapTimer = null; - // While the skip pill is visible, every tap in the same-direction zone + // While the skip readout is visible, every tap in the same-direction zone // stacks another skip immediately — repeat skips cost one tap, not a // fresh double-tap. A tap in the opposite zone falls through to pairing. if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) { - _handleStackingSkip(isForward: isForward); + _handleDoubleTapSkip(isForward: isForward); return; } @@ -480,31 +605,36 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { return renderObject is RenderBox ? renderObject.size : Size.zero; } - /// Handle stacking skip - add to accumulated skip when feedback is active. - void _handleStackingSkip({required bool isForward}) { - if (!widget.canControl) return; - - _accumulatedSkipSeconds += _seekTimeSmall; + /// Accumulate skip feedback. Consecutive skips in the same direction stack + /// into one running total; a direction flip restarts the count. + void _registerSkipFeedback({required bool isForward, required int seconds}) { + final stacking = _showDoubleTapFeedback && _lastDoubleTapWasForward == isForward; + _accumulatedSkipSeconds = stacking ? _accumulatedSkipSeconds + seconds : seconds; _showSkipFeedback(isForward: isForward); - - final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); - unawaited(_seekByOffset(delta)); } + /// Handle a skip-zone double tap (and every stacked tap that follows it). void _handleDoubleTapSkip({required bool isForward}) { if (!widget.canControl) return; - _accumulatedSkipSeconds = _seekTimeSmall; - _showSkipFeedback(isForward: isForward); + _registerSkipFeedback(isForward: isForward, seconds: _seekTimeSmall); final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall); unawaited(_seekByOffset(delta)); } + /// How long the skip badge stays at full opacity. 1200 ms gives time to read + /// the value and keep skipping; Maestro builds hold it far longer because + /// accessibility-tree queries on physical devices routinely outlast the + /// production timeout — the same reason the chrome hide delay is extended. + Duration get _skipFeedbackDuration => const bool.fromEnvironment('PLEZY_MAESTRO_E2E') + ? const Duration(seconds: 30) + : const Duration(milliseconds: 1200); + /// Show animated visual feedback for skip gesture void _showSkipFeedback({required bool isForward}) { // Cancel BOTH timers: a skip landing during the fade-out window must not - // leave the old hide timer pending, or it kills the fresh pill and zeroes + // leave the old hide timer pending, or it kills the fresh readout and zeroes // the accumulated count mid-display. _feedbackTimer?.cancel(); _feedbackHideTimer?.cancel(); @@ -518,8 +648,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { // Capture duration before timer to avoid context access in callback final slowDuration = tokens(context).slow; - // Fade out after delay (1200ms gives time to see value and continue tapping) - _feedbackTimer = Timer(const Duration(milliseconds: 1200), () { + _feedbackTimer = Timer(_skipFeedbackDuration, () { if (mounted) { _setControlsState(() { _doubleTapFeedbackOpacity = 0.0; diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index e4f87b8c..7b6e94f3 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -218,16 +218,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { } } - /// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop) - void _showControlsWithTimelineFocus() { - widget.chromeController.show(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _desktopControlsKey.currentState?.requestTimelineFocus(); - }); - } - /// Hide controls when navigating up from timeline (keyboard mode) /// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls void _hideControlsFromKeyboard() { @@ -262,6 +252,10 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { }); _reclaimFocusAfterControlsHide(); } else if (visibilityChanged) { + // The timeline is about to take over held-key seeking; commit whatever + // the hidden-chrome burst accumulated so it can't rebase from a stale + // position once the timeline's own accumulator starts. + _flushHiddenDirectionalSeek(); _setControlsState(() { _controlsMounted = true; _controlsOpaque = false; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index daa2db2e..ed1dbb9a 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -26,6 +26,7 @@ import 'package:flutter/services.dart' KeyEvent, KeyDownEvent, KeyUpEvent, + KeyRepeatEvent, HardwareKeyboard; import '../../services/fullscreen_state_manager.dart'; import '../../services/macos_window_service.dart'; @@ -43,6 +44,7 @@ import '../../focus/dpad_navigator.dart'; import '../../database/app_database.dart'; import '../../media/media_backend.dart'; import '../../media/media_item.dart'; +import '../../media/stepped_seek.dart'; import '../../models/livetv_capture_buffer.dart'; import '../../providers/multi_server_provider.dart'; import '../../media/media_source_info.dart'; @@ -67,6 +69,7 @@ import 'icons.dart'; import 'player_chrome_controller.dart'; import 'playback_extras_loader.dart'; import 'widgets/player_toast_indicator.dart'; +import 'widgets/transport_feedback_indicator.dart'; import '../../utils/app_logger.dart'; import '../../i18n/strings.g.dart'; import '../../focus/input_mode_tracker.dart'; @@ -391,8 +394,25 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req return lastDispatchedSeek == finalSeek; } +/// A user transport intent. `play`/`pause` are *directed* — a remote with +/// dedicated buttons must not flip the state it explicitly asked for. +enum TransportCommand { play, pause, toggle } + +/// Maps hardware media transport keys to their intent. Returns null for keys +/// that are not transport keys (including the configured play/pause hotkey, +/// which callers resolve to [TransportCommand.toggle] themselves). +TransportCommand? classifyTransportKey(LogicalKeyboardKey key) { + if (key == LogicalKeyboardKey.mediaPlay) return TransportCommand.play; + if (key == LogicalKeyboardKey.mediaPause) return TransportCommand.pause; + if (key == LogicalKeyboardKey.mediaPlayPause) return TransportCommand.toggle; + return null; +} + +/// Directional seeking with the chrome hidden owns the whole key burst — +/// repeats accelerate in place rather than escalating to the timeline — so +/// both the initial press and its repeats perform a step. @visibleForTesting -bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event is KeyDownEvent; +bool shouldStartHiddenDirectionalSeek(KeyEvent event) => event.isActionable; typedef PlaybackSourceChangeCallback = Future Function({ @@ -444,9 +464,10 @@ class PlexVideoControls extends StatefulWidget { /// playback state around the native player seek. final Future Function(Duration position)? onSeekRequested; - /// Called for app-level play/pause requests so the owning screen can track - /// user playback intent separately from transient buffering state. - final Future Function()? onPlayPauseRequested; + /// Called for app-level transport requests so the owning screen can track + /// user playback intent separately from transient buffering state, and + /// announce the accepted command. + final Future Function(TransportCommand command)? onPlayPauseRequested; /// Called when a seek operation completes (for Watch Together sync) final Function(Duration position)? onSeekCompleted; @@ -522,12 +543,19 @@ class PlexVideoControls extends StatefulWidget { /// Toast controller for VLC-style in-player notifications (rate changes, backend switch). final PlayerToastController toastController; + /// Seeds the chapter list so widget tests can exercise chapter-dependent + /// behaviour without a media-server client. Production always loads through + /// [VideoControlsPlaybackExtrasLoader]. + @visibleForTesting + final List? initialChapters; + const PlexVideoControls({ super.key, required this.player, required this.volumeController, required this.metadata, required this.toastController, + this.initialChapters, this.onNext, this.onPrevious, this.availableVersions = const [], @@ -600,8 +628,8 @@ class _PlexVideoControlsState extends State // item can start while a stale one is still in flight (and the stale // response is discarded). String? _extrasLoadKey; - List _chapters = []; - bool _chaptersLoaded = false; + late List _chapters = widget.initialChapters ?? []; + late bool _chaptersLoaded = widget.initialChapters != null; bool _isFullscreen = false; bool _isAlwaysOnTop = false; late final FocusNode _focusNode; @@ -634,7 +662,7 @@ class _PlexVideoControlsState extends State // Custom tap detection state (more reliable than Flutter's onDoubleTap) DateTime? _lastSkipTapTime; bool _lastSkipTapWasForward = true; - Timer? _feedbackHideTimer; // Removes the skip pill after its fade-out completes + Timer? _feedbackHideTimer; // Removes the skip readout after its fade-out completes Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls) final TwoFingerDoubleTapTracker _twoFingerDoubleTapTracker = TwoFingerDoubleTapTracker(); final MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker(); @@ -663,6 +691,12 @@ class _PlexVideoControlsState extends State late final Throttle _seekThrottle; Duration? _lastDispatchedTimelineSeek; Future? _lastDispatchedTimelineSeekFuture; + // Directional key seeking while the chrome is hidden (#1676). Owns the whole + // key burst — repeats accelerate in place rather than escalating to the + // timeline — and coalesces it into one absolute seek, like the timeline does. + late final DebouncedSeekAccumulator _hiddenSeek; + bool? _hiddenSeekForward; + int _hiddenSeekRepeatCount = 0; // Current marker state MediaMarker? _currentMarker; List _markers = []; @@ -701,7 +735,7 @@ class _PlexVideoControlsState extends State StreamSubscription? _rateSubscription; double? _lastReportedRate; // Suppression window used when long-press ends so the rate-restore emission - // doesn't flash a second pill as the rate snaps back. + // doesn't flash a second notice as the rate snaps back. DateTime? _suppressRateToastUntil; // PiP support @@ -725,6 +759,11 @@ class _PlexVideoControlsState extends State leading: true, trailing: true, ); + _hiddenSeek = DebouncedSeekAccumulator( + currentPosition: () => widget.player.state.position, + duration: () => widget.player.state.duration, + seek: (target) => unawaited(_seekToPosition(target)), + ); // Side effects: rotation lock + focus on nav-enable. Both fire immediately // so init wiring (orientation, focus) lives in one place. bindEffect(SettingsService.rotationLocked, _applyRotationLock); @@ -847,6 +886,7 @@ class _PlexVideoControlsState extends State _skipButtonDismissTimer?.cancel(); _singleTapTimer?.cancel(); _seekThrottle.cancel(); + _hiddenSeek.dispose(); _edgeAdjustmentTracker.cancel(); _edgeAdjustmentIndicator.dispose(); _pipService.isPipActive.removeListener(_onEdgeAdjustmentPipChanged); @@ -1132,8 +1172,9 @@ class _PlexVideoControlsState extends State ), ), ), - // Visual feedback overlay for double-tap - if (isMobile && _showDoubleTapFeedback) + // Transient skip badge: mobile double-tap and keyboard/remote + // seeking both use it so neither has to raise the full chrome. + if (_showDoubleTapFeedback) Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( @@ -1148,7 +1189,9 @@ class _PlexVideoControlsState extends State ), // Speed indicator overlay for long-press 2x if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())), - // Stream-driven VLC-style pill (rate changes, backend-switch notifications) + // Stream-driven transient feedback: an icon-only disc centred + // in the frame for accepted transport commands, a textual pill + // at the top for rate changes and other notices. Positioned.fill( child: IgnorePointer( child: ListenableBuilder( @@ -1156,14 +1199,21 @@ class _PlexVideoControlsState extends State builder: (context, _) { final toast = widget.toastController.current; if (toast == null) return const SizedBox.shrink(); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - child: PlayerToastIndicator( - key: ValueKey('${toast.icon.codePoint}:${toast.text}'), + return switch (toast.kind) { + PlayerToastKind.transport => TransportFeedbackIndicator( icon: toast.icon, text: toast.text, + pulse: toast.pulse, ), - ); + PlayerToastKind.notice => AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: PlayerToastIndicator( + key: ValueKey('${toast.icon.codePoint}:${toast.text}'), + icon: toast.icon, + text: toast.text, + ), + ), + }; }, ), ), diff --git a/lib/widgets/video_controls/widgets/double_tap_feedback.dart b/lib/widgets/video_controls/widgets/double_tap_feedback.dart index c174fb79..1a1a19b4 100644 --- a/lib/widgets/video_controls/widgets/double_tap_feedback.dart +++ b/lib/widgets/video_controls/widgets/double_tap_feedback.dart @@ -1,38 +1,158 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/formatters.dart'; +import '../../../utils/platform_detector.dart'; import '../../app_icon.dart'; -class DoubleTapFeedback extends StatelessWidget { +/// Label for the skip feedback. Plain `Ns` stays readable up to a minute; beyond +/// that (reachable by held D-pad seeking, which accelerates) a raw second +/// count is unreadable, so fall back to the M:SS timestamp form. +@visibleForTesting +String formatSkipFeedbackLabel(int seconds) { + if (seconds < 60) return '$seconds${t.settings.secondsShort}'; + return formatDurationTimestamp(Duration(seconds: seconds)); +} + +/// Transient seek readout at the side of the frame the seek travels toward: the +/// amount, and a single chevron on the same line drifting that way. +/// +/// Deliberately unbacked — no scrim, no puck. Anything large enough to read as a +/// surface also covers picture and subtitles, which is the complaint this +/// feedback exists to answer. Legibility comes from shadows instead. +/// +/// Only the chevron moves. The amount is what the viewer reads, so it stays put. +class DoubleTapFeedback extends StatefulWidget { final bool isForward; final int seconds; const DoubleTapFeedback({super.key, required this.isForward, required this.seconds}); + /// Inset from the anchored edge. TVs overscan roughly 5% of each edge, so + /// derive it from the viewport rather than assuming 1080p logical geometry — a + /// TV reporting 960dp at 2x would otherwise get double the intended inset. + /// Clamped so the readout never sits tighter than the touch layout, never + /// drifts toward centre on an ultra-wide viewport, and always leaves itself + /// room on a narrow one. + static double _horizontalInset(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final overscan = PlatformDetector.isTV() ? (width * 0.05).clamp(60.0, 160.0) : 60.0; + return overscan.clamp(0.0, math.max(0.0, (width - _minReadoutWidth) / 2)); + } + + /// Chevron plus a few characters of label at the largest step. + static const double _minReadoutWidth = 200; + + /// Type scale. A TV is read from across the room, so it needs a bigger step + /// than a handset at arm's length even though the two report a similar + /// logical width — 960dp at 2x versus roughly 900dp in landscape. + static double _labelSize(BuildContext context) => PlatformDetector.isTV() ? 34 : 26; + + static double _chevronSize(BuildContext context) => PlatformDetector.isTV() ? 46 : 36; + + /// How far the chevron drifts either side of centre, in logical pixels. Scaled + /// with the glyph so the motion stays proportional. + static double _driftDistance(BuildContext context) => _chevronSize(context) * 0.22; + + static const Duration _driftPeriod = Duration(milliseconds: 1100); + + @override + State createState() => _DoubleTapFeedbackState(); +} + +class _DoubleTapFeedbackState extends State with SingleTickerProviderStateMixin { + /// The chevron drifts the way the seek goes, looping free for as long as the + /// readout is up so a held key reads as continuous travel. + /// + /// Deliberately never restarted per press: key repeats arrive every few tens + /// of milliseconds, far faster than the cycle, so restarting would pin the + /// chevron at the start of its nudge for the whole burst. No per-press kick is + /// needed anyway - a same-direction press changes the amount, and a direction + /// flip flips the chevron and the side it sits on. + late final AnimationController _drift = AnimationController(duration: DoubleTapFeedback._driftPeriod, vsync: this) + ..repeat(); + + @override + void dispose() { + _drift.dispose(); + super.dispose(); + } + + /// The chevron never disappears; it only brightens as it travels. + static const double _minChevronOpacity = 0.7; + + /// Share of the cycle spent travelling outward, the rest returning. + static const double _outwardFraction = 0.7; + + static const List _legibility = [Shadow(color: Colors.black87, blurRadius: 6)]; + + Widget _buildChevron(BuildContext context) { + return AnimatedBuilder( + animation: _drift, + builder: (context, child) { + // Most of the cycle is the outward stroke; the return is brief, so the + // eye reads travel in the seek direction rather than a symmetric wobble. + // Both ends rest at zero, so the wrap needs no fade to hide a snap - the + // chevron is a persistent cue, never a blinking one. + final phase = _drift.value; + final travel = phase < _outwardFraction + ? Curves.easeOut.transform(phase / _outwardFraction) + : 1 - Curves.easeInOut.transform((phase - _outwardFraction) / (1 - _outwardFraction)); + final dx = travel * DoubleTapFeedback._driftDistance(context) * (widget.isForward ? 1 : -1); + return Transform.translate( + offset: Offset(dx, 0), + child: Opacity(opacity: _minChevronOpacity + (1 - _minChevronOpacity) * travel, child: child), + ); + }, + child: AppIcon( + widget.isForward ? Symbols.chevron_right_rounded : Symbols.chevron_left_rounded, + fill: 1, + color: Colors.white, + size: DoubleTapFeedback._chevronSize(context), + shadows: _legibility, + ), + ); + } + @override Widget build(BuildContext context) { - return Align( - alignment: isForward ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 60), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), - child: Column( - mainAxisSize: .min, - children: [ - AppIcon( - isForward ? Symbols.forward_media_rounded : Symbols.replay_rounded, - fill: 1, - color: Colors.white, - size: 32, - ), - const SizedBox(height: 4), - Text( - '$seconds${t.settings.secondsShort}', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold), - ), - ], + final isForward = widget.isForward; + + // Own semantics node with a spoken label: the visible "10s"/"2:55" is a + // glance affordance, while assistive tech gets the direction and amount. + // Without the container the text merges into the full-screen playback + // control behind it and never reaches the user. + return Semantics( + container: true, + liveRegion: true, + excludeSemantics: true, + label: isForward + ? t.videoControls.seekForwardButton(seconds: widget.seconds) + : t.videoControls.seekBackwardButton(seconds: widget.seconds), + child: Align( + alignment: isForward ? Alignment.centerRight : Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: DoubleTapFeedback._horizontalInset(context)), + child: Row( + mainAxisSize: .min, + children: [ + // Chevron leads on the side the seek travels toward. + if (!isForward) ...[_buildChevron(context), const SizedBox(width: 6)], + Text( + formatSkipFeedbackLabel(widget.seconds), + style: TextStyle( + color: Colors.white, + fontSize: DoubleTapFeedback._labelSize(context), + fontWeight: .bold, + shadows: _legibility, + ), + ), + if (isForward) ...[const SizedBox(width: 6), _buildChevron(context)], + ], + ), ), ), ); diff --git a/lib/widgets/video_controls/widgets/player_toast_indicator.dart b/lib/widgets/video_controls/widgets/player_toast_indicator.dart index 248b64db..27bf2ba7 100644 --- a/lib/widgets/video_controls/widgets/player_toast_indicator.dart +++ b/lib/widgets/video_controls/widgets/player_toast_indicator.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; +import 'transport_feedback_indicator.dart'; + /// VLC-style dark pill shown at top-center of the video player. /// Used for rate changes and other transient in-player notifications. class PlayerToastIndicator extends StatelessWidget { @@ -13,31 +15,41 @@ class PlayerToastIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - return Align( - alignment: .topCenter, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8), - child: Container( - margin: const EdgeInsets.only(top: 20), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Row( - mainAxisSize: .min, - children: [ - AppIcon(icon, fill: 1, color: Colors.white, size: 16), - const SizedBox(width: 4), - Flexible( - child: Text( - text, - maxLines: 1, - overflow: .ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold), + // Own semantics node: without it the pill's text merges into whatever + // full-screen control sits behind it (the "show playback controls" tap + // target), corrupting that button's name and hiding the status. liveRegion + // makes assistive tech announce the transition. + return Semantics( + container: true, + liveRegion: true, + excludeSemantics: true, + label: text, + child: Align( + alignment: .topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8), + child: Container( + margin: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + child: Row( + mainAxisSize: .min, + children: [ + AppIcon(icon, fill: 1, color: Colors.white, size: 16), + const SizedBox(width: 4), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: .ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold), + ), ), - ), - ], + ], + ), ), ), ), @@ -45,19 +57,58 @@ class PlayerToastIndicator extends StatelessWidget { } } +/// How a transient in-player notification presents itself. +enum PlayerToastKind { + /// Textual dark pill at the top of the frame: rate changes, chapter titles, + /// backend switches, errors. + notice, + + /// Icon-only disc at the centre of the frame confirming an accepted + /// play/pause command, in the shape viewers know from YouTube. + transport, +} + /// Owns the currently-displayed toast + auto-hide timer. /// Created per video-player session; disposed with the screen. class PlayerToastController extends ChangeNotifier { - ({IconData icon, String text})? _current; + ({IconData icon, String text, PlayerToastKind kind, int pulse})? _current; Timer? _timer; + int _pulse = 0; - ({IconData icon, String text})? get current => _current; + ({IconData icon, String text, PlayerToastKind kind, int pulse})? get current => _current; - void show(IconData icon, String text, {Duration duration = const Duration(milliseconds: 1200)}) { + /// Maestro builds hold every pill far longer: accessibility-tree queries on + /// physical devices routinely outlast the production timeout, the same + /// reason the chrome hide delay is extended for E2E. + static const Duration _maestroMinimumDuration = Duration(seconds: 30); + + /// Confirms an accepted play/pause command. [text] is not drawn - the disc is + /// icon-only - but it remains the semantics label so assistive tech and the + /// E2E accessibility tree still read "Paused"/"Playing". + /// + /// Lifetime comes from the disc itself, which fades itself back out, so the + /// widget is never unmounted mid-exit. + void showTransport(IconData icon, String text) { + show(icon, text, kind: PlayerToastKind.transport, duration: TransportFeedbackIndicator.totalDuration); + } + + void show( + IconData icon, + String text, { + Duration duration = const Duration(milliseconds: 1200), + PlayerToastKind kind = PlayerToastKind.notice, + }) { _timer?.cancel(); - _current = (icon: icon, text: text); + // Every accepted command carries a fresh pulse. Two identical commands in a + // row (an explicit pause while already paused, say) produce an identical + // icon/text pair, so without this the animated child would be reused and + // its one-shot pop would never replay. + _current = (icon: icon, text: text, kind: kind, pulse: ++_pulse); notifyListeners(); - _timer = Timer(duration, () { + final effective = const bool.fromEnvironment('PLEZY_MAESTRO_E2E') && duration < _maestroMinimumDuration + ? _maestroMinimumDuration + : duration; + _timer = Timer(effective, () { _current = null; _timer = null; notifyListeners(); diff --git a/lib/widgets/video_controls/widgets/transport_feedback_indicator.dart b/lib/widgets/video_controls/widgets/transport_feedback_indicator.dart new file mode 100644 index 00000000..44e36d7e --- /dev/null +++ b/lib/widgets/video_controls/widgets/transport_feedback_indicator.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../../app_icon.dart'; + +/// Centre-screen confirmation of an accepted play/pause command, in the shape +/// viewers know from YouTube: a translucent disc that grows and fades in, holds +/// briefly at rest, then leaves the way it arrived. +/// +/// Deliberately icon-only and centred. Subtitles are horizontally centred and +/// sit in the top or bottom band of the frame - a textual pill at the top +/// overlapped ASS `\an8` placement (song lyrics, sign translations), which is +/// exactly the readability complaint this feedback exists to avoid. +class TransportFeedbackIndicator extends StatefulWidget { + const TransportFeedbackIndicator({super.key, required this.icon, required this.text, required this.pulse}); + + final IconData icon; + + /// Not drawn. Carried for assistive tech and the E2E accessibility tree. + final String text; + + /// Monotonic per accepted command. Two identical commands in a row reuse this + /// State, so the pop replays off a pulse change rather than off icon/text. + final int pulse; + + /// Grow and fade in, hold long enough to read, then run the same motion in + /// reverse. Symmetric on purpose: the disc leaves the way it arrived. + static const Duration _enter = Duration(milliseconds: 150); + static const Duration _hold = Duration(milliseconds: 500); + static const Duration totalDuration = Duration(milliseconds: 800); + + /// The glyph nearly fills the disc — it is a state cue, not a button, so the + /// surface only needs to be big enough to keep the icon legible over bright + /// picture rather than to look tappable. + @visibleForTesting + static const double diameter = 72; + + @visibleForTesting + static const double iconSize = 44; + + @override + State createState() => _TransportFeedbackIndicatorState(); +} + +class _TransportFeedbackIndicatorState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: TransportFeedbackIndicator.totalDuration, + vsync: this, + ); + + static final double _enterWeight = TransportFeedbackIndicator._enter.inMilliseconds.toDouble(); + static final double _holdWeight = TransportFeedbackIndicator._hold.inMilliseconds.toDouble(); + static final double _exitWeight = + TransportFeedbackIndicator.totalDuration.inMilliseconds - _enterWeight - _holdWeight; + + late final Animation _scale = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0.8, end: 1.0).chain(CurveTween(curve: Curves.easeOut)), + weight: _enterWeight, + ), + TweenSequenceItem(tween: ConstantTween(1.0), weight: _holdWeight), + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.8).chain(CurveTween(curve: Curves.easeIn)), + weight: _exitWeight, + ), + ]).animate(_controller); + + late final Animation _opacity = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.easeOut)), + weight: _enterWeight, + ), + TweenSequenceItem(tween: ConstantTween(1.0), weight: _holdWeight), + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0).chain(CurveTween(curve: Curves.easeIn)), + weight: _exitWeight, + ), + ]).animate(_controller); + + @override + void initState() { + super.initState(); + _controller.forward(); + } + + @override + void didUpdateWidget(TransportFeedbackIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.pulse != widget.pulse) _controller.forward(from: 0); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Own semantics node: without it the label merges into the full-screen + // "show playback controls" target behind it, corrupting that button's name + // and hiding the status. liveRegion announces the transition. + return Semantics( + container: true, + liveRegion: true, + excludeSemantics: true, + label: widget.text, + child: Center( + child: FadeTransition( + opacity: _opacity, + child: ScaleTransition( + scale: _scale, + child: Container( + width: TransportFeedbackIndicator.diameter, + height: TransportFeedbackIndicator.diameter, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.55), shape: BoxShape.circle), + child: Center( + child: AppIcon(widget.icon, fill: 1, color: Colors.white, size: TransportFeedbackIndicator.iconSize), + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index eddf247f..bc9817d7 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -1497,7 +1497,7 @@ void main() { }); group('shouldStartHiddenDirectionalSeek', () { - test('accepts only the initial key-down event', () { + test('accepts the initial press and its repeats, so a held key keeps seeking in place', () { expect(shouldStartHiddenDirectionalSeek(_keyDown(LogicalKeyboardKey.arrowRight)), isTrue); expect( shouldStartHiddenDirectionalSeek( @@ -1507,9 +1507,14 @@ void main() { timeStamp: Duration.zero, ), ), + isTrue, + reason: 'hidden-chrome seeking owns the whole burst instead of escalating to the timeline', + ); + expect( + shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)), isFalse, + reason: 'release commits the burst, it does not add another step', ); - expect(shouldStartHiddenDirectionalSeek(_keyUp(LogicalKeyboardKey.arrowRight)), isFalse); }); }); diff --git a/test/widgets/video_controls_transient_feedback_test.dart b/test/widgets/video_controls_transient_feedback_test.dart new file mode 100644 index 00000000..c9919667 --- /dev/null +++ b/test/widgets/video_controls_transient_feedback_test.dart @@ -0,0 +1,924 @@ +import 'dart:math' as math; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; + +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:plezy/widgets/video_controls/video_controls.dart'; +import 'package:plezy/widgets/video_controls/widgets/double_tap_feedback.dart'; +import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/widgets/video_controls/widgets/transport_feedback_indicator.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +/// Regression coverage for #1676: remote/keyboard seeking and pausing must +/// drive playback through a transient badge instead of raising the full player +/// chrome, which covers the subtitles the user is trying to read. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('transient player feedback', () { + late _RecordingPlayer player; + late PlayerChromeController chrome; + late PlayerToastController toast; + late VideoVolumeController volume; + late PlaybackStateProvider playbackState; + late WatchTogetherProvider watchTogether; + late AppDatabase database; + late List transportCommands; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.seekTimeSmall, 10); + await settings.write(SettingsService.rewindOnResume, 5); + + // Android TV: PlatformDetector.isTV() drives both the directional-seek + // branch and the videoPlayerNavigationEnabled default. + TvDetectionService.debugSetAppleTVOverride(true); + PlatformDetector.debugSetIsDesktopOSOverride(false); + + database = AppDatabase.forTesting(NativeDatabase.memory()); + player = _RecordingPlayer(); + chrome = PlayerChromeController(); + toast = PlayerToastController(); + volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + playbackState = PlaybackStateProvider(); + watchTogether = WatchTogetherProvider(); + }); + + tearDown(() async { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + volume.dispose(); + playbackState.dispose(); + watchTogether.dispose(); + chrome.dispose(); + toast.dispose(); + await database.close(); + }); + + Future pumpControls( + WidgetTester tester, { + List? chapters, + bool wireTransportCallback = false, + bool isLive = false, + ValueChanged? onLiveSeekBy, + }) async { + transportCommands = []; + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: database), + ChangeNotifierProvider.value(value: playbackState), + ChangeNotifierProvider.value(value: watchTogether), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, extensions: const [testMonoTokens]), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: PlexVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'transient-feedback'), + toastController: toast, + chromeController: chrome, + initialChapters: chapters, + canNavigateMediaItems: false, + isLive: isLive, + onLiveSeekBy: onLiveSeekBy, + onPlayPauseRequested: wireTransportCallback + ? (command) async { + transportCommands.add(command); + await switch (command) { + TransportCommand.play => player.play(), + TransportCommand.pause => player.pause(), + TransportCommand.toggle => player.playOrPause(), + }; + } + : null, + ), + ), + ), + ), + ), + ); + await tester.pump(); + // Every case below starts from hidden chrome — the state the issue is about. + chrome.hide(); + chrome.markControlsHidden(); + await tester.pump(); + expect(chrome.controlsVisible, isFalse); + } + + Future settleFeedback(WidgetTester tester) async { + chrome.cancelAutoHide(); + toast.hide(); + await tester.pumpWidget(const SizedBox.shrink()); + } + + testWidgets('a right-arrow tap seeks without raising the chrome', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(chrome.controlsVisible, isFalse, reason: 'seeking must not cover the picture'); + expect(find.byType(DoubleTapFeedback), findsOneWidget); + expect(find.text('10s'), findsOneWidget); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(chrome.controlsVisible, isFalse); + expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]); + + await settleFeedback(tester); + }); + + testWidgets('repeated taps in one direction stack into a running total', (tester) async { + await pumpControls(tester); + + for (var i = 0; i < 3; i++) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + + expect(find.text('30s'), findsOneWidget); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('flipping direction restarts the badge count', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(find.text('10s'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(find.text('10s'), findsOneWidget, reason: 'the reverse burst is counted on its own'); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a held arrow accelerates and commits exactly one seek on release', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + for (var i = 0; i < 8; i++) { + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + + expect(chrome.controlsVisible, isFalse, reason: 'a held seek must not escalate to the scrub bar'); + expect(player.seeks, isEmpty, reason: 'the burst is coalesced, not dispatched per repeat'); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(player.seeks, hasLength(1)); + // 1 press at 10s + 5 repeats at 1.5x + 3 repeats at 3x = 10 + 75 + 90. + expect(player.seeks.single, const Duration(minutes: 10) + const Duration(seconds: 175)); + + await settleFeedback(tester); + }); + + testWidgets('up-arrow still raises the chrome as the deliberate escape hatch', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(chrome.controlsVisible, isTrue); + + await settleFeedback(tester); + }); + + testWidgets('a media fast-forward key skips with a badge and no chrome', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + + expect(chrome.controlsVisible, isFalse); + expect(find.text('10s'), findsOneWidget); + expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]); + + await settleFeedback(tester); + }); + + testWidgets('a rapid media-key burst commits the total the badge reports', (tester) async { + // No chapters, so the transport key falls back to a timed skip. A slow + // backend must not let the badge climb past what actually gets seeked. + player.freezePositionOnSeek = true; + await pumpControls(tester); + + for (var i = 0; i < 3; i++) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + } + + expect(find.text('30s'), findsOneWidget); + expect(player.seeks, [ + const Duration(minutes: 10, seconds: 10), + const Duration(minutes: 10, seconds: 20), + const Duration(minutes: 10, seconds: 30), + ]); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a media fast-forward key announces the chapter it lands on', (tester) async { + await pumpControls( + tester, + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 900000, title: 'Cold Open'), + MediaChapter(id: 2, startTimeOffset: 900000, endTimeOffset: 1800000, title: 'The Heist'), + ], + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + + expect(chrome.controlsVisible, isFalse); + expect(find.byType(DoubleTapFeedback), findsNothing, reason: 'a chapter jump is not an N-second skip'); + expect(find.byType(PlayerToastIndicator), findsOneWidget); + expect(find.text('The Heist'), findsOneWidget); + + await settleFeedback(tester); + }); + + testWidgets('a chapter seek past the last chapter announces nothing', (tester) async { + // Position 10:00 sits inside the final chapter, so there is nowhere + // forward to jump; the badge must not claim a jump that never happens. + await pumpControls( + tester, + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 300000, title: 'Cold Open'), + MediaChapter(id: 2, startTimeOffset: 300000, endTimeOffset: 2700000, title: 'The Heist'), + ], + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward); + await tester.pump(); + + expect(find.byType(PlayerToastIndicator), findsNothing); + expect(find.byType(DoubleTapFeedback), findsNothing); + expect(player.seeks, isEmpty); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a backward chapter seek at the very start announces nothing', (tester) async { + await pumpControls( + tester, + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 900000, title: 'Cold Open'), + MediaChapter(id: 2, startTimeOffset: 900000, endTimeOffset: 1800000, title: 'The Heist'), + ], + ); + player.setPosition(Duration.zero); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaRewind); + await tester.pump(); + + expect(find.byType(PlayerToastIndicator), findsNothing); + expect(player.seeks, isEmpty, reason: 'already at the start — nothing to rewind to'); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a live hold resets its acceleration tier when the key is released', (tester) async { + // Live seeks bypass the accumulator entirely, so nothing is ever pending; + // the release must still reset the tier or the next hold in the same + // direction resumes mid-acceleration. + final liveOffsets = []; + await pumpControls(tester, isLive: true, onLiveSeekBy: liveOffsets.add); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + for (var i = 0; i < 6; i++) { + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(liveOffsets, [10, 15, 15, 15, 15, 15, 30], reason: 'the first hold climbs through the tiers'); + + liveOffsets.clear(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(liveOffsets, [10, 15], reason: 'a fresh hold restarts at the slowest tier'); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a directed pause on an already-paused video neither resumes nor rewinds', (tester) async { + player.setPlaying(false); + await pumpControls(tester, wireTransportCallback: true); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPause); + await tester.pump(); + + expect(transportCommands, [TransportCommand.pause]); + expect(player.playOrPauseCalls, 0, reason: 'a dedicated pause button must never toggle'); + expect(player.pauseCalls, 1); + expect( + player.seeks, + isEmpty, + reason: 'rewind-on-resume must follow the resolved intent, not the current paused state', + ); + expect(player.state.position, const Duration(minutes: 10)); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a directed play on a paused video rewinds then resumes', (tester) async { + player.setPlaying(false); + await pumpControls(tester, wireTransportCallback: true); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPlay); + await tester.pump(); + + expect(transportCommands, [TransportCommand.play]); + expect(player.seeks, [const Duration(minutes: 9, seconds: 55)]); + expect(player.playCalls, 1); + expect(player.playOrPauseCalls, 0); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('the combined play/pause key toggles without raising the chrome', (tester) async { + await pumpControls(tester, wireTransportCallback: true); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaPlayPause); + await tester.pump(); + + expect(transportCommands, [TransportCommand.toggle]); + expect(player.playOrPauseCalls, 1); + expect(chrome.controlsVisible, isFalse, reason: 'pausing must not cover the subtitles either'); + + await settleFeedback(tester); + }); + + testWidgets('select raises the chrome before toggling so no badge flashes under it', (tester) async { + await pumpControls(tester, wireTransportCallback: true); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.select); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.select); + await tester.pump(); + + expect(chrome.controlsVisible, isTrue); + expect(transportCommands, [TransportCommand.toggle]); + + await settleFeedback(tester); + }); + + /// Scale of the disc's one-shot pop. Reading it back is how "the animation + /// replayed" becomes observable rather than assumed. + double discScale(WidgetTester tester) => tester + .widget( + find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(ScaleTransition)), + ) + .scale + .value; + + testWidgets('an accepted transport command shows a centred disc, not the top pill', (tester) async { + await pumpControls(tester); + + toast.showTransport(Symbols.pause_rounded, 'Paused'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 520)); + + expect(find.byType(TransportFeedbackIndicator), findsOneWidget); + expect( + find.byType(PlayerToastIndicator), + findsNothing, + reason: 'a top pill would land on ASS \\an8 subtitles - the readability complaint itself', + ); + // Centred in the frame, clear of both subtitle bands. + final disc = tester.getRect(find.byType(TransportFeedbackIndicator)); + final surface = tester.getRect(find.byType(PlexVideoControls)); + expect(disc.center.dx, moreOrLessEquals(surface.center.dx, epsilon: 1)); + expect(disc.center.dy, moreOrLessEquals(surface.center.dy, epsilon: 1)); + // Still announced, so assistive tech and the E2E tree keep reading it. + expect(find.bySemanticsLabel('Paused'), findsOneWidget); + + // The glyph nearly fills the disc. It is a state cue, not a button, so a + // wide ring of padding would just cover more picture for no gain. Measured + // at the held scale, where the transform is identity. + final circle = tester.getRect( + find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(DecoratedBox)), + ); + final glyph = tester.getRect( + find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(AppIcon)), + ); + expect(circle.width, moreOrLessEquals(circle.height, epsilon: 0.5), reason: 'a circle, not an oval'); + expect( + glyph.width / circle.width, + greaterThan(0.55), + reason: 'the icon should nearly fill the disc rather than float in padding', + ); + + await settleFeedback(tester); + }); + + testWidgets('a repeated identical transport command replays the pop', (tester) async { + await pumpControls(tester); + + toast.showTransport(Symbols.pause_rounded, 'Paused'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 520)); + final settled = discScale(tester); + + // Same icon and same label: without a per-command pulse this reuses the + // State and the pop would never run again. + toast.showTransport(Symbols.pause_rounded, 'Paused'); + await tester.pump(); + + expect( + discScale(tester), + lessThan(settled), + reason: 'an identical repeat must restart the pop, not sit at its finished scale', + ); + + await settleFeedback(tester); + }); + + testWidgets('the disc leaves the way it arrived', (tester) async { + await pumpControls(tester); + + double discOpacity() => tester + .widget( + find.descendant(of: find.byType(TransportFeedbackIndicator), matching: find.byType(FadeTransition)), + ) + .opacity + .value; + + toast.showTransport(Symbols.pause_rounded, 'Paused'); + await tester.pump(); + + // Grows and fades in from 0.8/0. + expect(discScale(tester), lessThan(1.0)); + expect(discOpacity(), lessThan(1.0)); + + // Holds fully visible at rest, long enough to read. + await tester.pump(const Duration(milliseconds: 300)); + expect(discScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + expect(discOpacity(), moreOrLessEquals(1.0, epsilon: 0.01)); + await tester.pump(const Duration(milliseconds: 300)); + expect(discScale(tester), moreOrLessEquals(1.0, epsilon: 0.01), reason: 'still held at 600ms'); + + // Then runs the same motion backwards rather than expanding away. + await tester.pump(const Duration(milliseconds: 120)); + expect(discScale(tester), lessThan(1.0), reason: 'shrinks back toward 0.8, never past 1.0'); + expect(discOpacity(), lessThan(1.0)); + + await settleFeedback(tester); + }); + + testWidgets('the seek readout sits unbacked at the edge it seeks toward', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final feedback = find.byType(DoubleTapFeedback); + // Nothing is painted behind it. A backdrop large enough to read as a + // surface is exactly what covers the picture and subtitles. + expect(find.descendant(of: feedback, matching: find.byType(ClipPath)), findsNothing); + expect(find.descendant(of: feedback, matching: find.byType(ColoredBox)), findsNothing); + expect(find.descendant(of: feedback, matching: find.byType(DecoratedBox)), findsNothing); + + // Amount and exactly one chevron, on one line, near the seek-side edge. + final chevron = find.descendant(of: feedback, matching: find.byType(AppIcon)); + expect(chevron, findsOneWidget); + final label = tester.getRect(find.text('10s')); + final arrow = tester.getRect(chevron); + expect(arrow.left, greaterThan(label.left), reason: 'the chevron leads in the travel direction'); + expect( + arrow.center.dy, + moreOrLessEquals(label.center.dy, epsilon: 2), + reason: 'chevron and amount share one line', + ); + + final surface = tester.getRect(find.byType(PlexVideoControls)); + expect(arrow.right, lessThan(surface.right), reason: 'inside the overscan-safe inset'); + expect(label.left, greaterThan(surface.center.dx), reason: 'anchored to the right half, not centred'); + expect(label.center.dy, moreOrLessEquals(surface.center.dy, epsilon: 2)); + + await settleFeedback(tester); + }); + + /// Opacity of the drifting chevron. Sampling it is how "still animating" + /// becomes observable. + double chevronOpacity(WidgetTester tester) => tester + .widget(find.descendant(of: find.byType(DoubleTapFeedback), matching: find.byType(Opacity))) + .opacity; + + double chevronDx(WidgetTester tester) => tester + .widget(find.descendant(of: find.byType(DoubleTapFeedback), matching: find.byType(Transform))) + .transform + .getTranslation() + .x; + + testWidgets('each stacked press shows its new total', (tester) async { + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('10s'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + + // The amount is the per-press feedback here; no extra kick is needed. + expect(find.text('20s'), findsOneWidget); + expect(find.text('10s'), findsNothing); + + await settleFeedback(tester); + }); + + testWidgets('the chevron stays visible and travels one way through a held burst', (tester) async { + // It is a persistent cue, not a blinking one: it must never fade out + // entirely, and it must only ever displace toward the seek direction. + await pumpControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + var peak = 0.0; + var floor = 1.0; + var mostBackward = 0.0; + var peakDx = 0.0; + final samples = []; + final positions = {}; + for (var i = 0; i < 20; i++) { + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(const Duration(milliseconds: 60)); + final o = chevronOpacity(tester); + peak = math.max(peak, o); + floor = math.min(floor, o); + final dx = chevronDx(tester); + mostBackward = math.min(mostBackward, dx); + peakDx = math.max(peakDx, dx); + samples.add(dx); + positions.add(double.parse(dx.toStringAsFixed(2))); + } + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(floor, greaterThan(0.6), reason: 'the chevron must never blink out mid-burst'); + expect(peak, greaterThan(0.9), reason: 'and must reach full strength as it travels'); + expect(mostBackward, greaterThanOrEqualTo(-0.01), reason: 'a forward seek never crosses behind its origin'); + expect(positions.length, greaterThan(3), reason: 'it keeps moving rather than sitting still'); + // The outward stroke dominates. Mean displacement over a cycle is exactly + // half the amplitude for a symmetric wobble, and higher when the chevron + // dwells at the far end and returns briefly. + final mean = samples.reduce((a, b) => a + b) / samples.length; + expect( + mean / peakDx, + greaterThan(0.55), + reason: 'travel should read as directional, not as an even back-and-forth', + ); + + await settleFeedback(tester); + }); + + testWidgets('the seek readout fits a narrow portrait viewport', (tester) async { + // Rotation is unlockable, so portrait playback is reachable, and the inset + // is derived from viewport width. Pump the readout at the real viewport + // size rather than through the harness, whose surface is pinned landscape. + const narrow = Size(400, 800); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: narrow), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.fromSize( + key: const ValueKey('viewport'), + size: narrow, + child: const Stack(children: [Positioned.fill(child: DoubleTapFeedback(isForward: true, seconds: 10))]), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + expect(tester.takeException(), isNull, reason: 'must not overflow at 400px wide'); + final viewport = tester.getRect(find.byKey(const ValueKey('viewport'))); + final row = tester.getRect(find.byType(Row)); + expect(row.left, greaterThanOrEqualTo(viewport.left), reason: 'stays on screen'); + expect(row.right, lessThanOrEqualTo(viewport.right)); + expect(row.width, lessThan(viewport.width), reason: 'a readout, not a full-width band'); + }); + }); + + // Desktop keyboard seeking never reaches the D-pad branch above: with + // videoPlayerNavigationEnabled false and no TV, the configured Left/Right and + // Shift+Left/Right shortcuts fall through to KeyboardShortcutsService. + group('desktop keyboard seeking', () { + late _RecordingPlayer player; + late PlayerChromeController chrome; + late PlayerToastController toast; + late VideoVolumeController volume; + late PlaybackStateProvider playbackState; + late WatchTogetherProvider watchTogether; + late AppDatabase database; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.seekTimeSmall, 10); + await settings.write(SettingsService.seekTimeLarge, 30); + await settings.write(SettingsService.videoPlayerNavigationEnabled, false); + + TvDetectionService.debugSetAppleTVOverride(false); + PlatformDetector.debugSetIsDesktopOSOverride(true); + + database = AppDatabase.forTesting(NativeDatabase.memory()); + player = _RecordingPlayer(); + chrome = PlayerChromeController(); + toast = PlayerToastController(); + volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + playbackState = PlaybackStateProvider(); + watchTogether = WatchTogetherProvider(); + }); + + tearDown(() async { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + volume.dispose(); + playbackState.dispose(); + watchTogether.dispose(); + chrome.dispose(); + toast.dispose(); + await database.close(); + }); + + Future pumpDesktopControls(WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: database), + ChangeNotifierProvider.value(value: playbackState), + ChangeNotifierProvider.value(value: watchTogether), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: PlexVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'desktop-keyboard-seek'), + toastController: toast, + chromeController: chrome, + canNavigateMediaItems: false, + ), + ), + ), + ), + ), + ); + // The shortcuts service loads asynchronously; without it the arrow keys + // are consumed before ever reaching a seek. + await tester.pumpAndSettle(); + chrome.hide(); + chrome.markControlsHidden(); + await tester.pump(); + expect(chrome.controlsVisible, isFalse); + } + + Future settleFeedback(WidgetTester tester) async { + chrome.cancelAutoHide(); + toast.hide(); + await tester.pumpWidget(const SizedBox.shrink()); + } + + Future pressKey(WidgetTester tester, LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(key); + await tester.pump(); + await tester.sendKeyUpEvent(key); + await tester.pump(); + } + + testWidgets('the configured seek shortcut shows the badge and leaves the chrome down', (tester) async { + await pumpDesktopControls(tester); + + await pressKey(tester, LogicalKeyboardKey.arrowRight); + + expect(chrome.controlsVisible, isFalse, reason: 'keyboard seeking must not cover the subtitles either'); + expect(find.byType(DoubleTapFeedback), findsOneWidget); + expect(find.text('10s'), findsOneWidget); + expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]); + + await settleFeedback(tester); + }); + + testWidgets('the backward shortcut reports its own direction', (tester) async { + await pumpDesktopControls(tester); + + await pressKey(tester, LogicalKeyboardKey.arrowLeft); + + expect(find.text('10s'), findsOneWidget); + expect(player.seeks, [const Duration(minutes: 9, seconds: 50)]); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('the large seek shortcut reports the large amount', (tester) async { + await pumpDesktopControls(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await pressKey(tester, LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + + expect(find.text('30s'), findsOneWidget); + expect(player.seeks, [const Duration(minutes: 10, seconds: 30)]); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + + testWidgets('a rapid burst commits the total the badge reports, even when seeks lag', (tester) async { + // A slow backend has not moved the position when the next press lands. + // Rebasing off player.state.position would peg every request near +10s + // while the badge climbed to 30s. + player.freezePositionOnSeek = true; + await pumpDesktopControls(tester); + + for (var i = 0; i < 3; i++) { + await pressKey(tester, LogicalKeyboardKey.arrowRight); + } + + expect(find.text('30s'), findsOneWidget); + expect(player.seeks, [ + const Duration(minutes: 10, seconds: 10), + const Duration(minutes: 10, seconds: 20), + const Duration(minutes: 10, seconds: 30), + ]); + expect( + player.seeks.last, + const Duration(minutes: 10) + const Duration(seconds: 30), + reason: 'the committed target must equal the badge total', + ); + expect(chrome.controlsVisible, isFalse); + + await settleFeedback(tester); + }); + }); + + group('formatSkipFeedbackLabel', () { + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + test('keeps a bare second count below a minute', () { + expect(formatSkipFeedbackLabel(10), '10s'); + expect(formatSkipFeedbackLabel(59), '59s'); + }); + + test('switches to a timestamp once a held seek passes a minute', () { + expect(formatSkipFeedbackLabel(60), '1:00'); + expect(formatSkipFeedbackLabel(175), '2:55'); + }); + }); +} + +/// Minimal [Player] that records transport calls and keeps a settable +/// playing/position state so intent-dependent behaviour can be asserted. +class _RecordingPlayer implements Player { + final List seeks = []; + int playCalls = 0; + int pauseCalls = 0; + int playOrPauseCalls = 0; + + /// Simulates a backend that has not applied the seek yet, so a caller that + /// rebases off [state] would compute a stale target. + bool freezePositionOnSeek = false; + + bool _playing = true; + Duration _position = const Duration(minutes: 10); + + void setPlaying(bool value) => _playing = value; + + void setPosition(Duration value) => _position = value; + + @override + String get playerType => 'mpv'; + + @override + PlayerState get state => + PlayerState(playing: _playing, position: _position, duration: const Duration(minutes: 45), seekable: true); + + @override + PlayerStreams get streams => PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: const Stream.empty(), + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + @override + Future seek(Duration position) async { + seeks.add(position); + if (!freezePositionOnSeek) _position = position; + } + + @override + Future play() async { + playCalls++; + _playing = true; + } + + @override + Future pause() async { + pauseCalls++; + _playing = false; + } + + @override + Future playOrPause() async { + playOrPauseCalls++; + _playing = !_playing; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}