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 098c258f5..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
@@ -17,11 +17,10 @@ internal val PlayerScreenRuntime.visibleAddonSubtitles: List
get() = filterAddonSubtitlesForSettings(
subtitles = addonSubtitles,
settings = playerSettingsUiState,
- selectedAddonSubtitleId = selectedAddonSubtitleId,
)
internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle?
- get() = addonSubtitles.firstOrNull { subtitle ->
+ get() = visibleAddonSubtitles.firstOrNull { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
@@ -147,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)
@@ -174,48 +174,69 @@ internal fun PlayerScreenRuntime.refreshTracks() {
}
if (!preferredSubtitleSelectionApplied) {
- val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
- preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
- SubtitleLanguageOption.FORCED
- } else {
- 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,
+ 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 5bd895def..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,26 +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,
+ 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 ->
- 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
}
@@ -95,40 +162,27 @@ internal fun findPreferredSubtitleTrackIndex(
internal fun filterAddonSubtitlesForSettings(
subtitles: List,
settings: PlayerSettingsUiState,
- selectedAddonSubtitleId: String?,
): List {
val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
if (!shouldFilter) return subtitles
val targets = preferredSubtitleTargetsForSettings(settings)
- if (targets.isEmpty()) {
- return subtitles.filter { subtitle ->
- subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
+ if (targets.isEmpty()) return emptyList()
+
+ return subtitles.filter { subtitle ->
+ targets.any { target ->
+ languageMatchesPreference(
+ trackLanguage = subtitle.language,
+ targetLanguage = target,
+ )
}
}
-
- val filtered = subtitles.filter { subtitle ->
- subtitle.id == selectedAddonSubtitleId ||
- subtitle.url == selectedAddonSubtitleId ||
- targets.any { target ->
- languageMatchesPreference(
- trackLanguage = subtitle.language,
- targetLanguage = target,
- )
- }
- }
- return filtered
}
internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List {
- val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
- SubtitleLanguageOption.FORCED
- } else {
- settings.preferredSubtitleLanguage
- }
return resolvePreferredSubtitleLanguageTargets(
- preferredSubtitleLanguage = preferredLanguage,
+ preferredSubtitleLanguage = settings.preferredSubtitleLanguage,
secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
).filterNot { it == SubtitleLanguageOption.FORCED }
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
new file mode 100644
index 000000000..3e6f98a8b
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt
@@ -0,0 +1,260 @@
+package com.nuvio.app.features.player
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class PlayerTrackSelectionTest {
+
+ @Test
+ fun forcedSelectionUsesPrimaryPreferredLanguageInsteadOfTrackOrder() {
+ val tracks = listOf(
+ subtitleTrack(index = 0, language = "ja", isForced = true),
+ subtitleTrack(index = 1, language = "en", isForced = false),
+ subtitleTrack(index = 2, language = "en", isForced = true),
+ )
+
+ val selectedIndex = findPreferredSubtitleTrackIndex(
+ tracks = tracks,
+ targets = listOf("en"),
+ mode = SubtitleAutoSelectionMode.FORCED_ONLY,
+ )
+
+ assertEquals(2, selectedIndex)
+ }
+
+ @Test
+ fun matchingAudioUsesForcedOnlyPrimarySubtitleTarget() {
+ val plan = assertNotNull(
+ resolveSubtitleAutoSelectionPlan(
+ selectedAudioLanguage = "en",
+ preferredAudioTargets = listOf("en"),
+ preferredSubtitleTargets = listOf("en", "fr"),
+ useForcedSubtitles = true,
+ ),
+ )
+
+ assertEquals(listOf("en"), plan.targets)
+ assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode)
+ }
+
+ @Test
+ fun forcedSelectionRejectsTracksOutsidePreferredLanguages() {
+ val tracks = listOf(
+ subtitleTrack(index = 0, language = "ja", isForced = true),
+ subtitleTrack(index = 1, language = "en", isForced = false),
+ )
+
+ val selectedIndex = findPreferredSubtitleTrackIndex(
+ tracks = tracks,
+ targets = listOf("en"),
+ 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(
+ addonSubtitle(id = "english", language = "en"),
+ addonSubtitle(id = "japanese", language = "ja"),
+ )
+ val settings = PlayerSettingsUiState(
+ preferredSubtitleLanguage = "en",
+ subtitleStyle = SubtitleStyleState.DEFAULT.copy(showOnlyPreferredLanguages = true),
+ )
+
+ val visibleSubtitles = filterAddonSubtitlesForSettings(
+ subtitles = subtitles,
+ settings = settings,
+ )
+
+ assertEquals(listOf("english"), visibleSubtitles.map { it.id })
+ }
+
+ @Test
+ fun forcedToggleKeepsPreferredLanguagesForAddonFiltering() {
+ val subtitles = listOf(
+ addonSubtitle(id = "japanese", language = "ja"),
+ addonSubtitle(id = "french", language = "fr"),
+ addonSubtitle(id = "english", language = "en"),
+ )
+ val settings = PlayerSettingsUiState(
+ preferredSubtitleLanguage = "en",
+ secondaryPreferredSubtitleLanguage = "fr",
+ subtitleStyle = SubtitleStyleState.DEFAULT.copy(
+ useForcedSubtitles = true,
+ showOnlyPreferredLanguages = true,
+ ),
+ )
+
+ val visibleSubtitles = filterAddonSubtitlesForSettings(
+ subtitles = subtitles,
+ settings = settings,
+ )
+
+ assertEquals(listOf("french", "english"), visibleSubtitles.map { it.id })
+ }
+
+ private fun subtitleTrack(
+ index: Int,
+ language: String?,
+ label: String = "Track $index",
+ isForced: Boolean,
+ ) = SubtitleTrack(
+ index = index,
+ id = "track-$index",
+ label = label,
+ language = language,
+ isForced = isForced,
+ )
+
+ private fun addonSubtitle(
+ id: String,
+ language: String,
+ ) = AddonSubtitle(
+ id = id,
+ url = "https://example.com/$id.srt",
+ language = language,
+ display = id,
+ addonName = "Addon",
+ )
+}