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 bb9e1852..c9fa7b12 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 @@ -83,6 +83,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L private const val WATCHDOG_TIMEOUT_MS = 8000L private const val DECODER_HANG_TIMEOUT_MS = 5000L + private const val FPS_SAMPLE_COUNT = 8 // Codec capability caches — codec support doesn't change at runtime private val hwAudioDecoderCache = HashMap() @@ -140,6 +141,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var frameRateManager: FrameRateManager? = null private val handler = Handler(Looper.getMainLooper()) + // FPS detection from frame timestamps (fallback when Format.frameRate is NO_VALUE) + @Volatile private var detectedFrameRate: Float = -1f + private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT) + @Volatile private var fpsTimestampCount = 0 + // Audio focus private var audioFocusManager: AudioFocusManager? = null @@ -255,8 +261,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { activity = activity, handler = handler, onDisplayChanged = { - if (exoPlayer?.isPlaying == false && audioFocusManager?.wasPlayingBeforeFocusLoss == true) { - Log.d(TAG, "Display changed, resuming playback") + if (exoPlayer?.isPlaying == false) { + Log.d(TAG, "Display changed after frame rate switch, resuming playback") exoPlayer?.play() } }, @@ -477,6 +483,17 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { exoPlayer!!.addListener(this) exoPlayer!!.addAnalyticsListener(decoderHangListener) + exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, _, _, _ -> + val count = fpsTimestampCount + if (count < FPS_SAMPLE_COUNT) { + fpsTimestamps[count] = presentationTimeUs + fpsTimestampCount = count + 1 + if (count + 1 == FPS_SAMPLE_COUNT) { + detectedFrameRate = computeFrameRate(fpsTimestamps) + Log.d(TAG, "Detected frame rate: $detectedFrameRate fps") + } + } + } surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) } Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}") @@ -1168,6 +1185,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { stopFrameWatchdog() cancelDecoderHangCheck() + // Reset FPS detection for new content + detectedFrameRate = -1f + fpsTimestampCount = 0 + // Reset DV7 retry flag when opening a different file if (uri != currentMediaUri) { dv7RetryAttempted = false @@ -1455,6 +1476,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { frameRateManager?.clearVideoFrameRate() } + private fun computeFrameRate(timestamps: LongArray): Float { + val deltas = (1 until FPS_SAMPLE_COUNT).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 } + if (deltas.isEmpty()) return -1f + val medianDelta = deltas.sorted()[deltas.size / 2] + val rawFps = 1_000_000.0 / medianDelta + return normalizeFrameRate(rawFps) + } + + private fun normalizeFrameRate(fps: Double): Float { + val knownRates = doubleArrayOf(23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0) + val nearest = knownRates.minByOrNull { kotlin.math.abs(it - fps) } ?: fps + return if (kotlin.math.abs(nearest - fps) < 0.5) nearest.toFloat() else fps.toFloat() + } + // Stats fun getStats(): Map { @@ -1471,7 +1506,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "videoMimeType" to videoFormat?.sampleMimeType, "videoWidth" to videoFormat?.width, "videoHeight" to videoFormat?.height, - "videoFps" to videoFormat?.frameRate, + "videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }), "videoBitrate" to videoFormat?.bitrate, "videoDecoderName" to (decoderInitName ?: videoDecoderInfo), "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, 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 91d17c9e..73148c67 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 @@ -37,16 +37,19 @@ class FrameRateManager( return } - if (surface == null) { - Log.d(TAG, "setVideoFrameRate: Surface not available") - return - } - log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (surface == null) { + Log.d(TAG, "setVideoFrameRate: Surface not available") + return + } + setFrameRateS(fps, surface, videoDurationMs) + } + // 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) } } @@ -58,6 +61,13 @@ class FrameRateManager( getDisplayManager().unregisterDisplayListener(it) displayListener = null } + // Restore default display mode on API M (preferredDisplayModeId persists) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + activity.window?.attributes?.let { attrs -> + attrs.preferredDisplayModeId = 0 + activity.window?.attributes = attrs + } + } } private fun registerDisplayListener() { @@ -79,13 +89,6 @@ class FrameRateManager( getDisplayManager().registerDisplayListener(displayListener, handler) } - @RequiresApi(Build.VERSION_CODES.R) - private fun setFrameRateR(fps: Float, surface: Surface) { - Log.d(TAG, "setFrameRateR: Setting frame rate to $fps") - surface.setFrameRate(fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE) - registerDisplayListener() - } - @RequiresApi(Build.VERSION_CODES.S) private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) { Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") @@ -162,7 +165,7 @@ class FrameRateManager( BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { modeToUse = mode break - } else if (mode.refreshRate % fps == 0f) { + } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) { modeToUse = mode break } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index d35b5a7e..df241b8a 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -251,6 +251,11 @@ class PlayerAndroid extends PlayerBase { return (state.duration.inMilliseconds / 1000.0).toString(); case 'seekable': return state.seekable ? 'yes' : 'no'; + // Video frame rate - query from ExoPlayer stats + case 'container-fps': + final fpsStats = await getStats(); + final fps = fpsStats['videoFps']; + return fps?.toString(); // Video dimensions - query from ExoPlayer stats case 'width': case 'dwidth': diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8e9519d0..7730c880 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -630,6 +630,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin // offline Android playback flows). Prevents a permanent loading spinner. if (!_hasFirstFrame.value && position.inMilliseconds > 0) { _hasFirstFrame.value = true; + + // Apply frame rate matching here too, since this fallback may fire + // before playbackRestart (race condition with resume positions > 0) + if (Platform.isAndroid && settingsService.getMatchContentFrameRate()) { + _applyFrameRateMatching(); + } } final duration = player!.state.duration; @@ -661,6 +667,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Apply frame rate matching on Android by setting the display refresh rate /// to match the video content's frame rate. + int _frameRateRetries = 0; Future _applyFrameRateMatching() async { if (player == null || !Platform.isAndroid) return; @@ -668,10 +675,20 @@ class VideoPlayerScreenState extends State with WidgetsBindin final fpsStr = await player!.getProperty('container-fps'); final fps = double.tryParse(fpsStr ?? ''); if (fps == null || fps <= 0) { + // ExoPlayer detects FPS from frame timestamps after ~8 rendered frames. + // STATE_READY fires before frames render, so retry until detection completes. + if (player is PlayerAndroid && _frameRateRetries < 10) { + _frameRateRetries++; + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted && player != null) _applyFrameRateMatching(); + }); + return; + } appLogger.d('Frame rate matching: No valid fps available ($fpsStr)'); return; } + _frameRateRetries = 0; final durationMs = player!.state.duration.inMilliseconds; await player!.setVideoFrameRate(fps, durationMs); @@ -1025,8 +1042,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Open video through Player if (result.videoUrl != null) { - // Reset first frame flag for new video + // Reset first frame flag and frame rate retry counter for new video _hasFirstFrame.value = false; + _frameRateRetries = 0; // Request audio focus before starting playback (Android) // This causes other media apps (Spotify, podcasts, etc.) to pause