diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 460d97b2..3f8dc198 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 83f2d5df..94a30475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 98708ead..e38957cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -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, @@ -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, + 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 85065b43..85ea35cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -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 -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index f56a8e2d..771a84e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index eb7c314b..880087e6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b6f8c2d..c9fc0ed5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ No scheduled recordings No update available None + Only Forced Subtitles Outro Path People diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index 0f3e5ed7..e41bf292 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -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 = + 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(