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 '../../services/video_volume_controller.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/content_strip_panel.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 VideoVolumeController volumeController; final MediaItem metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; final VoidCallback onPlayPause; final List chapters; final bool chaptersLoaded; final bool showChapterMarkersOnTimeline; final int seekTimeSmall; final VoidCallback onSeekToPreviousChapter; final VoidCallback onSeekToNextChapter; final VoidCallback? onSeekBackward; final VoidCallback? onSeekForward; final ValueChanged onSeek; final ValueChanged 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? 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? onLiveSeek; /// Relative live-TV skip callback (delta seconds); parent accumulates+debounces. final ValueChanged? 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? onContentStripVisibilityChanged; final PlayerChromeController? chromeController; /// Called when a seek should be executed by the owning screen. final Future 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.volumeController, required this.metadata, this.onNext, this.onPrevious, required this.onPlayPause, 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 createState() => DesktopVideoControlsState(); } class DesktopVideoControlsState extends State { 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 _trackControlFocusNodes; // List of button focus nodes for horizontal navigation late final List _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 _contentStripKey = GlobalKey(); // Track which button was last focused (for returning from content strip) FocusNode? _lastFocusedButtonNode; /// Whether the content strip has any content to show bool get _hasStripContent { return widget.chapters.isNotEmpty || (widget.showQueueTab && widget.onQueueItemSelected != null); } @override void initState() { super.initState(); _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, playheadJumps: widget.player.streams.playheadJump, onChanged: () { if (mounted) setState(() {}); }, ); } /// Drop a coalesced timeline burst that will never be committed, because what /// it was seeking through is being replaced. void abandonPendingSeek() => _timelineSeek.cancel(); @override void didUpdateWidget(DesktopVideoControls oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.player != widget.player) { _timelineSeek.attachPlayheadJumps(widget.player.streams.playheadJump); } 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 ContentStripHint(Symbols.keyboard_arrow_down_rounded), ], ), // Content strip (TV/dpad only) — replaces normal controls if (_contentStripVisible && widget.useDpadNavigation) ContentStripPanel( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8, top: 32), chevron: Symbols.keyboard_arrow_up_rounded, child: ContentStrip( key: _contentStripKey, player: widget.player, chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, serverId: widget.serverId, canControl: _canControl, 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( 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 ? widget.onPlayPause : 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( 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( stream: widget.player.streams.position, initialData: widget.player.state.position, builder: (context, posSnap) { return StreamBuilder( stream: widget.player.streams.duration, initialData: widget.player.state.duration, builder: (context, durSnap) { return StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, builder: (context, rateSnap) { final position = posSnap.data ?? Duration.zero; final duration = durSnap.data ?? Duration.zero; final remaining = duration - position; final rate = rateSnap.data ?? 1.0; if (remaining.inSeconds <= 0) return const SizedBox.shrink(); final text = t.videoControls.endsAt( time: formatFinishTime( remaining, rate: rate, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context), ), ); const style = TextStyle(color: Colors.white70, fontSize: 13); return 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( volumeController: widget.volumeController, 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, ), ), ); } }