feat: add auto frame rate matching
This commit is contained in:
parent
9e259ff919
commit
40ab9b29d6
10 changed files with 219 additions and 4 deletions
|
|
@ -2,6 +2,7 @@
|
|||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/Player" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/libass-android/lib_ass/src/main/cpp/libass-cmake/src/ass" vcs="Git" />
|
||||
|
|
|
|||
141
app/src/main/java/com/nuvio/tv/core/player/FrameRateUtils.kt
Normal file
141
app/src/main/java/com/nuvio/tv/core/player/FrameRateUtils.kt
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package com.nuvio.tv.core.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
|
||||
/**
|
||||
* Auto frame rate matching utility.
|
||||
* Switches the display refresh rate to match the video frame rate for judder-free playback.
|
||||
* Inspired by Just (Video) Player's implementation.
|
||||
*/
|
||||
object FrameRateUtils {
|
||||
|
||||
private const val TAG = "FrameRateUtils"
|
||||
|
||||
/** Saved original display mode ID so we can restore it when playback ends. */
|
||||
private var originalModeId: Int = -1
|
||||
|
||||
/**
|
||||
* Normalize a refresh rate to an integer × 100 for safe floating-point comparison.
|
||||
*/
|
||||
private fun normRate(rate: Float): Int = (rate * 100f).toInt()
|
||||
|
||||
/**
|
||||
* Attempt to match the display refresh rate to the video [frameRate].
|
||||
*
|
||||
* @param activity The current Activity (needed for window attributes).
|
||||
* @param frameRate The detected video frame rate (e.g. 23.976, 24, 25, 29.97, 30, 50, 59.94).
|
||||
* @return `true` if a mode switch was requested, `false` if not needed or unavailable.
|
||||
*/
|
||||
fun matchFrameRate(activity: Activity, frameRate: Float): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
|
||||
if (frameRate <= 0f) return false
|
||||
|
||||
try {
|
||||
val window = activity.window ?: return false
|
||||
val display = window.decorView.display ?: return false
|
||||
val supportedModes = display.supportedModes
|
||||
val activeMode = display.mode
|
||||
|
||||
if (supportedModes.size <= 1) return false
|
||||
|
||||
// Save original mode so we can restore later
|
||||
if (originalModeId == -1) {
|
||||
originalModeId = activeMode.modeId
|
||||
}
|
||||
|
||||
// Collect modes that match current resolution
|
||||
val sameSizeModes = supportedModes.filter {
|
||||
it.physicalWidth == activeMode.physicalWidth &&
|
||||
it.physicalHeight == activeMode.physicalHeight
|
||||
}
|
||||
|
||||
if (sameSizeModes.size <= 1) return false
|
||||
|
||||
// Among same-size modes, find ones with refresh rate >= video FPS
|
||||
val modesHigh = sameSizeModes.filter {
|
||||
normRate(it.refreshRate) >= normRate(frameRate)
|
||||
}
|
||||
|
||||
// Track the highest refresh rate mode at same resolution
|
||||
val modeTop = sameSizeModes.maxByOrNull { normRate(it.refreshRate) } ?: activeMode
|
||||
|
||||
// Find the best mode — one whose refresh rate is an exact integer multiple of the video FPS
|
||||
var modeBest: Display.Mode? = null
|
||||
for (mode in modesHigh) {
|
||||
if (normRate(mode.refreshRate) % normRate(frameRate) <= 1) {
|
||||
if (modeBest == null || normRate(mode.refreshRate) > normRate(modeBest.refreshRate)) {
|
||||
modeBest = mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to highest available if no exact multiple found
|
||||
if (modeBest == null) {
|
||||
modeBest = modeTop
|
||||
}
|
||||
|
||||
val switchNeeded = modeBest.modeId != activeMode.modeId
|
||||
if (switchNeeded) {
|
||||
Log.d(TAG, "Switching display mode: ${activeMode.refreshRate}Hz → ${modeBest.refreshRate}Hz " +
|
||||
"(video ${frameRate}fps)")
|
||||
val layoutParams = window.attributes
|
||||
layoutParams.preferredDisplayModeId = modeBest.modeId
|
||||
window.attributes = layoutParams
|
||||
} else {
|
||||
Log.d(TAG, "Display already at optimal rate ${activeMode.refreshRate}Hz for ${frameRate}fps")
|
||||
}
|
||||
|
||||
return switchNeeded
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to match frame rate", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original display mode that was active before frame rate matching.
|
||||
*
|
||||
* @param activity The current Activity.
|
||||
*/
|
||||
fun restoreOriginalMode(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
if (originalModeId == -1) return
|
||||
|
||||
try {
|
||||
val window = activity.window ?: return
|
||||
val layoutParams = window.attributes
|
||||
layoutParams.preferredDisplayModeId = originalModeId
|
||||
window.attributes = layoutParams
|
||||
Log.d(TAG, "Restored original display mode id=$originalModeId")
|
||||
originalModeId = -1
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restore display mode", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the video frame rate from an ExoPlayer Format and snap to standard rates.
|
||||
*
|
||||
* @param formatFrameRate The frame rate reported by ExoPlayer's video track Format.
|
||||
* @return The snapped standard frame rate, or the original if no standard match.
|
||||
*/
|
||||
fun snapToStandardRate(formatFrameRate: Float): Float {
|
||||
if (formatFrameRate <= 0f) return formatFrameRate
|
||||
return when {
|
||||
formatFrameRate in 23.90f..23.988f -> 24000f / 1001f // 23.976 (NTSC film)
|
||||
formatFrameRate in 23.988f..24.1f -> 24f
|
||||
formatFrameRate in 24.9f..25.1f -> 25f // PAL
|
||||
formatFrameRate in 29.90f..29.985f -> 30000f / 1001f // 29.97 NTSC
|
||||
formatFrameRate in 29.985f..30.1f -> 30f
|
||||
formatFrameRate in 49.9f..50.1f -> 50f // PAL interlaced
|
||||
formatFrameRate in 59.9f..59.97f -> 60000f / 1001f // 59.94 NTSC
|
||||
formatFrameRate in 59.97f..60.1f -> 60f
|
||||
else -> formatFrameRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,7 +109,9 @@ data class PlayerSettings(
|
|||
val skipSilence: Boolean = false,
|
||||
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
|
||||
val loadingOverlayEnabled: Boolean = true,
|
||||
val pauseOverlayEnabled: Boolean = true
|
||||
val pauseOverlayEnabled: Boolean = true,
|
||||
// Display settings
|
||||
val frameRateMatching: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -141,6 +143,7 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
|
||||
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
|
||||
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
|
||||
private val frameRateMatchingKey = booleanPreferencesKey("frame_rate_matching")
|
||||
|
||||
// Subtitle style settings keys
|
||||
private val subtitlePreferredLanguageKey = stringPreferencesKey("subtitle_preferred_language")
|
||||
|
|
@ -169,6 +172,7 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
preferredAudioLanguage = prefs[preferredAudioLanguageKey] ?: AudioLanguageOption.DEVICE,
|
||||
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
|
||||
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
|
||||
frameRateMatching = prefs[frameRateMatchingKey] ?: false,
|
||||
subtitleStyle = SubtitleStyleSettings(
|
||||
preferredLanguage = prefs[subtitlePreferredLanguageKey] ?: "en",
|
||||
secondaryPreferredLanguage = prefs[subtitleSecondaryLanguageKey],
|
||||
|
|
@ -238,6 +242,12 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setFrameRateMatching(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[frameRateMatchingKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to use libass for ASS/SSA subtitle rendering
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
|
||||
private var trailerDelayMs = 7000L
|
||||
|
||||
/** Once true, the trailer will never auto-play again for this screen session. */
|
||||
private var trailerHasBeenTriggered = false
|
||||
|
||||
init {
|
||||
observeLibraryState()
|
||||
observeWatchProgress()
|
||||
|
|
@ -481,15 +484,20 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
|
||||
val state = _uiState.value
|
||||
if (state.trailerUrl == null || state.isTrailerPlaying) return
|
||||
// Never restart the idle timer once the trailer has played or the user has interacted
|
||||
if (trailerHasBeenTriggered) return
|
||||
|
||||
idleTimerJob = viewModelScope.launch {
|
||||
delay(trailerDelayMs)
|
||||
trailerHasBeenTriggered = true
|
||||
_uiState.update { it.copy(isTrailerPlaying = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserInteraction() {
|
||||
idleTimerJob?.cancel()
|
||||
// Permanently prevent trailer from replaying for this screen session
|
||||
trailerHasBeenTriggered = true
|
||||
|
||||
if (_uiState.value.isTrailerPlaying) {
|
||||
_uiState.update { it.copy(isTrailerPlaying = false) }
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -134,6 +135,22 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Frame rate matching: switch display refresh rate to match video frame rate
|
||||
val activity = LocalContext.current as? android.app.Activity
|
||||
LaunchedEffect(uiState.detectedFrameRate, uiState.frameRateMatchingEnabled) {
|
||||
if (activity != null && uiState.frameRateMatchingEnabled && uiState.detectedFrameRate > 0f) {
|
||||
com.nuvio.tv.core.player.FrameRateUtils.matchFrameRate(activity, uiState.detectedFrameRate)
|
||||
}
|
||||
}
|
||||
// Restore original display mode when leaving the player
|
||||
DisposableEffect(activity) {
|
||||
onDispose {
|
||||
if (activity != null) {
|
||||
com.nuvio.tv.core.player.FrameRateUtils.restoreOriginalMode(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request focus for key events when controls visibility or panel state changes
|
||||
LaunchedEffect(uiState.showControls, uiState.showEpisodesPanel, uiState.showSourcesPanel) {
|
||||
if (uiState.showControls && !uiState.showEpisodesPanel && !uiState.showSourcesPanel &&
|
||||
|
|
|
|||
|
|
@ -81,7 +81,10 @@ data class PlayerUiState(
|
|||
val parentalGuideHasShown: Boolean = false,
|
||||
// Skip intro
|
||||
val activeSkipInterval: SkipInterval? = null,
|
||||
val skipIntervalDismissed: Boolean = false
|
||||
val skipIntervalDismissed: Boolean = false,
|
||||
// Frame rate matching
|
||||
val detectedFrameRate: Float = 0f,
|
||||
val frameRateMatchingEnabled: Boolean = false
|
||||
)
|
||||
|
||||
data class TrackInfo(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.media3.extractor.ts.TsExtractor
|
|||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.common.MimeTypes
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.core.player.FrameRateUtils
|
||||
import com.nuvio.tv.data.local.AudioLanguageOption
|
||||
import com.nuvio.tv.data.local.LibassRenderType
|
||||
import com.nuvio.tv.data.local.PlayerSettingsDataStore
|
||||
|
|
@ -527,6 +528,9 @@ class PlayerViewModel @Inject constructor(
|
|||
skipSilenceEnabled = true
|
||||
}
|
||||
|
||||
// Store frame rate matching preference for later use
|
||||
_uiState.update { it.copy(frameRateMatchingEnabled = playerSettings.frameRateMatching) }
|
||||
|
||||
// Loudness enhancer for volume boost beyond system max
|
||||
try {
|
||||
loudnessEnhancer?.release()
|
||||
|
|
@ -1139,6 +1143,19 @@ class PlayerViewModel @Inject constructor(
|
|||
val trackType = trackGroup.type
|
||||
|
||||
when (trackType) {
|
||||
C.TRACK_TYPE_VIDEO -> {
|
||||
// Detect video frame rate from the first selected video track
|
||||
for (i in 0 until trackGroup.length) {
|
||||
if (trackGroup.isTrackSelected(i)) {
|
||||
val format = trackGroup.getTrackFormat(i)
|
||||
if (format.frameRate > 0f) {
|
||||
val snapped = FrameRateUtils.snapToStandardRate(format.frameRate)
|
||||
_uiState.update { it.copy(detectedFrameRate = snapped) }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
C.TRACK_TYPE_AUDIO -> {
|
||||
for (i in 0 until trackGroup.length) {
|
||||
val format = trackGroup.getTrackFormat(i)
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ fun AboutSettingsContent() {
|
|||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.nuvio_text),
|
||||
contentDescription = "Nuvio",
|
||||
painter = painterResource(id = R.drawable.nuviotv_logo),
|
||||
contentDescription = "NuvioTV",
|
||||
modifier = Modifier
|
||||
.width(180.dp)
|
||||
.height(50.dp),
|
||||
|
|
|
|||
|
|
@ -223,6 +223,20 @@ fun PlaybackSettingsContent(
|
|||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ToggleSettingsItem(
|
||||
icon = Icons.Default.Speed,
|
||||
title = "Auto Frame Rate",
|
||||
subtitle = "Switch display refresh rate to match video frame rate for judder-free playback",
|
||||
isChecked = playerSettings.frameRateMatching,
|
||||
onCheckedChange = { enabled ->
|
||||
coroutineScope.launch {
|
||||
viewModel.setFrameRateMatching(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Trailer Section Header
|
||||
item {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class PlaybackSettingsViewModel @Inject constructor(
|
|||
playerSettingsDataStore.setPauseOverlayEnabled(enabled)
|
||||
}
|
||||
|
||||
suspend fun setFrameRateMatching(enabled: Boolean) {
|
||||
playerSettingsDataStore.setFrameRateMatching(enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to use libass for ASS/SSA subtitle rendering
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue