fix(android): force display mode matching

This commit is contained in:
edde746
2026-05-24 18:39:41 +02:00
parent d24c95f1e1
commit 8fe51075be
3 changed files with 24 additions and 121 deletions
@@ -2480,7 +2480,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
onComplete(false)
return
}
mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs, onComplete)
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, onComplete)
}
fun clearVideoFrameRate() {
@@ -789,7 +789,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
onComplete(false)
return
}
mgr.setVideoFrameRate(fps, videoDurationMs, surfaceView?.holder?.surface, extraDelayMs) { switched ->
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs) { switched ->
player?.let { updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") }
onComplete(switched)
}
@@ -7,7 +7,6 @@ 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 kotlin.math.abs
@@ -20,7 +19,6 @@ class FrameRateManager(
) {
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 const val RATE_TOLERANCE = 0.1f
@@ -32,11 +30,6 @@ class FrameRateManager(
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,
@@ -53,18 +46,16 @@ class FrameRateManager(
// 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
// no matching mode, or already matching); 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.
// - via a watchdog 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
) {
@@ -80,20 +71,10 @@ class FrameRateManager(
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
)
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, 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, extraDelayMs, onComplete)
else -> onComplete(false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setDisplayMode(fps, extraDelayMs, onComplete)
} else {
onComplete(false)
}
}
@@ -191,14 +172,6 @@ class FrameRateManager(
return null
}
private fun bestRefreshRateCandidate(fps: Float, refreshRates: FloatArray): RefreshRateCandidate? = refreshRates.asSequence()
.mapNotNull { rate -> matchRefreshRate(rate, fps)?.let { RefreshRateCandidate(rate, it) } }
.minWithOrNull(
compareBy<RefreshRateCandidate> { 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
@@ -215,13 +188,16 @@ class FrameRateManager(
}
@RequiresApi(Build.VERSION_CODES.M)
private fun currentDisplayMode(): Display.Mode? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display?.mode
private fun currentDisplay(): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display
} else {
@Suppress("DEPRECATION")
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay?.mode
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
}
@RequiresApi(Build.VERSION_CODES.M)
private fun currentDisplayMode(): Display.Mode? = currentDisplay()?.mode
@RequiresApi(Build.VERSION_CODES.M)
private fun describeMode(mode: Display.Mode?): String {
if (mode == null) return "unknown"
@@ -244,88 +220,12 @@ class FrameRateManager(
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
)
@RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(
fps: Float,
surface: Surface,
videoDurationMs: Long,
extraDelayMs: Long,
onComplete: (switched: Boolean) -> Unit
) {
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.
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,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
onComplete(false)
return
}
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
log("short video (${videoDurationMs}ms), using seamless-only switching")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
onComplete(false)
return
}
val alternativeRates = activity.display?.mode?.alternativeRefreshRates ?: floatArrayOf()
val seamlessMatch = bestRefreshRateCandidate(fps, alternativeRates)
log("alternative refresh rates=${alternativeRates.joinToString(prefix = "[", postfix = "]")}")
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,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
log("user preference allows non-seamless switch, requesting CHANGE_FRAME_RATE_ALWAYS")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener(fps, extraDelayMs, onComplete)
} else {
log("non-seamless switch not allowed (preference=$userPreference), using seamless-only")
surface.setFrameRate(
fps,
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, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
log("setFrameRateM fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val display = wm.defaultDisplay
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
log("setDisplayMode fps=$fps")
val display = currentDisplay()
if (display == null) {
log("display unavailable")
onComplete(false)
return
}
@@ -357,10 +257,13 @@ class FrameRateManager(
"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
val window = activity.window
if (window == null) {
log("window unavailable")
onComplete(false)
return
}
registerDisplayListener(fps, extraDelayMs, onComplete)
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
}
}