Add "Only Forced Subtitles" option to subtitle selection (#589)

## Description

Adds a client-side "Only Forced Subtitles" option when selecting
subtitles, per the discussion in #571.

  ### Changes

  - **TrackIndex.ONLY_FORCED** (`-3`): New constant for the special case
- **StreamChoiceService**: Intercepts `ONLY_FORCED` at the top of
`chooseSubtitleStream()` before server-side `SubtitlePlaybackMode` logic
(which remains untouched)
  - **UI**: Adds "Only Forced Subtitles" option to both:
    - In-player subtitle menu (`PlaybackDialog.kt`)
    - Pre-playback stream selection dialog (`Dialogs.kt`)

  ### Forced track detection

Uses metadata `isForced` flag as primary check, with title-based
fallback ("forced", "signs", "songs" patterns) when language matches
audio.

  ### Related issues

  Closes #571

  ### Screenshots

  N/A - subtitle selection menu only

  ### AI/LLM usage

  Used Claude Code for assistance and to draft PR description.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Justin Caveda 2026-01-11 12:11:39 -06:00 committed by GitHub
parent 8bdc8a6f8f
commit ed67e5575f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 279 additions and 5 deletions

View file

@ -133,7 +133,7 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
if (type == MediaStreamType.AUDIO) {
val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null) {
@ -147,7 +147,7 @@ class ItemPlaybackRepository
subtitlesDisabled = true,
)
} else {
val stream = source.mediaStreams?.first { it.index == trackIndex }
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
if (stream?.language != null) {
streamChoiceService.updateSubtitles(
item.data,

View file

@ -54,4 +54,9 @@ object TrackIndex {
* The user has explicitly disabled the tracks (eg turned off subtitles)
*/
const val DISABLED = -2
/**
* The user wants only forced subtitles (for foreign language dialogue, signs, etc.)
*/
const val ONLY_FORCED = -3
}

View file

@ -162,6 +162,40 @@ class StreamChoiceService
}
}
/**
* Resolves ONLY_FORCED to an actual subtitle track index.
* Returns the original index if not ONLY_FORCED or DISABLED.
*/
suspend fun resolveSubtitleIndex(
source: MediaSourceInfo,
audioStreamIndex: Int?,
seriesId: UUID?,
subtitleIndex: Int,
prefs: UserPreferences,
): Int? =
if (subtitleIndex != TrackIndex.ONLY_FORCED) {
subtitleIndex
} else {
val audioStream =
source.mediaStreams?.firstOrNull {
it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex
}
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser.value!!.rowId,
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
subtitleIndex = TrackIndex.ONLY_FORCED,
)
chooseSubtitleStream(
source = source,
audioStream = audioStream,
seriesId = seriesId,
itemPlayback = itemPlayback,
plc = null,
prefs = prefs,
)?.index
}
fun chooseSubtitleStream(
audioStreamLang: String?,
candidates: List<MediaStream>,
@ -171,6 +205,14 @@ class StreamChoiceService
): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
@ -261,6 +303,37 @@ class StreamChoiceService
}
}
}
/** Returns true if the track is forced (via metadata flag or title patterns). */
private fun isForcedOrSigns(track: MediaStream): Boolean {
if (track.isForced) return true
val title = track.title ?: track.displayTitle ?: return false
return title.contains("forced", ignoreCase = true) ||
title.contains("signs", ignoreCase = true) ||
title.contains("songs", ignoreCase = true)
}
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
private fun findForcedTrack(
candidates: List<MediaStream>,
subtitleLanguage: String?,
audioLanguage: String?,
): MediaStream? {
// 1. User's preferred subtitle language
if (subtitleLanguage != null) {
candidates
.firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) }
?.let { return it }
}
// 2. Audio language (for sign-subtitles if no preference match)
if (audioLanguage != null) {
candidates
.firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) }
?.let { return it }
}
// 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
}
}
private val String?.isUnknown: Boolean

View file

