fix(player): start the TV player with its chrome down
A television raised the whole OSD and timebar on every playback start. The chrome controller is born visible, and its auto-hide clock cannot arm until the first frame lands, so the controls did not merely appear early: they appeared exactly when the picture did, and then sat over the opening five seconds of every movie and episode. The timeline is gated behind the first frame, so the bar materialised on top of the video rather than over the loading spinner, which is what makes it read as a pop-up rather than as chrome that was already there. The route now opens with no chrome on TV. Nothing is lost: the loading spinner and buffering overlay are their own overlays, the screen focus node owns back, and the first D-pad press raises the controls the way it already does after every auto-hide. Pointer and touch platforms keep the chrome, where the viewer's hand is on the surface and the title and back affordance belong over the spinner. Initial presentation now follows initial visibility. They were separate: seeding only visibility would leave the route claiming its chrome was still presented, so PlayerNavigationCoordinator would read back as "hide the chrome", hide() would no-op against chrome that was never up, and the press would be swallowed instead of leaving the player. Controls that mount with the chrome already down now claim focus themselves. Focus normally reaches them through the hide transition, and their own autofocus cannot win it back because the screen node took it during the loading phase. Left alone, the screen node kept primary focus and its self-heal raised the entire OSD on the first D-pad press, which put the chrome straight back over the picture and bypassed the transient seek and transport indicators. Both player spinners now carry a label. They were bare progress indicators, so a screen reader announced nothing at all while the picture was coming up, and the TV Maestro flows had no way left to tell a loading player from a playing one once the Pause button stopped appearing on its own. The two TV flows are repaired to match. They waited on that button, and now wait for the labelled spinner to clear, which cannot happen before the media is opened. 05 additionally reaches Search by D-pad rather than a percentage coordinate, because a tap flips InputModeTracker to pointer mode and collapses the rail it is aiming at, and it gates on the play-next prompt's own Cancel action: "Next Episode" is also the credits skip button, so the old assertion could pass without the prompt ever opening. close #1765
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Пауза",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Покажи контролите за възпроизвеждане",
|
||||
"hidePlaybackControls": "Скрий контролите за възпроизвеждане",
|
||||
"seekBackwardButton": "Превърти назад ${seconds} секунди",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Vis afspilningsknapper",
|
||||
"hidePlaybackControls": "Skjul afspilningsknapper",
|
||||
"seekBackwardButton": "Spol ${seconds} sekunder tilbage",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Wiedergabesteuerung anzeigen",
|
||||
"hidePlaybackControls": "Wiedergabesteuerung ausblenden",
|
||||
"seekBackwardButton": "${seconds} Sekunden zurück",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "一時停止",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "再生コントロールを表示",
|
||||
"hidePlaybackControls": "再生コントロールを非表示",
|
||||
"seekBackwardButton": "${seconds}秒戻る",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Кідірту",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Басқару элементтерін көрсету",
|
||||
"hidePlaybackControls": "Басқару элементтерін жасыру",
|
||||
"seekBackwardButton": "${seconds} секунд артқа айналдыру",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "일시정지",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "재생 컨트롤 표시",
|
||||
"hidePlaybackControls": "재생 컨트롤 숨기기",
|
||||
"seekBackwardButton": "${seconds}초 뒤로 이동",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pause",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Vis avspillingskontroller",
|
||||
"hidePlaybackControls": "Skjul avspillingskontroller",
|
||||
"seekBackwardButton": "Spol tilbake ${seconds} sekunder",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pauzeren",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Afspeelbediening tonen",
|
||||
"hidePlaybackControls": "Afspeelbediening verbergen",
|
||||
"seekBackwardButton": "${seconds} seconden terugspoelen",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Пауза",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Показать элементы управления воспроизведением",
|
||||
"hidePlaybackControls": "Скрыть элементы управления воспроизведением",
|
||||
"seekBackwardButton": "Перемотать назад на ${seconds} с",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pausa",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Visa uppspelningskontroller",
|
||||
"hidePlaybackControls": "Dölj uppspelningskontroller",
|
||||
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Duraklat",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Oynatma kontrollerini göster",
|
||||
"hidePlaybackControls": "Oynatma kontrollerini gizle",
|
||||
"seekBackwardButton": "${seconds} saniye geri sar",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "Pauza",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "Boshqaruv tugmalarini koʻrsatish",
|
||||
"hidePlaybackControls": "Boshqaruv tugmalarini yashirish",
|
||||
"seekBackwardButton": "${seconds} soniya orqaga oʻtkazish",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "暫停",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "顯示播放控制項",
|
||||
"hidePlaybackControls": "隱藏播放控制項",
|
||||
"seekBackwardButton": "後退 ${seconds} 秒",
|
||||
|
||||
@@ -598,6 +598,7 @@
|
||||
"pauseButton": "暂停",
|
||||
"playbackPaused": "",
|
||||
"playbackResumed": "",
|
||||
"loadingVideo": "",
|
||||
"showPlaybackControls": "显示播放控制项",
|
||||
"hidePlaybackControls": "隐藏播放控制项",
|
||||
"seekBackwardButton": "快退 ${seconds} 秒",
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<bool> isBuffering;
|
||||
final ValueListenable<bool> 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _hasFatalPlaybackError = false;
|
||||
|
||||
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<bool> {
|
||||
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;
|
||||
|
||||
@@ -851,6 +851,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -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<bool>(false);
|
||||
final hasFirstFrame = ValueNotifier<bool>(false);
|
||||
final isExiting = ValueNotifier<bool>(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) {
|
||||
|
||||
@@ -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<PlayerChromeController> _openPlayerChrome(WidgetTester tester, {VoidCallback? whileMounted}) async {
|
||||
final key = GlobalKey<VideoPlayerScreenState>();
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<bool> hasFirstFrame;
|
||||
late FocusNode screenFocusNode;
|
||||
late List<LogicalKeyboardKey> 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<bool>(false);
|
||||
screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
|
||||
keysReachingScreen = <LogicalKeyboardKey>[];
|
||||
});
|
||||
|
||||
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<AppDatabase>.value(value: database),
|
||||
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
|
||||
ChangeNotifierProvider<WatchTogetherProvider>.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<void> 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<Duration> 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<void> seek(Duration position) async {
|
||||
seeks.add(position);
|
||||
_position = position;
|
||||
}
|
||||
|
||||
@override
|
||||
PlayerStreams get streams => PlayerStreams(
|
||||
playing: const Stream<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
buffering: const Stream<bool>.empty(),
|
||||
position: const Stream<Duration>.empty(),
|
||||
duration: const Stream<Duration>.empty(),
|
||||
seekable: const Stream<bool>.empty(),
|
||||
buffer: const Stream<Duration>.empty(),
|
||||
volume: const Stream<double>.empty(),
|
||||
rate: const Stream<double>.empty(),
|
||||
tracks: const Stream<Tracks>.empty(),
|
||||
track: const Stream<TrackSelection>.empty(),
|
||||
log: const Stream<PlayerLog>.empty(),
|
||||
error: const Stream<PlayerError>.empty(),
|
||||
audioDevice: const Stream<AudioDevice>.empty(),
|
||||
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||
playbackRestart: const Stream<void>.empty(),
|
||||
backendSwitched: const Stream<void>.empty(),
|
||||
);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user