fix(player): harden persisted subtitle switching

This commit is contained in:
Hassan 2026-07-22 01:10:11 +03:00
parent 84bd1525cc
commit 002d459b72
10 changed files with 78 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -180,6 +180,7 @@ internal class PlayerScreenRuntime(
var showAudioModal by mutableStateOf(false)
var showSubtitleModal by mutableStateOf(false)
var subtitleModalVideoId by mutableStateOf<String?>(null)
var showVideoSettingsModal by mutableStateOf(false)
var audioTracks by mutableStateOf<List<AudioTrack>>(emptyList())
var subtitleTracks by mutableStateOf<List<SubtitleTrack>>(emptyList())

View file

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

View file

@ -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 = {

View file

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

View file

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

View file

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