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 4817bce8..c302c581 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 5303fe9e..d4e1b224 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 @@ -1350,11 +1350,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) { @@ -1408,6 +1422,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, @@ -1424,6 +1440,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/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt index bbd98327..5855bf5b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt @@ -13,6 +13,7 @@ internal actual object PlayerTrackPreferenceStorage { private const val addonSubtitleIdKey = "addon_subtitle_id" private const val addonSubtitleUrlKey = "addon_subtitle_url" private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name" + private const val addonSubtitleVideoIdKey = "addon_subtitle_video_id" private const val audioLanguageKey = "audio_language" private const val audioNameKey = "audio_name" private const val audioTrackIdKey = "audio_track_id" @@ -34,6 +35,7 @@ internal actual object PlayerTrackPreferenceStorage { addonSubtitleId = loadString(addonSubtitleIdKey, id), addonSubtitleUrl = loadString(addonSubtitleUrlKey, id), addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id), + addonSubtitleVideoId = loadString(addonSubtitleVideoIdKey, id), audioLanguage = loadString(audioLanguageKey, id), audioName = loadString(audioNameKey, id), audioTrackId = loadString(audioTrackIdKey, id), @@ -47,6 +49,7 @@ internal actual object PlayerTrackPreferenceStorage { it.addonSubtitleId, it.addonSubtitleUrl, it.addonSubtitleAddonName, + it.addonSubtitleVideoId, it.audioLanguage, it.audioName, it.audioTrackId, @@ -64,6 +67,7 @@ internal actual object PlayerTrackPreferenceStorage { putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId) putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl) putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName) + putOptionalString(addonSubtitleVideoIdKey, id, preference.addonSubtitleVideoId) putOptionalString(audioLanguageKey, id, preference.audioLanguage) putOptionalString(audioNameKey, id, preference.audioName) putOptionalString(audioTrackIdKey, id, preference.audioTrackId) 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 51654899..d7dc87e3 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,12 @@ 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 + useCustomSubtitles = false hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration = 0L pendingScrobbleStartAfterSeek = false 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 86b44aed..e57f206f 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 43b4977a..14fb6483 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 c4715427..47e4a30f 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 @@ -56,6 +56,7 @@ internal fun PlayerScreenRuntime.persistInternalSubtitlePreference(track: Subtit addonSubtitleId = null, addonSubtitleUrl = null, addonSubtitleAddonName = null, + addonSubtitleVideoId = null, ) } } @@ -70,17 +71,19 @@ internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonS addonSubtitleId = subtitle.id, addonSubtitleUrl = subtitle.url, addonSubtitleAddonName = subtitle.addonName, + addonSubtitleVideoId = playbackSession.videoId, ) } } -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() && @@ -98,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()) { @@ -110,6 +114,7 @@ internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { if (restoredSubtitleIndex >= 0) { if (useCustomSubtitles) { playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) + subtitleTracksInvalidated = true } else { playerController?.selectSubtitleTrack(restoredSubtitleIndex) } @@ -121,18 +126,28 @@ internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { } } PersistedSubtitleSelectionType.ADDON -> { - val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } + val url = persistedAddonSubtitleUrlForVideo( + preference = preference, + videoId = playbackSession.videoId, + ) if (url != null) { selectedAddonSubtitleId = preference.addonSubtitleId ?: url selectedSubtitleIndex = -1 useCustomSubtitles = true playerController?.setSubtitleUri(url) preferredSubtitleSelectionApplied = true + } else { + playerController?.clearExternalSubtitle() + selectedAddonSubtitleId = null + selectedSubtitleIndex = -1 + useCustomSubtitles = false + subtitleTracksInvalidated = true } } } trackPreferenceRestoreApplied = true + return subtitleTracksInvalidated } internal fun PlayerScreenRuntime.refreshTracks() { @@ -144,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 d83c22fd..7560cb88 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 @@ -270,6 +270,7 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, onSpeedClick = { cyclePlaybackSpeed() }, onSubtitleClick = { refreshTracks() + subtitleModalVideoId = playbackSession.videoId showSubtitleModal = true }, onAudioClick = { @@ -464,7 +465,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 @@ -478,7 +482,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/PlayerTrackPreferenceStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt index e941f20a..1c5dec51 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt @@ -8,6 +8,7 @@ data class PersistedPlayerTrackPreference( val addonSubtitleId: String? = null, val addonSubtitleUrl: String? = null, val addonSubtitleAddonName: String? = null, + val addonSubtitleVideoId: String? = null, val audioLanguage: String? = null, val audioName: String? = null, val audioTrackId: String? = null, 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 a9bdeb4a..4137e4e5 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 @@ -219,3 +219,17 @@ internal fun findPersistedSubtitleTrackIndex( } return -1 } + +internal fun persistedAddonSubtitleUrlForVideo( + preference: PersistedPlayerTrackPreference, + videoId: String, +): String? { + val persistedVideoId = preference.addonSubtitleVideoId?.takeIf { it.isNotBlank() } ?: return null + 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 3e6f98a8..b95ec7bc 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 @@ -234,6 +234,49 @@ class PlayerTrackSelectionTest { assertEquals(listOf("french", "english"), visibleSubtitles.map { it.id }) } + @Test + fun addonSubtitleUrlIsRestoredForTheSameEpisode() { + val preference = PersistedPlayerTrackPreference( + addonSubtitleUrl = "https://example.com/episode-1.srt", + addonSubtitleVideoId = "series:1:1", + ) + + assertEquals( + "https://example.com/episode-1.srt", + persistedAddonSubtitleUrlForVideo(preference, "series:1:1"), + ) + } + + @Test + fun addonSubtitleUrlIsNotReusedForAnotherEpisode() { + val preference = PersistedPlayerTrackPreference( + addonSubtitleUrl = "https://example.com/episode-1.srt", + addonSubtitleVideoId = "series:1:1", + ) + + assertEquals(null, persistedAddonSubtitleUrlForVideo(preference, "series:1:2")) + } + + @Test + fun legacyUnscopedAddonSubtitleUrlIsNotRestored() { + val preference = PersistedPlayerTrackPreference( + addonSubtitleUrl = "https://example.com/episode-1.srt", + ) + + 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 80b095dd..2b8c4d89 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 @@ -60,6 +60,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 @@ -82,7 +96,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) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt index f48941f8..8a0bcda6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt @@ -11,6 +11,7 @@ internal actual object PlayerTrackPreferenceStorage { private const val addonSubtitleIdKey = "addon_subtitle_id" private const val addonSubtitleUrlKey = "addon_subtitle_url" private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name" + private const val addonSubtitleVideoIdKey = "addon_subtitle_video_id" private const val audioLanguageKey = "audio_language" private const val audioNameKey = "audio_name" private const val audioTrackIdKey = "audio_track_id" @@ -26,6 +27,7 @@ internal actual object PlayerTrackPreferenceStorage { addonSubtitleId = loadString(addonSubtitleIdKey, id), addonSubtitleUrl = loadString(addonSubtitleUrlKey, id), addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id), + addonSubtitleVideoId = loadString(addonSubtitleVideoIdKey, id), audioLanguage = loadString(audioLanguageKey, id), audioName = loadString(audioNameKey, id), audioTrackId = loadString(audioTrackIdKey, id), @@ -39,6 +41,7 @@ internal actual object PlayerTrackPreferenceStorage { it.addonSubtitleId, it.addonSubtitleUrl, it.addonSubtitleAddonName, + it.addonSubtitleVideoId, it.audioLanguage, it.audioName, it.audioTrackId, @@ -55,6 +58,7 @@ internal actual object PlayerTrackPreferenceStorage { saveOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId) saveOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl) saveOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName) + saveOptionalString(addonSubtitleVideoIdKey, id, preference.addonSubtitleVideoId) saveOptionalString(audioLanguageKey, id, preference.audioLanguage) saveOptionalString(audioNameKey, id, preference.audioName) saveOptionalString(audioTrackIdKey, id, preference.audioTrackId)