@@ -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() {
|
||||
|
||||
@@ -433,14 +433,19 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -239,10 +239,17 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user