feat: redesign TV player controls

- 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
This commit is contained in:
edde746
2026-03-15 03:10:51 +01:00
parent d236e7fec6
commit ac397f32a3
2 changed files with 99 additions and 19 deletions
@@ -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<DesktopVideoControls> {
}
}
/// 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<DesktopVideoControls> {
/// 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<DesktopVideoControls> {
},
),
),
// 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,
+47 -6
View File
@@ -770,11 +770,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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) {