diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index f9050807..171b8342 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -103,9 +103,12 @@ class PlayerAndroid extends PlayerBase { if (disposed) return; await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; - configureTimeline(offset: timelineOffset, duration: timelineDuration); + // ExoPlayer reports Plex copyts transcodes in source-time coordinates, + // unlike mpv which rebases them to zero. Do not add the timeline offset + // again on Android ExoPlayer or seeks/progress jump to roughly 2x (#1221). + configureTimeline(offset: Duration.zero, duration: timelineDuration); clearTracks(); - resetPlaybackProgress(startPosition); + resetPlaybackProgress(media.start ?? timelineOffset); setSeekable(false); // Show the video layer diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index f1554985..880c3a5c 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -133,6 +133,56 @@ void main() { ); }); + test('ExoPlayer maps copyts transcode streams as absolute timeline positions', () async { + final calls = []; + + await _withMockChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) { + calls.add(call); + switch (call.method) { + case 'initialize': + return Future.value(true); + default: + return Future.value(null); + } + }, + testBody: () async { + final player = PlayerAndroid(); + try { + const timelineStart = Duration(seconds: 2058); // 34:18 + const timelineDuration = Duration(seconds: 2903); // 48:23 + await player.open( + Media('https://example.test/transcode.mkv'), + timelineOffset: timelineStart, + timelineDuration: timelineDuration, + ); + + expect(player.state.position, timelineStart); + expect(player.state.duration, timelineDuration); + + final openCall = calls.singleWhere((call) => call.method == 'open'); + final openArgs = Map.from(openCall.arguments as Map); + expect(openArgs['startPositionMs'], 0); + + await Future.delayed(const Duration(milliseconds: 260)); + player.handlePropertyChange('time-pos', 2058.0); + expect(player.state.position, timelineStart); + + await player.seek(const Duration(minutes: 40)); + + final seekCall = calls.lastWhere((call) => call.method == 'seek'); + final seekArgs = Map.from(seekCall.arguments as Map); + expect(seekArgs['positionMs'], const Duration(minutes: 40).inMilliseconds); + expect(player.state.position, const Duration(minutes: 40)); + } finally { + await player.dispose(); + } + }, + ); + }); + test('MPV clears stale Dart track state before opening new media', () async { await _withMockChannels( methodChannelName: 'com.plezy/mpv_player',