feat: add content strip for dpad navigation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user