fix(player): skip relative to the position a jump landed on
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
This commit is contained in:
@@ -247,15 +247,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
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);
|
||||
|
||||
@@ -123,8 +123,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onSkipMarker: onSkipMarker,
|
||||
onNextEpisode: widget.onNext,
|
||||
onPreviousEpisode: widget.onPrevious,
|
||||
onNextEpisode: _abandoningBurst(widget.onNext),
|
||||
onPreviousEpisode: _abandoningBurst(widget.onPrevious),
|
||||
onScreenshot: _showScreenshotToast,
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
|
||||
@@ -96,7 +96,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
|
||||
|
||||
if (marker.isCredits && isAtEnd) {
|
||||
if (!skipAutoPlayCountdown && widget.onNext != null) {
|
||||
widget.onNext!.call();
|
||||
_abandoningBurst(widget.onNext)!.call();
|
||||
} else {
|
||||
// Seeking to EOF is unreliable due to position stream throttling,
|
||||
// so pause and defer to the parent's completion flow.
|
||||
|
||||
@@ -17,8 +17,8 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
player: widget.player,
|
||||
volumeController: widget.volumeController,
|
||||
metadata: widget.metadata,
|
||||
onNext: widget.onNext,
|
||||
onPrevious: widget.onPrevious,
|
||||
onNext: _abandoningBurst(widget.onNext),
|
||||
onPrevious: _abandoningBurst(widget.onPrevious),
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
chapters: _chapters,
|
||||
chaptersLoaded: _chaptersLoaded,
|
||||
@@ -46,9 +46,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
isAtLiveEdge: widget.isAtLiveEdge,
|
||||
streamStartEpoch: widget.streamStartEpoch,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek),
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
onJumpToLive: widget.onJumpToLive,
|
||||
onJumpToLive: _abandoningBurst(widget.onJumpToLive),
|
||||
useDpadNavigation: useDpad,
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems,
|
||||
@@ -65,6 +65,11 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
void _onQueueItemSelected(MediaItem item) {
|
||||
// Same contract as next/previous: the switch is asynchronous, so a burst
|
||||
// still armed here would debounce into a seek on the outgoing item.
|
||||
_hiddenSeek.cancel();
|
||||
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||
_dismissSkipFeedback();
|
||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
videoPlayerState?.navigateToQueueItem(item);
|
||||
}
|
||||
|
||||
@@ -622,10 +622,51 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
_showSkipFeedback(isForward: isForward);
|
||||
}
|
||||
|
||||
/// Wrap an absolute live action so it takes down the badge a pending live
|
||||
/// skip raised.
|
||||
///
|
||||
/// Live relative skips bypass [_hiddenSeek] for the parent's epoch
|
||||
/// accumulator (#1253), so the jump the reopen announces finds no pending
|
||||
/// target here and nothing retires the readout. The absolute action cancels
|
||||
/// that queued skip, so its promised total is no longer going anywhere.
|
||||
ValueChanged<int>? _liveSeekAbandoningBurst(ValueChanged<int>? onLiveSeek) {
|
||||
if (onLiveSeek == null) return null;
|
||||
return (offset) {
|
||||
_dismissSkipFeedback();
|
||||
onLiveSeek(offset);
|
||||
};
|
||||
}
|
||||
|
||||
/// Wrap an action that replaces what is playing — a live channel switch, the
|
||||
/// next or previous item — so the badge goes with the timeline it described.
|
||||
///
|
||||
/// The pending skip is cancelled by the switch itself (live keeps its offset
|
||||
/// in the parent accumulator, video re-keys on the new item), but neither
|
||||
/// route runs through [_hiddenSeek], so nothing else takes the readout down.
|
||||
VoidCallback? _abandoningBurst(VoidCallback? action) {
|
||||
if (action == null) return null;
|
||||
return () {
|
||||
// Cancel as well as hide: a held-arrow target stays armed across the
|
||||
// asynchronous switch and would otherwise debounce into a seek on the
|
||||
// outgoing player before the new item re-keys the controls. The focused
|
||||
// desktop timeline coalesces into its own accumulator, so it needs the
|
||||
// same treatment.
|
||||
_hiddenSeek.cancel();
|
||||
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||
_dismissSkipFeedback();
|
||||
action();
|
||||
};
|
||||
}
|
||||
|
||||
/// Handle a completed skip-zone double tap.
|
||||
void _handleDoubleTapSkip({required bool isForward}) {
|
||||
if (!widget.canControl) return;
|
||||
|
||||
// This tap supersedes any burst the keyboard/D-pad left pending, and it
|
||||
// shares the badge with it. Retire that burst first, or its abandonment —
|
||||
// triggered by this tap's own seek — would take down the readout this tap
|
||||
// is about to put up.
|
||||
_hiddenSeek.cancel();
|
||||
_registerSkipFeedback(isForward: isForward, seconds: _seekTimeSmall);
|
||||
|
||||
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
||||
@@ -642,6 +683,9 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
|
||||
/// Show animated visual feedback for skip gesture
|
||||
void _showSkipFeedback({required bool isForward}) {
|
||||
// Reads `tokens(context)` below, so a caller reaching here after disposal
|
||||
// would touch a defunct element rather than merely no-op.
|
||||
if (!mounted) return;
|
||||
// Cancel BOTH timers: a skip landing during the fade-out window must not
|
||||
// leave the old hide timer pending, or it kills the fresh readout and zeroes
|
||||
// the accumulated count mid-display.
|
||||
@@ -675,6 +719,22 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
});
|
||||
}
|
||||
|
||||
/// Take the skip readout down at once, because the burst it was counting will
|
||||
/// never be committed. Fading it out would keep showing a total the player is
|
||||
/// not going to seek to; zeroing it without hiding would flash `0s`.
|
||||
void _dismissSkipFeedback() {
|
||||
_feedbackTimer?.cancel();
|
||||
_feedbackTimer = null;
|
||||
_feedbackHideTimer?.cancel();
|
||||
_feedbackHideTimer = null;
|
||||
if (!_showDoubleTapFeedback && _accumulatedSkipSeconds == 0) return;
|
||||
_setControlsState(() {
|
||||
_showDoubleTapFeedback = false;
|
||||
_doubleTapFeedbackOpacity = 0.0;
|
||||
_accumulatedSkipSeconds = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle tap on controls overlay - route to skip zones or toggle controls
|
||||
void _handleControlsOverlayTap(TapUpDetails details, Size size) {
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
|
||||
@@ -790,6 +790,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
currentPosition: () => widget.player.state.position,
|
||||
duration: () => widget.player.state.duration,
|
||||
seek: (target) => unawaited(_seekToPosition(target)),
|
||||
playheadJumps: widget.player.streams.playheadJump,
|
||||
// The badge is a promise about the coalesced burst. Once that burst is
|
||||
// abandoned — the timeline, a chapter jump, a peer, a stream rebuilt at a
|
||||
// resume position, a new item, a swapped player — the promise is void, so
|
||||
// take the readout down instead of leaving a total nothing will seek to
|
||||
// (#1819, keeping the #1676 badge-matches-seek invariant).
|
||||
onBurstAbandoned: _dismissSkipFeedback,
|
||||
);
|
||||
// Side effects: rotation lock + focus on nav-enable. Both fire immediately
|
||||
// so init wiring (orientation, focus) lives in one place.
|
||||
@@ -876,6 +883,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.player != widget.player) {
|
||||
++_subtitleVisibilityWriteGeneration;
|
||||
// Otherwise the accumulator keeps listening to the retired player and
|
||||
// never hears the new one move.
|
||||
_hiddenSeek.attachPlayheadJumps(widget.player.streams.playheadJump);
|
||||
}
|
||||
if (oldWidget.chromeController != widget.chromeController) {
|
||||
oldWidget.chromeController.removeListener(_onChromeChanged);
|
||||
@@ -888,6 +898,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// the per-item chapters/markers/skip state when the item changes.
|
||||
// (Quality/version switches keep the same item, so no refetch churn.)
|
||||
if (oldWidget.metadata.globalKey != widget.metadata.globalKey) {
|
||||
// A pending skip is an offset into the outgoing item's timeline; letting
|
||||
// it survive would rebase the next press onto the previous episode. This
|
||||
// is the lifecycle backstop for every route that replaces the item
|
||||
// without going through a wrapped next/previous callback — natural
|
||||
// completion, a peer, an external change — so it also has to reach the
|
||||
// desktop timeline's own accumulator and the shared readout.
|
||||
_hiddenSeek.cancel();
|
||||
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||
_dismissSkipFeedback();
|
||||
_setControlsState(() {
|
||||
_chapters = [];
|
||||
_chaptersLoaded = false;
|
||||
@@ -1187,8 +1206,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||
onStartAutoHide: widget.chromeController.startAutoHide,
|
||||
onBack: widget.onBack,
|
||||
onNext: widget.onNext,
|
||||
onPrevious: widget.onPrevious,
|
||||
onNext: _abandoningBurst(widget.onNext),
|
||||
onPrevious: _abandoningBurst(widget.onPrevious),
|
||||
canControl: widget.canControl,
|
||||
hasFirstFrame: widget.hasFirstFrame,
|
||||
thumbnailDataBuilder: widget.thumbnailDataBuilder,
|
||||
@@ -1197,7 +1216,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
captureBuffer: widget.captureBuffer,
|
||||
isAtLiveEdge: widget.isAtLiveEdge,
|
||||
streamStartEpoch: widget.streamStartEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek),
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: canShowQueue,
|
||||
onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null,
|
||||
|
||||
Reference in New Issue
Block a user