From 8bf5dbb379c02c52b33e00aad23c57bf87491846 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:06:04 +0200 Subject: [PATCH] fix(player): resume mpv startup frame after subtitles --- .../video_player/parts/episode_navigation.dart | 11 +++++++++-- .../video_player/parts/playback_open.dart | 16 +++++++++++++--- .../video_player/parts/playback_start.dart | 12 +++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 839008a5..12a729c5 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -439,14 +439,20 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _trackManager = trackManager; trackManager.cacheExternalSubtitles(result.externalSubtitles); + final resumeForStartupFrame = + frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart; await _applyTracksAfterOpen( trackManager: trackManager, externalSubtitlePlan: externalSubtitlePlan, // Same guard as the start path: don't resume a player a newer flow // owns, and let a pending startup gate (or Watch Together's group - // start) own the resume instead. + // start) own the resume instead. Android mpv external-subtitle opens + // resume once here so the startup refresh gate can observe a frame. shouldResumeAfterSubtitleLoad: () => - !frameRatePlan.holdPlaybackStart && !wtOwnsStart && mounted && player == currentPlayer, + (!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) && + !wtOwnsStart && + mounted && + player == currentPlayer, applySelectionWhenResumeSkipped: wtOwnsStart && !frameRatePlan.holdPlaybackStart, ); if (!isCurrentReload()) return true; @@ -461,6 +467,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { reason: reason, wtOwnsStart: wtOwnsStart, ), + playbackResumedForStartupFrame: resumeForStartupFrame, ); if (!isCurrentReload()) return true; diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index a5f3a47d..8f99e9c5 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -215,7 +215,17 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required SettingsService settingsService, required _FrameRateStartupPlan plan, required Future Function(String reason) resumeAfterStartupGate, + bool playbackResumedForStartupFrame = false, }) async { + Future resumeAfterRefresh(String reason) async { + if (playbackResumedForStartupFrame) { + appLogger.d('Frame rate matching: continuing already-resumed playback after $reason'); + await _playWithPlaybackIntent(currentPlayer); + } else { + await resumeAfterStartupGate(reason); + } + } + // Fallback refresh-rate path. The player was opened paused; // setVideoFrameRate awaits the real display-change event (+ settle + // user delay) before returning, then we start playback. @@ -241,7 +251,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { // Always resume — either the switch completed and we want to play, // or no switch was needed and we need to start playback now that the // preparation gate has been cleared. - await resumeAfterStartupGate('post-open frame rate switch'); + await resumeAfterRefresh('post-open frame rate switch'); unawaited( Sentry.addBreadcrumb( @@ -256,10 +266,10 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { if (startupReady) { await Future.delayed(const Duration(milliseconds: 100)); await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'pre-load frame rate startup'); - await resumeAfterStartupGate('startup decoder refresh'); + await resumeAfterRefresh('startup decoder refresh'); } else { appLogger.w('Frame rate matching: skipping Android MPV decoder refresh because startup frame timed out'); - await resumeAfterStartupGate('startup frame timeout'); + await resumeAfterRefresh('startup frame timeout'); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 7d959277..4c3a045c 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -320,14 +320,19 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // Store external subtitles for re-use after backend fallback _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + final resumeForStartupFrame = + frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart; await _applyTracksAfterOpen( trackManager: _trackManager!, externalSubtitlePlan: externalSubtitlePlan, // When a startup gate below owns the resume, skip this one to - // avoid a double-play. Watch Together stays paused for the group - // start, so selection is armed through the resume-skipped branch. + // avoid a double-play. Android mpv external-subtitle opens are the + // exception: after sub-add we must resume once so mpv can produce the + // startup frame that the decoder-refresh gate is waiting for. + // Watch Together stays paused for the group start, so selection is + // armed through the resume-skipped branch. shouldResumeAfterSubtitleLoad: () => - !shouldHoldPlaybackStart && !wtOwnsStart && mounted && player == currentPlayer, + (!shouldHoldPlaybackStart || resumeForStartupFrame) && !wtOwnsStart && mounted && player == currentPlayer, applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart, ); @@ -342,6 +347,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { wtOwnsStart: wtOwnsStart, wtStartupHold: wtStartupHold, ), + playbackResumedForStartupFrame: resumeForStartupFrame, ); // Backstop: if the gate never ran its resume path (unmounted race), // don't leave Watch Together readiness held forever.