@@ -78,6 +78,14 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seekToTimelinePosition(Duration position) {
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
final seekFuture = _seekToPosition(clamped, notifyCompletion: false);
|
||||
_lastDispatchedTimelineSeek = clamped;
|
||||
_lastDispatchedTimelineSeekFuture = seekFuture;
|
||||
return seekFuture;
|
||||
}
|
||||
|
||||
Future<void> _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<void>((_) {
|
||||
if (!mounted) return;
|
||||
widget.onSeekCompleted?.call(clamped);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_seekToPosition(clamped));
|
||||
}
|
||||
|
||||
void _holdTimelineScrub() {
|
||||
|
||||
@@ -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<void> Function({
|
||||
int? newMediaIndex,
|
||||
@@ -443,6 +452,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
Timer? _edgeAdjustmentIndicatorClearTimer;
|
||||
// Seek throttle
|
||||
late final Throttle _seekThrottle;
|
||||
Duration? _lastDispatchedTimelineSeek;
|
||||
Future<void>? _lastDispatchedTimelineSeekFuture;
|
||||
// Current marker state
|
||||
MediaMarker? _currentMarker;
|
||||
List<MediaMarker> _markers = [];
|
||||
@@ -495,7 +506,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
|
||||
_seekThrottle = throttle(
|
||||
(Duration pos) {
|
||||
unawaited(_seekToPosition(pos, notifyCompletion: false));
|
||||
unawaited(_seekToTimelinePosition(pos));
|
||||
},
|
||||
const Duration(milliseconds: 200),
|
||||
leading: true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user