fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and dropped focus onto Play/Pause, even with Video Player Navigation off. Two independent paths did it. InputModeTracker promoted on any key satisfying isNavigationKey, a set that unioned activation, dismissal and the menu key with the arrows and consulted no setting at all; separately the surface's Select handler always asked the chrome for focus. Escape had the same effect, which on desktop reads as the mouse cursor vanishing mid-playback. Both now ask one predicate. eventRequestsFocusNavigation decides whether the app switches to keyboard mode and whether a key may hand focus to the chrome, so the two cannot disagree and focus can never land on a control while focus chrome is still suppressed. Activation and dismissal act on what already has focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an arrow that will really traverse answer yes. The one input the predicate cannot read off the event, whether the focused feature owns arrow keys, rides on the node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet, prompt and OSD button stays an ordinary traversal target with nothing to re-enable. playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace five hand-copied pref-or-isTV expressions and a screen-level cache that disagreed with the live getter after a toggle. Services whose input is synthesized past HardwareKeyboard announce themselves through InputModeTracker.reportNonPointerInput rather than two static callbacks and three copies of a highlight-strategy write. That registration is now identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker after the incoming one initialised and cleared both callbacks, so gamepad and companion remote input had stopped switching to keyboard mode entirely. Falling out of the same rule: a companion heartbeat no longer flips an idle desktop host into keyboard mode, analog-stick drift promotes only past the deadzone that actually navigates, Enter keeps toggling playback once the chrome is up, Tab both reaches and traverses the OSD, and the player surface claims the remote from mount rather than only when the chrome starts hidden, so the first key on a desktop route is a playback shortcut instead of the screen node's chrome-raising self-heal. isNavigationKey becomes isReservedControlKey, since its real meaning is a shell key rather than a text character and the old name is what invited the conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
This commit is contained in:
@@ -392,7 +392,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
|
||||
}
|
||||
|
||||
final character = event.character;
|
||||
if (character != null && character.isNotEmpty && !key.isNavigationKey) {
|
||||
if (character != null && character.isNotEmpty && !key.isReservedControlKey) {
|
||||
_insert(character);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -422,7 +422,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
|
||||
}
|
||||
|
||||
final character = event.character;
|
||||
if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
|
||||
if (character != null && character.isNotEmpty && !key.isReservedControlKey && !_isControlCharacter(character)) {
|
||||
_insert(character);
|
||||
_dismissForPhysicalKeyboardInput();
|
||||
return true;
|
||||
|
||||
@@ -152,7 +152,7 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
this.onLiveSeek,
|
||||
this.onLiveSeekBy,
|
||||
this.onJumpToLive,
|
||||
this.useDpadNavigation = false,
|
||||
required this.useDpadNavigation,
|
||||
this.serverId,
|
||||
this.showQueueTab = false,
|
||||
this.onQueueItemSelected,
|
||||
@@ -297,16 +297,17 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request focus on the play/pause button (called when controls shown via keyboard)
|
||||
/// Move focus to the play/pause button.
|
||||
///
|
||||
/// Raw mechanism: it does not decide whether focus *should* enter the chrome.
|
||||
/// A key that raises the chrome makes that decision with
|
||||
/// `eventRequestsFocusNavigation` before queueing a
|
||||
/// [PlayerChromeFocusTarget]; internal hand-offs (the skip-marker button's
|
||||
/// ArrowDown, an item swap) are already inside a focus session.
|
||||
void requestPlayPauseFocus() {
|
||||
_playPauseFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Request focus on the timeline (called when controls shown via LEFT/RIGHT)
|
||||
void requestTimelineFocus() {
|
||||
_timelineFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Hide content strip (called by parent when controls hide)
|
||||
void hideContentStrip() {
|
||||
if (_contentStripVisible) {
|
||||
|
||||
@@ -66,19 +66,27 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
return event is KeyDownEvent ? _transportCommandFor(event) : null;
|
||||
}
|
||||
|
||||
void _activateHiddenControlsPrimaryAction() {
|
||||
/// The player surface's Select action.
|
||||
///
|
||||
/// [requestFocus] is the caller's `eventRequestsFocusNavigation` answer, so a
|
||||
/// remote OK lands on Play/Pause while a physical-keyboard Enter leaves focus
|
||||
/// where it is — activation is not a request to start navigating.
|
||||
void _activatePlayerSurfaceSelect({required bool requestFocus}) {
|
||||
if (!widget.canControl) {
|
||||
_showControlsWithFocus();
|
||||
_showControlsWithFocus(requestFocus: requestFocus);
|
||||
return;
|
||||
}
|
||||
if (_isSkipMarkerButtonVisible) {
|
||||
// Skip-Intro is the primary action only while the chrome is down and the
|
||||
// button is the sole affordance on screen; with the OSD up it is a real
|
||||
// focusable control and Select must stay "toggle playback".
|
||||
if (!_showControls && _isSkipMarkerButtonVisible) {
|
||||
_activateSkipMarker();
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
_showControlsWithFocus(requestFocus: requestFocus);
|
||||
unawaited(_playOrPause());
|
||||
}
|
||||
|
||||
@@ -161,7 +169,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
|
||||
if (_videoPlayerNavigationEnabled) return false;
|
||||
if (videoPlayerNavigationPreference()) return false;
|
||||
|
||||
// Skip on mobile (unless TV)
|
||||
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
|
||||
@@ -214,7 +222,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
// Consume KeyUp events for navigation keys to prevent leaking to previous routes.
|
||||
// Let non-navigation keys (volume, etc.) pass through to the OS.
|
||||
if (!event.isActionable) {
|
||||
if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored;
|
||||
if (!event.logicalKey.isReservedControlKey) return KeyEventResult.ignored;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -231,7 +239,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
// 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) {
|
||||
if ((videoPlayerNavigationPreference() || isMobile) && event is KeyDownEvent) {
|
||||
unawaited(_playOrPause(command: transportCommand));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
@@ -256,18 +264,35 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Handle Select/Enter when controls are hidden.
|
||||
// Only intercept if this Focus node itself has primary focus (not a descendant).
|
||||
// When the skip marker button is the only visible affordance, Select activates
|
||||
// it; otherwise it falls back to play/pause + show controls.
|
||||
if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) {
|
||||
return handleOneShotSelect(event, _activateHiddenControlsPrimaryAction);
|
||||
// Select on the player surface. Only intercept when this Focus node itself
|
||||
// holds primary focus — a focused OSD control owns its own activation.
|
||||
// Whether the raised chrome also takes focus is the key's own answer, so
|
||||
// mode and focus can never disagree: a remote OK starts a focus session, a
|
||||
// physical-keyboard Enter just shows the controls and toggles playback.
|
||||
if (_isSelectKey(key) && _focusNode.hasPrimaryFocus) {
|
||||
return handleOneShotSelect(
|
||||
event,
|
||||
() => _activatePlayerSurfaceSelect(requestFocus: eventRequestsFocusNavigation(event, focused: _focusNode)),
|
||||
);
|
||||
}
|
||||
|
||||
// Tab is the deliberate way into the OSD (#1797). With the chrome down,
|
||||
// raise it and hand it focus; with the chrome up, let Flutter's app-level
|
||||
// Shortcuts run NextFocusAction and walk in, rather than consuming the key
|
||||
// into a dead end below. Returning ignored cannot leak to the route below:
|
||||
// key dispatch only walks the current focus chain, and covered routes are
|
||||
// not on it.
|
||||
if (key == LogicalKeyboardKey.tab && _focusNode.hasPrimaryFocus) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.handled;
|
||||
if (_showControls) return KeyEventResult.ignored;
|
||||
_showControlsWithFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// 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 (!isMobile && _isDirectionalKey(key) && playerDirectionalNavigationEnabled()) {
|
||||
if (!_showControls) {
|
||||
if (_isHorizontalKey(key)) {
|
||||
if (shouldStartHiddenDirectionalSeek(event)) {
|
||||
@@ -279,19 +304,31 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Children (DesktopVideoControls) handle navigation first via their own onKeyEvent.
|
||||
// If we reach here, children already declined the event — consume it to prevent leaking.
|
||||
// Children (DesktopVideoControls) handle navigation first via their own
|
||||
// onKeyEvent. Reaching here with the surface still focused means nothing
|
||||
// in the chrome owns focus yet — hand it over instead of consuming the
|
||||
// key into nothing. This is the same key that just switched the app into
|
||||
// keyboard mode, so focus has to become visible or the two diverge.
|
||||
if (_focusNode.hasPrimaryFocus) {
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Reserved control keys are consumed rather than returned as ignored, so
|
||||
// they cannot leak to the route below. Tab is the exception: app-level
|
||||
// Shortcuts turn it into NextFocusAction, which is how focus traverses
|
||||
// *inside* the chrome, and key dispatch only walks the current focus chain
|
||||
// so it cannot reach a covered route anyway.
|
||||
final consumeToPreventLeak = key.isReservedControlKey && key != LogicalKeyboardKey.tab;
|
||||
|
||||
// Pass other events to the keyboard shortcuts service.
|
||||
if (_keyboardService == null) {
|
||||
return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||
return consumeToPreventLeak ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final result = _dispatchShortcut(event, onSkipMarker: _performAutoSkip);
|
||||
if (!event.logicalKey.isNavigationKey) return result;
|
||||
// Never return .ignored for navigation keys — prevent leaking to previous routes.
|
||||
if (!consumeToPreventLeak) return result;
|
||||
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
// Auto-focus skip button on TV when marker appears (only in keyboard/TV mode)
|
||||
if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) {
|
||||
if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_skipMarkerFocusNode.requestFocus();
|
||||
|
||||
@@ -7,7 +7,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
playbackState: playbackState,
|
||||
onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop,
|
||||
);
|
||||
final useDpad = _videoPlayerNavigationEnabled || PlatformDetector.isTV();
|
||||
final useDpad = playerDirectionalNavigationEnabled();
|
||||
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
|
||||
@@ -16,14 +16,29 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus play/pause button if we're in keyboard navigation mode (desktop/TV only)
|
||||
void _focusPlayPauseIfKeyboardMode() {
|
||||
if (!mounted) return;
|
||||
if (!_videoPlayerNavigationEnabled) return;
|
||||
/// Focus Play/Pause when the viewer is already driving with keyboard/D-pad
|
||||
/// and opted into player navigation.
|
||||
///
|
||||
/// This is an *automatic* grab — no key caused it — so it additionally
|
||||
/// requires an active keyboard session; a key-driven grab only needs
|
||||
/// [eventRequestsFocusNavigation].
|
||||
///
|
||||
/// Returns whether it actually moved focus, because the caller uses that to
|
||||
/// decide whether the player surface still needs to claim the remote. It must
|
||||
/// therefore report `false` when the chrome is not mounted — a TV route opens
|
||||
/// with the chrome down, and claiming that it focused something there would
|
||||
/// leave the remote parked on the screen node (#1765).
|
||||
bool _focusPlayPauseIfKeyboardMode() {
|
||||
if (!mounted || !_showControls) return false;
|
||||
// The raw preference, not the directional policy: a TV viewer who turned
|
||||
// player navigation off must not get Play/Pause focused on open.
|
||||
if (!videoPlayerNavigationPreference()) return false;
|
||||
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
|
||||
if (!isMobile && InputModeTracker.isKeyboardMode(context)) {
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
}
|
||||
if (isMobile || !InputModeTracker.isKeyboardMode(context, listen: false)) return false;
|
||||
final controls = _desktopControlsKey.currentState;
|
||||
if (controls == null) return false;
|
||||
controls.requestPlayPauseFocus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Listen to playback state changes to manage auto-hide timer
|
||||
@@ -54,7 +69,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
return const Duration(seconds: 30);
|
||||
}
|
||||
final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV();
|
||||
if (isMobile || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) {
|
||||
if (isMobile || playerDirectionalNavigationEnabled()) {
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
return const Duration(seconds: 3);
|
||||
@@ -285,16 +300,19 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
/// self-heal raises the whole chrome on the first actionable key, which is
|
||||
/// what the transient seek and transport indicators exist to avoid.
|
||||
void _claimPlayerSurfaceFocus() {
|
||||
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
|
||||
if (sheetOpen) return;
|
||||
if (_sheetIsOpen()) return;
|
||||
_focusNode.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_focusNode.hasPrimaryFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
// Re-check: a sheet or a route can open during the frame we deferred
|
||||
// over, and the retry must not pull the remote back out of it.
|
||||
if (!mounted || _focusNode.hasPrimaryFocus || _sheetIsOpen()) return;
|
||||
if (ModalRoute.of(context)?.isCurrent != true) return;
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
bool _sheetIsOpen() => OverlaySheetController.maybeOf(context)?.isOpen ?? false;
|
||||
|
||||
void _requestFocusTarget(PlayerChromeFocusTarget target) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !widget.chromeController.controlsVisible) return;
|
||||
@@ -304,8 +322,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
switch (target) {
|
||||
case PlayerChromeFocusTarget.playPause:
|
||||
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
||||
case PlayerChromeFocusTarget.timeline:
|
||||
_desktopControlsKey.currentState?.requestTimelineFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import 'package:flutter/material.dart'
|
||||
enum PlayerChromeHold { pip, contentStrip, promptInteraction, scrub }
|
||||
|
||||
/// Focus target to request after chrome has rebuilt visible controls.
|
||||
enum PlayerChromeFocusTarget { playPause, timeline }
|
||||
///
|
||||
/// Named rather than a bool so the call sites that hand the chrome focus say
|
||||
/// what they mean, and so adding a target forces every consumer to decide.
|
||||
enum PlayerChromeFocusTarget { playPause }
|
||||
|
||||
/// Owns video-player chrome visibility and auto-hide policy for one player route.
|
||||
class PlayerChromeController extends ChangeNotifier implements ValueListenable<bool> {
|
||||
|
||||
@@ -40,6 +40,7 @@ import '../../mixins/mounted_set_state_mixin.dart';
|
||||
import '../../mpv/mpv.dart';
|
||||
import '../overlay_sheet.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focus_navigation_intent.dart';
|
||||
|
||||
import '../../database/app_database.dart';
|
||||
import '../../media/media_backend.dart';
|
||||
@@ -217,6 +218,52 @@ bool shouldShowSkipMarkerButton({
|
||||
return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible);
|
||||
}
|
||||
|
||||
/// The viewer's raw "Video Player Navigation" preference, ignoring TV.
|
||||
///
|
||||
/// Use this for genuine preferences — auto-hide delay, Escape semantics,
|
||||
/// hardware transport interception. For "do arrow keys move focus in the
|
||||
/// player?" use [playerDirectionalNavigationEnabled] instead: OR-ing `isTV()`
|
||||
/// into a preference read changes behaviour for a TV viewer who turned the
|
||||
/// preference off.
|
||||
///
|
||||
/// Reads through [SettingsService.instanceOrNull] because focus policy is
|
||||
/// consulted from a global key handler that can run before startup finishes.
|
||||
/// The pre-init answer is `false`; the preference's real default is
|
||||
/// `TvDetectionService.isTVSync`, so a pre-init read understates it on TV —
|
||||
/// harmless only because no player route, and so no
|
||||
/// [DirectionalShortcutFocusNode], can mount before settings are loaded.
|
||||
bool videoPlayerNavigationPreference() =>
|
||||
SettingsService.instanceOrNull?.read(SettingsService.videoPlayerNavigationEnabled) ?? false;
|
||||
|
||||
/// Whether the video player treats arrow / D-pad keys as focus navigation
|
||||
/// rather than playback shortcuts. A television always navigates.
|
||||
///
|
||||
/// This is the single derivation of that policy. Every site that used to spell
|
||||
/// it `pref || PlatformDetector.isTV()` by hand reads it from here.
|
||||
bool playerDirectionalNavigationEnabled() => videoPlayerNavigationPreference() || PlatformDetector.isTV();
|
||||
|
||||
/// Builds one of the player's catch-all surface nodes.
|
||||
///
|
||||
/// Both the screen node and the player-surface node can hold primary focus
|
||||
/// without the viewer ever having navigated — they autofocus and are actively
|
||||
/// reclaimed — so an arrow pressed while they hold focus is a playback
|
||||
/// shortcut, not traversal, and must not switch the app into keyboard mode.
|
||||
/// Minting them here keeps that derivation in one place; [alsoOwns] adds the
|
||||
/// per-key exceptions a surface still claims once navigation is enabled.
|
||||
///
|
||||
/// `skipTraversal` keeps these full-screen invisible nodes out of the Tab ring:
|
||||
/// they are entered by autofocus and explicit requests, never by traversal.
|
||||
DirectionalShortcutFocusNode playerSurfaceFocusNode(
|
||||
String debugLabel, {
|
||||
bool Function(LogicalKeyboardKey key)? alsoOwns,
|
||||
}) {
|
||||
return DirectionalShortcutFocusNode(
|
||||
debugLabel: debugLabel,
|
||||
skipTraversal: true,
|
||||
consumesDirectionalKeys: (key) => !playerDirectionalNavigationEnabled() || (alsoOwns?.call(key) ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
enum PlayerNavigationKey { none, physicalEscape, back, home }
|
||||
|
||||
enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer }
|
||||
@@ -743,8 +790,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// Skip button dismiss state
|
||||
bool _skipButtonDismissed = false;
|
||||
Timer? _skipButtonDismissTimer;
|
||||
// Video player navigation (use arrow keys to navigate controls)
|
||||
bool get _videoPlayerNavigationEnabled => _settings.read(SettingsService.videoPlayerNavigationEnabled);
|
||||
// Performance overlay
|
||||
bool get _showPerformanceOverlay => _settings.read(SettingsService.showPerformanceOverlay);
|
||||
bool get _autoHidePerformanceOverlay => _settings.read(SettingsService.autoHidePerformanceOverlay);
|
||||
@@ -776,7 +821,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_lastControlsVisible = widget.chromeController.controlsVisible;
|
||||
_controlsMounted = _lastControlsVisible;
|
||||
_controlsOpaque = _lastControlsVisible;
|
||||
_focusNode = FocusNode();
|
||||
// Horizontal arrows stay the player's even with navigation enabled: with
|
||||
// the chrome down they run a hidden seek (see parts/key_events.dart), so
|
||||
// they are not evidence of a focus session. Up/Down raises the chrome and
|
||||
// is traversal, so it promotes.
|
||||
_focusNode = playerSurfaceFocusNode(
|
||||
'PlayerSurface',
|
||||
alsoOwns: (key) => !_showControls && (key.isLeftKey || key.isRightKey),
|
||||
);
|
||||
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
|
||||
_seekThrottle = throttle(
|
||||
(Duration pos) {
|
||||
@@ -861,11 +913,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_lastReportedRate = widget.player.state.rate;
|
||||
_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) _claimPlayerSurfaceFocus();
|
||||
// The player surface owns the remote whenever no chrome control was
|
||||
// deliberately given focus. A route that opens with the chrome already up
|
||||
// (every desktop route — see playerChromeStartsVisible) never runs the
|
||||
// hide transition that normally hands focus down here, and this Focus
|
||||
// autofocuses too late to win it back from the screen node claimed during
|
||||
// the loading phase. Leaving focus parked up there is what turns the first
|
||||
// actionable key into a chrome-raising self-heal instead of a playback
|
||||
// shortcut.
|
||||
if (!_focusPlayPauseIfKeyboardMode()) _claimPlayerSurfaceFocus();
|
||||
if (PlatformDetector.isMobile(context) && !PlatformDetector.isTV()) {
|
||||
_refreshDeviceAdjustmentValues();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user