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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user