From 2b45557743a8b3c3a938cf0208bd37cb542f55ab Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 21 May 2026 09:58:13 +0200 Subject: [PATCH] fix(android): preserve mpv frame-rate startup position --- .../edde746/plezy/shared/FrameRateManager.kt | 12 +++--- .../video_player/parts/display_matching.dart | 23 ++++++++--- .../video_player/parts/playback_start.dart | 12 +++--- test/mpv/player_open_test.dart | 40 +++++++++++++++++++ 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt index ce29b3c9..c0acf8ab 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FrameRateManager.kt @@ -142,7 +142,7 @@ class FrameRateManager( cb(switched) } - private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { + private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { // Resolve any previous pending op before starting a new one. firePendingCompletion("superseded", switched = false) pendingCompletion = onComplete @@ -157,7 +157,7 @@ class FrameRateManager( getDisplayManager().unregisterDisplayListener(this) displayListener = null - val settle = Runnable { firePendingCompletion("display settled", switched = true) } + val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) } pendingSettleRunnable = settle handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs) } @@ -167,7 +167,7 @@ class FrameRateManager( // Watchdog: if the TV never signals a display change (silently ignoring // the mode request), still complete after a bounded wait so the caller // doesn't hang. - val watchdog = Runnable { firePendingCompletion("watchdog", switched = true) } + val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) } watchdogRunnable = watchdog handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS) } @@ -295,7 +295,7 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ALWAYS ) - registerDisplayListener(extraDelayMs, onComplete) + registerDisplayListener(fps, extraDelayMs, onComplete) } else { val userPreference = getDisplayManager().matchContentFrameRateUserPreference if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { @@ -305,7 +305,7 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ALWAYS ) - registerDisplayListener(extraDelayMs, onComplete) + registerDisplayListener(fps, extraDelayMs, onComplete) } else { log("non-seamless switch not allowed (preference=$userPreference), using seamless-only") surface.setFrameRate( @@ -361,6 +361,6 @@ class FrameRateManager( attrs.preferredDisplayModeId = modeToUse.modeId activity.window?.attributes = attrs } - registerDisplayListener(extraDelayMs, onComplete) + registerDisplayListener(fps, extraDelayMs, onComplete) } } diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index b73b4887..ab2eaa63 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -73,7 +73,10 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { final p = player; if (!mounted || !Platform.isAndroid || p == null || p is PlayerAndroid) return; - // Subscribe before flushing so the broadcast event isn't dropped when + final isLive = widget.isLive; + final targetPosition = p.state.position; + + // Subscribe before refreshing so the broadcast event isn't dropped when // the restart fires synchronously fast. var timedOut = false; final restartFuture = p.streams.playbackRestart.first.timeout( @@ -84,16 +87,24 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { ); final sw = Stopwatch()..start(); try { - appLogger.d('Frame rate matching: flushing Android MPV buffers ($reason, command=drop-buffers)'); - await p.command(['drop-buffers']); + if (isLive) { + appLogger.d('Frame rate matching: flushing Android MPV live buffers ($reason, command=drop-buffers)'); + await p.command(['drop-buffers']); + } else { + appLogger.d( + 'Frame rate matching: refreshing Android MPV decoder ' + '($reason, target=${targetPosition.inMilliseconds}ms)', + ); + await p.seek(targetPosition); + } await restartFuture; appLogger.d( - 'Frame rate matching: flushed Android MPV buffers ' - '($reason, waited=${sw.elapsedMilliseconds}ms, ' + 'Frame rate matching: refreshed Android MPV decoder ' + '($reason, target=${isLive ? 'live' : '${targetPosition.inMilliseconds}ms'}, waited=${sw.elapsedMilliseconds}ms, ' 'gate=${timedOut ? 'timeout' : 'playback-restart'})', ); } catch (e) { - appLogger.w('Failed to flush Android MPV buffers after frame rate switch ($reason)', error: e); + appLogger.w('Failed to refresh Android MPV decoder after frame rate switch ($reason)', error: e); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index a0050961..b3e81a90 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -292,13 +292,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs); if (needsAndroidMpvStartupRefresh) { - appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush'); + appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh'); androidMpvStartupReady = currentPlayer.streams.playbackRestart.first .then((_) => true) .timeout( const Duration(seconds: 15), onTimeout: () { - appLogger.w('Timed out waiting for Android MPV startup frame before buffer flush'); + appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh'); return false; }, ); @@ -503,15 +503,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ), ); } else if (needsAndroidMpvStartupRefresh && mounted && player == currentPlayer) { - appLogger.d('Frame rate matching: waiting for Android MPV startup frame before buffer flush'); + appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh'); final startupReady = androidMpvStartupReady == null ? false : await androidMpvStartupReady; if (mounted && player == currentPlayer) { if (startupReady) { await Future.delayed(const Duration(milliseconds: 100)); await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup'); - await resumeAfterStartupGate('startup buffer flush'); + await resumeAfterStartupGate('startup decoder refresh'); } else { - appLogger.w('Frame rate matching: skipping Android MPV buffer flush because startup frame timed out'); + appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out'); await resumeAfterStartupGate('startup frame timeout'); } } @@ -519,7 +519,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { unawaited( Sentry.addBreadcrumb( Breadcrumb( - message: 'Android MPV startup buffer flush after pre-load frame-rate switch', + message: 'Android MPV startup decoder refresh after pre-load frame-rate switch', category: 'player', ), ), diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index e424f2d2..2b7f55b9 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -147,6 +147,46 @@ void main() { }, ); }); + + test('MPV refresh seek preserves timeline offset position', () async { + final calls = []; + + await _withMockChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_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 = PlayerNative(); + try { + const timelineStart = Duration(milliseconds: 143894); + await player.open( + Media('https://example.test/transcode.mkv'), + timelineOffset: timelineStart, + timelineDuration: const Duration(seconds: 1502), + ); + + expect(player.state.position, timelineStart); + + await player.seek(timelineStart); + + final seekCall = calls.lastWhere((call) => call.method == 'command'); + final args = Map.from(seekCall.arguments as Map)['args'] as List; + expect(args, ['seek', '0.0', 'absolute']); + expect(player.state.position, timelineStart); + } finally { + await player.dispose(); + } + }, + ); + }); }); }