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

@ -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(