Files
plezy/lib/widgets/video_controls/parts/visibility.dart
T
edde746 e3703892b3 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.
2026-08-07 13:23:53 +02:00

329 lines
12 KiB
Dart

part of '../video_controls.dart';
extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
/// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready
void _onFirstFrameReady() {
final hasFrame = widget.hasFirstFrame?.value ?? true;
widget.chromeController.setHasFirstFrame(hasFrame);
if (hasFrame) {
// Retry with network-first if initial cache-first returned empty
if (_chapters.isEmpty && _markers.isEmpty) {
_loadPlaybackExtras(forceRefresh: true);
}
_syncCurrentMarkerForCurrentPosition();
} else {
_clearCurrentMarker();
}
}
/// 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, 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
void _listenToPlayingState() {
_playingSubscription = widget.player.streams.playing.listen((isPlaying) {
widget.chromeController.setPlaying(isPlaying);
});
}
/// Listen to completed stream to show controls when video ends
void _listenToCompleted() {
_completedSubscription = widget.player.streams.completed.listen((completed) {
if (completed && mounted) {
if (_isLongPressing) {
_handleLongPressCancel();
}
widget.chromeController.show(restartAutoHide: false);
widget.chromeController.cancelAutoHide();
}
});
}
/// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse.
/// Maestro builds extend the delay because accessibility-tree queries can take
/// longer than the production timeout on physical devices.
Duration get _hideDelay {
if (const bool.fromEnvironment('PLEZY_MAESTRO_E2E')) {
return const Duration(seconds: 30);
}
final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV();
if (isMobile || playerDirectionalNavigationEnabled()) {
return const Duration(seconds: 5);
}
return const Duration(seconds: 3);
}
/// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus.
void _hideControls() {
if (!mounted) return;
widget.chromeController.hide();
}
void _startHideTimer() => widget.chromeController.startAutoHide();
/// Restart the hide timer on user interaction for the current playback state.
void _restartHideTimerForCurrentPlaybackState() => widget.chromeController.restartAutoHideForCurrentPlaybackState();
void _handlePointerSignal(PointerSignalEvent event) {
if (event is! PointerScrollEvent) return;
_cancelAutoSkipFromUserInteraction();
widget.volumeController.adjust(-event.scrollDelta.dy / 20);
_showControlsFromPointerActivity();
}
/// Show controls in response to pointer activity (mouse/trackpad movement).
void _showControlsFromPointerActivity() {
widget.chromeController.recordPointerActivity();
}
void _toggleControls() {
widget.chromeController.toggle();
}
void _toggleControlsFromSemantics() {
if (_showControls) {
widget.chromeController.hide();
return;
}
widget.chromeController.show(restartAutoHide: false);
widget.chromeController.cancelAutoHide();
}
/// Apply preferred orientations for the given lock state. Wired to
/// [SettingsService.rotationLocked] via [bindEffect] so any change — from
/// this toggle or from the settings screen — fires the same SystemChrome call.
void _applyRotationLock(bool locked) {
if (PlatformDetector.isAutomotive()) return;
unawaited(
SystemChrome.setPreferredOrientations(
locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values,
),
);
}
void _toggleRotationLock() {
unawaited(_settings.write(SettingsService.rotationLocked, !_isRotationLocked));
}
void _toggleScreenLock() {
final locking = !_isScreenLocked;
_setControlsState(() {
_isScreenLocked = locking;
if (locking) {
_showLockIcon = true;
}
});
if (locking) {
_cancelEdgeAdjustmentGesture();
widget.chromeController.hide(ignoreHolds: true);
_startLockIconHideTimer();
}
}
void _startLockIconHideTimer() {
_lockIconTimer?.cancel();
_lockIconTimer = Timer(const Duration(seconds: 3), () {
if (mounted) _setControlsState(() => _showLockIcon = false);
});
}
void _unlockScreen() {
_setControlsState(() {
_isScreenLocked = false;
_showLockIcon = false;
});
_lockIconTimer?.cancel();
widget.chromeController.show();
}
void _updateTrafficLightVisibility() async {
final generation = ++_trafficLightVisibilityGeneration;
// When maximized or fullscreen, always keep traffic lights visible so the
// user can reach them without the controls-hide-on-mouse-leave race.
// In normal windowed mode, toggle with controls as before.
final isMaximizedOrFullscreen = await windowManager.isMaximized() || await MacOSWindowService.isFullscreen();
if (!mounted || generation != _trafficLightVisibilityGeneration) return;
final visible = isMaximizedOrFullscreen || _showControls;
await MacOSWindowService.setTrafficLightsVisible(visible);
}
Future<void> _checkPipSupport() async {
if (!PlatformDetector.supportsPictureInPicture()) {
return;
}
try {
final supported = await PipService.isSupported();
if (mounted) {
_setControlsState(() {
_isPipSupported = supported;
});
}
} catch (e) {
return;
}
}
Future<void> _toggleFullscreen() async {
if (!PlatformDetector.isDesktopOS()) return;
await FullscreenStateManager().toggleFullscreen();
}
/// Initialize always-on-top state from window manager (desktop only)
Future<void> _initAlwaysOnTopState() async {
final isOnTop = await windowManager.isAlwaysOnTop();
if (mounted && isOnTop != _isAlwaysOnTop) {
_setControlsState(() {
_isAlwaysOnTop = isOnTop;
});
}
}
/// Toggle always-on-top window mode (desktop only)
Future<void> _toggleAlwaysOnTop() async {
if (!PlatformDetector.isDesktopOS()) return;
final newValue = !_isAlwaysOnTop;
await windowManager.setAlwaysOnTop(newValue);
if (!mounted) return;
_setControlsState(() {
_isAlwaysOnTop = newValue;
});
}
/// Show controls and optionally focus play/pause on keyboard input (desktop only)
void _showControlsWithFocus({bool requestFocus = true}) {
widget.chromeController.show();
if (requestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_desktopControlsKey.currentState?.requestPlayPauseFocus();
});
} else {
// When not requesting focus on play/pause, ensure main focus node keeps focus
// This prevents focus from being lost when controls become visible
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
}
}
/// Hide controls when navigating up from timeline (keyboard mode)
/// If skip marker button or Play Next dialog is visible, focus it instead of hiding controls
void _hideControlsFromKeyboard() {
if (widget.playNextFocusNode != null) {
widget.playNextFocusNode!.requestFocus();
return;
}
if (_currentMarker != null) {
_skipMarkerFocusNode.requestFocus();
return;
}
if (_showControls) {
_hideControls();
}
}
void _onChromeChanged() {
if (!mounted) return;
final controlsVisible = widget.chromeController.controlsVisible;
final visibilityChanged = controlsVisible != _lastControlsVisible;
final focusTarget = widget.chromeController.takeFocusTarget();
_lastControlsVisible = controlsVisible;
if (visibilityChanged && !controlsVisible) {
_desktopControlsKey.currentState?.hideContentStrip();
_cancelSkipButtonDismissTimer();
_setControlsState(() {
_controlsOpaque = false;
if (_currentMarker != null) _skipButtonDismissed = true;
});
_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
// position once the timeline's own accumulator starts.
_flushHiddenDirectionalSeek();
_setControlsState(() {
_controlsMounted = true;
_controlsOpaque = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls || !_controlsMounted) return;
_setControlsState(() => _controlsOpaque = true);
});
} else if (controlsVisible && !_controlsMounted) {
_setControlsState(() {
_controlsMounted = true;
_controlsOpaque = true;
});
}
if (visibilityChanged && Platform.isMacOS) {
_updateTrafficLightVisibility();
}
if (focusTarget != null) {
_requestFocusTarget(focusTarget);
}
}
/// 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 _claimPlayerSurfaceFocus() {
if (_sheetIsOpen()) return;
_focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 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;
// Never steal focus from an open sheet (same rule as
// _claimPlayerSurfaceFocus).
if (OverlaySheetController.maybeOf(context)?.isOpen ?? false) return;
switch (target) {
case PlayerChromeFocusTarget.playPause:
_desktopControlsKey.currentState?.requestPlayPauseFocus();
}
});
}
}