diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 7fa93a2a..c2e7106a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -265,12 +265,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { frameRateManager = FrameRateManager( activity = activity, handler = handler, - onDisplayChanged = { - if (exoPlayer?.isPlaying == false) { - Log.d(TAG, "Display changed after frame rate switch, resuming playback") - exoPlayer?.play() - } - }, log = { emitLog("info", "framerate", it) } ) @@ -1616,8 +1610,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Frame Rate Matching - fun setVideoFrameRate(fps: Float, videoDurationMs: Long) { - frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface) + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit, + ) { + val mgr = frameRateManager + if (mgr == null) { + onComplete(false) + return + } + mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) } fun clearVideoFrameRate() { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 6504d295..e0637b21 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -433,14 +433,19 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { val fps = call.argument("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L + val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration") + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } if (usingMpvFallback) { - mpvCore?.setVideoFrameRate(fps, duration) + val core = mpvCore + if (core == null) result.success(false) + else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) } else { - playerCore?.setVideoFrameRate(fps, duration) + val core = playerCore + if (core == null) result.success(false) + else core.setVideoFrameRate(fps, duration, extraDelayMs, onComplete) } - result.success(null) } private fun handleClearVideoFrameRate(result: MethodChannel.Result) { 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 7e358396..4518ea0d 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 @@ -134,9 +134,6 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { frameRateManager = FrameRateManager( activity = activity, handler = handler, - onDisplayChanged = { - requestAutoResume("display change") - } ) // Create FrameLayout container for video @@ -707,8 +704,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Frame Rate Matching - fun setVideoFrameRate(fps: Float, videoDurationMs: Long) { - frameRateManager?.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface) + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit, + ) { + val mgr = frameRateManager + if (mgr == null) { + onComplete(false) + return + } + mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete) } fun clearVideoFrameRate() { 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 cd556693..d06403f4 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 @@ -239,10 +239,17 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) { val fps = call.argument("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L + val extraDelayMs = call.argument("extraDelayMs")?.toLong() ?: 0L - Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration") - playerCore?.setVideoFrameRate(fps, duration) - result.success(null) + Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs") + val core = playerCore + if (core == null) { + result.success(false) + return + } + core.setVideoFrameRate(fps, duration, extraDelayMs) { switched -> + result.success(switched) + } } private fun handleClearVideoFrameRate(result: MethodChannel.Result) { 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 73148c67..fff3d49e 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 @@ -15,52 +15,74 @@ import java.math.RoundingMode class FrameRateManager( private val activity: Activity, private val handler: Handler, - private val onDisplayChanged: () -> Unit, private val log: (String) -> Unit = { Log.d(TAG, it) } ) { companion object { private const val TAG = "FrameRateManager" private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes + private const val DISPLAY_SETTLE_MS = 2000L + private const val WATCHDOG_MARGIN_MS = 3000L } private var currentVideoFps: Float = 0f private var displayListener: DisplayManager.DisplayListener? = null + private var pendingSettleRunnable: Runnable? = null + private var watchdogRunnable: Runnable? = null + private var pendingCompletion: ((switched: Boolean) -> Unit)? = null private fun getDisplayManager(): DisplayManager { return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager } - fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) { + /// Request a display frame-rate switch. Invokes [onComplete] once, either: + /// - immediately with `switched=false` when no switch is needed (invalid + /// fps, no matching mode, seamless fallback); or + /// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the + /// caller's [extraDelayMs], with `switched=true`; or + /// - via a watchdog with `switched=true` if the real event never arrives, + /// so the caller doesn't hang. + /// + /// The caller is responsible for pausing playback before calling and + /// resuming it after [onComplete] fires. + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + surface: Surface?, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit, + ) { currentVideoFps = fps if (fps <= 0f) { Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping") + onComplete(false) return } - log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") + log("fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=${extraDelayMs}, API=${Build.VERSION.SDK_INT}") when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (surface == null) { Log.d(TAG, "setVideoFrameRate: Surface not available") + onComplete(false) return } - setFrameRateS(fps, surface, videoDurationMs) + setFrameRateS(fps, surface, videoDurationMs, extraDelayMs, onComplete) } // API R's Surface.setFrameRate() only supports seamless switching (no // CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to // preferredDisplayModeId which directly sets the display mode. - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps, extraDelayMs, onComplete) + else -> onComplete(false) } } fun clearVideoFrameRate() { Log.d(TAG, "clearVideoFrameRate") currentVideoFps = 0f - displayListener?.let { - getDisplayManager().unregisterDisplayListener(it) - displayListener = null - } + // Resolve any pending setVideoFrameRate future as "not switched" so + // the Dart caller's await doesn't hang on player dispose. + firePendingCompletion("clear", switched = false) // Restore default display mode on API M (preferredDisplayModeId persists) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { activity.window?.attributes?.let { attrs -> @@ -70,29 +92,90 @@ class FrameRateManager( } } - private fun registerDisplayListener() { + private fun cancelPendingCallbacks() { + pendingSettleRunnable?.let { handler.removeCallbacks(it) } + watchdogRunnable?.let { handler.removeCallbacks(it) } + pendingSettleRunnable = null + watchdogRunnable = null + } + + private fun firePendingCompletion(reason: String, switched: Boolean) { + cancelPendingCallbacks() displayListener?.let { getDisplayManager().unregisterDisplayListener(it) + displayListener = null } + val cb = pendingCompletion ?: return + pendingCompletion = null + Log.d(TAG, "FrameRateManager complete ($reason, switched=$switched)") + cb(switched) + } + + private fun registerDisplayListener(extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { + // Resolve any previous pending op before starting a new one. + firePendingCompletion("superseded", switched = false) + pendingCompletion = onComplete displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) = Unit override fun onDisplayRemoved(displayId: Int) = Unit override fun onDisplayChanged(displayId: Int) { - handler.postDelayed({ - onDisplayChanged() - }, 2000L) + // Unregister immediately so a chatty display (e.g. several + // onDisplayChanged events during HDMI renegotiation) doesn't + // queue multiple settle callbacks. getDisplayManager().unregisterDisplayListener(this) displayListener = null + + val settle = Runnable { firePendingCompletion("display settled", switched = true) } + pendingSettleRunnable = settle + handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs) } } getDisplayManager().registerDisplayListener(displayListener, handler) + + // 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) } + watchdogRunnable = watchdog + handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS) + } + + private fun currentRateMatchesFps(fps: Float): Boolean { + val current = activity.display?.mode?.refreshRate ?: return false + if (current <= 0f) return false + // Treat "equal within a frame" and "clean multiple" as a match — + // same tolerance the API M matcher uses below. + if (kotlin.math.abs(current - fps) < 0.1f) return true + val mod = current % fps + return mod < 0.1f || (fps - mod) < 0.1f } @RequiresApi(Build.VERSION_CODES.S) - private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) { + private fun setFrameRateS( + fps: Float, + surface: Surface, + videoDurationMs: Long, + extraDelayMs: Long, + onComplete: (switched: Boolean) -> Unit, + ) { Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") + // If the current display rate already satisfies the video fps, issue + // the hint for book-keeping but skip the listener — otherwise we'd + // wait for an onDisplayChanged event that never fires and end up + // burning the watchdog timeout for no reason. + if (currentRateMatchesFps(fps)) { + Log.d(TAG, "Current display rate already matches ${fps}fps, no switch needed") + surface.setFrameRate( + fps, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + ) + onComplete(false) + return + } + if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { Log.d(TAG, "Short video, using seamless-only switching") surface.setFrameRate( @@ -100,6 +183,7 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS ) + onComplete(false) return } @@ -122,7 +206,7 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ALWAYS ) - registerDisplayListener() + registerDisplayListener(extraDelayMs, onComplete) } else { val userPreference = getDisplayManager().matchContentFrameRateUserPreference if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { @@ -132,7 +216,7 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ALWAYS ) - registerDisplayListener() + registerDisplayListener(extraDelayMs, onComplete) } else { Log.d(TAG, "Non-seamless switch not allowed, using seamless-only") surface.setFrameRate( @@ -140,45 +224,56 @@ class FrameRateManager( Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS ) + onComplete(false) } } } @RequiresApi(Build.VERSION_CODES.M) - private fun setFrameRateM(fps: Float) { + private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { Log.d(TAG, "setFrameRateM: fps=$fps") val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager @Suppress("DEPRECATION") - val display = wm.defaultDisplay ?: return + val display = wm.defaultDisplay + if (display == null) { + onComplete(false) + return + } - display.supportedModes?.let { supportedModes -> - val currentMode = display.mode - var modeToUse = currentMode + val supportedModes = display.supportedModes + if (supportedModes == null) { + onComplete(false) + return + } + val currentMode = display.mode + var modeToUse = currentMode - for (mode in supportedModes) { - if (mode.physicalHeight != currentMode.physicalHeight || - mode.physicalWidth != currentMode.physicalWidth) { - continue - } - - if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == - BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { - modeToUse = mode - break - } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) { - modeToUse = mode - break - } + for (mode in supportedModes) { + if (mode.physicalHeight != currentMode.physicalHeight || + mode.physicalWidth != currentMode.physicalWidth) { + continue } - if (modeToUse != currentMode) { - Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") - activity.window?.attributes?.let { attrs -> - attrs.preferredDisplayModeId = modeToUse.modeId - activity.window?.attributes = attrs - } - registerDisplayListener() + if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) == + BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { + modeToUse = mode + break + } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) { + modeToUse = mode + break } } + + if (modeToUse == currentMode) { + onComplete(false) + return + } + + Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = modeToUse.modeId + activity.window?.attributes = attrs + } + registerDisplayListener(extraDelayMs, onComplete) } } diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 6f596a08..eebfc56b 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -9,6 +9,7 @@ class PlexMediaInfo { final List subtitleTracks; final List chapters; final int? partId; + final double? frameRate; PlexMediaInfo({ required this.videoUrl, @@ -16,6 +17,7 @@ class PlexMediaInfo { required this.subtitleTracks, required this.chapters, this.partId, + this.frameRate, }); int? getPartId() => partId; @@ -31,12 +33,15 @@ class PlexMediaInfo { final audioTracks = []; final subtitleTracks = []; + double? frameRate; if (streams != null) { for (final s in streams) { try { final streamType = s['streamType'] as int?; - if (streamType == 2) { + if (streamType == 1) { + frameRate ??= (s['frameRate'] as num?)?.toDouble(); + } else if (streamType == 2) { audioTracks.add( PlexAudioTrack( id: s['id'] as int, @@ -72,7 +77,13 @@ class PlexMediaInfo { } } - return PlexMediaInfo(videoUrl: '', audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: const []); + return PlexMediaInfo( + videoUrl: '', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: const [], + frameRate: frameRate, + ); } } diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index c6196d9b..1ec311a9 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -288,7 +288,11 @@ class Media { /// Optional start position for playback. final Duration? start; - const Media(this.uri, {this.headers, this.start}); + /// Optional pre-known video frame rate (from server metadata), used to drive + /// display refresh-rate matching before the first frame renders. + final double? fps; + + const Media(this.uri, {this.headers, this.start, this.fps}); @override String toString() => 'Media($uri)'; diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 16eadc8d..8f9568fd 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -385,9 +385,14 @@ class PlayerAndroid extends PlayerBase { // ============================================ @override - Future setVideoFrameRate(double fps, int durationMs) async { - if (disposed || !initialized) return; - await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async { + if (disposed || !initialized) return false; + final result = await invoke('setVideoFrameRate', { + 'fps': fps, + 'duration': durationMs, + 'extraDelayMs': extraDelayMs, + }); + return result ?? false; } @override diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index c2ca4b46..b827ed07 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -213,9 +213,17 @@ abstract class Player { /// /// [fps] - The video frame rate (e.g., 23.976, 24, 30, 60). /// [durationMs] - The video duration in milliseconds. + /// [extraDelayMs] - Extra settle time (ms) added to the native display-change + /// wait before playback is auto-resumed. Used to absorb the + /// user-configured "display switch delay" on Android TV. /// - /// On other platforms, this is a no-op. - Future setVideoFrameRate(double fps, int durationMs); + /// Returns `true` if a display mode switch was initiated and the platform + /// will resume playback once the display settles; `false` if no switch was + /// needed (seamless fallback, invalid fps, no matching mode), in which case + /// the caller is responsible for starting playback itself. + /// + /// On other platforms, this is a no-op that returns `false`. + Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}); /// Clear the video frame rate hint and restore default display mode. /// diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 16399583..6e4d666e 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -544,8 +544,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future updateFrame() async {} @override - // ignore: no-empty-block - base no-op, overridden by platform subclasses - Future setVideoFrameRate(double fps, int durationMs) async {} + Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 915bca8d..8a89ca4c 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -277,9 +277,14 @@ class PlayerNative extends PlayerBase { } @override - Future setVideoFrameRate(double fps, int durationMs) async { - if (!Platform.isAndroid || disposed || !initialized) return; - await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async { + if (!Platform.isAndroid || disposed || !initialized) return false; + final result = await invoke('setVideoFrameRate', { + 'fps': fps, + 'duration': durationMs, + 'extraDelayMs': extraDelayMs, + }); + return result ?? false; } @override diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 48fb1cec..ef6c18cd 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -124,7 +124,9 @@ class _PlaybackSettingsScreenState extends State { if (Platform.isAndroid) _buildMatchContentFrameRate(), if (Platform.isWindows) _buildMatchRefreshRate(), if (Platform.isWindows) _buildMatchDynamicRange(), - if (Platform.isWindows && (_matchRefreshRate || _matchDynamicRange)) _buildDisplaySwitchDelay(), + if ((Platform.isWindows && (_matchRefreshRate || _matchDynamicRange)) || + (Platform.isAndroid && _matchContentFrameRate)) + _buildDisplaySwitchDelay(), if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(), _buildBufferSizeSelector(), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 504db5c8..4933e25e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -862,8 +862,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// to match the video content's frame rate. int _frameRateRetries = 0; bool _suppressMediaPauseDuringFrameRateSwitch = false; + // True once a frame-rate switch has been requested for the current playback + // session — either via the pre-playback primary path (Plex metadata fps) or + // via the post-`playbackRestart` fallback. Prevents double-switching. + bool _frameRateMatchingApplied = false; Future _applyFrameRateMatching() async { if (player == null || !Platform.isAndroid) return; + if (_frameRateMatchingApplied) return; try { final fpsStr = await player!.getProperty('container-fps'); @@ -883,22 +888,47 @@ class VideoPlayerScreenState extends State with WidgetsBindin } _frameRateRetries = 0; + _frameRateMatchingApplied = true; final durationMs = player!.state.duration.inMilliseconds; + final settingsService = await SettingsService.getInstance(); + final delaySec = settingsService.getDisplaySwitchDelay(); // Suppress spurious PauseEvent from MediaSession during HDMI renegotiation. // Fire Stick (and similar Android TV devices) send onPause() through the // MediaSession callback when the display mode changes for frame rate matching. _suppressMediaPauseDuringFrameRateSwitch = true; - await player!.setVideoFrameRate(fps, durationMs); - Future.delayed(const Duration(seconds: 2), () { + Future.delayed(Duration(seconds: 2 + delaySec + 1), () { _suppressMediaPauseDuringFrameRateSwitch = false; }); - // Set MPV video-sync mode for smoother playback when display is synced - await player!.setProperty('video-sync', 'display-tempo'); + // Pause so the playback clock doesn't advance while the TV renegotiates + // HDMI. The native setVideoFrameRate call below awaits the real display + // change event (+ settle + user delay) before returning, and then we + // resume — same shape as the primary pre-playback path, just later. + try { + await player!.pause(); + } catch (e) { + appLogger.w('Failed to pause before frame rate switch', error: e); + } - Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching: ${fps}fps', category: 'player')); - appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms)'); + final didSwitch = await player!.setVideoFrameRate(fps, durationMs, extraDelayMs: delaySec * 1000); + + // Set MPV video-sync mode for smoother playback when display is synced + try { + await player!.setProperty('video-sync', 'display-tempo'); + } catch (_) {} + + if (mounted && player != null) { + await player!.play(); + } + + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s', + category: 'player', + ), + ); + appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)'); } catch (e) { appLogger.w('Failed to apply frame rate matching', error: e); } @@ -1357,11 +1387,20 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); } + // Primary refresh-rate path: when Plex metadata provides an fps and the + // user has frame-rate matching on, open the player paused so the HDMI + // refresh-rate switch can complete before any frame renders. + final settingsService = await SettingsService.getInstance(); + final preKnownFps = result.mediaInfo?.frameRate; + final willAutoSwitch = + Platform.isAndroid && settingsService.getMatchContentFrameRate() && preKnownFps != null && preKnownFps > 0; + // Open video through Player if (result.videoUrl != null) { // Reset first frame flag and frame rate retry counter for new video _hasFirstFrame.value = false; _frameRateRetries = 0; + _frameRateMatchingApplied = false; // Request audio focus before starting playback (Android) // This causes other media apps (Spotify, podcasts, etc.) to pause @@ -1398,15 +1437,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin // them in a single prepare() — no media reload needed for selection. // MPV (all platforms including Android): external subs added after open via sub-add. await player!.open( - Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), - play: isExoPlayer || !hasExternalSubs, + Media(result.videoUrl!, start: resumePosition, headers: plexHeaders, fps: preKnownFps), + play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs), externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, ); // Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale) // Must be called after open() since that's when ExoPlayer initializes if (player is PlayerAndroid) { - final settingsService = await SettingsService.getInstance(); await (player as PlayerAndroid).setSubtitleStyle( fontSize: settingsService.getSubtitleFontSize().toDouble(), textColor: settingsService.getSubtitleTextColor(), @@ -1507,13 +1545,60 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { await _trackManager!.addExternalSubtitles(result.externalSubtitles); } finally { - await _trackManager!.resumeAfterSubtitleLoad(); + // When willAutoSwitch the pre-playback refresh-rate block below + // owns the resume, so skip this one to avoid a double-play. + if (!willAutoSwitch) { + await _trackManager!.resumeAfterSubtitleLoad(); + } } } else { // Android (subs attached at open time) or no external subs: // apply once tracks are available _trackManager!.applyTrackSelectionWhenReady(); } + + // Initiate the HDMI refresh-rate switch BEFORE any frame renders. + // The player was opened paused; setVideoFrameRate awaits the real + // display-change event (+ settle + user delay) before returning, and + // then we start playback — so the first frame the user sees is after + // the switch has settled. + if (willAutoSwitch && mounted && player != null) { + _frameRateMatchingApplied = true; + final delaySec = settingsService.getDisplaySwitchDelay(); + final durationMs = _currentMetadata.duration ?? player!.state.duration.inMilliseconds; + _suppressMediaPauseDuringFrameRateSwitch = true; + Future.delayed(Duration(seconds: 2 + delaySec + 1), () { + _suppressMediaPauseDuringFrameRateSwitch = false; + }); + bool didSwitch = false; + try { + didSwitch = await player!.setVideoFrameRate(preKnownFps, durationMs, extraDelayMs: delaySec * 1000); + // MPV video-sync tuning (no-op on ExoPlayer). + try { + await player!.setProperty('video-sync', 'display-tempo'); + } catch (_) {} + } catch (e) { + appLogger.w('Failed to apply pre-playback frame rate matching', error: e); + } + + // 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(); + } + } + + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s', + category: 'player', + ), + ); + } } } on PlaybackException catch (e) { if (mounted) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 24fd6dce..3dc22293 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -826,17 +826,23 @@ class PlexClient { } } - /// Parse audio and subtitle tracks from a stream list - ({List audio, List subtitles}) _parseStreams(List? streams) { + /// Parse audio/subtitle tracks and the video stream's frame rate from a + /// raw Part.Stream list in a single pass. + ({List audio, List subtitles, double? frameRate}) _parseStreams( + List? streams, + ) { final audioTracks = []; final subtitleTracks = []; + double? frameRate; - if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks); + if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); for (var stream in streams) { final streamType = stream['streamType'] as int?; - if (streamType == PlexStreamType.audio) { + if (streamType == PlexStreamType.video) { + frameRate ??= (stream['frameRate'] as num?)?.toDouble(); + } else if (streamType == PlexStreamType.audio) { audioTracks.add( PlexAudioTrack( id: stream['id'] as int, @@ -868,7 +874,7 @@ class PlexClient { } } - return (audio: audioTracks, subtitles: subtitleTracks); + return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); } /// Parse chapters from metadata JSON @@ -1237,6 +1243,7 @@ class PlexClient { subtitleTracks: streams.subtitles, chapters: chapters, partId: part['id'] as int?, + frameRate: streams.frameRate, ); } }