From c56e73eefe294114bad1cb7a5ef63f16dca7525e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:15:53 +0100 Subject: [PATCH] feat: add content strip for dpad navigation --- .../desktop_video_controls.dart | 557 ++++++++++++------ .../video_controls/video_controls.dart | 16 + .../video_controls/widgets/content_strip.dart | 263 ++++++++- .../widgets/track_chapter_controls.dart | 20 + 4 files changed, 657 insertions(+), 199 deletions(-) diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index ced36e67..e60bc4e2 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -15,6 +15,7 @@ import '../../utils/formatters.dart'; import '../../i18n/strings.g.dart'; import '../../focus/focusable_wrapper.dart'; import 'models/track_controls_state.dart'; +import 'widgets/content_strip.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/video_controls_header.dart'; @@ -61,6 +62,27 @@ class DesktopVideoControls extends StatefulWidget { /// Channel name for live TV display final String? liveChannelName; + /// Whether to use dpad navigation for content strip (TV or keyboard nav mode) + final bool useDpadNavigation; + + /// Server ID for content strip images + final String? serverId; + + /// Whether to show the queue tab in the content strip + final bool showQueueTab; + + /// Called when a queue item is selected in the content strip + final Function(PlexMetadata)? onQueueItemSelected; + + /// Called to cancel auto-hide timer (e.g., when content strip is shown) + final VoidCallback? onCancelAutoHide; + + /// Called to start auto-hide timer + final VoidCallback? onStartAutoHide; + + /// Called when content strip visibility changes + final ValueChanged? onContentStripVisibilityChanged; + const DesktopVideoControls({ super.key, required this.player, @@ -86,6 +108,13 @@ class DesktopVideoControls extends StatefulWidget { this.hasFirstFrame, this.thumbnailDataBuilder, this.liveChannelName, + this.useDpadNavigation = false, + this.serverId, + this.showQueueTab = false, + this.onQueueItemSelected, + this.onCancelAutoHide, + this.onStartAutoHide, + this.onContentStripVisibilityChanged, }); @override @@ -120,6 +149,18 @@ class DesktopVideoControlsState extends State { LogicalKeyboardKey? _seekDirection; // Current direction being held int _seekRepeatCount = 0; // Consecutive key repeats for acceleration + // Content strip state + bool _contentStripVisible = false; + final GlobalKey _contentStripKey = GlobalKey(); + + // Track which button was last focused (for returning from content strip) + FocusNode? _lastFocusedButtonNode; + + /// Whether the content strip has any content to show + bool get _hasStripContent { + return widget.chapters.isNotEmpty || (widget.showQueueTab && widget.onQueueItemSelected != null); + } + @override void initState() { super.initState(); @@ -180,6 +221,16 @@ class DesktopVideoControlsState extends State { /// Get focus nodes for track controls List get trackControlFocusNodes => _trackControlFocusNodes; + /// Hide content strip (called by parent when controls hide) + void hideContentStrip() { + if (_contentStripVisible) { + setState(() { + _contentStripVisible = false; + }); + widget.onContentStripVisibilityChanged?.call(false); + } + } + /// Handle left navigation from first track control - go to volume void navigateFromTrackToVolume() { _volumeFocusNode.requestFocus(); @@ -195,6 +246,66 @@ class DesktopVideoControlsState extends State { } } + /// Track the last focused button node for returning from content strip + void _onButtonRowFocusChange(bool hasFocus) { + if (hasFocus) { + widget.onFocusActivity?.call(); + // Find which button or track control has focus + for (final node in _buttonFocusNodes) { + if (node.hasFocus) { + _lastFocusedButtonNode = node; + return; + } + } + for (final node in _trackControlFocusNodes) { + if (node.hasFocus) { + _lastFocusedButtonNode = node; + return; + } + } + if (_volumeFocusNode.hasFocus) { + _lastFocusedButtonNode = _volumeFocusNode; + } + } + } + + void _showContentStrip() { + if (!widget.useDpadNavigation || !_hasStripContent) return; + if (_contentStripVisible) { + // Already visible - focus into it + _contentStripKey.currentState?.requestInitialFocus(); + return; + } + + setState(() { + _contentStripVisible = true; + }); + widget.onContentStripVisibilityChanged?.call(true); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _contentStripKey.currentState?.requestInitialFocus(); + }); + } + + void _onContentStripNavigateUp() { + // Hide content strip and show normal controls again + setState(() { + _contentStripVisible = false; + }); + widget.onContentStripVisibilityChanged?.call(false); + + // Return focus to the last focused button (or play/pause as fallback) + WidgetsBinding.instance.addPostFrameCallback((_) { + final target = _lastFocusedButtonNode; + if (target != null && target.context != null) { + target.requestFocus(); + } else { + _playPauseFocusNode.requestFocus(); + } + widget.onFocusActivity?.call(); + }); + } + /// Handle directional navigation for bottom control row. /// /// Returns [KeyEventResult.handled] if the key was processed, @@ -225,6 +336,14 @@ class DesktopVideoControlsState extends State { return KeyEventResult.handled; } + if (key == LogicalKeyboardKey.arrowDown) { + if (widget.useDpadNavigation && _hasStripContent) { + _showContentStrip(); + return KeyEventResult.handled; + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } @@ -347,8 +466,60 @@ class DesktopVideoControlsState extends State { FirstFrameGuard( hasFirstFrame: widget.hasFirstFrame, placeholder: const Expanded(child: SizedBox.shrink()), - builder: (context) => - Expanded(child: Column(children: [const Spacer(), _buildBottomControlsContent(context, hasFrame: true)])), + builder: (context) => Expanded( + child: Column( + children: [ + const Spacer(), + // When content strip is visible, hide the normal controls (like mobile) + if (!_contentStripVisible) + Stack( + clipBehavior: Clip.none, + children: [ + _buildBottomControlsContent(context, hasFrame: true), + // Down arrow hint when strip content is available + if (widget.useDpadNavigation && _hasStripContent) + const Positioned( + left: 0, + right: 0, + bottom: 12, + child: Icon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), + ), + ], + ), + // Content strip (TV/dpad only) — replaces normal controls + if (_contentStripVisible && widget.useDpadNavigation) + Container( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), + const SizedBox(height: 4), + ContentStrip( + key: _contentStripKey, + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + serverId: widget.serverId, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + useFocusNavigation: true, + onNavigateUp: _onContentStripNavigateUp, + onFocusActivity: widget.onFocusActivity, + ), + ], + ), + ), + ], + ), + ), ), ], ); @@ -428,197 +599,211 @@ class DesktopVideoControlsState extends State { const SizedBox(height: 4), ], // Row 2: Playback controls and options - Row( - children: [ - if (!_isLive) ...[ - // Previous item - Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevItemFocusNode, - index: 0, - icon: Symbols.skip_previous_rounded, - color: widget.onPrevious != null && _canControl ? Colors.white : Colors.white54, - onPressed: _canControl ? widget.onPrevious : null, - semanticLabel: t.videoControls.previousButton, + Focus( + onFocusChange: _onButtonRowFocusChange, + skipTraversal: true, + child: Row( + children: [ + if (!_isLive) ...[ + // Previous item + Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevItemFocusNode, + index: 0, + icon: Symbols.skip_previous_rounded, + color: widget.onPrevious != null && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl ? widget.onPrevious : null, + semanticLabel: t.videoControls.previousButton, + ), ), - ), - // Previous chapter - StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnapshot) { - final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); - return Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _prevChapterFocusNode, - index: 1, - icon: Symbols.fast_rewind_rounded, - color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, - onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, - semanticLabel: t.videoControls.previousChapterButton, - tooltip: prevLabel, - ), - ); - }, - ), - // Skip backward - Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipBackFocusNode, - index: 2, - icon: widget.getReplayIcon(widget.seekTimeSmall), - onPressed: _canControl ? widget.onSeekBackward : null, - semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), - ), - ), - ], - // Play/Pause - Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: PlayPauseStreamBuilder( - player: widget.player, - builder: (context, isPlaying) { - return _buildFocusableButton( - focusNode: _playPauseFocusNode, - index: 3, - icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, - iconSize: 32, - onPressed: _canControl - ? () { - if (isPlaying) { - widget.player.pause(); - } else { - widget.player.play(); - } - } - : null, - semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, - ); - }, - ), - ), - if (!_isLive) ...[ - // Skip forward - Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _skipForwardFocusNode, - index: 4, - icon: widget.getForwardIcon(widget.seekTimeSmall), - onPressed: _canControl ? widget.onSeekForward : null, - semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), - ), - ), - // Next chapter - StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnapshot) { - final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); - return Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextChapterFocusNode, - index: 5, - icon: Symbols.fast_forward_rounded, - color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, - onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, - semanticLabel: t.videoControls.nextChapterButton, - tooltip: nextLabel, - ), - ); - }, - ), - // Next item - Opacity( - opacity: _canControl ? 1.0 : 0.5, - child: _buildFocusableButton( - focusNode: _nextItemFocusNode, - index: 6, - icon: Symbols.skip_next_rounded, - color: widget.onNext != null && _canControl ? Colors.white : Colors.white54, - onPressed: _canControl ? widget.onNext : null, - semanticLabel: t.videoControls.nextButton, - ), - ), - ], - // Finish time (hidden for live TV and when too narrow to fit) - if (_isLive) - const Spacer() - else - Expanded( - child: StreamBuilder( + // Previous chapter + StreamBuilder( stream: widget.player.streams.position, initialData: widget.player.state.position, - builder: (context, posSnap) { - return StreamBuilder( - stream: widget.player.streams.duration, - initialData: widget.player.state.duration, - builder: (context, durSnap) { - return StreamBuilder( - stream: widget.player.streams.rate, - initialData: widget.player.state.rate, - builder: (context, rateSnap) { - final position = posSnap.data ?? Duration.zero; - final duration = durSnap.data ?? Duration.zero; - final remaining = duration - position; - final rate = rateSnap.data ?? 1.0; - if (remaining.inSeconds <= 0) return const SizedBox.shrink(); - - final text = t.videoControls.endsAt( - time: formatFinishTime( - remaining, - rate: rate, - is24Hour: MediaQuery.alwaysUse24HourFormatOf(context), - ), - ); - const style = TextStyle(color: Colors.white70, fontSize: 13); - - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - final textWidth = tp.width + 8; - tp.dispose(); - if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(text, style: style), - ); - }, - ); - }, - ); - }, + builder: (context, posSnapshot) { + final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); + return Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _prevChapterFocusNode, + index: 1, + icon: Symbols.fast_rewind_rounded, + color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, + semanticLabel: t.videoControls.previousChapterButton, + tooltip: prevLabel, + ), + ); + }, + ), + // Skip backward + Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipBackFocusNode, + index: 2, + icon: widget.getReplayIcon(widget.seekTimeSmall), + onPressed: _canControl ? widget.onSeekBackward : null, + semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), + ), + ), + ], + // Play/Pause + Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: PlayPauseStreamBuilder( + player: widget.player, + builder: (context, isPlaying) { + return _buildFocusableButton( + focusNode: _playPauseFocusNode, + index: 3, + icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, + iconSize: 32, + onPressed: _canControl + ? () { + if (isPlaying) { + widget.player.pause(); + } else { + widget.player.play(); + } + } + : null, + semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, ); }, ), ), - // Volume control - 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, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - trackControlsState: _trackControlsState, - focusNodes: _trackControlFocusNodes, - onFocusChange: _onFocusChange, - onNavigateLeft: navigateFromTrackToVolume, - ), - ], + if (!_isLive) ...[ + // Skip forward + Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _skipForwardFocusNode, + index: 4, + icon: widget.getForwardIcon(widget.seekTimeSmall), + onPressed: _canControl ? widget.onSeekForward : null, + semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), + ), + ), + // Next chapter + StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnapshot) { + final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); + return Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextChapterFocusNode, + index: 5, + icon: Symbols.fast_forward_rounded, + color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, + semanticLabel: t.videoControls.nextChapterButton, + tooltip: nextLabel, + ), + ); + }, + ), + // Next item + Opacity( + opacity: _canControl ? 1.0 : 0.5, + child: _buildFocusableButton( + focusNode: _nextItemFocusNode, + index: 6, + icon: Symbols.skip_next_rounded, + color: widget.onNext != null && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl ? widget.onNext : null, + semanticLabel: t.videoControls.nextButton, + ), + ), + ], + // Finish time (hidden for live TV and when too narrow to fit) + if (_isLive) + const Spacer() + else + Expanded( + child: StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnap) { + return StreamBuilder( + stream: widget.player.streams.duration, + initialData: widget.player.state.duration, + builder: (context, durSnap) { + return StreamBuilder( + stream: widget.player.streams.rate, + initialData: widget.player.state.rate, + builder: (context, rateSnap) { + final position = posSnap.data ?? Duration.zero; + final duration = durSnap.data ?? Duration.zero; + final remaining = duration - position; + final rate = rateSnap.data ?? 1.0; + if (remaining.inSeconds <= 0) return const SizedBox.shrink(); + + final text = t.videoControls.endsAt( + time: formatFinishTime( + remaining, + rate: rate, + is24Hour: MediaQuery.alwaysUse24HourFormatOf(context), + ), + ); + const style = TextStyle(color: Colors.white70, fontSize: 13); + + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + final textWidth = tp.width + 8; + tp.dispose(); + if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(text, style: style), + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + // Volume control + 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, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + trackControlsState: _trackControlsState, + focusNodes: _trackControlFocusNodes, + onFocusChange: _onFocusChange, + onNavigateLeft: navigateFromTrackToVolume, + onNavigateUp: () { + _timelineFocusNode.requestFocus(); + widget.onFocusActivity?.call(); + }, + onNavigateDown: () { + if (widget.useDpadNavigation && _hasStripContent) { + _showContentStrip(); + } + }, + hideChaptersAndQueue: widget.useDpadNavigation && _hasStripContent, + ), + ], + ), ), ], ), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a627f2f6..b2a3f288 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -743,6 +743,7 @@ class _PlexVideoControlsState extends State with WindowListen _skipButtonDismissed = true; } }); + _desktopControlsKey.currentState?.hideContentStrip(); _cancelSkipButtonDismissTimer(); widget.controlsVisible?.value = false; if (Platform.isMacOS) { @@ -2065,6 +2066,7 @@ class _PlexVideoControlsState extends State with WindowListen playbackState: playbackState, onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, ); + final useDpad = _videoPlayerNavigationEnabled || PlatformDetector.isTV(); return Listener( behavior: HitTestBehavior.translucent, @@ -2093,6 +2095,20 @@ class _PlexVideoControlsState extends State with WindowListen hasFirstFrame: widget.hasFirstFrame, thumbnailDataBuilder: widget.thumbnailDataBuilder, liveChannelName: widget.liveChannelName, + useDpadNavigation: useDpad, + serverId: widget.metadata.serverId, + showQueueTab: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onContentStripVisibilityChanged: (visible) { + setState(() => _isContentStripVisible = visible); + if (visible) { + _hideTimer?.cancel(); + } else { + _restartHideTimerIfPlaying(); + } + }, ), ); } diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 23424bce..b20d0dca 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/focusable_wrapper.dart'; import '../../../i18n/strings.g.dart'; import '../../../mpv/mpv.dart'; import '../../../models/plex_media_info.dart'; @@ -24,6 +27,16 @@ class ContentStrip extends StatefulWidget { final bool showQueueTab; final Function(PlexMetadata)? onQueueItemSelected; + /// Whether to use dpad/focus-based navigation (TV mode). + /// When true, no tab bar is shown — pages are navigated via UP/DOWN. + final bool useFocusNavigation; + + /// Called when navigating UP from the top-most strip page (back to buttons). + final VoidCallback? onNavigateUp; + + /// Called on any focus activity (to reset auto-hide timer). + final VoidCallback? onFocusActivity; + const ContentStrip({ super.key, required this.player, @@ -32,21 +45,28 @@ class ContentStrip extends StatefulWidget { this.serverId, this.showQueueTab = false, this.onQueueItemSelected, + this.useFocusNavigation = false, + this.onNavigateUp, + this.onFocusActivity, }); @override - State createState() => _ContentStripState(); + State createState() => ContentStripState(); } enum _StripTab { chapters, queue } -class _ContentStripState extends State { +class ContentStripState extends State { late _StripTab _activeTab; final ScrollController _chapterScrollController = ScrollController(); final ScrollController _queueScrollController = ScrollController(); bool _hasAutoScrolledChapters = false; bool _hasAutoScrolledQueue = false; + // Focus nodes for focus navigation mode + List _chapterFocusNodes = []; + List _queueFocusNodes = []; + bool get _hasChapters => widget.chapters.isNotEmpty; bool get _hasQueue => widget.showQueueTab && widget.onQueueItemSelected != null; bool get _hasBothTabs => _hasChapters && _hasQueue; @@ -61,9 +81,153 @@ class _ContentStripState extends State { void dispose() { _chapterScrollController.dispose(); _queueScrollController.dispose(); + for (final node in _chapterFocusNodes) { + node.dispose(); + } + for (final node in _queueFocusNodes) { + node.dispose(); + } super.dispose(); } + /// Request focus on the current chapter or queue item (called by parent when strip appears). + void requestInitialFocus() { + if (_activeTab == _StripTab.chapters && _chapterFocusNodes.isNotEmpty) { + final currentIndex = _getCurrentChapterIndex(); + final idx = (currentIndex ?? 0).clamp(0, _chapterFocusNodes.length - 1); + _chapterFocusNodes[idx].requestFocus(); + _scrollToFocusedNode(_chapterFocusNodes[idx]); + } else if (_activeTab == _StripTab.queue && _queueFocusNodes.isNotEmpty) { + final currentIndex = _getCurrentQueueIndex(); + final idx = (currentIndex ?? 0).clamp(0, _queueFocusNodes.length - 1); + _queueFocusNodes[idx].requestFocus(); + _scrollToFocusedNode(_queueFocusNodes[idx]); + } + } + + int? _getCurrentChapterIndex() { + final currentPositionMs = widget.player.state.position.inMilliseconds; + for (int i = 0; i < widget.chapters.length; i++) { + final chapter = widget.chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + return i; + } + } + return null; + } + + int? _getCurrentQueueIndex() { + try { + final playbackState = context.read(); + final items = playbackState.loadedItems; + final currentItemID = playbackState.currentPlayQueueItemID; + final idx = items.indexWhere((item) => item.playQueueItemID == currentItemID); + return idx >= 0 ? idx : null; + } catch (_) { + return null; + } + } + + void _ensureFocusNodes(List nodes, int count, String prefix) { + while (nodes.length < count) { + nodes.add(FocusNode(debugLabel: '$prefix${nodes.length}')); + } + while (nodes.length > count) { + nodes.removeLast().dispose(); + } + } + + void _scrollToFocusedNode(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final context = node.context; + if (context == null) return; + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + }); + } + + KeyEventResult _handleFocusItemKeyEvent( + FocusNode node, + KeyEvent event, + int index, + int totalItems, + _StripTab page, + ) { + if (!event.isActionable) return KeyEventResult.ignored; + + final key = event.logicalKey; + + // LEFT/RIGHT - navigate between items + if (key == LogicalKeyboardKey.arrowLeft) { + final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; + if (index > 0) { + nodes[index - 1].requestFocus(); + _scrollToFocusedNode(nodes[index - 1]); + widget.onFocusActivity?.call(); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowRight) { + final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; + if (index < totalItems - 1) { + nodes[index + 1].requestFocus(); + _scrollToFocusedNode(nodes[index + 1]); + widget.onFocusActivity?.call(); + } + return KeyEventResult.handled; + } + + // UP - navigate to previous layer + if (key == LogicalKeyboardKey.arrowUp) { + if (page == _StripTab.queue && _hasChapters) { + // Switch to chapters page and focus current chapter + setState(() => _activeTab = _StripTab.chapters); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _chapterFocusNodes.isNotEmpty) { + final idx = (_getCurrentChapterIndex() ?? 0).clamp(0, _chapterFocusNodes.length - 1); + _chapterFocusNodes[idx].requestFocus(); + _scrollToFocusedNode(_chapterFocusNodes[idx]); + } + }); + widget.onFocusActivity?.call(); + } else { + // chapters page (or queue without chapters) → go back to buttons + widget.onNavigateUp?.call(); + } + return KeyEventResult.handled; + } + + // DOWN - navigate to next layer + if (key == LogicalKeyboardKey.arrowDown) { + if (page == _StripTab.chapters && _hasQueue) { + // Switch to queue page and focus current queue item + setState(() => _activeTab = _StripTab.queue); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _queueFocusNodes.isNotEmpty) { + final idx = (_getCurrentQueueIndex() ?? 0).clamp(0, _queueFocusNodes.length - 1); + _queueFocusNodes[idx].requestFocus(); + _scrollToFocusedNode(_queueFocusNodes[idx]); + } + }); + widget.onFocusActivity?.call(); + } + // On queue page or chapters-only, consume to prevent bubbling + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + PlexClient? _tryGetClient(BuildContext context, String? serverId) { return context.tryGetClientForServer(serverId); } @@ -83,18 +247,30 @@ class _ContentStripState extends State { Widget build(BuildContext context) { final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final stripHeight = isTablet ? 170.0 : 106.0; + // Add extra height for focus decoration when in focus navigation mode + final effectiveStripHeight = widget.useFocusNavigation ? stripHeight + 16.0 : stripHeight; return SafeArea( top: false, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 0 : 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_hasBothTabs) _buildTabBar(), - const SizedBox(height: 8), + // Tab bar only shown in touch mode when both tabs exist + if (_hasBothTabs && !widget.useFocusNavigation) _buildTabBar(), + // In focus mode, show a small label for the current page + if (widget.useFocusNavigation) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _activeTab == _StripTab.chapters ? t.videoControls.chapters : t.videoControls.queue, + style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + if (!widget.useFocusNavigation) const SizedBox(height: 8), SizedBox( - height: stripHeight, + height: effectiveStripHeight, child: _activeTab == _StripTab.chapters ? _buildChapterStrip(isTablet) : _buildQueueStrip(isTablet), ), ], @@ -168,11 +344,17 @@ class _ContentStripState extends State { }); } + // Ensure focus nodes for focus navigation mode + if (widget.useFocusNavigation) { + _ensureFocusNodes(_chapterFocusNodes, widget.chapters.length, 'ChapterFocus'); + } + return ListView.builder( controller: _chapterScrollController, scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, itemCount: widget.chapters.length, - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), itemBuilder: (context, index) { final chapter = widget.chapters[index]; final isCurrent = currentChapterIndex == index; @@ -181,7 +363,9 @@ class _ContentStripState extends State { ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) : null; - return _buildStripItem( + final onTap = () => widget.player.seek(chapter.startTime); + + final item = _buildStripItem( context: context, isCurrent: isCurrent, isTablet: isTablet, @@ -199,8 +383,29 @@ class _ContentStripState extends State { : null, title: chapter.label, subtitle: formatDurationTimestamp(chapter.startTime), - onTap: () => widget.player.seek(chapter.startTime), + onTap: onTap, ); + + if (widget.useFocusNavigation) { + return Align( + alignment: Alignment.topCenter, + child: FocusableWrapper( + focusNode: _chapterFocusNodes[index], + onSelect: onTap, + onKeyEvent: (node, event) => + _handleFocusItemKeyEvent(node, event, index, widget.chapters.length, _StripTab.chapters), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: item, + ), + ); + } + + return item; }, ); }, @@ -224,11 +429,17 @@ class _ContentStripState extends State { }); } + // Ensure focus nodes for focus navigation mode + if (widget.useFocusNavigation) { + _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); + } + return ListView.builder( controller: _queueScrollController, scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, itemCount: items.length, - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), itemBuilder: (context, index) { final item = items[index]; final isCurrent = item.playQueueItemID == currentItemID; @@ -240,7 +451,9 @@ class _ContentStripState extends State { } catch (_) {} } - return _buildStripItem( + final onTap = () => widget.onQueueItemSelected?.call(item); + + final stripItem = _buildStripItem( context: context, isCurrent: isCurrent, isTablet: isTablet, @@ -257,8 +470,29 @@ class _ContentStripState extends State { : null, title: item.title, subtitle: _buildQueueSubtitle(item), - onTap: () => widget.onQueueItemSelected?.call(item), + onTap: onTap, ); + + if (widget.useFocusNavigation) { + return Align( + alignment: Alignment.topCenter, + child: FocusableWrapper( + focusNode: _queueFocusNodes[index], + onSelect: onTap, + onKeyEvent: (node, event) => + _handleFocusItemKeyEvent(node, event, index, items.length, _StripTab.queue), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: stripItem, + ), + ); + } + + return stripItem; }, ); }, @@ -288,12 +522,14 @@ class _ContentStripState extends State { final titleFontSize = isTablet ? 13.0 : 11.0; final subtitleFontSize = isTablet ? 12.0 : 10.0; + final verticalMargin = widget.useFocusNavigation ? 4.0 : 0.0; return GestureDetector( onTap: onTap, child: Container( width: itemWidth, - margin: const EdgeInsets.symmetric(horizontal: 6), + margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Thumbnail @@ -347,6 +583,7 @@ class _ContentStripState extends State { ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted, fontSize: subtitleFontSize, + fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 65367f50..f6c50b11 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -39,6 +39,12 @@ class TrackChapterControls extends StatelessWidget { /// Called to navigate left from the first button final VoidCallback? onNavigateLeft; + /// Called to navigate up from any button (e.g., to focus timeline on TV) + final VoidCallback? onNavigateUp; + + /// Called to navigate down from any button (e.g., to show content strip on TV) + final VoidCallback? onNavigateDown; + /// Whether to hide the chapters and queue buttons (mobile uses content strip instead) final bool hideChaptersAndQueue; @@ -51,6 +57,8 @@ class TrackChapterControls extends StatelessWidget { this.focusNodes, this.onFocusChange, this.onNavigateLeft, + this.onNavigateUp, + this.onNavigateDown, this.hideChaptersAndQueue = false, }); @@ -116,6 +124,18 @@ class TrackChapterControls extends StatelessWidget { return KeyEventResult.handled; } + // UP arrow - navigate up (e.g., to timeline) + if (key == LogicalKeyboardKey.arrowUp) { + onNavigateUp?.call(); + return KeyEventResult.handled; + } + + // DOWN arrow - navigate down (e.g., to content strip) + if (key == LogicalKeyboardKey.arrowDown) { + onNavigateDown?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }