feat: add content strip for dpad navigation

This commit is contained in:
edde746
2026-03-07 20:17:28 +01:00
parent 7cfa18e1e9
commit c56e73eefe
4 changed files with 657 additions and 199 deletions
@@ -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<bool>? 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<DesktopVideoControls> {
LogicalKeyboardKey? _seekDirection; // Current direction being held
int _seekRepeatCount = 0; // Consecutive key repeats for acceleration
// Content strip state
bool _contentStripVisible = false;
final GlobalKey<ContentStripState> _contentStripKey = GlobalKey<ContentStripState>();
// 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<DesktopVideoControls> {
/// Get focus nodes for track controls
List<FocusNode> 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<DesktopVideoControls> {
}
}
/// 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<DesktopVideoControls> {
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<DesktopVideoControls> {
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<DesktopVideoControls> {
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<Duration>(
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<Duration>(
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<Duration>(
// Previous chapter
StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnap) {
return StreamBuilder<Duration>(
stream: widget.player.streams.duration,
initialData: widget.player.state.duration,
builder: (context, durSnap) {
return StreamBuilder<double>(
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<Duration>(
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<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnap) {
return StreamBuilder<Duration>(
stream: widget.player.streams.duration,
initialData: widget.player.state.duration,
builder: (context, durSnap) {
return StreamBuilder<double>(
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,
),
],
),
),
],
),
@@ -743,6 +743,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_skipButtonDismissed = true;
}
});
_desktopControlsKey.currentState?.hideContentStrip();
_cancelSkipButtonDismissTimer();
widget.controlsVisible?.value = false;
if (Platform.isMacOS) {
@@ -2065,6 +2066,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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();
}
},
),
);
}
@@ -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<ContentStrip> createState() => _ContentStripState();
State<ContentStrip> createState() => ContentStripState();
}
enum _StripTab { chapters, queue }
class _ContentStripState extends State<ContentStrip> {
class ContentStripState extends State<ContentStrip> {
late _StripTab _activeTab;
final ScrollController _chapterScrollController = ScrollController();
final ScrollController _queueScrollController = ScrollController();
bool _hasAutoScrolledChapters = false;
bool _hasAutoScrolledQueue = false;
// Focus nodes for focus navigation mode
List<FocusNode> _chapterFocusNodes = [];
List<FocusNode> _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<ContentStrip> {
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<PlaybackStateProvider>();
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<FocusNode> 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<ContentStrip> {
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<ContentStrip> {
});
}
// 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<ContentStrip> {
? 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<ContentStrip> {
: 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<ContentStrip> {
});
}
// 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<ContentStrip> {
} 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<ContentStrip> {
: 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<ContentStrip> {
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<ContentStrip> {
? 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,
@@ -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;
}