Files
plezy/lib/widgets/video_controls/parts/markers.dart
T
edde746 4816e3928f 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
2026-08-07 08:43:48 +02:00

236 lines
7.0 KiB
Dart

part of '../video_controls.dart';
extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
void _listenToPosition() {
_positionSubscription = widget.player.streams.position.listen((position) {
_syncCurrentMarkerForPosition(position);
});
}
void _syncCurrentMarkerForCurrentPosition() {
_syncCurrentMarkerForPosition(widget.player.state.position);
}
void _syncCurrentMarkerForPosition(Duration position) {
if (!_hasRenderedFirstFrame || _markers.isEmpty || !_markersLoaded) {
_clearCurrentMarker();
return;
}
MediaMarker? foundMarker;
for (final marker in _markers) {
if (marker.containsPosition(position)) {
foundMarker = marker;
break;
}
}
if (foundMarker != _currentMarker && mounted) {
_updateCurrentMarker(foundMarker);
}
}
void _clearCurrentMarker() {
final hasMarkerState =
_currentMarker != null ||
_skipButtonDismissed ||
_autoSkipTimer != null ||
_autoSkipProgress != 0.0 ||
_skipButtonDismissTimer != null;
if (!hasMarkerState) return;
if (_currentMarker != null || _skipButtonDismissed) {
_setControlsState(() {
_currentMarker = null;
_skipButtonDismissed = false;
});
}
if (_skipMarkerFocusNode.hasFocus) _skipMarkerFocusNode.unfocus();
_cancelAutoSkipTimer();
_cancelSkipButtonDismissTimer();
}
/// Updates the current marker and manages auto-skip/focus behavior.
void _updateCurrentMarker(MediaMarker? foundMarker) {
if (!_hasRenderedFirstFrame) {
_clearCurrentMarker();
return;
}
if (foundMarker == null) {
_clearCurrentMarker();
return;
}
_setControlsState(() {
_currentMarker = foundMarker;
_skipButtonDismissed = false;
});
_startAutoSkipTimer(foundMarker);
// Auto-skip OFF: dismiss button after 7s if no interaction
// Auto-skip ON: button stays until controls hide
if (!_shouldAutoSkipForMarker(foundMarker)) {
_startSkipButtonDismissTimer();
}
// Auto-focus skip button on TV when marker appears (only in keyboard/TV mode)
if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_skipMarkerFocusNode.requestFocus();
}
});
}
}
Future<void> _skipMarker({bool skipAutoPlayCountdown = false}) async {
if (!widget.canControl) return;
if (_currentMarker == null || !_hasRenderedFirstFrame) return;
final marker = _currentMarker!;
final endTime = marker.endTime;
final duration = widget.player.state.duration;
final isAtEnd = duration > Duration.zero && (duration - endTime).inMilliseconds <= 1000;
if (marker.isCredits && isAtEnd) {
if (!skipAutoPlayCountdown && widget.onNext != null) {
_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.
await widget.player.pause();
widget.onReachedEnd?.call(skipAutoPlayCountdown: skipAutoPlayCountdown);
}
} else {
await _seekToPosition(endTime);
}
if (!mounted) return;
_setControlsState(() {
_currentMarker = null;
});
_cancelAutoSkipTimer();
_cancelSkipButtonDismissTimer();
}
void _startAutoSkipTimer(MediaMarker marker) {
_cancelAutoSkipTimer();
if (!_hasRenderedFirstFrame) return;
final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro);
if (!shouldAutoSkip || _autoSkipDelay <= 0) return;
_autoSkipProgress = 0.0;
const tickDuration = Duration(milliseconds: 200);
final totalTicks = (_autoSkipDelay * 1000) / tickDuration.inMilliseconds;
if (totalTicks <= 0) return;
_autoSkipTimer = Timer.periodic(tickDuration, (timer) {
if (!mounted || _currentMarker != marker) {
timer.cancel();
return;
}
_setControlsState(() {
_autoSkipProgress = (timer.tick / totalTicks).clamp(0.0, 1.0);
});
if (timer.tick >= totalTicks) {
timer.cancel();
_performAutoSkip(skipAutoPlayCountdown: true);
}
});
}
void _cancelAutoSkipTimer() {
final hadTimer = _autoSkipTimer != null;
_autoSkipTimer?.cancel();
_autoSkipTimer = null;
if (mounted && (hadTimer || _autoSkipProgress != 0.0)) {
_setControlsState(() {
_autoSkipProgress = 0.0;
});
}
}
bool _cancelAutoSkipFromUserInteraction() {
final hadActiveTimer = _autoSkipTimer?.isActive ?? false;
if (!hadActiveTimer) return false;
_cancelAutoSkipTimer();
if (_currentMarker != null && !_skipButtonDismissed) {
_startSkipButtonDismissTimer();
}
return true;
}
/// Starts/restarts the skip button dismiss timer. When it fires, hides the
/// button and cancels any active auto-skip countdown.
void _startSkipButtonDismissTimer() {
_skipButtonDismissTimer?.cancel();
if (!_hasRenderedFirstFrame) return;
_skipButtonDismissTimer = Timer(const Duration(seconds: 7), () {
if (!mounted || _currentMarker == null) return;
_setControlsState(() {
_skipButtonDismissed = true;
});
_cancelAutoSkipTimer();
});
}
void _cancelSkipButtonDismissTimer() {
_skipButtonDismissTimer?.cancel();
_skipButtonDismissTimer = null;
}
/// Perform the appropriate skip action based on marker type and next episode availability
void _performAutoSkip({bool skipAutoPlayCountdown = false}) {
if (!widget.canControl) return;
if (_currentMarker == null || !_hasRenderedFirstFrame) return;
unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown));
}
bool _shouldAutoSkipForMarker(MediaMarker marker) {
return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro);
}
bool _shouldShowAutoSkip() {
if (_currentMarker == null) return false;
return _shouldAutoSkipForMarker(_currentMarker!);
}
bool get _isSkipMarkerButtonVisible => shouldShowSkipMarkerButton(
hasFirstFrame: _hasRenderedFirstFrame,
hasMarker: _currentMarker != null,
hasPlayNextPrompt: widget.playNextFocusNode != null,
skipButtonDismissed: _skipButtonDismissed,
controlsVisible: _showControls,
);
void _activateSkipMarker() {
if (!_isSkipMarkerButtonVisible) return;
_cancelAutoSkipTimer();
_performAutoSkip();
}
Widget _buildSkipMarkerButton() {
final isAutoSkipActive = _autoSkipTimer?.isActive ?? false;
return SkipMarkerButton(
marker: _currentMarker!,
playerDuration: widget.player.state.duration,
hasNextEpisode: widget.onNext != null,
isAutoSkipActive: isAutoSkipActive,
shouldShowAutoSkip: _shouldShowAutoSkip(),
autoSkipDelay: _autoSkipDelay,
autoSkipProgress: _autoSkipProgress,
focusNode: _skipMarkerFocusNode,
onActivate: _activateSkipMarker,
onFocusDown: () => _desktopControlsKey.currentState?.requestPlayPauseFocus(),
);
}
}