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

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