fix(playback): prevent spurious seek jumps

close #946
This commit is contained in:
edde746
2026-05-05 09:52:19 +02:00
parent 05c4d6dff3
commit 94dbae0af9
6 changed files with 120 additions and 2 deletions
@@ -649,6 +649,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
positionUpdateRunnable = null
}
private fun resetPlaybackProgress(startPositionMs: Long) {
lastPosition = startPositionMs
lastDuration = 0L
lastBufferedPosition = 0L
delegate?.onPropertyChange("time-pos", startPositionMs / 1000.0)
delegate?.onPropertyChange("duration", 0.0)
delegate?.onPropertyChange("demuxer-cache-time", 0.0)
delegate?.onPropertyChange("eof-reached", false)
}
private fun emitSeekable(seekable: Boolean, force: Boolean = false) {
if (!force && lastSeekable == seekable) return
lastSeekable = seekable
@@ -1380,6 +1390,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentMediaUri = uri
currentHeaders = headers
currentMediaIsLive = isLive
resetPlaybackProgress(startPositionMs)
// Apply auth/custom headers to the HTTP DataSource for this session
httpDataSourceFactory?.setDefaultRequestProperties(
@@ -1530,7 +1541,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
fun seekTo(positionMs: Long) {
exoPlayer?.seekTo(positionMs)
val player = exoPlayer ?: return
val durationMs = player.duration
val clampedPositionMs = if (!currentMediaIsLive && durationMs != C.TIME_UNSET && durationMs > 0L) {
positionMs.coerceIn(0L, durationMs)
} else {
positionMs.coerceAtLeast(0L)
}
player.seekTo(clampedPositionMs)
lastPosition = clampedPositionMs
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
}
fun setVolume(volume: Float) {
+3 -1
View File
@@ -98,6 +98,8 @@ class PlayerAndroid extends PlayerBase {
}) async {
if (disposed) return;
await _ensureInitialized();
final startPosition = media.start ?? Duration.zero;
resetPlaybackProgress(startPosition);
setSeekable(false);
// Show the video layer
@@ -106,7 +108,7 @@ class PlayerAndroid extends PlayerBase {
await invoke('open', {
'uri': media.uri,
'headers': media.headers,
'startPositionMs': media.start?.inMilliseconds ?? 0,
'startPositionMs': startPosition.inMilliseconds,
'autoPlay': play,
'isLive': isLive,
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
+17
View File
@@ -484,6 +484,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
seekableController.add(seekable);
}
@protected
void resetPlaybackProgress(Duration position) {
_positionMs = position.inMilliseconds;
_state = _state.copyWith(
completed: false,
position: position,
duration: Duration.zero,
buffer: Duration.zero,
bufferRanges: const [],
);
completedController.add(false);
positionController.add(position);
durationController.add(Duration.zero);
bufferController.add(Duration.zero);
bufferRangesController.add(const []);
}
@protected
Future<T?> invoke<T>(String method, [dynamic args]) async {
if (_disposed) return null;
@@ -236,6 +236,7 @@ class _TimelineSliderState extends State<TimelineSlider> {
disableScale: true,
focusColor: Colors.transparent,
semanticLabel: t.videoControls.timelineSlider,
descendantsAreFocusable: false,
child: slider,
);
}
+33
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart' show Player, PlayerState;
import 'package:plezy/utils/player_utils.dart';
void main() {
@@ -12,4 +13,36 @@ void main() {
expect(shouldRestartBeforePreviousItem(const Duration(milliseconds: 3001)), isTrue);
});
});
group('clampSeekPosition', () {
test('clamps negative positions to zero', () {
final player = _FakePlayer(duration: const Duration(minutes: 5));
expect(clampSeekPosition(player, const Duration(seconds: -10)), Duration.zero);
});
test('clamps positions beyond a known duration', () {
final player = _FakePlayer(duration: const Duration(minutes: 5));
expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 5));
});
test('does not upper-clamp when duration is unknown', () {
final player = _FakePlayer(duration: Duration.zero);
expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 6));
});
});
}
class _FakePlayer implements Player {
_FakePlayer({required Duration duration}) : _state = PlayerState(duration: duration);
final PlayerState _state;
@override
PlayerState get state => _state;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+45
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart';
void main() {
group('effectiveVersionQualityControls', () {
@@ -67,4 +69,47 @@ void main() {
expect(mobileSkipZoneForTap(position: const Offset(900, 580), size: size), isNull);
});
});
group('TimelineSlider', () {
testWidgets('routes keyboard input through the custom focus handler', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var keyEvents = 0;
var seekEvents = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
focusNode: focusNode,
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowRight) {
keyEvents++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
onSeek: (_) => seekEvents++,
onSeekEnd: (_) {},
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(keyEvents, 1);
expect(seekEvents, 0);
});
});
}