From 89f256138d91abed7ae78959241df79035aa962d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 10:50:11 +0200 Subject: [PATCH] fix(player): flush mpv buffers on frame-rate startup --- .../video_player/parts/display_matching.dart | 22 +++----- .../video_player/parts/playback_start.dart | 56 ++++++++++--------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index f18f3afa..11de026b 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -69,37 +69,31 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { } } - Future _refreshAndroidMpvDecoderAfterFrameRateSwitch({ - required String reason, - Duration? fallbackPosition, - }) async { + Future _refreshAndroidMpvDecoderAfterFrameRateSwitch({required String reason}) async { final p = player; if (!mounted || !Platform.isAndroid || p == null || p is PlayerAndroid) return; - final positionMs = p.state.position.inMilliseconds; - final fallbackMs = fallbackPosition?.inMilliseconds ?? 0; - final targetMs = positionMs > 0 ? positionMs : (fallbackMs > 0 ? fallbackMs : 1); - // Subscribe before the seek so the broadcast event isn't dropped when + // Subscribe before flushing so the broadcast event isn't dropped when // the restart fires synchronously fast. var timedOut = false; final restartFuture = p.streams.playbackRestart.first.timeout( - const Duration(milliseconds: 2500), + const Duration(seconds: 4), onTimeout: () { timedOut = true; }, ); final sw = Stopwatch()..start(); try { - appLogger.d('Frame rate matching: refreshing Android MPV decoder ($reason, target=${targetMs}ms)'); - await p.command(['seek', (targetMs / 1000.0).toStringAsFixed(3), 'absolute+exact']); + appLogger.d('Frame rate matching: flushing Android MPV buffers ($reason, command=drop-buffers)'); + await p.command(['drop-buffers']); await restartFuture; appLogger.d( - 'Frame rate matching: refreshed Android MPV decoder ' - '($reason, target=${targetMs}ms, waited=${sw.elapsedMilliseconds}ms, ' + 'Frame rate matching: flushed Android MPV buffers ' + '($reason, waited=${sw.elapsedMilliseconds}ms, ' 'gate=${timedOut ? 'timeout' : 'playback-restart'})', ); } catch (e) { - appLogger.w('Failed to refresh Android MPV decoder after frame rate switch ($reason)', error: e); + appLogger.w('Failed to flush Android MPV buffers 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 7cfc6e1f..802117a9 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -179,16 +179,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { preKnownFps > 0; final isExoPlayer = player is PlayerAndroid; final isAndroidMpv = Platform.isAndroid && !isExoPlayer; + final needsAndroidMpvFrameRateStartup = willAutoSwitch && isAndroidMpv && result.videoUrl != null; var didPreLoadFrameRateSwitch = false; - var needsPostOpenFrameRateSwitch = willAutoSwitch; - var needsAndroidMpvStartupRefresh = false; + var needsPostOpenFrameRateSwitch = willAutoSwitch && !needsAndroidMpvFrameRateStartup; + var needsAndroidMpvStartupRefresh = needsAndroidMpvFrameRateStartup; final hasExternalSubs = result.externalSubtitles.isNotEmpty; - Future? androidMpvStartupReady; + Future? androidMpvStartupReady; // MPV on Android can decode and present its first paused frame before a // post-open display switch settles. Switch first when metadata already // gives us the FPS so MediaCodec starts after the display mode change. - if (willAutoSwitch && isAndroidMpv && result.videoUrl != null) { + if (needsAndroidMpvFrameRateStartup) { final delaySec = settingsService.read(SettingsService.displaySwitchDelay); final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds; _suppressMediaPauseDuringFrameRateSwitch = true; @@ -207,8 +208,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); if (didPreLoadFrameRateSwitch) { _frameRateMatchingApplied = true; - needsPostOpenFrameRateSwitch = false; - needsAndroidMpvStartupRefresh = true; } appLogger.d( 'Frame rate matching: pre-load MPV switch complete ' @@ -217,6 +216,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); } catch (e) { appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e); + needsPostOpenFrameRateSwitch = true; + needsAndroidMpvStartupRefresh = false; } } @@ -229,7 +230,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _hasFirstFrame.value = false; _frameRateRetries = 0; _frameRateMatchingApplied = false; - if (didPreLoadFrameRateSwitch) { + if (didPreLoadFrameRateSwitch || needsAndroidMpvFrameRateStartup) { _frameRateMatchingApplied = true; } @@ -272,13 +273,16 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs); if (needsAndroidMpvStartupRefresh) { - appLogger.d('Frame rate matching: opening Android MPV paused for startup decoder refresh'); - androidMpvStartupReady = player!.streams.playbackRestart.first.timeout( - const Duration(seconds: 8), - onTimeout: () { - appLogger.w('Timed out waiting for Android MPV startup frame before decoder refresh'); - }, - ); + appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush'); + androidMpvStartupReady = player!.streams.playbackRestart.first + .then((_) => true) + .timeout( + const Duration(seconds: 15), + onTimeout: () { + appLogger.w('Timed out waiting for Android MPV startup frame before buffer flush'); + return false; + }, + ); } // ExoPlayer: attach external subs at open time so it discovers @@ -442,10 +446,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { try { didSwitch = await player!.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000); if (didSwitch) { - await _refreshAndroidMpvDecoderAfterFrameRateSwitch( - reason: 'post-open frame rate switch', - fallbackPosition: resumePosition, - ); + await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-open frame rate switch'); } } catch (e) { appLogger.w('Failed to apply pre-playback frame rate matching', error: e); @@ -465,20 +466,23 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ), ); } else if (needsAndroidMpvStartupRefresh && mounted && player != null) { - appLogger.d('Frame rate matching: waiting for Android MPV startup frame before decoder refresh'); - await androidMpvStartupReady; + appLogger.d('Frame rate matching: waiting for Android MPV startup frame before buffer flush'); + final startupReady = androidMpvStartupReady == null ? false : await androidMpvStartupReady; if (mounted && player != null) { - await _refreshAndroidMpvDecoderAfterFrameRateSwitch( - reason: 'pre-load frame rate startup', - fallbackPosition: resumePosition, - ); - await resumeAfterStartupGate('startup decoder refresh'); + if (startupReady) { + await Future.delayed(const Duration(milliseconds: 100)); + await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup'); + await resumeAfterStartupGate('startup buffer flush'); + } else { + appLogger.w('Frame rate matching: skipping Android MPV buffer flush because startup frame timed out'); + await resumeAfterStartupGate('startup frame timeout'); + } } unawaited( Sentry.addBreadcrumb( Breadcrumb( - message: 'Android MPV startup decoder refresh after pre-load frame-rate switch', + message: 'Android MPV startup buffer flush after pre-load frame-rate switch', category: 'player', ), ),