From 9113b5567ae61943dfdf31a3a689c2a3574c74ea Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:35:56 +0530 Subject: [PATCH] fix(player): make forced subtitle selection audio-aware --- .../composeResources/values/strings.xml | 2 +- .../player/PlayerScreenRuntimeTrackActions.kt | 93 ++++++---- .../features/player/PlayerTrackSelection.kt | 93 ++++++++-- .../player/PlayerTrackSelectionTest.kt | 169 ++++++++++++++++-- 4 files changed, 292 insertions(+), 65 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 16bc657d6..1aef63cd2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1024,7 +1024,7 @@ Subtitle Size Text Color Use Forced Subtitles - Prefer forced subtitles when matching your subtitle language settings. + Prefer forced subtitles when audio matches the subtitle language; if unavailable, select nothing. Vertical Offset Skip Intro Use introdb.app to detect intros and recaps. 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 9a110d18b..c4715427c 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 @@ -146,23 +146,24 @@ internal fun PlayerScreenRuntime.refreshTracks() { restorePersistedTrackPreferenceIfNeeded() + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + contentOriginalLanguage = resolveContentLanguage( + language = metaUiState.meta?.language, + country = metaUiState.meta?.country, + ) ?: args.contentLanguage, + ) + if (!preferredAudioSelectionApplied) { - val preferredAudioTargets = resolvePreferredAudioLanguageTargets( - preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - contentOriginalLanguage = resolveContentLanguage( - language = metaUiState.meta?.language, - country = metaUiState.meta?.country, - ) ?: args.contentLanguage, - ) if (preferredAudioTargets.isEmpty()) { preferredAudioSelectionApplied = true } else if (audioTracks.isNotEmpty()) { val preferredAudioIndex = findPreferredTrackIndex( tracks = audioTracks, targets = preferredAudioTargets, - language = { track -> track.language }, + language = ::resolveAudioTrackLanguageTarget, ) if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { playerController?.selectAudioTrack(preferredAudioIndex) @@ -173,45 +174,69 @@ internal fun PlayerScreenRuntime.refreshTracks() { } if (!preferredSubtitleSelectionApplied) { - val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + val preferredSubtitleLanguage = normalizeLanguageCode( + playerSettingsUiState.preferredSubtitleLanguage, ) + val preferredSubtitleTargets = if ( + preferredSubtitleLanguage == SubtitleLanguageOption.NONE || + preferredSubtitleLanguage == SubtitleLanguageOption.FORCED + ) { + emptyList() + } else { + resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + } + val selectedAudioTrack = audioTracks.firstOrNull { track -> track.index == selectedAudioIndex } + ?: audioTracks.firstOrNull { it.isSelected } + val selectionPlan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = resolveAudioTrackLanguageTarget(selectedAudioTrack), + preferredAudioTargets = preferredAudioTargets, + preferredSubtitleTargets = preferredSubtitleTargets, + useForcedSubtitles = subtitleStyle.useForcedSubtitles, + ) + if (selectionPlan == null) { + disableAutomaticSubtitleSelection() + return + } - if (preferredSubtitleTargets.isEmpty()) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false + if (selectionPlan.targets.isEmpty()) { + disableAutomaticSubtitleSelection() preferredSubtitleSelectionApplied = true } else if (subtitleTracks.isNotEmpty()) { val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( tracks = subtitleTracks, - targets = preferredSubtitleTargets, - requireForced = subtitleStyle.useForcedSubtitles, + targets = selectionPlan.targets, + mode = selectionPlan.mode, ) if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { playerController?.selectSubtitleTrack(preferredSubtitleIndex) selectedSubtitleIndex = preferredSubtitleIndex selectedAddonSubtitleId = null useCustomSubtitles = false - } else if ( - preferredSubtitleIndex < 0 && - (subtitleStyle.useForcedSubtitles || - normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == - SubtitleLanguageOption.FORCED) - ) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) + } else if (preferredSubtitleIndex < 0) { + val activeSubtitleTrack = subtitleTracks.firstOrNull { track -> + track.index == selectedSubtitleIndex + } ?: subtitleTracks.firstOrNull { it.isSelected } + if ( + selectionPlan.mode == SubtitleAutoSelectionMode.FORCED_ONLY || + activeSubtitleTrack?.isForced == true + ) { + disableAutomaticSubtitleSelection() } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false } preferredSubtitleSelectionApplied = true } } } + +private fun PlayerScreenRuntime.disableAutomaticSubtitleSelection() { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false +} 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 9bdfdc4c7..a9bdeb4aa 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 @@ -65,28 +65,93 @@ internal fun findPreferredTrackIndex( return -1 } +internal enum class SubtitleAutoSelectionMode { + FORCED_ONLY, + NORMAL_ONLY, +} + +internal data class SubtitleAutoSelectionPlan( + val targets: List, + val mode: SubtitleAutoSelectionMode, +) + +internal fun resolveAudioTrackLanguageTarget(track: AudioTrack?): String? { + if (track == null) return null + + val directLanguage = normalizeLanguageCode(track.language) + ?.takeUnless { it == "und" || it == "unknown" } + if (directLanguage != null) return directLanguage + + val selectableLanguages = AvailableLanguageOptions + .mapNotNull { option -> normalizeLanguageCode(option.code) } + .toSet() + return listOf(track.label, track.id).firstNotNullOfOrNull { value -> + normalizeLanguageCode(value)?.takeIf(selectableLanguages::contains) + } +} + +internal fun resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage: String?, + preferredAudioTargets: List, + preferredSubtitleTargets: List, + useForcedSubtitles: Boolean, +): SubtitleAutoSelectionPlan? { + val normalizedAudioLanguage = normalizeLanguageCode(selectedAudioLanguage) + if (useForcedSubtitles && normalizedAudioLanguage == null) return null + + val subtitleTargets = preferredSubtitleTargets + .mapNotNull(::normalizeLanguageCode) + .filterNot { target -> + target == SubtitleLanguageOption.NONE || + target == SubtitleLanguageOption.FORCED || + target == AudioLanguageOption.DEFAULT + } + .distinct() + val primarySubtitleTarget = subtitleTargets.firstOrNull() + val forcedTarget = when { + !useForcedSubtitles -> null + primarySubtitleTarget != null && + normalizedAudioLanguage != null && + languageMatchesPreference(normalizedAudioLanguage, primarySubtitleTarget) -> + primarySubtitleTarget + primarySubtitleTarget == null && + normalizedAudioLanguage != null && + preferredAudioTargets.any { target -> + languageMatchesPreference(normalizedAudioLanguage, target) + } -> normalizedAudioLanguage + else -> null + } + + return SubtitleAutoSelectionPlan( + targets = forcedTarget?.let(::listOf) ?: subtitleTargets, + mode = if (forcedTarget != null) { + SubtitleAutoSelectionMode.FORCED_ONLY + } else { + SubtitleAutoSelectionMode.NORMAL_ONLY + }, + ) +} + internal fun findPreferredSubtitleTrackIndex( tracks: List, targets: List, - requireForced: Boolean = false, + mode: SubtitleAutoSelectionMode, ): Int { if (targets.isEmpty()) return -1 - for ((targetPosition, target) in targets.withIndex()) { + for (target in targets) { val normalizedTarget = normalizeLanguageCode(target) ?: continue - if (normalizedTarget == SubtitleLanguageOption.FORCED) { - val forcedIndex = tracks.indexOfFirst { it.isForced } - if (forcedIndex >= 0) return forcedIndex - if (targetPosition == 0) return -1 - continue - } - val matchIndex = tracks.indexOfFirst { track -> - (!requireForced || track.isForced) && - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) + when (mode) { + SubtitleAutoSelectionMode.FORCED_ONLY -> track.isForced + SubtitleAutoSelectionMode.NORMAL_ONLY -> !track.isForced + } && + listOf(track.language, track.label, track.id).any { trackLanguage -> + languageMatchesPreference( + trackLanguage = trackLanguage, + targetLanguage = normalizedTarget, + ) + } } if (matchIndex >= 0) return matchIndex } 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 3e4fa2dc9..3e6f98a8b 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 @@ -2,6 +2,8 @@ package com.nuvio.app.features.player import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull class PlayerTrackSelectionTest { @@ -16,27 +18,25 @@ class PlayerTrackSelectionTest { val selectedIndex = findPreferredSubtitleTrackIndex( tracks = tracks, targets = listOf("en"), - requireForced = true, + mode = SubtitleAutoSelectionMode.FORCED_ONLY, ) assertEquals(2, selectedIndex) } @Test - fun forcedSelectionFallsBackToSecondaryPreferredLanguage() { - val tracks = listOf( - subtitleTrack(index = 0, language = "ja", isForced = true), - subtitleTrack(index = 1, language = "en", isForced = false), - subtitleTrack(index = 2, language = "fr", isForced = true), + fun matchingAudioUsesForcedOnlyPrimarySubtitleTarget() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), ) - val selectedIndex = findPreferredSubtitleTrackIndex( - tracks = tracks, - targets = listOf("en", "fr"), - requireForced = true, - ) - - assertEquals(2, selectedIndex) + assertEquals(listOf("en"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) } @Test @@ -49,12 +49,148 @@ class PlayerTrackSelectionTest { val selectedIndex = findPreferredSubtitleTrackIndex( tracks = tracks, targets = listOf("en"), - requireForced = true, + mode = SubtitleAutoSelectionMode.FORCED_ONLY, ) assertEquals(-1, selectedIndex) } + @Test + fun differentAudioUsesNormalPreferredSubtitles() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun audioMatchingOnlySecondarySubtitleTargetUsesNormalSubtitles() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "fr", + preferredAudioTargets = listOf("fr"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("en", "fr"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + } + + @Test + fun forcedToggleOffExcludesForcedTracks() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = false, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun forcedToggleOffRejectsForcedOnlyTrackList() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf(subtitleTrack(index = 0, language = "en", isForced = true)), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.NORMAL_ONLY, + ) + + assertEquals(-1, selectedIndex) + } + + @Test + fun forcedModeWithoutSubtitleTargetUsesMatchingSelectedAudioLanguage() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = emptyList(), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("ja"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) + } + + @Test + fun forcedModeWaitsUntilSelectedAudioIsKnown() { + val plan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = null, + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = true, + ) + + assertNull(plan) + } + + @Test + fun resolvesSelectedAudioLanguageFromTrackLabel() { + val target = resolveAudioTrackLanguageTarget( + AudioTrack( + index = 0, + id = "audio-0", + label = "English Original", + language = null, + isSelected = true, + ), + ) + + assertEquals("en", target) + } + + @Test + fun forcedSelectionMatchesSubtitleLanguageFromTrackLabel() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf( + subtitleTrack( + index = 0, + language = null, + label = "English Forced", + isForced = true, + ), + ), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.FORCED_ONLY, + ) + + assertEquals(0, selectedIndex) + } + @Test fun preferredOnlyFilteringRemovesNonPreferredAddons() { val subtitles = listOf( @@ -100,12 +236,13 @@ class PlayerTrackSelectionTest { private fun subtitleTrack( index: Int, - language: String, + language: String?, + label: String = "Track $index", isForced: Boolean, ) = SubtitleTrack( index = index, id = "track-$index", - label = "Track $index", + label = label, language = language, isForced = isForced, )