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