diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 4d7a1ec5..44c1e1b0 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/java/com/nuvio/tv/core/player/FrameRateUtils.kt b/app/src/main/java/com/nuvio/tv/core/player/FrameRateUtils.kt new file mode 100644 index 00000000..b029c811 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/player/FrameRateUtils.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/PlayerSettingsDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/PlayerSettingsDataStore.kt index dcb57e68..f1ffe943 100644 --- a/app/src/main/java/com/nuvio/tv/data/local/PlayerSettingsDataStore.kt +++ b/app/src/main/java/com/nuvio/tv/data/local/PlayerSettingsDataStore.kt @@ -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 */ diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt index 1b264421..cc224dc1 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index 9af0d32b..1a01e95c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -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 && diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt index 37e94694..e54e165b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt @@ -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( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index dbfec7c5..b1edc8f1 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -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) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/AboutScreen.kt index 9316b48a..11f67552 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/AboutScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/AboutScreen.kt @@ -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), diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsScreen.kt index 438121a4..0ded32f3 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsScreen.kt @@ -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( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsViewModel.kt index 253d9d4c..87213e83 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/PlaybackSettingsViewModel.kt @@ -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 */