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 18cfedc8..d0d66e4c 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 @@ -174,7 +174,8 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { ) frameRateManager = FrameRateManager( activity = activity, - handler = handler + handler = handler, + log = { emitLog("info", "framerate", it) } ) // Create FrameLayout container for video @@ -276,6 +277,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Flow collectors + private fun emitLog(level: String, prefix: String, text: String) { + delegate?.onEvent( + "log-message", + mapOf( + "prefix" to prefix, + "level" to level, + "text" to text + ) + ) + } + private fun collectEvents(p: MpvPlayer) { scope.launch(start = CoroutineStart.UNDISPATCHED) { p.eventFlow.collect { event -> @@ -317,14 +329,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { private fun collectLogMessages(p: MpvPlayer) { scope.launch(start = CoroutineStart.UNDISPATCHED) { p.logFlow.collect { msg -> - delegate?.onEvent( - "log-message", - mapOf( - "prefix" to msg.prefix, - "level" to msg.level.name.lowercase(), - "text" to msg.text - ) - ) + emitLog(msg.level.name.lowercase(), msg.prefix, msg.text) } } } 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 446fadff..ce29b3c9 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 @@ -6,11 +6,12 @@ import android.hardware.display.DisplayManager import android.os.Build import android.os.Handler import android.util.Log +import android.view.Display import android.view.Surface import android.view.WindowManager import androidx.annotation.RequiresApi -import java.math.BigDecimal -import java.math.RoundingMode +import kotlin.math.abs +import kotlin.math.roundToInt class FrameRateManager( private val activity: Activity, @@ -22,8 +23,26 @@ class 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 const val RATE_TOLERANCE = 0.1f } + private data class RefreshRateMatch( + val reason: String, + val priority: Int, + val error: Float + ) + + private data class RefreshRateCandidate( + val refreshRate: Float, + val match: RefreshRateMatch + ) + + @RequiresApi(Build.VERSION_CODES.M) + private data class DisplayModeCandidate( + val mode: Display.Mode, + val match: RefreshRateMatch + ) + private var currentVideoFps: Float = 0f private var displayListener: DisplayManager.DisplayListener? = null private var pendingSettleRunnable: Runnable? = null @@ -32,16 +51,16 @@ class FrameRateManager( private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - // / 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. + // 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, @@ -56,7 +75,10 @@ class FrameRateManager( return } - log("fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, API=${Build.VERSION.SDK_INT}") + log( + "request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " + + "API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}" + ) when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { @@ -116,7 +138,7 @@ class FrameRateManager( } val cb = pendingCompletion ?: return pendingCompletion = null - Log.d(TAG, "FrameRateManager complete ($reason, switched=$switched)") + log("complete reason=$reason, switched=$switched, currentMode=${currentModeDescription()}") cb(switched) } @@ -150,16 +172,78 @@ class FrameRateManager( 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 + private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? { + if (refreshRate <= 0f || fps <= 0f) return null + + val exactError = abs(refreshRate - fps) + if (exactError < RATE_TOLERANCE) { + return RefreshRateMatch(reason = "exact", priority = 0, error = exactError) + } + + val multiple = (refreshRate / fps).roundToInt() + if (multiple > 1) { + val multipleError = abs(refreshRate - (fps * multiple)) + if (multipleError < RATE_TOLERANCE) { + return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError) + } + } + + return null } + private fun bestRefreshRateCandidate(fps: Float, refreshRates: FloatArray): RefreshRateCandidate? = refreshRates.asSequence() + .mapNotNull { rate -> matchRefreshRate(rate, fps)?.let { RefreshRateCandidate(rate, it) } } + .minWithOrNull( + compareBy { it.match.priority } + .thenBy { it.match.error } + .thenBy { it.refreshRate } + ) + + private fun currentRateMatch(fps: Float): RefreshRateMatch? { + val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + currentDisplayMode()?.refreshRate + } else { + null + } ?: return null + return matchRefreshRate(current, fps) + } + + private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + describeMode(currentDisplayMode()) + } else { + "unavailable" + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun currentDisplayMode(): Display.Mode? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.display?.mode + } else { + @Suppress("DEPRECATION") + (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay?.mode + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun describeMode(mode: Display.Mode?): String { + if (mode == null) return "unknown" + return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz" + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun describeSupportedModes(modes: Array): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) } + + @RequiresApi(Build.VERSION_CODES.M) + private fun findBestModeMatch(fps: Float, currentMode: Display.Mode, supportedModes: Array): DisplayModeCandidate? = supportedModes.asSequence() + .filter { mode -> + mode.physicalHeight == currentMode.physicalHeight && + mode.physicalWidth == currentMode.physicalWidth + } + .mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } } + .minWithOrNull( + compareBy { it.match.priority } + .thenBy { it.match.error } + .thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) } + ) + @RequiresApi(Build.VERSION_CODES.S) private fun setFrameRateS( fps: Float, @@ -168,14 +252,15 @@ class FrameRateManager( extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit ) { - Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") + log("setFrameRateS fps=$fps, duration=${videoDurationMs}ms, currentMode=${currentModeDescription()}") // 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") + val currentMatch = currentRateMatch(fps) + if (currentMatch != null) { + log("current display rate already matches ${fps}fps (${currentMatch.reason}), no switch needed") surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, @@ -186,7 +271,7 @@ class FrameRateManager( } if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) { - Log.d(TAG, "Short video, using seamless-only switching") + log("short video (${videoDurationMs}ms), using seamless-only switching") surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, @@ -196,21 +281,15 @@ class FrameRateManager( return } - var seamless = false - activity.display?.mode?.alternativeRefreshRates?.let { refreshRates -> - for (rate in refreshRates) { - if (fps.toString().startsWith(rate.toString()) || - rate.toString().startsWith(fps.toString()) || - rate % fps == 0f - ) { - seamless = true - break - } - } - } + val alternativeRates = activity.display?.mode?.alternativeRefreshRates ?: floatArrayOf() + val seamlessMatch = bestRefreshRateCandidate(fps, alternativeRates) + log("alternative refresh rates=${alternativeRates.joinToString(prefix = "[", postfix = "]")}") - if (seamless) { - log("Seamless switch available for ${fps}fps") + if (seamlessMatch != null) { + log( + "seamless switch available for ${fps}fps: " + + "${seamlessMatch.refreshRate}Hz (${seamlessMatch.match.reason}, error=${seamlessMatch.match.error})" + ) surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, @@ -220,7 +299,7 @@ class FrameRateManager( } else { val userPreference = getDisplayManager().matchContentFrameRateUserPreference if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) { - Log.d(TAG, "User preference allows non-seamless switch") + log("user preference allows non-seamless switch, requesting CHANGE_FRAME_RATE_ALWAYS") surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, @@ -228,7 +307,7 @@ class FrameRateManager( ) registerDisplayListener(extraDelayMs, onComplete) } else { - Log.d(TAG, "Non-seamless switch not allowed, using seamless-only") + log("non-seamless switch not allowed (preference=$userPreference), using seamless-only") surface.setFrameRate( fps, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, @@ -241,7 +320,7 @@ class FrameRateManager( @RequiresApi(Build.VERSION_CODES.M) private fun setFrameRateM(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) { - Log.d(TAG, "setFrameRateM: fps=$fps") + log("setFrameRateM fps=$fps") val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager @Suppress("DEPRECATION") @@ -253,36 +332,31 @@ class FrameRateManager( val supportedModes = display.supportedModes if (supportedModes == null) { + log("supported display modes unavailable") onComplete(false) return } val currentMode = display.mode - var modeToUse = currentMode + log("supported modes=${describeSupportedModes(supportedModes)}") - 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 - } - } - - if (modeToUse == currentMode) { + val modeMatch = findBestModeMatch(fps, currentMode, supportedModes) + if (modeMatch == null) { + log("no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight}") onComplete(false) return } - Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)") + val modeToUse = modeMatch.mode + if (modeToUse.modeId == currentMode.modeId) { + log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed") + onComplete(false) + return + } + + log( + "switching to ${describeMode(modeToUse)} for ${fps}fps " + + "(${modeMatch.match.reason}, error=${modeMatch.match.error})" + ) activity.window?.attributes?.let { attrs -> attrs.preferredDisplayModeId = modeToUse.modeId activity.window?.attributes = attrs diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index e7d2cae3..a0050961 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -193,7 +193,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final needsAndroidMpvFrameRateStartup = willAutoSwitch && isAndroidMpv && result.videoUrl != null; var didPreLoadFrameRateSwitch = false; var needsPostOpenFrameRateSwitch = willAutoSwitch && !needsAndroidMpvFrameRateStartup; - var needsAndroidMpvStartupRefresh = needsAndroidMpvFrameRateStartup; + var needsAndroidMpvStartupRefresh = false; final hasExternalSubs = result.externalSubtitles.isNotEmpty; Future? androidMpvStartupReady; @@ -220,6 +220,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { if (!mounted || player != currentPlayer) return; if (didPreLoadFrameRateSwitch) { _frameRateMatchingApplied = true; + needsAndroidMpvStartupRefresh = true; } appLogger.d( 'Frame rate matching: pre-load MPV switch complete '