fix(player): keep the remote on the player surface after a window switch (#1797)

Returning to the desktop window with the chrome still up left arrow keys
navigating the OSD instead of seeking: the first press seeked and silently
moved focus onto Play/Pause, and every press after that walked the buttons.

A window blur drops Flutter's primary focus to the root scope, so the player
screen's reclaim parks it on its own node. The only handoff back down to the
controls was the chrome visible->hidden transition, so with the OSD up nothing
reclaimed it -- hence the reported workarounds of letting the controls hide, or
moving the pointer off the player and back. Pointer exit normally hides the
chrome and masks this, which is why it only shows when the pointer stays over
the player while another window takes focus.

Hand the surface back on window re-activation, next to the existing hide-path
claim, and rename the helper since it is no longer hidden-chrome specific. The
claim runs synchronously because a platform callback is not guaranteed to be
followed by a frame; the screen's reclaim re-tests hasFocus when it runs, so the
two no longer compete.

Also gate the screen's self-heal so a directional key no longer pulls focus into
the OSD when "Video Player Navigation" is off -- Tab and select keep their path
in, which the ungated return value would otherwise consume with nowhere to go.
This commit is contained in:
edde746
2026-08-05 15:24:57 +02:00
parent 23b8befe11
commit 9d51a040c3
5 changed files with 365 additions and 6 deletions
@@ -250,7 +250,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
_controlsOpaque = false;
if (_currentMarker != null) _skipButtonDismissed = true;
});
_claimHiddenChromeFocus();
_claimPlayerSurfaceFocus();
} 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,11 +280,11 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
}
}
/// Park focus on the player surface so the hidden-chrome key layer owns the
/// Park focus on the player surface so this widget's 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() {
void _claimPlayerSurfaceFocus() {
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (sheetOpen) return;
_focusNode.requestFocus();
@@ -299,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
// _claimHiddenChromeFocus).
// _claimPlayerSurfaceFocus).
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) return;
switch (target) {
case PlayerChromeFocusTarget.playPause:
+20 -1
View File
@@ -858,7 +858,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// 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 (!widget.chromeController.controlsVisible) _claimPlayerSurfaceFocus();
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
_refreshDeviceAdjustmentValues();
}
@@ -1010,6 +1010,25 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
/// Re-activating the window drops Flutter's primary focus to the root scope,
/// and the enclosing player screen reclaims it onto its own node. Nothing
/// hands it back down while the chrome stays visible — the hide transition is
/// the only other handoff — so the next arrow key reaches the screen's
/// self-heal and jumps focus into the OSD (#1797). Take the surface back
/// unless a control below already owns it.
@override
void onWindowFocus() {
// Claim now rather than post-frame: this arrives on a platform callback,
// which is not guaranteed to be followed by a frame. The screen's own
// reclaim re-tests `hasFocus` when it runs, so once the surface holds the
// remote the two no longer compete.
if (!mounted || _focusNode.hasFocus) return;
// A route pushed above the player still leaves these controls mounted;
// re-activating the window must not pull the remote off the top route.
if (ModalRoute.of(context)?.isCurrent != true) return;
_claimPlayerSurfaceFocus();
}
@override
// ignore: no-empty-block - required by WindowListener interface
void onWindowResize() {}