diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index a3189f80..18cfedc8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -693,13 +693,20 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { p.observeProperty(name, fmt) } - fun command(args: Array) { - if (!isInitialized || disposing || args.isEmpty()) return + fun command(args: Array, onComplete: ((Boolean) -> Unit)? = null) { + if (!isInitialized || disposing || args.isEmpty() || !scope.isActive) { + onComplete?.invoke(false) + return + } scope.launch { + var success = false try { player?.command(*args) + success = true } catch (e: Exception) { Log.w(TAG, "command failed", e) + } finally { + onComplete?.invoke(success) } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index f1a7c3b8..b1cb808d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -271,8 +271,14 @@ class MpvPlayerPlugin : return } - playerCore?.command(args.toTypedArray()) - result.success(null) + val core = playerCore + if (core == null) { + result.success(null) + return + } + core.command(args.toTypedArray()) { + result.success(null) + } } private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index 1bd5704c..d3873ab3 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -48,7 +48,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000); if (didSwitch) { - await _refreshAndroidMpvDecoderAfterFrameRateSwitch(); + await _refreshAndroidMpvDecoderAfterFrameRateSwitch(reason: 'post-first-frame display switch'); } if (mounted && player != null) { @@ -69,17 +69,23 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { } } - Future _refreshAndroidMpvDecoderAfterFrameRateSwitch() async { + Future _refreshAndroidMpvDecoderAfterFrameRateSwitch({ + required String reason, + Duration? fallbackPosition, + }) async { final p = player; if (!mounted || !Platform.isAndroid || p == null || p is PlayerAndroid) return; final positionMs = p.state.position.inMilliseconds; - final targetMs = positionMs <= 0 ? 1 : positionMs; + final fallbackMs = fallbackPosition?.inMilliseconds ?? 0; + final targetMs = positionMs > 0 ? positionMs : (fallbackMs > 0 ? fallbackMs : 1); 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: refreshed Android MPV decoder at ${targetMs}ms'); + await Future.delayed(const Duration(milliseconds: 150)); + appLogger.d('Frame rate matching: refreshed Android MPV decoder ($reason, target=${targetMs}ms)'); } catch (e) { - appLogger.w('Failed to refresh Android MPV decoder after frame rate switch', 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 3e5d989b..7cfc6e1f 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -180,7 +180,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final isExoPlayer = player is PlayerAndroid; final isAndroidMpv = Platform.isAndroid && !isExoPlayer; var didPreLoadFrameRateSwitch = false; - var shouldPauseForFrameRateSwitch = willAutoSwitch; + var needsPostOpenFrameRateSwitch = willAutoSwitch; + var needsAndroidMpvStartupRefresh = false; + final hasExternalSubs = result.externalSubtitles.isNotEmpty; + 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 @@ -193,7 +196,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _suppressMediaPauseDuringFrameRateSwitch = false; }); try { - appLogger.d('Frame rate matching: pre-load MPV switch to ${preKnownFps}fps'); + appLogger.d( + 'Frame rate matching: pre-load MPV switch to ${preKnownFps}fps ' + '(duration: ${durationMs}ms, delay=${delaySec}s)', + ); didPreLoadFrameRateSwitch = await player!.setVideoFrameRate( preKnownFps, durationMs, @@ -201,17 +207,22 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); if (didPreLoadFrameRateSwitch) { _frameRateMatchingApplied = true; - shouldPauseForFrameRateSwitch = false; + needsPostOpenFrameRateSwitch = false; + needsAndroidMpvStartupRefresh = true; } appLogger.d( 'Frame rate matching: pre-load MPV switch complete ' - '(switched=$didPreLoadFrameRateSwitch, delay=${delaySec}s)', + '(switched=$didPreLoadFrameRateSwitch, delay=${delaySec}s, ' + 'startupRefresh=$needsAndroidMpvStartupRefresh)', ); } catch (e) { appLogger.w('Failed to apply pre-load MPV frame rate matching', error: e); } } + final shouldHoldPlaybackStart = needsPostOpenFrameRateSwitch || needsAndroidMpvStartupRefresh; + Duration? resumePosition; + // Open video through Player if (result.videoUrl != null) { // Reset first frame flag and frame rate retry counter for new video @@ -236,7 +247,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // Pass resume position if available. // In offline mode, prefer locally tracked progress over the cached server value // since the user may have watched further since downloading. - Duration? resumePosition; if (_isOfflinePlayback) { final globalKey = _currentMetadata.globalKey; final localOffset = await offlineWatchService.getLocalViewOffset(globalKey); @@ -260,8 +270,16 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); } - final hasExternalSubs = result.externalSubtitles.isNotEmpty; - final shouldAutoPlay = !shouldPauseForFrameRateSwitch && (isExoPlayer || !hasExternalSubs); + 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'); + }, + ); + } // ExoPlayer: attach external subs at open time so it discovers // them in a single prepare() — no media reload needed for selection. @@ -378,6 +396,16 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // Store external subtitles for re-use after backend fallback _trackManager!.cacheExternalSubtitles(result.externalSubtitles); + Future resumeAfterStartupGate(String reason) async { + if (!mounted || player == null) return; + appLogger.d('Frame rate matching: resuming playback after $reason'); + if (player is! PlayerAndroid && hasExternalSubs) { + await _trackManager!.resumeAfterSubtitleLoad(); + } else { + await player!.play(); + } + } + // MPV with external subs: add after open via sub-add, // opened paused to avoid race condition (issue #226) if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { @@ -387,9 +415,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { try { await _trackManager!.addExternalSubtitles(result.externalSubtitles); } finally { - // When the post-open refresh-rate block below owns the resume, + // When a startup gate below owns the resume, // skip this one to avoid a double-play. - if (!shouldPauseForFrameRateSwitch) { + if (!shouldHoldPlaybackStart) { await _trackManager!.resumeAfterSubtitleLoad(); } } @@ -402,7 +430,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // 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. - if (shouldPauseForFrameRateSwitch && mounted && player != null) { + if (needsPostOpenFrameRateSwitch && mounted && player != null) { _frameRateMatchingApplied = true; final delaySec = settingsService.read(SettingsService.displaySwitchDelay); final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds; @@ -414,7 +442,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { try { didSwitch = await player!.setVideoFrameRate(preKnownFps!, durationMs, extraDelayMs: delaySec * 1000); if (didSwitch) { - await _refreshAndroidMpvDecoderAfterFrameRateSwitch(); + await _refreshAndroidMpvDecoderAfterFrameRateSwitch( + reason: 'post-open frame rate switch', + fallbackPosition: resumePosition, + ); } } catch (e) { appLogger.w('Failed to apply pre-playback frame rate matching', error: e); @@ -423,13 +454,7 @@ extension _VideoPlayerPlaybackStartMethods 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. - if (mounted && player != null) { - if (player is! PlayerAndroid && result.externalSubtitles.isNotEmpty) { - await _trackManager!.resumeAfterSubtitleLoad(); - } else { - await player!.play(); - } - } + await resumeAfterStartupGate('post-open frame rate switch'); unawaited( Sentry.addBreadcrumb( @@ -439,6 +464,25 @@ 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; + if (mounted && player != null) { + await _refreshAndroidMpvDecoderAfterFrameRateSwitch( + reason: 'pre-load frame rate startup', + fallbackPosition: resumePosition, + ); + await resumeAfterStartupGate('startup decoder refresh'); + } + + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Android MPV startup decoder refresh after pre-load frame-rate switch', + category: 'player', + ), + ), + ); } } } on PlaybackException catch (e) {