From ac397f32a3a08cd6739c4e5a6826bc12721a8fc3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:10:00 +0100 Subject: [PATCH] feat: redesign TV player controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BACK key dismisses layers (content strip → controls → exit) with early BackKeyCoordinator marking to prevent PopScope race condition - Hide volume control and PiP button on TV - Fix focus chain with mounted-node-aware navigation for live/non-live - Fix focus reclaim on controls hide (hasPrimaryFocus vs hasFocus) to prevent screen-level self-heal from stealing timeline focus - LEFT/RIGHT shows controls with timeline focused for seeking Close #701 --- .../desktop_video_controls.dart | 65 +++++++++++++++---- .../video_controls/video_controls.dart | 53 +++++++++++++-- 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index ba7e61b3..a2b3d53e 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -11,6 +11,7 @@ import '../../models/plex_media_info.dart'; import '../../models/plex_metadata.dart'; import '../../services/fullscreen_state_manager.dart'; import '../../utils/desktop_window_padding.dart'; +import '../../utils/platform_detector.dart'; import '../../utils/formatters.dart'; import '../../i18n/strings.g.dart'; import '../../focus/focusable_wrapper.dart'; @@ -235,9 +236,27 @@ class DesktopVideoControlsState extends State { } } - /// Handle left navigation from first track control - go to volume + /// Dismiss content strip and restore focus (called by parent on BACK key) + void dismissContentStrip() { + if (!_contentStripVisible) return; + _onContentStripNavigateUp(); + } + + /// Handle left navigation from first track control - go to volume (or last button on TV) void navigateFromTrackToVolume() { - _volumeFocusNode.requestFocus(); + if (PlatformDetector.isTV()) { + // On TV (no volume), go to last mounted button + for (int i = _buttonFocusNodes.length - 1; i >= 0; i--) { + if (_buttonFocusNodes[i].context != null) { + _buttonFocusNodes[i].requestFocus(); + widget.onFocusActivity?.call(); + return; + } + } + _playPauseFocusNode.requestFocus(); // fallback + } else { + _volumeFocusNode.requestFocus(); + } widget.onFocusActivity?.call(); } @@ -353,8 +372,26 @@ class DesktopVideoControlsState extends State { /// Handle key events for horizontal button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index) { - final leftTarget = index > 0 ? _buttonFocusNodes[index - 1] : null; - final rightTarget = index < _buttonFocusNodes.length - 1 ? _buttonFocusNodes[index + 1] : _volumeFocusNode; + // Find nearest mounted left neighbor + FocusNode? leftTarget; + for (int i = index - 1; i >= 0; i--) { + if (_buttonFocusNodes[i].context != null) { + leftTarget = _buttonFocusNodes[i]; + break; + } + } + + // Find nearest mounted right neighbor, falling through to volume/track controls + FocusNode? rightTarget; + for (int i = index + 1; i < _buttonFocusNodes.length; i++) { + if (_buttonFocusNodes[i].context != null) { + rightTarget = _buttonFocusNodes[i]; + break; + } + } + rightTarget ??= PlatformDetector.isTV() + ? (_trackControlFocusNodes.isNotEmpty ? _trackControlFocusNodes.first : null) + : _volumeFocusNode; return _handleDirectionalNavigation(event, leftTarget: leftTarget, rightTarget: rightTarget); } @@ -783,15 +820,17 @@ class DesktopVideoControlsState extends State { }, ), ), - // Volume control - VolumeControl( - player: widget.player, - focusNode: _volumeFocusNode, - onKeyEvent: _handleVolumeKeyEvent, - onFocusChange: _onFocusChange, - onFocusActivity: widget.onFocusActivity, - ), - const SizedBox(width: 16), + // Volume control (hidden on TV — hardware handles volume) + if (!PlatformDetector.isTV()) ...[ + VolumeControl( + player: widget.player, + focusNode: _volumeFocusNode, + onKeyEvent: _handleVolumeKeyEvent, + onFocusChange: _onFocusChange, + onFocusActivity: widget.onFocusActivity, + ), + const SizedBox(width: 16), + ], // Audio track, subtitle, and chapter controls TrackChapterControls( player: widget.player, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index fc3759c2..f7155ac8 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -770,11 +770,14 @@ class _PlexVideoControlsState extends State with WindowListen // sheet navigation (e.g. the compact sync bar). final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; if (!sheetOpen) { - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } + // Always request primary focus on _focusNode — not just when hasFocus is + // false. hasFocus is true when a descendant (e.g. play/pause) has focus, + // but we need _focusNode itself to hold primary focus so its onKeyEvent + // fires for the next d-pad press (otherwise focus escapes to the screen- + // level self-heal handler which shows controls with play/pause focus). + _focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_focusNode.hasFocus) { + if (mounted && !_focusNode.hasPrimaryFocus) { _focusNode.requestFocus(); } }); @@ -1047,7 +1050,7 @@ class _PlexVideoControlsState extends State with WindowListen isRotationLocked: _isRotationLocked, isFullscreen: _isFullscreen, isAlwaysOnTop: _isAlwaysOnTop, - onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) + onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null, @@ -1540,7 +1543,26 @@ class _PlexVideoControlsState extends State with WindowListen // handler would call Navigator.pop() alongside the sheet's handler. final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; if (sheetOpen) return false; + // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult + // sees it before KeyUp — prevents the system back from racing ahead. + if (PlatformDetector.isTV() && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } final backResult = handleBackKeyAction(event, () { + if (PlatformDetector.isTV()) { + if (_showControls) { + if (_isContentStripVisible) { + _desktopControlsKey.currentState?.dismissContentStrip(); + setState(() => _isContentStripVisible = false); + _restartHideTimerIfPlaying(); + return; + } + _hideControls(); + return; + } + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } if (!_showControls) { _showControlsWithFocus(); } else { @@ -1699,7 +1721,26 @@ class _PlexVideoControlsState extends State with WindowListen } return KeyEventResult.handled; } + // On TV, mark coordinator early (KeyDown) so PopScope.onPopInvokedWithResult + // sees it before KeyUp — prevents the system back from racing ahead. + if (PlatformDetector.isTV() && event.logicalKey.isBackKey && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + } final backResult = handleBackKeyAction(event, () { + if (PlatformDetector.isTV()) { + if (_showControls) { + if (_isContentStripVisible) { + _desktopControlsKey.currentState?.dismissContentStrip(); + setState(() => _isContentStripVisible = false); + _restartHideTimerIfPlaying(); + return; + } + _hideControls(); + return; + } + (widget.onBack ?? () => Navigator.of(context).pop(true))(); + return; + } if (!_showControls) { _showControlsWithFocus(); return; @@ -1771,7 +1812,7 @@ class _PlexVideoControlsState extends State with WindowListen // On desktop/TV, show controls on directional input // LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause - if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) { + if (!isMobile && _isDirectionalKey(key) && (_videoPlayerNavigationEnabled || PlatformDetector.isTV())) { if (!_showControls) { final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; if (isHorizontal) {