Merge branch 'feat/frame-rate-matching'

This commit is contained in:
edde746
2026-01-11 13:04:01 +01:00
15 changed files with 397 additions and 10 deletions
@@ -1,16 +1,26 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.TextureView
import android.view.WindowManager
import androidx.annotation.RequiresApi
import dev.jdtech.mpv.MPVLib
import java.math.BigDecimal
import java.math.RoundingMode
interface MpvPlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
@@ -24,6 +34,7 @@ class MpvPlayerCore(private val activity: Activity) :
companion object {
private const val TAG = "MpvPlayerCore"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
}
private var surfaceView: SurfaceView? = null
@@ -34,6 +45,11 @@ class MpvPlayerCore(private val activity: Activity) :
var isInitialized: Boolean = false
private set
// Frame rate matching
private var currentVideoFps: Float = 0f
private var displayListener: DisplayManager.DisplayListener? = null
private val handler = Handler(Looper.getMainLooper())
private fun ensureFlutterOverlayOnTop() {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
@@ -300,11 +316,203 @@ class MpvPlayerCore(private val activity: Activity) :
}
}
// Frame Rate Matching
private fun getDisplayManager(): DisplayManager {
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
}
/**
* Set the video frame rate for display refresh rate matching.
* Based on VLC Android's FrameRateManager implementation.
*/
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
currentVideoFps = fps
if (fps <= 0f) {
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
return
}
val surface = surfaceView?.holder?.surface
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
Log.d(TAG, "setVideoFrameRate: 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.M -> setFrameRateM(fps)
}
}
/**
* Clear frame rate setting and cleanup display listener.
*/
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
currentVideoFps = 0f
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
displayListener = null
}
}
/**
* Create and register display listener for mode switch completion.
* Resumes playback after display mode change (needed for HDMI/projectors).
*/
private fun registerDisplayListener() {
displayListener?.let {
getDisplayManager().unregisterDisplayListener(it)
}
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
override fun onDisplayRemoved(displayId: Int) = Unit
override fun onDisplayChanged(displayId: Int) {
// Mode switch may pause playback (HDMI), wait and resume
handler.postDelayed({
try {
val isPaused = MPVLib.getPropertyBoolean("pause")
if (isPaused) {
Log.d(TAG, "Display changed, resuming playback")
MPVLib.setPropertyBoolean("pause", false)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume playback after display change", e)
}
}, 2000L) // Wait 2 seconds for mode switch to complete
getDisplayManager().unregisterDisplayListener(this)
displayListener = null
}
}
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")
// For short videos (<5min), only switch if seamless
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
Log.d(TAG, "Short video, using seamless-only switching")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
return
}
// For longer videos, check if switch will be seamless
var seamless = false
activity.display?.mode?.alternativeRefreshRates?.let { refreshRates ->
for (rate in refreshRates) {
// Check if rates match or are integer multiples
if (fps.toString().startsWith(rate.toString()) ||
rate.toString().startsWith(fps.toString()) ||
rate % fps == 0f) {
seamless = true
break
}
}
}
if (seamless) {
Log.d(TAG, "Seamless switch available, using CHANGE_FRAME_RATE_ALWAYS")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
// Non-seamless: only switch if user enabled it at OS level
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
Log.d(TAG, "User preference allows non-seamless switch")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ALWAYS
)
registerDisplayListener()
} else {
Log.d(TAG, "Non-seamless switch not allowed by user preference, using seamless-only")
surface.setFrameRate(
fps,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
)
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun setFrameRateM(fps: Float) {
Log.d(TAG, "setFrameRateM: fps=$fps")
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = wm.defaultDisplay ?: return
display.supportedModes?.let { supportedModes ->
val currentMode = display.mode
var modeToUse = currentMode
for (mode in supportedModes) {
// Skip modes with different resolution
if (mode.physicalHeight != currentMode.physicalHeight ||
mode.physicalWidth != currentMode.physicalWidth) {
continue
}
Log.d(TAG, "Supported mode: ${mode.modeId} - ${mode.refreshRate}Hz")
// Check for exact match
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
modeToUse = mode
Log.d(TAG, "Found exact match: ${mode.refreshRate}Hz")
break
}
// Check for integer multiple (e.g., 48Hz for 24fps)
else if (mode.refreshRate % fps == 0f) {
modeToUse = mode
Log.d(TAG, "Found integer multiple: ${mode.refreshRate}Hz")
break
}
}
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()
} else {
Log.d(TAG, "No better mode found, staying at ${currentMode.refreshRate}Hz")
}
}
}
// Cleanup
fun dispose() {
Log.d(TAG, "Disposing")
// Clean up frame rate listener
clearVideoFrameRate()
MPVLib.removeObserver(this)
MPVLib.removeLogObserver(this)
@@ -94,6 +94,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
"observeProperty" -> handleObserveProperty(call, result)
"command" -> handleCommand(call, result)
"setVisible" -> handleSetVisible(call, result)
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
else -> result.notImplemented()
}
@@ -202,6 +204,21 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
result.success(null)
}
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
playerCore?.setVideoFrameRate(fps, duration)
result.success(null)
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
// MpvPlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {