Merge branch 'sub/forced' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-18 17:37:00 +05:30
commit 3c253b1b78
4 changed files with 409 additions and 74 deletions

View file

@ -1024,7 +1024,7 @@
<string name="settings_playback_subtitle_size">Subtitle Size</string>
<string name="settings_playback_subtitle_text_color">Text Color</string>
<string name="settings_playback_subtitle_use_forced">Use Forced Subtitles</string>
<string name="settings_playback_subtitle_use_forced_description">Prefer forced subtitles when matching your subtitle language settings.</string>
<string name="settings_playback_subtitle_use_forced_description">Prefer forced subtitles when audio matches the subtitle language; if unavailable, select nothing.</string>
<string name="settings_playback_subtitle_vertical_offset">Vertical Offset</string>
<string name="settings_playback_skip_intro_outro_recap">Skip Intro</string>
<string name="settings_playback_skip_intro_outro_recap_description">Use introdb.app to detect intros and recaps.</string>

View file

@ -17,11 +17,10 @@ internal val PlayerScreenRuntime.visibleAddonSubtitles: List<AddonSubtitle>
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
}

View file

@ -65,26 +65,93 @@ internal fun <T> findPreferredTrackIndex(
return -1
}
internal enum class SubtitleAutoSelectionMode {
FORCED_ONLY,
NORMAL_ONLY,
}
internal data class SubtitleAutoSelectionPlan(
val targets: List<String>,
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<String>,
preferredSubtitleTargets: List<String>,
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<SubtitleTrack>,
targets: List<String>,
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<AddonSubtitle>,
settings: PlayerSettingsUiState,
selectedAddonSubtitleId: String?,
): List<AddonSubtitle> {
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<String> {
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 }

View file

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