mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
8bdc8a6f8f
commit
ed67e5575f
8 changed files with 279 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue