From 002d459b72703009ecb57b114c4357014c1ad41e Mon Sep 17 00:00:00 2001 From: Hassan Date: Wed, 22 Jul 2026 01:10:11 +0300 Subject: [PATCH] fix(player): harden persisted subtitle switching --- ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../features/player/PlayerEngine.android.kt | 19 ++++++++++++++++++- .../PlayerScreenRuntimePlaybackActions.kt | 2 ++ .../PlayerScreenRuntimeSourceActions.kt | 2 ++ .../player/PlayerScreenRuntimeState.kt | 1 + .../player/PlayerScreenRuntimeTrackActions.kt | 18 +++++++++++++----- .../features/player/PlayerScreenRuntimeUi.kt | 11 +++++++++-- .../features/player/PlayerTrackSelection.kt | 5 +++++ .../player/PlayerTrackSelectionTest.kt | 11 +++++++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 17 ++++++++++++++++- 10 files changed, 78 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 4817bce80..c302c5814 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -9,6 +9,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_library_display_settings", "nuvio_home_catalog_settings", "nuvio_player_settings", + "nuvio_player_track_preferences", "torrent_settings", "nuvio_profile_cache", "nuvio_avatar_cache", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 85fe5fddc..771cbf321 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -1267,11 +1267,25 @@ private class NuvioLibmpvView( } override fun clearExternalSubtitle() { + extractLibmpvTracks(context, type = "sub") + .filter { it.isExternal } + .forEach { track -> mpv.command("sub-remove", track.id.toString()) } mpv.setPropertyString("sid", "no") } override fun clearExternalSubtitleAndSelect(trackIndex: Int) { - selectSubtitleTrack(trackIndex) + val tracks = extractLibmpvTracks(context, type = "sub") + val targetTrackId = tracks.getOrNull(trackIndex) + ?.takeUnless { it.isExternal } + ?.id + tracks + .filter { it.isExternal } + .forEach { track -> mpv.command("sub-remove", track.id.toString()) } + if (targetTrackId == null) { + mpv.setPropertyString("sid", "no") + } else { + mpv.setPropertyInt("sid", targetTrackId) + } } override fun applySubtitleStyle(style: SubtitleStyleState) { @@ -1325,6 +1339,8 @@ private class NuvioLibmpvView( label = label, language = language, isSelected = node.nodeBoolean("selected") ?: false, + isExternal = node.nodeBoolean("external") == true || + node.nodeString("external-filename") != null, isForced = inferForcedSubtitleTrack( label = label, language = language, @@ -1341,6 +1357,7 @@ private data class LibmpvTrack( val label: String, val language: String?, val isSelected: Boolean, + val isExternal: Boolean, val isForced: Boolean, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 0061754e6..d7dc87e34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -65,6 +65,8 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { val videoIdentity = "$identity:$activeVideoId:$activeSeasonNumber:$activeEpisodeNumber" if (lastResetVideoIdentity != videoIdentity) { lastResetVideoIdentity = videoIdentity + showSubtitleModal = false + subtitleModalVideoId = null trackPreferenceRestoreApplied = false preferredSubtitleSelectionApplied = false selectedAddonSubtitleId = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 86b44aedb..e57f206f1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -426,6 +426,8 @@ private data class EpisodeResume(val positionMs: Long, val fraction: Float?) private fun PlayerScreenRuntime.resetEpisodePanelAndNextEpisodeState() { showNextEpisodeCard = false + showSubtitleModal = false + subtitleModalVideoId = null showSourcesPanel = false showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index b6f4e42ed..f14542e7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -180,6 +180,7 @@ internal class PlayerScreenRuntime( var showAudioModal by mutableStateOf(false) var showSubtitleModal by mutableStateOf(false) + var subtitleModalVideoId by mutableStateOf(null) var showVideoSettingsModal by mutableStateOf(false) var audioTracks by mutableStateOf>(emptyList()) var subtitleTracks by mutableStateOf>(emptyList()) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index 58e25d992..47e4a30f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -76,13 +76,14 @@ internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonS } } -internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { - if (trackPreferenceRestoreApplied) return +internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded(): Boolean { + if (trackPreferenceRestoreApplied) return false val preference = PlayerTrackPreferenceStorage.load(parentMetaId) if (preference == null) { trackPreferenceRestoreApplied = true - return + return false } + var subtitleTracksInvalidated = false if ( audioTracks.isNotEmpty() && @@ -100,11 +101,12 @@ internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { when (preference.subtitleType) { PersistedSubtitleSelectionType.DISABLED -> { - playerController?.selectSubtitleTrack(-1) + playerController?.clearExternalSubtitle() selectedSubtitleIndex = -1 selectedAddonSubtitleId = null useCustomSubtitles = false preferredSubtitleSelectionApplied = true + subtitleTracksInvalidated = true } PersistedSubtitleSelectionType.INTERNAL -> { if (subtitleTracks.isNotEmpty()) { @@ -112,6 +114,7 @@ internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { if (restoredSubtitleIndex >= 0) { if (useCustomSubtitles) { playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) + subtitleTracksInvalidated = true } else { playerController?.selectSubtitleTrack(restoredSubtitleIndex) } @@ -138,11 +141,13 @@ internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { selectedAddonSubtitleId = null selectedSubtitleIndex = -1 useCustomSubtitles = false + subtitleTracksInvalidated = true } } } trackPreferenceRestoreApplied = true + return subtitleTracksInvalidated } internal fun PlayerScreenRuntime.refreshTracks() { @@ -154,7 +159,10 @@ internal fun PlayerScreenRuntime.refreshTracks() { val selectedSub = subtitleTracks.firstOrNull { it.isSelected } if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index - restorePersistedTrackPreferenceIfNeeded() + if (restorePersistedTrackPreferenceIfNeeded()) { + subtitleTracks = ctrl.getSubtitleTracks() + selectedSubtitleIndex = subtitleTracks.firstOrNull { it.isSelected }?.index ?: -1 + } val preferredAudioTargets = resolvePreferredAudioLanguageTargets( preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 227a9d59b..092d2f2e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -222,6 +222,7 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, onSpeedClick = { cyclePlaybackSpeed() }, onSubtitleClick = { refreshTracks() + subtitleModalVideoId = playbackSession.videoId showSubtitleModal = true }, onAudioClick = { @@ -416,7 +417,10 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { playerController?.selectSubtitleTrack(index) } }, - onAddonSubtitleSelected = { addon -> + onAddonSubtitleSelected = selection@{ addon -> + if (!isSubtitleModalSelectionCurrent(subtitleModalVideoId, playbackSession.videoId)) { + return@selection + } selectedAddonSubtitleId = addon.id selectedSubtitleIndex = -1 useCustomSubtitles = true @@ -430,7 +434,10 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onAutoSyncCapture = { captureSubtitleAutoSyncTime() }, onAutoSyncCueSelected = { cue -> applySubtitleAutoSyncCue(cue) }, onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) }, - onSubtitleModalDismissed = { showSubtitleModal = false }, + onSubtitleModalDismissed = { + showSubtitleModal = false + subtitleModalVideoId = null + }, showVideoSettingsModal = showVideoSettingsModal, playerSettings = playerSettingsUiState, onVideoSettingsChanged = { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt index e56ebbf04..4137e4e5f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -228,3 +228,8 @@ internal fun persistedAddonSubtitleUrlForVideo( if (persistedVideoId != videoId.takeIf { it.isNotBlank() }) return null return preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } } + +internal fun isSubtitleModalSelectionCurrent( + modalVideoId: String?, + currentVideoId: String, +): Boolean = !modalVideoId.isNullOrBlank() && modalVideoId == currentVideoId diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt index b3f7a0616..b95ec7bcb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt @@ -266,6 +266,17 @@ class PlayerTrackSelectionTest { assertEquals(null, persistedAddonSubtitleUrlForVideo(preference, "series:1:1")) } + @Test + fun subtitleModalSelectionIsAcceptedForTheSameEpisode() { + assertEquals(true, isSubtitleModalSelectionCurrent("series:1:1", "series:1:1")) + } + + @Test + fun subtitleModalSelectionIsRejectedAfterEpisodeChanges() { + assertEquals(false, isSubtitleModalSelectionCurrent("series:1:1", "series:1:2")) + assertEquals(false, isSubtitleModalSelectionCurrent(null, "series:1:2")) + } + private fun subtitleTrack( index: Int, language: String?, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index af7080491..7ad06ea71 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -59,6 +59,20 @@ internal actual object PlatformLocalAccountDataCleaner { "collection_mobile_settings_payload", "collections_payload", ) + private val playerTrackPreferencePrefixes = listOf( + "subtitle_type|", + "subtitle_language|", + "subtitle_name|", + "subtitle_track_id|", + "addon_subtitle_id|", + "addon_subtitle_url|", + "addon_subtitle_addon_name|", + "addon_subtitle_video_id|", + "audio_language|", + "audio_name|", + "audio_track_id|", + "subtitle_delay_ms|", + ) actual fun wipe() { val defaults = NSUserDefaults.standardUserDefaults @@ -81,7 +95,8 @@ internal actual object PlatformLocalAccountDataCleaner { val keyString = key as? String ?: continue if ( keyString.startsWith("stream_link_") || - keyString.startsWith("cw_enrichment_cache_") + keyString.startsWith("cw_enrichment_cache_") || + playerTrackPreferencePrefixes.any(keyString::startsWith) ) { defaults.removeObjectForKey(keyString) }