A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819
1007 lines
38 KiB
Dart
1007 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 '../../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<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.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<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,
|
|
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<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 ? 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<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(
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|