From 6720137edc20da4815b5751d86b0f640b0889f9d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:51:03 +0100 Subject: [PATCH] fix: dpad support for Skip Intro and Play Next buttons close #268 --- lib/screens/video_player_screen.dart | 119 ++++++++++--- .../video_controls/video_controls.dart | 167 ++++++++++++------ 2 files changed, 204 insertions(+), 82 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 36a0fc68..264f59f7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -41,6 +41,8 @@ import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; +import '../focus/focusable_wrapper.dart'; +import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; import '../watch_together/providers/watch_together_provider.dart'; @@ -99,6 +101,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin int _autoPlayCountdown = 5; bool _completionTriggered = false; + // Play Next dialog focus nodes (for TV D-pad navigation) + late final FocusNode _playNextCancelFocusNode; + late final FocusNode _playNextConfirmFocusNode; + // App lifecycle state tracking bool _wasPlayingBeforeInactive = false; @@ -128,6 +134,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _activeRatingKey = widget.metadata.ratingKey; _activeMediaIndex = widget.selectedMediaIndex; + // Initialize Play Next dialog focus nodes + _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); + _playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm'); + appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}'); if (widget.preferredAudioTrack != null) { appLogger.d( @@ -1062,6 +1072,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Cancel auto-play timer _autoPlayTimer?.cancel(); + // Dispose Play Next dialog focus nodes + _playNextCancelFocusNode.dispose(); + _playNextConfirmFocusNode.dispose(); + // Clear media controls and dispose manager _mediaControlsManager?.clear(); _mediaControlsManager?.dispose(); @@ -1136,6 +1150,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (completed && _nextEpisode != null && !_showPlayNextDialog && !_completionTriggered) { _completionTriggered = true; + // Capture keyboard mode before async gap + final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); + final settings = await SettingsService.getInstance(); final autoPlayEnabled = settings.getAutoPlayNextEpisode(); @@ -1144,6 +1161,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin _autoPlayCountdown = autoPlayEnabled ? 5 : -1; }); + // Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode) + if (isKeyboardMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _playNextConfirmFocusNode.requestFocus(); + } + }); + } + if (autoPlayEnabled) { _startAutoPlayTimer(); } @@ -1641,6 +1667,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin onBack: _handleBackButton, canControl: canControl, hasFirstFrame: _hasFirstFrame, + playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, ), ); }, @@ -1731,35 +1758,79 @@ class VideoPlayerScreenState extends State with WidgetsBindin Row( children: [ Expanded( - child: OutlinedButton( - onPressed: _cancelAutoPlay, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), + child: FocusableWrapper( + focusNode: _playNextCancelFocusNode, + onSelect: _cancelAutoPlay, + useBackgroundFocus: true, + autoScroll: false, + borderRadius: 20, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + // RIGHT arrow moves focus to Play Next button + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _playNextConfirmFocusNode.requestFocus(); + return KeyEventResult.handled; + } + // Trap focus - consume UP/DOWN to prevent escape + if (event.logicalKey == LogicalKeyboardKey.arrowUp || + event.logicalKey == LogicalKeyboardKey.arrowDown) { + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: OutlinedButton( + onPressed: _cancelAutoPlay, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(t.dialog.cancel), ), - child: Text(t.dialog.cancel), ), ), const SizedBox(width: 8), Expanded( - child: FilledButton( - onPressed: _playNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_autoPlayCountdown > 0) ...[ - Text('$_autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], + child: FocusableWrapper( + focusNode: _playNextConfirmFocusNode, + onSelect: _playNext, + useBackgroundFocus: true, + autoScroll: false, + borderRadius: 20, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + // LEFT arrow moves focus to Cancel button + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _playNextCancelFocusNode.requestFocus(); + return KeyEventResult.handled; + } + // Trap focus - consume UP/DOWN to prevent escape + if (event.logicalKey == LogicalKeyboardKey.arrowUp || + event.logicalKey == LogicalKeyboardKey.arrowDown) { + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_autoPlayCountdown > 0) ...[ + Text('$_autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], + ), ), ), ), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 2be7fb09..dd3ef6a4 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -20,6 +20,7 @@ import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; import '../../focus/dpad_navigator.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../services/plex_client.dart'; import '../../services/plex_api_cache.dart'; @@ -61,6 +62,7 @@ Widget plexVideoControlsBuilder( VoidCallback? onBack, bool canControl = true, ValueNotifier? hasFirstFrame, + FocusNode? playNextFocusNode, }) { return PlexVideoControls( player: player, @@ -78,6 +80,7 @@ Widget plexVideoControlsBuilder( onBack: onBack, canControl: canControl, hasFirstFrame: hasFirstFrame, + playNextFocusNode: playNextFocusNode, ); } @@ -106,6 +109,9 @@ class PlexVideoControls extends StatefulWidget { /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; + /// Optional focus node for Play Next dialog button (for TV navigation from timeline) + final FocusNode? playNextFocusNode; + const PlexVideoControls({ super.key, required this.player, @@ -123,6 +129,7 @@ class PlexVideoControls extends StatefulWidget { this.onBack, this.canControl = true, this.hasFirstFrame, + this.playNextFocusNode, }); @override @@ -185,6 +192,8 @@ class _PlexVideoControlsState extends State with WindowListen bool _showPerformanceOverlay = false; // Long-press 2x speed state bool _isLongPressing = false; + // Skip marker button focus node (for TV D-pad navigation) + late final FocusNode _skipMarkerFocusNode; double? _rateBeforeLongPress; bool _showSpeedIndicator = false; @@ -196,6 +205,7 @@ class _PlexVideoControlsState extends State with WindowListen void initState() { super.initState(); _focusNode = FocusNode(); + _skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton'); _seekThrottle = throttle( (Duration pos) => widget.player.seek(pos), const Duration(milliseconds: 200), @@ -271,6 +281,15 @@ class _PlexVideoControlsState extends State with WindowListen // Start auto-skip timer for new marker if (foundMarker != null) { _startAutoSkipTimer(foundMarker); + + // Auto-focus skip button on TV when marker appears (only in keyboard/TV mode, if controls hidden) + if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_showControls) { + _skipMarkerFocusNode.requestFocus(); + } + }); + } } else { _cancelAutoSkipTimer(); } @@ -447,6 +466,7 @@ class _PlexVideoControlsState extends State with WindowListen _playingSubscription?.cancel(); _completedSubscription?.cancel(); _focusNode.dispose(); + _skipMarkerFocusNode.dispose(); // Restore original rate if long-press was active when disposed if (_isLongPressing && _rateBeforeLongPress != null) { widget.player.setRate(_rateBeforeLongPress!); @@ -1219,7 +1239,20 @@ class _PlexVideoControlsState extends State with WindowListen } /// 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 skip marker button is visible, focus it instead of hiding controls + if (_currentMarker != null) { + _skipMarkerFocusNode.requestFocus(); + return; + } + + // If Play Next dialog is visible (focus node provided), focus it instead of hiding controls + if (widget.playNextFocusNode != null) { + widget.playNextFocusNode!.requestFocus(); + return; + } + if (_showControls) { setState(() { _showControls = false; @@ -1300,8 +1333,7 @@ class _PlexVideoControlsState extends State with WindowListen // LEFT/RIGHT focuses timeline for seeking, UP/DOWN focuses play/pause if (!isMobile && _isDirectionalKey(key) && _videoPlayerNavigationEnabled) { if (!_showControls) { - final isHorizontal = key == LogicalKeyboardKey.arrowLeft || - key == LogicalKeyboardKey.arrowRight; + final isHorizontal = key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; if (isHorizontal) { _showControlsWithTimelineFocus(); } else { @@ -1620,65 +1652,84 @@ class _PlexVideoControlsState extends State with WindowListen : baseButtonText; final IconData buttonIcon = showNextEpisode ? Symbols.skip_next_rounded : Symbols.fast_forward_rounded; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - if (isAutoSkipActive) { - _cancelAutoSkipTimer(); - } - // Always perform the skip action when tapped - _performAutoSkip(); - }, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: Stack( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - buttonText, - style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 8), - AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20), - ], - ), - ), - // Progress indicator overlay - if (isAutoSkipActive && shouldShowAutoSkip) - Positioned.fill( - child: ClipRRect( + return FocusableWrapper( + focusNode: _skipMarkerFocusNode, + onSelect: () { + if (isAutoSkipActive) { + _cancelAutoSkipTimer(); + } + _performAutoSkip(); + }, + borderRadius: tokens(context).radiusSm, + useBackgroundFocus: true, + autoScroll: false, + onKeyEvent: (node, event) { + // DOWN arrow returns focus to play/pause button + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) { + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (isAutoSkipActive) { + _cancelAutoSkipTimer(); + } + _performAutoSkip(); + }, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: Row( - children: [ - Expanded( - flex: (_autoSkipProgress * 100).round(), - child: Container( - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - ), - ), - ), - Expanded( - flex: ((1.0 - _autoSkipProgress) * 100).round(), - child: Container(decoration: const BoxDecoration(color: Colors.transparent)), - ), - ], - ), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + buttonText, + style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 8), + AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20), + ], ), ), - ], + // Progress indicator overlay + if (isAutoSkipActive && shouldShowAutoSkip) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Row( + children: [ + Expanded( + flex: (_autoSkipProgress * 100).round(), + child: Container( + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + ), + ), + ), + Expanded( + flex: ((1.0 - _autoSkipProgress) * 100).round(), + child: Container(decoration: const BoxDecoration(color: Colors.transparent)), + ), + ], + ), + ), + ), + ], + ), ), ), );