@ -528,6 +528,16 @@ fun chooseStream(
onClick = { onClick.invoke(TrackIndex.DISABLED) },
),
)
add(
DialogItem(
headlineContent = {
Text(text = stringResource(R.string.only_forced_subtitles))
},
supportingContent = {
},
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
),
)
}
addAll(
streams.filter { it.type == type }.mapIndexed { index, stream ->

View file

@ -106,6 +106,8 @@ fun PlaybackDialog(
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex))
} else if (subtitleIndex == TrackIndex.DISABLED) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
} else if (subtitleIndex == TrackIndex.ONLY_FORCED) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED))
}
},
onSelectSearch = {
@ -322,6 +324,23 @@ fun SubtitleChoiceBottomDialog(
supportingContent = {},
)
}
item {
ListItem(
selected = currentChoice == TrackIndex.ONLY_FORCED,
onClick = {
onSelectChoice(TrackIndex.ONLY_FORCED)
},
leadingContent = {
SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED)
},
headlineContent = {
Text(
text = stringResource(R.string.only_forced_subtitles),
)
},
supportingContent = {},
)
}
itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(

View file

@ -523,7 +523,13 @@ class PlaybackViewModel
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
)
if (userInitiated) {
viewModelScope.launchIO {
@ -744,11 +750,27 @@ class PlaybackViewModel
type = MediaStreamType.AUDIO,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
// Resolve ONLY_FORCED to actual track based on new audio language
val source = currentPlayback.value?.mediaSourceInfo
val resolvedSubtitleIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = index,
seriesId = item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences,
)
} else {
itemPlayback.subtitleIndex.takeIf { it >= 0 }
}
changeStreams(
item,
itemPlayback,
index,
itemPlayback.subtitleIndex,
resolvedSubtitleIndex,
onMain { player.currentPosition },
true,
)
@ -766,11 +788,27 @@ class PlaybackViewModel
type = MediaStreamType.SUBTITLE,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
// Resolve ONLY_FORCED to actual track index for playback
val source = currentPlayback.value?.mediaSourceInfo
val resolvedIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = itemPlayback.audioIndex,
seriesId = item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
} else {
index.takeIf { it >= 0 }
}
changeStreams(
item,
itemPlayback,
itemPlayback.audioIndex,
index,
resolvedIndex,
onMain { player.currentPosition },
true,
)

View file

@ -75,6 +75,7 @@
<string name="no_scheduled_recordings">No scheduled recordings</string>
<string name="no_update_available">No update available</string>
<string name="none">None</string>
<string name="only_forced_subtitles">Only Forced Subtitles</string>
<string name="outro">Outro</string>
<string name="path">Path</string>
<string name="people">People</string>

View file

@ -613,6 +613,132 @@ class TestStreamChoiceServiceMultipleChoices(
}
}
/**
* Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED).
* This tests the findForcedTrack function with user subtitle language preference.
*/
@RunWith(Parameterized::class)
class TestStreamChoiceServiceOnlyForcedClientOverride(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
// Test 1: Prefer user's subtitle language preference for forced tracks
TestInput(
expectedIndex = 1, // spa forced track (matches user pref)
userSubtitleMode = null,
userSubtitleLang = "spa",
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 2: User subtitle preference matches Signs track via title (not forced flag)
TestInput(
expectedIndex = 0, // eng signs track via title detection
userSubtitleMode = null,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "jpn", // different from subtitle pref
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 3: Falls back to audio-matching forced when no preference match
TestInput(
expectedIndex = 0, // eng forced (audio match, step 2)
userSubtitleMode = null,
userSubtitleLang = "spa", // no spa forced exists
subtitles =
listOf(
subtitle(0, "eng", forced = true), // matches audio
subtitle(1, "und", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 4: Falls back to audio-matching signs when user pref has no match
TestInput(
expectedIndex = 0, // eng signs (audio match fallback)
userSubtitleMode = null,
userSubtitleLang = "fre", // user prefers French, no French forced exists
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 5: Use audio language for signs/songs when NO subtitle preference
TestInput(
expectedIndex = 0, // eng signs track matching audio
userSubtitleMode = null,
userSubtitleLang = null, // no preference
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 6: Unknown language forced track with no preference
TestInput(
expectedIndex = 0, // unknown forced track
userSubtitleMode = null,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, null, forced = true), // unknown/null language
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 7: Unknown language forced track when no audio match
TestInput(
expectedIndex = 0, // unknown forced track (step 3)
userSubtitleMode = null,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "und", forced = true), // unknown language forced
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng", // no eng tracks exist
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 8: No matching forced track returns null (not irrelevant language)
TestInput(
expectedIndex = null, // no forced track matches - return null instead of wrong language
userSubtitleMode = null,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "chi", forced = true), // Chinese forced - wrong language
subtitle(1, "spa", forced = true), // Spanish forced - wrong language
),
streamAudioLang = "eng", // audio is English, no English forced
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
)
}
}
data class TestInput(
val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?,
@ -674,6 +800,7 @@ fun subtitle(
lang: String?,
default: Boolean = false,
forced: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
type = MediaStreamType.SUBTITLE,
@ -686,6 +813,7 @@ fun subtitle(
isExternal = false,
isTextSubtitleStream = true,
supportsExternalStream = true,
title = title,
)
private fun itemPlayback(