From 930534ee315c3acd77d5c11547f981df78db9a78 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:04:29 +0200 Subject: [PATCH] fix: avoid duplicate timeline seek close #1421 --- .../video_controls/parts/playback_input.dart | 37 +++++++++++++++- .../video_controls/video_controls.dart | 13 +++++- test/widgets/video_controls_test.dart | 43 +++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index d04640b9..4c8af7ee 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -78,6 +78,14 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { } } + Future _seekToTimelinePosition(Duration position) { + final clamped = clampSeekPosition(widget.player, position); + final seekFuture = _seekToPosition(clamped, notifyCompletion: false); + _lastDispatchedTimelineSeek = clamped; + _lastDispatchedTimelineSeekFuture = seekFuture; + return seekFuture; + } + Future _playOrPause() async { if (!widget.player.state.playing && _rewindOnResume > 0) { final target = widget.player.state.position - Duration(seconds: _rewindOnResume); @@ -89,14 +97,39 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { /// Throttled seek for timeline slider - executes immediately then throttles to 200ms void _throttledSeek(Duration position) { - if (widget.isTranscoding) return; + if (widget.isTranscoding) { + _lastDispatchedTimelineSeek = null; + _lastDispatchedTimelineSeekFuture = null; + return; + } _seekThrottle([position]); } /// Finalizes the seek when user stops scrubbing the timeline void _finalizeSeek(Duration position) { _seekThrottle.cancel(); - unawaited(_seekToPosition(position)); + final clamped = clampSeekPosition(widget.player, position); + + if (shouldSkipDuplicateTimelineSeek( + isTranscoding: widget.isTranscoding, + lastDispatchedSeek: _lastDispatchedTimelineSeek, + finalSeek: clamped, + )) { + final seekFuture = _lastDispatchedTimelineSeekFuture; + if (seekFuture == null) { + widget.onSeekCompleted?.call(clamped); + return; + } + unawaited( + seekFuture.then((_) { + if (!mounted) return; + widget.onSeekCompleted?.call(clamped); + }), + ); + return; + } + + unawaited(_seekToPosition(clamped)); } void _holdTimelineScrub() { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index ad53d21c..e5c8fbdf 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -186,6 +186,15 @@ KeyEventResult handlePromptDismissBackKey(KeyEvent event, VoidCallback? onDismis return handleBackKeyAction(event, onDismissPrompt); } +@visibleForTesting +bool shouldSkipDuplicateTimelineSeek({ + required bool isTranscoding, + required Duration? lastDispatchedSeek, + required Duration finalSeek, +}) { + return !isTranscoding && lastDispatchedSeek == finalSeek; +} + typedef PlaybackSourceChangeCallback = Future Function({ int? newMediaIndex, @@ -443,6 +452,8 @@ class _PlexVideoControlsState extends State Timer? _edgeAdjustmentIndicatorClearTimer; // Seek throttle late final Throttle _seekThrottle; + Duration? _lastDispatchedTimelineSeek; + Future? _lastDispatchedTimelineSeekFuture; // Current marker state MediaMarker? _currentMarker; List _markers = []; @@ -495,7 +506,7 @@ class _PlexVideoControlsState extends State _skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton'); _seekThrottle = throttle( (Duration pos) { - unawaited(_seekToPosition(pos, notifyCompletion: false)); + unawaited(_seekToTimelinePosition(pos)); }, const Duration(milliseconds: 200), leading: true, diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 996cc701..9f5d46f5 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -729,6 +729,49 @@ void main() { }); }); + group('shouldSkipDuplicateTimelineSeek', () { + test('skips a matching non-transcode final seek', () { + expect( + shouldSkipDuplicateTimelineSeek( + isTranscoding: false, + lastDispatchedSeek: const Duration(minutes: 7, seconds: 30), + finalSeek: const Duration(minutes: 7, seconds: 30), + ), + isTrue, + ); + }); + + test('does not skip matching transcode seek', () { + expect( + shouldSkipDuplicateTimelineSeek( + isTranscoding: true, + lastDispatchedSeek: const Duration(minutes: 7, seconds: 30), + finalSeek: const Duration(minutes: 7, seconds: 30), + ), + isFalse, + ); + }); + + test('does not skip when no matching seek was already dispatched', () { + expect( + shouldSkipDuplicateTimelineSeek( + isTranscoding: false, + lastDispatchedSeek: const Duration(minutes: 7), + finalSeek: const Duration(minutes: 7, seconds: 30), + ), + isFalse, + ); + expect( + shouldSkipDuplicateTimelineSeek( + isTranscoding: false, + lastDispatchedSeek: null, + finalSeek: const Duration(minutes: 7, seconds: 30), + ), + isFalse, + ); + }); + }); + group('SyncOffsetControl', () { testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en);