mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue