fix: match content frame rate

close #736
This commit is contained in:
edde746
2026-03-22 08:12:16 +01:00
parent 4b05ed79ea
commit 48e57fd375
4 changed files with 80 additions and 19 deletions
@@ -83,6 +83,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
private const val WATCHDOG_TIMEOUT_MS = 8000L private const val WATCHDOG_TIMEOUT_MS = 8000L
private const val DECODER_HANG_TIMEOUT_MS = 5000L 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 // Codec capability caches — codec support doesn't change at runtime
private val hwAudioDecoderCache = HashMap<String, Boolean>() private val hwAudioDecoderCache = HashMap<String, Boolean>()
@@ -140,6 +141,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var frameRateManager: FrameRateManager? = null private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper()) 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 // Audio focus
private var audioFocusManager: AudioFocusManager? = null private var audioFocusManager: AudioFocusManager? = null
@@ -255,8 +261,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
activity = activity, activity = activity,
handler = handler, handler = handler,
onDisplayChanged = { onDisplayChanged = {
if (exoPlayer?.isPlaying == false && audioFocusManager?.wasPlayingBeforeFocusLoss == true) { if (exoPlayer?.isPlaying == false) {
Log.d(TAG, "Display changed, resuming playback") Log.d(TAG, "Display changed after frame rate switch, resuming playback")
exoPlayer?.play() exoPlayer?.play()
} }
}, },
@@ -477,6 +483,17 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
exoPlayer!!.addListener(this) exoPlayer!!.addListener(this)
exoPlayer!!.addAnalyticsListener(decoderHangListener) 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) } surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) }
Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}") Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}")
@@ -1168,6 +1185,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog() stopFrameWatchdog()
cancelDecoderHangCheck() cancelDecoderHangCheck()
// Reset FPS detection for new content
detectedFrameRate = -1f
fpsTimestampCount = 0
// Reset DV7 retry flag when opening a different file // Reset DV7 retry flag when opening a different file
if (uri != currentMediaUri) { if (uri != currentMediaUri) {
dv7RetryAttempted = false dv7RetryAttempted = false
@@ -1455,6 +1476,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
frameRateManager?.clearVideoFrameRate() 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 // Stats
fun getStats(): Map<String, Any?> { fun getStats(): Map<String, Any?> {
@@ -1471,7 +1506,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"videoMimeType" to videoFormat?.sampleMimeType, "videoMimeType" to videoFormat?.sampleMimeType,
"videoWidth" to videoFormat?.width, "videoWidth" to videoFormat?.width,
"videoHeight" to videoFormat?.height, "videoHeight" to videoFormat?.height,
"videoFps" to videoFormat?.frameRate, "videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }),
"videoBitrate" to videoFormat?.bitrate, "videoBitrate" to videoFormat?.bitrate,
"videoDecoderName" to (decoderInitName ?: videoDecoderInfo), "videoDecoderName" to (decoderInitName ?: videoDecoderInfo),
"videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount,
@@ -37,16 +37,19 @@ class FrameRateManager(
return return
} }
if (surface == null) {
Log.d(TAG, "setVideoFrameRate: Surface not available")
return
}
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}") log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
when { when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> setFrameRateS(fps, surface, videoDurationMs) Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> setFrameRateR(fps, surface) 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) Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
} }
} }
@@ -58,6 +61,13 @@ class FrameRateManager(
getDisplayManager().unregisterDisplayListener(it) getDisplayManager().unregisterDisplayListener(it)
displayListener = null 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() { private fun registerDisplayListener() {
@@ -79,13 +89,6 @@ class FrameRateManager(
getDisplayManager().registerDisplayListener(displayListener, handler) 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) @RequiresApi(Build.VERSION_CODES.S)
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) { private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms") Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
@@ -162,7 +165,7 @@ class FrameRateManager(
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) { BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
modeToUse = mode modeToUse = mode
break break
} else if (mode.refreshRate % fps == 0f) { } else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
modeToUse = mode modeToUse = mode
break break
} }
@@ -251,6 +251,11 @@ class PlayerAndroid extends PlayerBase {
return (state.duration.inMilliseconds / 1000.0).toString(); return (state.duration.inMilliseconds / 1000.0).toString();
case 'seekable': case 'seekable':
return state.seekable ? 'yes' : 'no'; 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 // Video dimensions - query from ExoPlayer stats
case 'width': case 'width':
case 'dwidth': case 'dwidth':
+19 -1
View File
@@ -630,6 +630,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// offline Android playback flows). Prevents a permanent loading spinner. // offline Android playback flows). Prevents a permanent loading spinner.
if (!_hasFirstFrame.value && position.inMilliseconds > 0) { if (!_hasFirstFrame.value && position.inMilliseconds > 0) {
_hasFirstFrame.value = true; _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; final duration = player!.state.duration;
@@ -661,6 +667,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// Apply frame rate matching on Android by setting the display refresh rate /// Apply frame rate matching on Android by setting the display refresh rate
/// to match the video content's frame rate. /// to match the video content's frame rate.
int _frameRateRetries = 0;
Future<void> _applyFrameRateMatching() async { Future<void> _applyFrameRateMatching() async {
if (player == null || !Platform.isAndroid) return; if (player == null || !Platform.isAndroid) return;
@@ -668,10 +675,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final fpsStr = await player!.getProperty('container-fps'); final fpsStr = await player!.getProperty('container-fps');
final fps = double.tryParse(fpsStr ?? ''); final fps = double.tryParse(fpsStr ?? '');
if (fps == null || fps <= 0) { 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)'); appLogger.d('Frame rate matching: No valid fps available ($fpsStr)');
return; return;
} }
_frameRateRetries = 0;
final durationMs = player!.state.duration.inMilliseconds; final durationMs = player!.state.duration.inMilliseconds;
await player!.setVideoFrameRate(fps, durationMs); await player!.setVideoFrameRate(fps, durationMs);
@@ -1025,8 +1042,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Open video through Player // Open video through Player
if (result.videoUrl != null) { 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; _hasFirstFrame.value = false;
_frameRateRetries = 0;
// Request audio focus before starting playback (Android) // Request audio focus before starting playback (Android)
// This causes other media apps (Spotify, podcasts, etc.) to pause // This causes other media apps (Spotify, podcasts, etc.) to pause