@@ -83,6 +83,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
|
||||
private const val WATCHDOG_TIMEOUT_MS = 8000L
|
||||
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
|
||||
private val hwAudioDecoderCache = HashMap<String, Boolean>()
|
||||
@@ -140,6 +141,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
private var frameRateManager: FrameRateManager? = null
|
||||
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
|
||||
private var audioFocusManager: AudioFocusManager? = null
|
||||
|
||||
@@ -255,8 +261,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
activity = activity,
|
||||
handler = handler,
|
||||
onDisplayChanged = {
|
||||
if (exoPlayer?.isPlaying == false && audioFocusManager?.wasPlayingBeforeFocusLoss == true) {
|
||||
Log.d(TAG, "Display changed, resuming playback")
|
||||
if (exoPlayer?.isPlaying == false) {
|
||||
Log.d(TAG, "Display changed after frame rate switch, resuming playback")
|
||||
exoPlayer?.play()
|
||||
}
|
||||
},
|
||||
@@ -477,6 +483,17 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
exoPlayer!!.addListener(this)
|
||||
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) }
|
||||
|
||||
Log.d(TAG, "SubtitleView childCount after ASS setup: ${subtitleView?.childCount}")
|
||||
@@ -1168,6 +1185,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
stopFrameWatchdog()
|
||||
cancelDecoderHangCheck()
|
||||
|
||||
// Reset FPS detection for new content
|
||||
detectedFrameRate = -1f
|
||||
fpsTimestampCount = 0
|
||||
|
||||
// Reset DV7 retry flag when opening a different file
|
||||
if (uri != currentMediaUri) {
|
||||
dv7RetryAttempted = false
|
||||
@@ -1455,6 +1476,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
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
|
||||
|
||||
fun getStats(): Map<String, Any?> {
|
||||
@@ -1471,7 +1506,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
"videoMimeType" to videoFormat?.sampleMimeType,
|
||||
"videoWidth" to videoFormat?.width,
|
||||
"videoHeight" to videoFormat?.height,
|
||||
"videoFps" to videoFormat?.frameRate,
|
||||
"videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }),
|
||||
"videoBitrate" to videoFormat?.bitrate,
|
||||
"videoDecoderName" to (decoderInitName ?: videoDecoderInfo),
|
||||
"videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount,
|
||||
|
||||
@@ -37,16 +37,19 @@ class FrameRateManager(
|
||||
return
|
||||
}
|
||||
|
||||
if (surface == null) {
|
||||
Log.d(TAG, "setVideoFrameRate: Surface not available")
|
||||
return
|
||||
}
|
||||
|
||||
log("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.S -> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +61,13 @@ class FrameRateManager(
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
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() {
|
||||
@@ -79,13 +89,6 @@ class FrameRateManager(
|
||||
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")
|
||||
@@ -162,7 +165,7 @@ class FrameRateManager(
|
||||
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
|
||||
modeToUse = mode
|
||||
break
|
||||
} else if (mode.refreshRate % fps == 0f) {
|
||||
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
|
||||
modeToUse = mode
|
||||
break
|
||||
}
|
||||
|
||||
@@ -251,6 +251,11 @@ class PlayerAndroid extends PlayerBase {
|
||||
return (state.duration.inMilliseconds / 1000.0).toString();
|
||||
case 'seekable':
|
||||
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
|
||||
case 'width':
|
||||
case 'dwidth':
|
||||
|
||||
@@ -630,6 +630,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// offline Android playback flows). Prevents a permanent loading spinner.
|
||||
if (!_hasFirstFrame.value && position.inMilliseconds > 0) {
|
||||
_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;
|
||||
@@ -661,6 +667,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
/// Apply frame rate matching on Android by setting the display refresh rate
|
||||
/// to match the video content's frame rate.
|
||||
int _frameRateRetries = 0;
|
||||
Future<void> _applyFrameRateMatching() async {
|
||||
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 fps = double.tryParse(fpsStr ?? '');
|
||||
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)');
|
||||
return;
|
||||
}
|
||||
|
||||
_frameRateRetries = 0;
|
||||
final durationMs = player!.state.duration.inMilliseconds;
|
||||
await player!.setVideoFrameRate(fps, durationMs);
|
||||
|
||||
@@ -1025,8 +1042,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
// Open video through Player
|
||||
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;
|
||||
_frameRateRetries = 0;
|
||||
|
||||
// Request audio focus before starting playback (Android)
|
||||
// This causes other media apps (Spotify, podcasts, etc.) to pause
|
||||
|
||||
Reference in New Issue
Block a user