diff --git a/.maestro/regression_flows/04_tv_player_keys.yaml b/.maestro/regression_flows/04_tv_player_keys.yaml index 4187f227..c9335d46 100644 --- a/.maestro/regression_flows/04_tv_player_keys.yaml +++ b/.maestro/regression_flows/04_tv_player_keys.yaml @@ -16,13 +16,20 @@ tags: visible: "(?s)^(Play|Resume).*$" commands: - pressKey: "Remote Dpad Center" +# The TV player opens with its chrome down so the OSD and timebar never sit +# over the opening seconds of the picture (#1765), so the Pause button can no +# longer stand in for "the player has finished loading". Wait for the detail +# screen to go first: the labelled spinner is necessarily up the moment the +# player route owns the frame, and it only clears once playback reports its +# first frame or gives up, so this can never advance before the media opens. +# A give-up lands on the transport assertions below, which is where it belongs. - extendedWaitUntil: - visible: "Pause" + notVisible: "Overview" timeout: 30000 -- pressKey: "back" - extendedWaitUntil: - notVisible: "Pause" - timeout: 10000 + notVisible: "Loading video" + timeout: 30000 +- assertNotVisible: "(?s)^(Play|Pause)$" # 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. diff --git a/.maestro/regression_flows/05_tv_next_episode_back.yaml b/.maestro/regression_flows/05_tv_next_episode_back.yaml index ffc757a8..1069c8c5 100644 --- a/.maestro/regression_flows/05_tv_next_episode_back.yaml +++ b/.maestro/regression_flows/05_tv_next_episode_back.yaml @@ -7,9 +7,23 @@ tags: - playback --- - runFlow: ../subflows/onboard_jellyfin_tv.yaml -- tapOn: - point: "6%,22%" -- tapOn: "Search" +# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A +# tap here flips InputModeTracker into pointer mode, which collapses the rail +# to icons and leaves no "Search" label to hit — and a percentage coordinate +# does not survive the jump from a phone in forced TV mode to a 4K television. +- pressKey: "Remote Dpad Left" +- extendedWaitUntil: + visible: "Search" + timeout: 15000 +- repeat: + times: 12 + while: + notVisible: + text: "Search" + focused: true + commands: + - pressKey: "Remote Dpad Down" +- pressKey: "Remote Dpad Center" - extendedWaitUntil: visible: "Search movies, shows, music..." timeout: 10000 @@ -24,13 +38,24 @@ tags: - waitForAnimationToEnd: timeout: 5000 - tapOn: "(?s)^Play S1E1$" +# The TV player opens with its chrome down (#1765), so the Pause button can no +# longer stand in for "the player has finished loading". Wait for the detail +# screen to go first: the labelled spinner is necessarily up the moment the +# player route owns the frame, and it only clears once playback reports its +# first frame or gives up, so this can never advance before the media opens. - extendedWaitUntil: - visible: "Pause" + notVisible: "(?s)^Play S1E1$" + timeout: 30000 +- extendedWaitUntil: + notVisible: "Loading video" timeout: 30000 - pressKey: "Remote Media Fast Forward" - pressKey: "Remote Media Fast Forward" +# Gate on the prompt's own Cancel action, the label the iOS branch below taps. +# "Next Episode" is the credits skip button at this point in the episode, so +# waiting on it would let Back fire before the prompt ever opened. - extendedWaitUntil: - visible: "Next Episode" + visible: "(?s)^Cancel$" timeout: 20000 - runFlow: when: @@ -42,7 +67,10 @@ tags: platform: Android commands: - pressKey: "back" -- assertNotVisible: "Next Episode" +# Dismissal is animated, so give it a bounded wait rather than one instant look. +- extendedWaitUntil: + notVisible: "(?s)^Cancel$" + timeout: 10000 - assertNotVisible: "Overview" - pressKey: "Remote Dpad Up" - extendedWaitUntil: diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 7cf6d487..5101b646 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Fasilə", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 db9ffce9..2a075ded 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Пауза", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "Покажи контролите за възпроизвеждане", "hidePlaybackControls": "Скрий контролите за възпроизвеждане", "seekBackwardButton": "Превърти назад ${seconds} секунди", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index fadc6aa0..d7ead2a5 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pause", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 a36f8064..57c33ff8 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pause", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 2ba8ff57..3353a0bc 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pause", "playbackPaused": "Paused", "playbackResumed": "Playing", + "loadingVideo": "Loading video", "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 3cd67a0a..26c4ef6d 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pausar", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 b7c33922..b9259cb3 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pause", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 4468be7b..ddd02d3e 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Szünet", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 e6b2480e..f8766769 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pausa", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 c0091266..fc25953a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "一時停止", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "再生コントロールを表示", "hidePlaybackControls": "再生コントロールを非表示", "seekBackwardButton": "${seconds}秒戻る", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index ed2feca2..2f6ad86b 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Кідірту", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "Басқару элементтерін көрсету", "hidePlaybackControls": "Басқару элементтерін жасыру", "seekBackwardButton": "${seconds} секунд артқа айналдыру", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index b1623981..0ab23c0d 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "일시정지", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "재생 컨트롤 표시", "hidePlaybackControls": "재생 컨트롤 숨기기", "seekBackwardButton": "${seconds}초 뒤로 이동", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index e88db695..8baf28ad 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pause", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 7a154197..f42a9ae4 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pauzeren", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 dddee436..5358ff85 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pauza", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 f8fb55e1..cf271b11 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pausar", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 0cd72be2..f41e3ce0 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Пауза", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "Показать элементы управления воспроизведением", "hidePlaybackControls": "Скрыть элементы управления воспроизведением", "seekBackwardButton": "Перемотать назад на ${seconds} с", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 7b48f615..b28c6fc9 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: 32945 (1497 per locale) +/// Strings: 32946 (1497 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 ace9d2e6..59a7893b 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1820,6 +1820,9 @@ class Translations$videoControls$en { /// en: 'Playing' String get playbackResumed => 'Playing'; + /// en: 'Loading video' + String get loadingVideo => 'Loading video'; + /// en: 'Show playback controls' String get showPlaybackControls => 'Show playback controls'; @@ -6599,6 +6602,7 @@ extension on Translations { 'videoControls.pauseButton' => 'Pause', 'videoControls.playbackPaused' => 'Paused', 'videoControls.playbackResumed' => 'Playing', + 'videoControls.loadingVideo' => 'Loading video', 'videoControls.showPlaybackControls' => 'Show playback controls', 'videoControls.hidePlaybackControls' => 'Hide playback controls', 'videoControls.seekBackwardButton' => ({required Object seconds}) => 'Seek backward ${seconds} seconds', @@ -7056,9 +7060,9 @@ extension on Translations { 'explore.stats.dropped' => ({required Object n}) => '${n} dropped', 'explore.season.winter' => 'Winter', 'explore.season.spring' => 'Spring', - 'explore.season.summer' => 'Summer', _ => null, } ?? switch (path) { + 'explore.season.summer' => 'Summer', 'explore.season.fall' => 'Fall', 'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}', 'explore.format.tv' => 'TV', @@ -7570,9 +7574,9 @@ extension on Translations { 'externalPlayer.playerUrlScheme' => 'URL Scheme', 'externalPlayer.off' => 'Off', 'externalPlayer.launchFailed' => 'Failed to open external player', - 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', _ => null, } ?? switch (path) { + 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', 'externalPlayer.playInExternalPlayer' => 'Play in External Player', 'metadataEdit.editMetadata' => 'Edit...', 'metadataEdit.screenTitle' => 'Edit Metadata', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 07760835..d2785cac 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pausa", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 a0e969a7..9cdffbbe 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Duraklat", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 56fc206d..6ed3c89f 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "Pauza", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "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 6a8a1f6c..8070e582 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "暫停", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "顯示播放控制項", "hidePlaybackControls": "隱藏播放控制項", "seekBackwardButton": "後退 ${seconds} 秒", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index ba42c82f..4a6e7917 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -598,6 +598,7 @@ "pauseButton": "暂停", "playbackPaused": "", "playbackResumed": "", + "loadingVideo": "", "showPlaybackControls": "显示播放控制项", "hidePlaybackControls": "隐藏播放控制项", "seekBackwardButton": "快退 ${seconds} 秒", diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index b694d3bd..d33b886c 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -79,7 +79,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { Widget _buildLoadingSpinner() { return const Scaffold( backgroundColor: Colors.black, - body: Center(child: CircularProgressIndicator(color: Colors.white)), + body: Center(child: PlayerLoadingIndicator()), ); } @@ -95,7 +95,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { fit: StackFit.expand, children: [ Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame), - const Center(child: CircularProgressIndicator(color: Colors.white)), + const Center(child: PlayerLoadingIndicator()), ], ); } diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 698aa541..5d891a4b 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -46,6 +46,28 @@ class VideoPlayerMacPipPlaceholder extends StatelessWidget { } } +/// The player's loading spinner. +/// +/// Labelled rather than silent: a bare [CircularProgressIndicator] contributes +/// no semantics at all, so a screen reader announced nothing while the picture +/// was still coming up. The label also makes "the first frame has not rendered +/// yet" an observable state instead of something inferred from the chrome, +/// which a television no longer raises on startup (#1765). +class PlayerLoadingIndicator extends StatelessWidget { + final double strokeWidth; + + const PlayerLoadingIndicator({super.key, this.strokeWidth = 4}); + + @override + Widget build(BuildContext context) { + return CircularProgressIndicator( + color: Colors.white, + strokeWidth: strokeWidth, + semanticsLabel: t.videoControls.loadingVideo, + ); + } +} + class VideoPlayerBufferingOverlay extends StatelessWidget { final ValueListenable isBuffering; final ValueListenable hasFirstFrame; @@ -77,7 +99,7 @@ class VideoPlayerBufferingOverlay extends StatelessWidget { child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 3), + child: const PlayerLoadingIndicator(strokeWidth: 3), ), ), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index b2701a91..b8e283db 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -135,6 +135,17 @@ bool shouldAutoStartReloadedMedia({ required bool startPaused, }) => wasPlayingBeforeReload && !watchTogetherOwnsStart && !startPaused; +/// Whether a freshly opened player route starts with its chrome up. +/// +/// A television starts with the controls down. Auto-hide cannot even arm until +/// the first frame lands, so chrome raised by the route opening would park the +/// whole OSD and timebar over the first seconds of picture (#1765). The route +/// keeps its own loading surface and buffering overlay, the screen focus node +/// owns back, and the first D-pad press raises the chrome, so a remote loses +/// nothing. Pointer and touch platforms keep it: the viewer's hand is already +/// on the surface, and the title and back affordance belong over the spinner. +bool playerChromeStartsVisible({required bool isTv}) => !isTv; + /// Builds an item-agnostic subtitle preference for an episode replacement. /// /// Source ids and sidecar URIs belong to the current media item. Only the @@ -737,7 +748,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _hasFatalPlaybackError = false; final ValueNotifier _isExiting = ValueNotifier(false); - final PlayerChromeController _chromeController = PlayerChromeController(); + final PlayerChromeController _chromeController = PlayerChromeController( + initiallyVisible: playerChromeStartsVisible(isTv: PlatformDetector.isTV()), + ); + + /// Lets startup-policy coverage assert the chrome this route actually opened + /// with, rather than a controller the test seeded itself. + @visibleForTesting + PlayerChromeController get chromeController => _chromeController; + late final PlayerNavigationCoordinator _playerNavigationCoordinator; @override diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 7b6e94f3..3e24775f 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -250,7 +250,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { _controlsOpaque = false; if (_currentMarker != null) _skipButtonDismissed = true; }); - _reclaimFocusAfterControlsHide(); + _claimHiddenChromeFocus(); } 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 @@ -280,7 +280,11 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { } } - void _reclaimFocusAfterControlsHide() { + /// Park focus on the player surface so the hidden-chrome key layer owns the + /// remote. Without this the screen node keeps primary focus and its + /// self-heal raises the whole chrome on the first actionable key, which is + /// what the transient seek and transport indicators exist to avoid. + void _claimHiddenChromeFocus() { final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; if (sheetOpen) return; _focusNode.requestFocus(); @@ -295,7 +299,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !widget.chromeController.controlsVisible) return; // Never steal focus from an open sheet (same rule as - // _reclaimFocusAfterControlsHide). + // _claimHiddenChromeFocus). if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) return; switch (target) { case PlayerChromeFocusTarget.playPause: diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart index 1e6529e2..45eb0213 100644 --- a/lib/widgets/video_controls/player_chrome_controller.dart +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -12,10 +12,12 @@ enum PlayerChromeFocusTarget { playPause, timeline } /// Owns video-player chrome visibility and auto-hide policy for one player route. class PlayerChromeController extends ChangeNotifier implements ValueListenable { - PlayerChromeController({this._controlsVisible = true}); + PlayerChromeController({bool initiallyVisible = true}) + : _controlsVisible = initiallyVisible, + _controlsPresented = initiallyVisible; bool _controlsVisible; - bool _controlsPresented = true; + bool _controlsPresented; bool _contentStripVisible = false; bool _playing = false; bool _hasFirstFrame = true; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 4bff1baf..1963a823 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -851,6 +851,10 @@ class _PlexVideoControlsState extends State _rateSubscription = widget.player.streams.rate.listen(_onRateChanged); _loadPlaybackExtras(); _focusPlayPauseIfKeyboardMode(); + // A route that opened with no chrome never ran the hide transition that + // normally hands focus down here, and this Focus autofocuses too late to + // win it: the screen node claimed it during the loading phase. + if (!widget.chromeController.controlsVisible) _claimHiddenChromeFocus(); if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) { _refreshDeviceAdjustmentValues(); } diff --git a/test/screens/video_player/player_prompt_overlays_test.dart b/test/screens/video_player/player_prompt_overlays_test.dart index deb04da0..37e5492c 100644 --- a/test/screens/video_player/player_prompt_overlays_test.dart +++ b/test/screens/video_player/player_prompt_overlays_test.dart @@ -148,6 +148,39 @@ void main() { expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isFalse); expect(notifications, 0); }); + + testWidgets('the buffering spinner announces loading until the first frame renders', (tester) async { + PipService().isPipActive.value = false; + final isBuffering = ValueNotifier(false); + final hasFirstFrame = ValueNotifier(false); + final isExiting = ValueNotifier(false); + addTearDown(isBuffering.dispose); + addTearDown(hasFirstFrame.dispose); + addTearDown(isExiting.dispose); + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + _wrapPrompt( + VideoPlayerBufferingOverlay(isBuffering: isBuffering, hasFirstFrame: hasFirstFrame, isExiting: isExiting), + ), + ); + + // The TV player no longer raises its chrome on startup (#1765), so this + // label is what tells "the player is still waiting for its first frame" + // apart from "it has stopped waiting" — the readiness gate the Maestro TV + // flows use in place of the Pause button. + expect(find.bySemanticsLabel('Loading video'), findsOneWidget); + + hasFirstFrame.value = true; + await tester.pump(); + expect(find.bySemanticsLabel('Loading video'), findsNothing); + + isBuffering.value = true; + await tester.pump(); + expect(find.bySemanticsLabel('Loading video'), findsOneWidget, reason: 'a mid-playback stall loads again'); + + semantics.dispose(); + }); } Widget _wrapPrompt(Widget child) { diff --git a/test/screens/video_player/player_startup_chrome_test.dart b/test/screens/video_player/player_startup_chrome_test.dart new file mode 100644 index 00000000..7351564f --- /dev/null +++ b/test/screens/video_player/player_startup_chrome_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/screens/video_player_screen.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/mock_player_channels.dart'; +import '../../test_helpers/prefs.dart'; + +/// Regression coverage for #1765: a television opened the player with the whole +/// OSD and timebar up, and auto-hide cannot arm before the first frame, so the +/// chrome sat over the opening seconds of every video. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + test('a television opens the player with its chrome down', () { + expect(playerChromeStartsVisible(isTv: true), isFalse); + expect(playerChromeStartsVisible(isTv: false), isTrue); + }); + + testWidgets('the TV player route opens with the chrome down and unpresented', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + + final chrome = await _openPlayerChrome(tester); + + expect(chrome.controlsVisible, isFalse); + // Presented must fall with visible, or PlayerNavigationCoordinator reads + // back as "hide the chrome", hide() no-ops, and back is swallowed. + expect(chrome.controlsPresented, isFalse); + }); + + testWidgets('pointer and touch routes keep the chrome over the loading surface', (tester) async { + TvDetectionService.debugSetAppleTVOverride(false); + + final chrome = await _openPlayerChrome(tester); + + expect(chrome.controlsVisible, isTrue); + expect(chrome.controlsPresented, isTrue); + }); + + testWidgets('the route announces loading from its first frame, with or without chrome', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final semantics = tester.ensureSemantics(); + + // The Maestro TV flows replaced the Pause button with this label as their + // readiness gate, which only holds if it is on screen from the moment the + // player route owns the frame — long before any media opens. + await _openPlayerChrome( + tester, + whileMounted: () { + expect(find.bySemanticsLabel('Loading video'), findsOneWidget); + }, + ); + + semantics.dispose(); + }); +} + +Future _openPlayerChrome(WidgetTester tester, {VoidCallback? whileMounted}) async { + final key = GlobalKey(); + late PlayerChromeController chrome; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + testBody: () async { + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => PlaybackStateProvider(), + child: MaterialApp( + home: VideoPlayerScreen( + key: key, + metadata: testMediaItem(title: 'Startup chrome video'), + isOffline: true, + ), + ), + ), + ); + chrome = key.currentState!.chromeController; + whileMounted?.call(); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + return chrome; +} diff --git a/test/widgets/player_chrome_controller_test.dart b/test/widgets/player_chrome_controller_test.dart index d5034685..8d9614dc 100644 --- a/test/widgets/player_chrome_controller_test.dart +++ b/test/widgets/player_chrome_controller_test.dart @@ -133,6 +133,25 @@ void main() { expect(controller.controlsPresented, isFalse); }); + test('a hidden start is also unpresented, so back is not classified as hide-the-chrome', () { + final controller = PlayerChromeController(initiallyVisible: false); + addTearDown(controller.dispose); + + expect(controller.controlsVisible, isFalse); + expect(controller.controlsPresented, isFalse); + expect(controller.hide(), isFalse, reason: 'there is nothing to hide, so back must fall through to the route'); + }); + + test('showing after a hidden start restores both visibility and presentation', () { + final controller = PlayerChromeController(initiallyVisible: false); + addTearDown(controller.dispose); + + controller.show(); + + expect(controller.controlsVisible, isTrue); + expect(controller.controlsPresented, isTrue); + }); + test('a stale fade-out completion cannot hide controls that were shown again', () { final controller = PlayerChromeController(); addTearDown(controller.dispose); diff --git a/test/widgets/video_controls_startup_chrome_test.dart b/test/widgets/video_controls_startup_chrome_test.dart new file mode 100644 index 00000000..198d205b --- /dev/null +++ b/test/widgets/video_controls_startup_chrome_test.dart @@ -0,0 +1,246 @@ +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/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/desktop_video_controls.dart'; +import 'package:plezy/widgets/video_controls/mobile_video_controls.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:plezy/widgets/video_controls/video_controls.dart'; +import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +/// Regression coverage for #1765: a player route that opens with its chrome +/// down must stay down across the whole startup sequence. Auto-hide cannot arm +/// before the first frame, so chrome that survives into the picture parks the +/// OSD and timebar over the opening seconds of the video. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('startup chrome', () { + late _PlayingPlayer player; + late PlayerChromeController chrome; + late PlayerToastController toast; + late VideoVolumeController volume; + late PlaybackStateProvider playbackState; + late WatchTogetherProvider watchTogether; + late AppDatabase database; + late ValueNotifier hasFirstFrame; + late FocusNode screenFocusNode; + late List keysReachingScreen; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + + TvDetectionService.debugSetAppleTVOverride(true); + PlatformDetector.debugSetIsDesktopOSOverride(false); + + database = AppDatabase.forTesting(NativeDatabase.memory()); + player = _PlayingPlayer(); + chrome = PlayerChromeController(initiallyVisible: false); + toast = PlayerToastController(); + volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + playbackState = PlaybackStateProvider(); + watchTogether = WatchTogetherProvider(); + hasFirstFrame = ValueNotifier(false); + screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen'); + keysReachingScreen = []; + }); + + tearDown(() async { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + hasFirstFrame.dispose(); + screenFocusNode.dispose(); + volume.dispose(); + playbackState.dispose(); + watchTogether.dispose(); + chrome.dispose(); + toast.dispose(); + await database.close(); + }); + + Widget shell(Widget child) { + return 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: Focus( + focusNode: screenFocusNode, + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) keysReachingScreen.add(event.logicalKey); + return KeyEventResult.ignored; + }, + child: child, + ), + ), + ), + ), + ); + } + + Future pumpControls(WidgetTester tester) async { + // Two phases, like the route itself: the loading surface first, so the + // screen node owns primary focus before the controls exist. The controls' + // own `autofocus` cannot win it back afterwards — an explicit claim must. + await tester.pumpWidget(shell(const SizedBox.expand())); + await tester.pump(); + expect(screenFocusNode.hasPrimaryFocus, isTrue, reason: 'the loading phase owns focus, as on the real route'); + + await tester.pumpWidget( + shell( + PlexVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'startup-chrome'), + toastController: toast, + chromeController: chrome, + hasFirstFrame: hasFirstFrame, + canNavigateMediaItems: false, + ), + ), + ); + await tester.pump(); + } + + void expectNoChrome(String reason) { + expect(chrome.controlsVisible, isFalse, reason: reason); + // Presented must fall with visible, or Back is classified as "hide the + // chrome", no-ops against already-hidden chrome, and never exits. + expect(chrome.controlsPresented, isFalse, reason: reason); + expect(find.byType(DesktopVideoControls), findsNothing, reason: reason); + expect(find.byType(MobileVideoControls), findsNothing, reason: reason); + } + + testWidgets('a hidden start never mounts the OSD while loading or once the picture arrives', (tester) async { + await pumpControls(tester); + + expectNoChrome('the route opened with the chrome down'); + + hasFirstFrame.value = true; + await tester.pump(); + expectNoChrome('the first frame must not raise the OSD over the picture'); + + // Well past the TV auto-hide delay: nothing may surface late either. + await tester.pump(const Duration(seconds: 10)); + expectNoChrome('no deferred timer may raise the OSD after startup'); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('the viewer can still raise the OSD after a hidden start', (tester) async { + await pumpControls(tester); + hasFirstFrame.value = true; + await tester.pump(); + + chrome.show(); + await tester.pumpAndSettle(); + + expect(chrome.controlsVisible, isTrue); + expect(chrome.controlsPresented, isTrue); + expect(find.byType(DesktopVideoControls), findsOneWidget); + + chrome.cancelAutoHide(); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('a hidden start hands the remote to the hidden-chrome key layer', (tester) async { + await pumpControls(tester); + hasFirstFrame.value = true; + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + // The controls' own Focus autofocuses too late to win the scope: without + // an explicit claim the screen node keeps primary focus, the arrow + // reaches it first, and its self-heal raises the whole OSD on this very + // first press (#1765). + expect( + keysReachingScreen, + isEmpty, + reason: 'the hidden-chrome layer must own the remote, not the screen self-heal', + ); + expect(find.text('10s'), findsOneWidget, reason: 'the seek badge answers, not the chrome'); + expectNoChrome('a directional seek must leave the picture alone'); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(player.seeks, [const Duration(seconds: 10)]); + + chrome.cancelAutoHide(); + await tester.pumpWidget(const SizedBox.shrink()); + }); + }); +} + +/// Minimal [Player] that reports steady playback, the state a startup sequence +/// settles into once the media opens. +class _PlayingPlayer implements Player { + final List seeks = []; + Duration _position = Duration.zero; + + @override + String get playerType => 'mpv'; + + @override + PlayerState get state => + PlayerState(playing: true, position: _position, duration: const Duration(minutes: 45), seekable: true); + + @override + Future seek(Duration position) async { + seeks.add(position); + _position = position; + } + + @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 + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 231b8a25..fdf6be24 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -623,6 +623,18 @@ void main() { expect(exits, 1); }); + testWidgets('Back exits on the first press when the route opened with no chrome', (tester) async { + final chromeController = PlayerChromeController(initiallyVisible: false); + addTearDown(chromeController.dispose); + var exits = 0; + final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++); + await pumpNavigationFocus(tester, coordinator); + + await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + + expect(exits, 1, reason: 'a TV start has no chrome to hide, so back belongs to the route (#1765)'); + }); + testWidgets('physical Escape outside fullscreen hides presented chrome without exiting', (tester) async { final chromeController = PlayerChromeController(); addTearDown(chromeController.dispose);