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.
313 lines
10 KiB
Dart
313 lines
10 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 button if we're in keyboard navigation mode (desktop/TV only)
|
|
void _focusPlayPauseIfKeyboardMode() {
|
|
if (!mounted) return;
|
|
if (!_videoPlayerNavigationEnabled) return;
|
|
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
|
|
if (!isMobile && InputModeTracker.isKeyboardMode(context)) {
|
|
_desktopControlsKey.currentState?.requestPlayPauseFocus();
|
|
}
|
|
}
|
|
|
|
/// 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 || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) {
|
|
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() {
|
|
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
|
|
if (sheetOpen) return;
|
|
_focusNode.requestFocus();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && !_focusNode.hasPrimaryFocus) {
|
|
_focusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
case PlayerChromeFocusTarget.timeline:
|
|
_desktopControlsKey.currentState?.requestTimelineFocus();
|
|
}
|
|
});
|
|
}
|
|
}
|