Files
plezy/lib/widgets/video_controls/desktop_video_controls.dart
T

1023 lines
38 KiB
Dart

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../focus/dpad_navigator.dart';
import '../../media/media_item.dart';
import '../../media/stepped_seek.dart';
import '../../mpv/mpv.dart';
import '../../media/media_source_info.dart';
import '../../services/fullscreen_state_manager.dart';
import '../../services/scrub_preview_source.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/platform_detector.dart';
import '../../utils/formatters.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.dart';
import '../../models/livetv_capture_buffer.dart';
import 'models/track_controls_state.dart';
import 'player_chrome_controller.dart';
import 'widgets/content_strip.dart';
import 'widgets/live_timeline_bar.dart';
import 'widgets/first_frame_guard.dart';
import 'widgets/play_pause_stream_builder.dart';
import 'widgets/video_controls_header.dart';
import 'widgets/video_timeline_bar.dart';
import 'widgets/volume_control.dart';
import 'widgets/track_chapter_controls.dart';
/// Desktop-specific video controls layout with top bar and bottom controls
class DesktopVideoControls extends StatefulWidget {
final Player player;
final MediaItem metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
final List<MediaChapter> chapters;
final bool chaptersLoaded;
final bool showChapterMarkersOnTimeline;
final int seekTimeSmall;
final VoidCallback onSeekToPreviousChapter;
final VoidCallback onSeekToNextChapter;
final VoidCallback? onSeekBackward;
final VoidCallback? onSeekForward;
final ValueChanged<Duration> onSeek;
final ValueChanged<Duration> onSeekEnd;
final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd;
final IconData Function(int) getReplayIcon;
final IconData Function(int) getForwardIcon;
/// Called when focus activity occurs (to reset hide timer)
final VoidCallback? onFocusActivity;
/// Called to request focus on play/pause button (e.g., when controls shown via keyboard)
final VoidCallback? onRequestPlayPauseFocus;
/// Called when user navigates up from timeline (to hide controls)
final VoidCallback? onHideControls;
final TrackControlsState trackControlsState;
final VoidCallback? onBack;
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final ScrubFrame? Function(Duration time)? thumbnailDataBuilder;
/// Channel name for live TV display
final String? liveChannelName;
// Live TV time-shift
final CaptureBuffer? captureBuffer;
final bool isAtLiveEdge;
final double streamStartEpoch;
final int? currentPositionEpoch;
final ValueChanged<int>? onLiveSeek;
/// Relative live-TV skip callback (delta seconds); parent accumulates+debounces.
final ValueChanged<int>? onLiveSeekBy;
final VoidCallback? onJumpToLive;
/// 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(MediaItem)? 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;
final PlayerChromeController? chromeController;
/// Called when a seek should be executed by the owning screen.
final Future<void> Function(Duration position)? onSeekRequested;
/// Called when a seek operation completes successfully.
final Function(Duration position)? onSeekCompleted;
const DesktopVideoControls({
super.key,
required this.player,
required this.metadata,
this.onNext,
this.onPrevious,
required this.chapters,
required this.chaptersLoaded,
this.showChapterMarkersOnTimeline = true,
required this.seekTimeSmall,
required this.onSeekToPreviousChapter,
required this.onSeekToNextChapter,
this.onSeekBackward,
this.onSeekForward,
required this.onSeek,
required this.onSeekEnd,
this.onScrubStart,
this.onScrubEnd,
required this.getReplayIcon,
required this.getForwardIcon,
this.onFocusActivity,
this.onRequestPlayPauseFocus,
this.onHideControls,
this.trackControlsState = const TrackControlsState(),
this.onBack,
this.hasFirstFrame,
this.thumbnailDataBuilder,
this.liveChannelName,
this.captureBuffer,
this.isAtLiveEdge = true,
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onLiveSeekBy,
this.onJumpToLive,
this.useDpadNavigation = false,
this.serverId,
this.showQueueTab = false,
this.onQueueItemSelected,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onContentStripVisibilityChanged,
this.chromeController,
this.onSeekRequested,
this.onSeekCompleted,
});
@override
State<DesktopVideoControls> createState() => DesktopVideoControlsState();
}
class DesktopVideoControlsState extends State<DesktopVideoControls> {
TrackControlsState get _trackControlsState => widget.trackControlsState;
bool get _canControl => _trackControlsState.canControl;
bool get _isLive => _trackControlsState.isLive;
// Focus nodes for playback control buttons
late final FocusNode _prevItemFocusNode;
late final FocusNode _prevChapterFocusNode;
late final FocusNode _skipBackFocusNode;
late final FocusNode _playPauseFocusNode;
late final FocusNode _skipForwardFocusNode;
late final FocusNode _nextChapterFocusNode;
late final FocusNode _nextItemFocusNode;
late final FocusNode _goToLiveFocusNode;
late final FocusNode _timelineFocusNode;
// Focus node for volume control
late final FocusNode _volumeFocusNode;
// Focus nodes for track/chapter controls (max 8 buttons possible)
late final List<FocusNode> _trackControlFocusNodes;
// List of button focus nodes for horizontal navigation
late final List<FocusNode> _buttonFocusNodes;
// Progressive seek acceleration state
LogicalKeyboardKey? _seekDirection; // Current direction being held
int _seekRepeatCount = 0; // Consecutive key repeats for acceleration
// Preview thumbnail during sustained dpad/keyboard seeking
bool _showKeyRepeatThumbnail = false;
Timer? _keyRepeatThumbnailTimer;
late final DebouncedSeekAccumulator _timelineSeek;
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
// 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();
_prevItemFocusNode = FocusNode(debugLabel: 'PrevItem');
_prevChapterFocusNode = FocusNode(debugLabel: 'PrevChapter');
_skipBackFocusNode = FocusNode(debugLabel: 'SkipBack');
_playPauseFocusNode = FocusNode(debugLabel: 'PlayPause');
_skipForwardFocusNode = FocusNode(debugLabel: 'SkipForward');
_nextChapterFocusNode = FocusNode(debugLabel: 'NextChapter');
_nextItemFocusNode = FocusNode(debugLabel: 'NextItem');
_goToLiveFocusNode = FocusNode(debugLabel: 'GoToLive');
_timelineFocusNode = FocusNode(debugLabel: 'Timeline');
_volumeFocusNode = FocusNode(debugLabel: 'Volume');
// Create focus nodes for track controls (up to 8 buttons)
_trackControlFocusNodes = List.generate(8, (i) => FocusNode(debugLabel: 'TrackControl$i'));
_buttonFocusNodes = [
_prevItemFocusNode,
_prevChapterFocusNode,
_skipBackFocusNode,
_playPauseFocusNode,
_skipForwardFocusNode,
_nextChapterFocusNode,
_nextItemFocusNode,
_goToLiveFocusNode,
];
widget.chromeController?.addListener(_onChromeControllerChanged);
_timelineSeek = DebouncedSeekAccumulator(
currentPosition: () => widget.player.state.position,
duration: () => widget.player.state.duration,
seek: widget.onSeekEnd,
onChanged: () {
if (mounted) setState(() {});
},
);
}
@override
void didUpdateWidget(DesktopVideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.chromeController != widget.chromeController) {
oldWidget.chromeController?.removeListener(_onChromeControllerChanged);
widget.chromeController?.addListener(_onChromeControllerChanged);
}
}
@override
void dispose() {
widget.chromeController?.removeListener(_onChromeControllerChanged);
_keyRepeatThumbnailTimer?.cancel();
_timelineSeek.dispose();
_prevItemFocusNode.dispose();
_prevChapterFocusNode.dispose();
_skipBackFocusNode.dispose();
_playPauseFocusNode.dispose();
_skipForwardFocusNode.dispose();
_nextChapterFocusNode.dispose();
_nextItemFocusNode.dispose();
_goToLiveFocusNode.dispose();
_timelineFocusNode.dispose();
_volumeFocusNode.dispose();
for (final node in _trackControlFocusNodes) {
node.dispose();
}
super.dispose();
}
void _onChromeControllerChanged() {
if (widget.chromeController?.contentStripVisible == false) {
hideContentStrip();
}
}
/// Request focus on the play/pause button (called when controls shown via keyboard)
void requestPlayPauseFocus() {
_playPauseFocusNode.requestFocus();
}
/// Request focus on the timeline (called when controls shown via LEFT/RIGHT)
void requestTimelineFocus() {
_timelineFocusNode.requestFocus();
}
/// Hide content strip (called by parent when controls hide)
void hideContentStrip() {
if (_contentStripVisible) {
setState(() {
_contentStripVisible = false;
});
widget.onContentStripVisibilityChanged?.call(false);
}
}
/// Dismiss content strip and restore focus (called by parent on BACK key)
void dismissContentStrip() {
if (!_contentStripVisible) return;
_onContentStripNavigateUp();
}
/// Handle left navigation from first track control - go to volume (or last button on TV)
void navigateFromTrackToVolume() {
if (PlatformDetector.isTV()) {
// On TV (no volume), go to last mounted button
for (int i = _buttonFocusNodes.length - 1; i >= 0; i--) {
if (_buttonFocusNodes[i].context != null) {
_buttonFocusNodes[i].requestFocus();
widget.onFocusActivity?.call();
return;
}
}
_playPauseFocusNode.requestFocus(); // fallback
} else {
_volumeFocusNode.requestFocus();
}
widget.onFocusActivity?.call();
}
void _onFocusChange(bool hasFocus) {
if (hasFocus) {
widget.onFocusActivity?.call();
} else {
// Reset progressive seek state when timeline loses focus
_timelineSeek.flush();
_resetSeekState();
}
}
/// 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((_) {
if (!mounted) return;
_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((_) {
if (!mounted) return;
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,
/// [KeyEventResult.ignored] otherwise.
/// UP always navigates to timeline.
KeyEventResult _handleDirectionalNavigation(KeyEvent event, {FocusNode? leftTarget, FocusNode? rightTarget}) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowLeft) {
leftTarget?.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
rightTarget?.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
_timelineFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
if (widget.useDpadNavigation && _hasStripContent) {
_showContentStrip();
return KeyEventResult.handled;
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Handle key events for horizontal button navigation
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index) {
// Find nearest mounted left neighbor
FocusNode? leftTarget;
for (int i = index - 1; i >= 0; i--) {
if (_buttonFocusNodes[i].context != null) {
leftTarget = _buttonFocusNodes[i];
break;
}
}
// Find nearest mounted right neighbor, falling through to volume/track controls
FocusNode? rightTarget;
for (int i = index + 1; i < _buttonFocusNodes.length; i++) {
if (_buttonFocusNodes[i].context != null) {
rightTarget = _buttonFocusNodes[i];
break;
}
}
rightTarget ??= PlatformDetector.isTV()
? (_trackControlFocusNodes.isNotEmpty ? _trackControlFocusNodes.first : null)
: _volumeFocusNode;
return _handleDirectionalNavigation(event, leftTarget: leftTarget, rightTarget: rightTarget);
}
/// Handle key events for volume control navigation
KeyEventResult _handleVolumeKeyEvent(FocusNode _, KeyEvent event) {
return _handleDirectionalNavigation(
event,
leftTarget: _nextItemFocusNode,
rightTarget: _trackControlFocusNodes.isNotEmpty ? _trackControlFocusNodes.first : null,
);
}
/// Reset progressive seek state
void _resetSeekState() {
_seekDirection = null;
_seekRepeatCount = 0;
_keyRepeatThumbnailTimer?.cancel();
_keyRepeatThumbnailTimer = null;
if (_showKeyRepeatThumbnail) {
setState(() => _showKeyRepeatThumbnail = false);
}
}
/// Show the timeline preview thumbnail during sustained key-repeat seeking.
/// Arms a short timer that hides the thumbnail once repeats stop.
void _triggerKeyRepeatThumbnail() {
if (!_showKeyRepeatThumbnail) {
setState(() => _showKeyRepeatThumbnail = true);
}
_keyRepeatThumbnailTimer?.cancel();
_keyRepeatThumbnailTimer = Timer(_keyRepeatThumbnailTimeout, () {
if (!mounted) return;
setState(() => _showKeyRepeatThumbnail = false);
});
}
/// Handle key events for timeline navigation
KeyEventResult _handleTimelineKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Handle key release: commit the accumulated scrub target with a single
// seek (a no-op when nothing is pending) and reset progressive seek state.
if (event is KeyUpEvent) {
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
_timelineSeek.flush();
_resetSeekState();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final duration = widget.player.state.duration;
// UP arrow - hide controls and reset seek state
if (key == LogicalKeyboardKey.arrowUp) {
_timelineSeek.flush();
_resetSeekState();
widget.onHideControls?.call();
return KeyEventResult.handled;
}
// DOWN arrow - move focus to play/pause button and reset seek state
if (key == LogicalKeyboardKey.arrowDown) {
_timelineSeek.flush();
_resetSeekState();
_playPauseFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// LEFT/RIGHT for smooth scrubbing with progressive acceleration
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
// Ignore seeking if user cannot control
if (!_canControl) return KeyEventResult.handled;
// Track direction change - reset if direction changes
if (_seekDirection != key) {
_seekDirection = key;
_seekRepeatCount = 0;
}
if (event is KeyRepeatEvent) {
_seekRepeatCount++;
_triggerKeyRepeatThumbnail();
}
final isForward = key == LogicalKeyboardKey.arrowRight;
final effectiveMultiplier = event is KeyRepeatEvent ? steppedSeekMultiplier(_seekRepeatCount) : 1.0;
// Live TV: relative epoch-based seeking via the parent accumulator, which
// coalesces a rapid/held burst into one transcode re-open (#1253). The
// acceleration multiplier still grows the per-press step; the accumulator
// sums them.
if (_isLive && widget.onLiveSeekBy != null) {
final stepSeconds = (widget.seekTimeSmall * effectiveMultiplier).clamp(1, 300).round();
widget.onLiveSeekBy!(isForward ? stepSeconds : -stepSeconds);
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
final baseStepMs = widget.seekTimeSmall * 1000;
final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120_000).toInt();
final step = Duration(milliseconds: stepMs);
_timelineSeek.seekBy(isForward ? step : -step);
// Move only the preview while the key is held; commit a single seek once
// the burst pauses (debounce) or the key is released. Firing a real seek
// per key-repeat floods a direct/progressive stream with overlapping range
// requests and wedges low-power devices (e.g. Fire TV Stick) in BUFFERING.
// The player's `buffering` flag lags the key-repeat rate, so it can't gate
// this reliably — coalescing unconditionally matches the existing transcode
// path and cannot flood regardless of hardware.
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Top bar with back button and title (always visible)
_buildTopBar(context),
FirstFrameGuard(
hasFirstFrame: widget.hasFirstFrame,
placeholder: const Expanded(child: SizedBox.shrink()),
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, top: 32),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.65),
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.0, 0.42, 1.0],
),
),
child: Column(
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,
onSeekRequested: widget.onSeekRequested,
onSeekCompleted: widget.onSeekCompleted,
useFocusNavigation: true,
onNavigateUp: _onContentStripNavigateUp,
onFocusActivity: widget.onFocusActivity,
),
],
),
),
],
),
),
),
],
);
}
Widget _buildTopBar(BuildContext _) {
// Use global fullscreen state for padding
return ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) {
final isFullscreen = FullscreenStateManager().isFullscreen;
// In fullscreen on macOS, use less left padding since traffic lights auto-hide
// In normal mode on macOS, need more padding to avoid traffic lights
double leftPadding;
if (Platform.isMacOS) {
leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft;
} else {
leftPadding = DesktopWindowPadding.macOSLeftFullscreen;
}
return _buildTopBarContent(context, leftPadding);
},
);
}
Widget _buildTopBarContent(BuildContext _, double leftPadding) {
final topBar = Padding(
padding: .only(left: leftPadding, right: 16),
child: Row(
children: [
Expanded(
child: VideoControlsHeader(
metadata: widget.metadata,
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
onBack: widget.onBack,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
),
),
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))),
child: Text(
t.liveTv.live,
style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 12),
),
),
],
],
),
);
return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true);
}
Widget _buildBottomControlsContent(BuildContext _, {required bool hasFrame}) {
final canInteract = _canControl && hasFrame;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
if (_isLive && widget.captureBuffer != null) ...[
LiveTimelineBar(
player: widget.player,
captureBuffer: widget.captureBuffer!,
streamStartEpoch: widget.streamStartEpoch,
isAtLiveEdge: widget.isAtLiveEdge,
onSeekEnd: widget.onLiveSeek,
horizontalLayout: true,
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: canInteract,
),
] else if (!_isLive) ...[
VideoTimelineBar(
player: widget.player,
chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded,
showChapterMarkersOnTimeline: widget.showChapterMarkersOnTimeline,
onSeek: widget.onSeek,
onSeekEnd: widget.onSeekEnd,
onScrubStart: widget.onScrubStart,
onScrubEnd: widget.onScrubEnd,
horizontalLayout: true,
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: canInteract,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
showKeyRepeatThumbnail: _showKeyRepeatThumbnail,
previewPosition: _timelineSeek.pendingPosition,
),
],
// Row 2: Playback controls and options
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,
),
);
},
),
],
if (!_isLive || widget.captureBuffer != null) ...[
// 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 || widget.captureBuffer != null) ...[
// 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),
),
),
],
// Go to Live button (only when time-shifted behind live edge)
if (_isLive && widget.captureBuffer != null && !widget.isAtLiveEdge && widget.onJumpToLive != null) ...[
_buildFocusableButton(
focusNode: _goToLiveFocusNode,
index: 7,
icon: Symbols.stream_rounded,
onPressed: _canControl ? widget.onJumpToLive : null,
semanticLabel: t.liveTv.goToLive,
tooltip: t.liveTv.goToLive,
),
],
if (!_isLive) ...[
// 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, faded when too narrow)
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 Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(text, style: style, maxLines: 1, softWrap: false, overflow: .fade),
);
},
);
},
);
},
),
),
// Volume control (hidden on TV — hardware handles volume)
if (!PlatformDetector.isTV()) ...[
VolumeControl(
player: widget.player,
focusNode: _volumeFocusNode,
onKeyEvent: _handleVolumeKeyEvent,
onFocusChange: _onFocusChange,
onFocusActivity: widget.onFocusActivity,
),
const SizedBox(width: 16),
],
// Audio track, subtitle, and chapter controls
TrackChapterControls(
player: widget.player,
chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded,
trackControlsState: _trackControlsState,
onSeekRequested: widget.onSeekRequested,
onSeekCompleted: widget.onSeekCompleted,
focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
onNavigateUp: () {
_timelineFocusNode.requestFocus();
widget.onFocusActivity?.call();
},
onNavigateDown: () {
if (widget.useDpadNavigation && _hasStripContent) {
_showContentStrip();
}
},
hideChaptersAndQueue: widget.useDpadNavigation && _hasStripContent,
),
],
),
),
],
),
);
}
/// Returns the label of the next chapter the user would seek to, or null.
String? _getNextChapterLabel(Duration position) {
final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: true);
return index == null ? null : widget.chapters[index].label;
}
/// Returns the label of the previous chapter the user would seek to, or null.
String? _getPreviousChapterLabel(Duration position) {
final index = MediaChapter.seekTargetIndex(position, widget.chapters, forward: false);
return index == null ? null : widget.chapters[index].label;
}
Widget _buildFocusableButton({
required FocusNode focusNode,
required int index,
required IconData icon,
required VoidCallback? onPressed,
required String semanticLabel,
Color color = Colors.white,
double iconSize = 24,
String? tooltip,
}) {
return FocusableWrapper(
focusNode: focusNode,
onSelect: onPressed,
onKeyEvent: (node, event) => _handleButtonKeyEvent(node, event, index),
onFocusChange: _onFocusChange,
borderRadius: 20,
autoScroll: false,
useBackgroundFocus: true,
semanticLabel: semanticLabel,
child: Semantics(
label: semanticLabel,
button: true,
excludeSemantics: true,
child: IconButton(
icon: AppIcon(icon, fill: 1, color: color, size: iconSize),
iconSize: iconSize,
tooltip: tooltip,
onPressed: onPressed,
),
),
);
}
}