feat: add auto frame rate matching

This commit is contained in:
tapframe 2026-02-08 12:29:05 +05:30
parent 9e259ff919
commit 40ab9b29d6
10 changed files with 219 additions and 4 deletions

View file

@ -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" />

View 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
}
}
}

View file

@ -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
*/

View file

@ -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) }

View file

@ -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 &&

View file

@ -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(

View file

@ -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)

View file

@ -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),

View file

@ -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(

View file

@ -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
*/