@@ -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':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user