From 2eafaedbc6b7f4308ee0f077c67ec0936d73a221 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 14 Jul 2026 07:32:16 +0800 Subject: [PATCH] Add preferred subtitle language --- .../tv/model/PlaybackUiConventions.kt | 15 +++ .../tv/model/SubtitleLanguagePreference.kt | 117 ++++++++++++++++++ .../com/miruplay/tv/model/SubtitleTracks.kt | 35 +++--- .../miruplay/tv/model/SubtitleTracksTest.kt | 84 +++++++++++++ .../preferences/PlaybackPreferencesManager.kt | 17 +++ .../PlaybackPreferencesManagerTest.kt | 13 ++ .../tv/desktop/DesktopPlaybackPanels.kt | 91 +++++++++++++- .../tv/desktop/DesktopWebControlService.kt | 37 ++++++ .../tv/desktop/MiruPlayDesktopComposeApp.kt | 25 +++- .../tv/desktop/DesktopPlaybackPanelTest.kt | 10 +- .../tv/desktop/DesktopWebControlServerTest.kt | 43 +++++++ .../tv/player/ExoPlaybackController.kt | 46 ++++++- .../PlaybackPreferencesRepository.kt | 3 + .../SettingsPreferenceActionCoordinator.kt | 9 ++ ...SettingsPreferenceActionCoordinatorTest.kt | 25 ++++ .../desktop/DesktopRepositoryStore.kt | 2 + ...FileBackedPlaybackPreferencesRepository.kt | 8 ++ .../desktop/DesktopRepositoriesTest.kt | 47 +++++++ .../tv/ui/settings/AddSourceScreen.kt | 61 ++++++++- .../tv/ui/settings/SettingsViewModel.kt | 8 ++ .../ui/settings/SettingsViewModelTokenTest.kt | 2 + .../tv/webcontrol/WebControlModels.kt | 4 + .../webcontrol/WebControlSettingsRouteTest.kt | 19 +++ web-control/frontend/src/App.vue | 29 ++++- .../tv/webcontrol/WebControlService.kt | 8 ++ 25 files changed, 736 insertions(+), 22 deletions(-) create mode 100644 core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleLanguagePreference.kt diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt index fa19a054..3f6d91cf 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt @@ -225,6 +225,21 @@ fun playbackEndSettingsTitleLabel(): String = "播放结束" fun playbackEndSettingsDescriptionLabel(): String = "选定剧集播完后,可以直接回到详情页,也可以自动切到下一集。" +fun preferredSubtitleLanguageSettingsTitleLabel(): String = "字幕语言优先级" + +fun preferredSubtitleLanguageSettingsDescriptionLabel(): String = + "有匹配字幕时自动优先选择;没有匹配时保留视频原本的默认字幕。" + +fun SubtitleLanguagePreference.displayLabel(): String = + when (this) { + SubtitleLanguagePreference.AUTO -> "自动" + SubtitleLanguagePreference.CHINESE_SIMPLIFIED -> "简体中文" + SubtitleLanguagePreference.CHINESE_TRADITIONAL -> "繁体中文" + SubtitleLanguagePreference.CHINESE -> "中文" + SubtitleLanguagePreference.ENGLISH -> "英语" + SubtitleLanguagePreference.JAPANESE -> "日语" + } + fun pictureSettingsTitleLabel(): String = "画面 / Tone Mapping" diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleLanguagePreference.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleLanguagePreference.kt new file mode 100644 index 00000000..2a477461 --- /dev/null +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleLanguagePreference.kt @@ -0,0 +1,117 @@ +package com.miruplay.tv.model + +enum class SubtitleLanguagePreference(val storageValue: String) { + AUTO("auto"), + CHINESE_SIMPLIFIED("zh_hans"), + CHINESE_TRADITIONAL("zh_hant"), + CHINESE("zh"), + ENGLISH("en"), + JAPANESE("ja"); + + companion object { + fun fromStorageValue(value: String?): SubtitleLanguagePreference = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: AUTO + } +} + +fun prioritizeSubtitlePaths( + paths: List, + preference: SubtitleLanguagePreference, +): List { + val tracks = buildExternalSubtitleTracks(paths) + val preferredIndex = preferredSubtitleTrackIndex(tracks, preference) ?: return tracks.map(SubtitleTrack::path) + return buildList { + add(tracks[preferredIndex].path) + tracks.forEachIndexed { index, track -> + if (index != preferredIndex) add(track.path) + } + } +} + +fun preferredSubtitleTrackIndex( + tracks: List, + preference: SubtitleLanguagePreference, +): Int? { + if (preference == SubtitleLanguagePreference.AUTO) return null + return tracks + .mapIndexed { index, track -> index to subtitlePreferenceScore(track, preference) } + .filter { (_, score) -> score > 0 } + .maxWithOrNull(compareBy> { it.second }.thenBy { -it.first }) + ?.first +} + +private fun subtitlePreferenceScore( + track: SubtitleTrack, + preference: SubtitleLanguagePreference, +): Int { + val detected = detectedSubtitleLanguage(track) ?: return 0 + return when (preference) { + SubtitleLanguagePreference.AUTO -> 0 + SubtitleLanguagePreference.CHINESE_SIMPLIFIED -> when (detected) { + DetectedSubtitleLanguage.CHINESE_SIMPLIFIED -> 3 + DetectedSubtitleLanguage.CHINESE -> 2 + DetectedSubtitleLanguage.CHINESE_TRADITIONAL -> 1 + else -> 0 + } + SubtitleLanguagePreference.CHINESE_TRADITIONAL -> when (detected) { + DetectedSubtitleLanguage.CHINESE_TRADITIONAL -> 3 + DetectedSubtitleLanguage.CHINESE -> 2 + DetectedSubtitleLanguage.CHINESE_SIMPLIFIED -> 1 + else -> 0 + } + SubtitleLanguagePreference.CHINESE -> if (detected.isChinese) 3 else 0 + SubtitleLanguagePreference.ENGLISH -> if (detected == DetectedSubtitleLanguage.ENGLISH) 3 else 0 + SubtitleLanguagePreference.JAPANESE -> if (detected == DetectedSubtitleLanguage.JAPANESE) 3 else 0 + } +} + +private fun detectedSubtitleLanguage(track: SubtitleTrack): DetectedSubtitleLanguage? { + val declared = detectSubtitleLanguage(track.language) + val descriptive = detectSubtitleLanguage(track.title) + return when { + declared == DetectedSubtitleLanguage.CHINESE && descriptive?.isChinese == true -> descriptive + declared != null -> declared + else -> descriptive + } +} + +private fun detectSubtitleLanguage(value: String): DetectedSubtitleLanguage? { + if (value.isBlank() || value.equals("und", ignoreCase = true)) return null + val normalized = value.lowercase().replace('_', '-') + val tokens = normalized.split(Regex("[^a-z0-9]+")) + .filter(String::isNotBlank) + val pairs = tokens.zipWithNext { first, second -> "$first-$second" } + val aliases = (pairs + tokens).toSet() + return when { + CHINESE_SIMPLIFIED_TEXT.any(normalized::contains) || aliases.any(CHINESE_SIMPLIFIED_ALIASES::contains) -> + DetectedSubtitleLanguage.CHINESE_SIMPLIFIED + CHINESE_TRADITIONAL_TEXT.any(normalized::contains) || aliases.any(CHINESE_TRADITIONAL_ALIASES::contains) -> + DetectedSubtitleLanguage.CHINESE_TRADITIONAL + CHINESE_TEXT.any(normalized::contains) || aliases.any(CHINESE_ALIASES::contains) -> + DetectedSubtitleLanguage.CHINESE + ENGLISH_TEXT.any(normalized::contains) || aliases.any(ENGLISH_ALIASES::contains) -> + DetectedSubtitleLanguage.ENGLISH + JAPANESE_TEXT.any(normalized::contains) || aliases.any(JAPANESE_ALIASES::contains) -> + DetectedSubtitleLanguage.JAPANESE + else -> null + } +} + +private enum class DetectedSubtitleLanguage(val isChinese: Boolean = false) { + CHINESE_SIMPLIFIED(true), + CHINESE_TRADITIONAL(true), + CHINESE(true), + ENGLISH, + JAPANESE, +} + +private val CHINESE_SIMPLIFIED_ALIASES = setOf("zh-hans", "zh-cn", "zh-sg", "chs", "sc", "gb", "gb2312") +private val CHINESE_TRADITIONAL_ALIASES = setOf("zh-hant", "zh-tw", "zh-hk", "zh-mo", "cht", "tc", "big5") +private val CHINESE_ALIASES = setOf("zh", "chi", "zho", "chinese") +private val ENGLISH_ALIASES = setOf("en", "eng", "english") +private val JAPANESE_ALIASES = setOf("ja", "jpn", "jp", "japanese") +private val CHINESE_SIMPLIFIED_TEXT = setOf("简中", "简体", "簡中") +private val CHINESE_TRADITIONAL_TEXT = setOf("繁中", "繁体", "繁體", "正體") +private val CHINESE_TEXT = setOf("中文", "汉语", "漢語") +private val ENGLISH_TEXT = setOf("英文", "英语", "英語") +private val JAPANESE_TEXT = setOf("日文", "日语", "日語") diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt index e80b8e15..08d8238e 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/SubtitleTracks.kt @@ -1,5 +1,8 @@ package com.miruplay.tv.model +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + fun buildExternalSubtitleTracks(value: String): List = buildExternalSubtitleTracks(value.split(';', '\n')) @@ -36,7 +39,7 @@ fun externalSubtitleTrackFromPath(path: String): SubtitleTrack { val normalized = path.trim() return SubtitleTrack( language = subtitleLanguageFromPath(normalized), - title = normalized.substringAfterLast('/').substringAfterLast('\\'), + title = subtitleFileName(normalized), isExternal = true, path = normalized, format = subtitleFormatFromPath(normalized), @@ -44,7 +47,7 @@ fun externalSubtitleTrackFromPath(path: String): SubtitleTrack { } fun subtitleFormatFromPath(path: String): SubtitleFormat = - when (path.substringAfterLast('.', "").lowercase()) { + when (subtitleFileName(path).substringAfterLast('.', "").lowercase()) { "ass" -> SubtitleFormat.ASS "ssa" -> SubtitleFormat.SSA "vtt" -> SubtitleFormat.VTT @@ -52,15 +55,15 @@ fun subtitleFormatFromPath(path: String): SubtitleFormat = } private fun subtitleLanguageFromPath(path: String): String { - val stem = MediaPathConventions.stem(MediaPathConventions.fileName(path)) - val candidate = listOf('.', '_') - .map { separator -> stem.substringAfterLast(separator, "") } - .firstOrNull(::isSubtitleLanguageCode) + val stem = MediaPathConventions.stem(subtitleFileName(path)).replace('_', '-') + val candidate = SUBTITLE_LANGUAGE_SUFFIX.find(stem) + ?.groupValues + ?.get(1) ?.lowercase() ?: return "und" return when (candidate) { - "chs" -> "zh-Hans" - "cht" -> "zh-Hant" + "chs", "sc" -> "zh-Hans" + "cht", "tc" -> "zh-Hant" "chi", "zho" -> "zh" "eng" -> "en" "jpn" -> "ja" @@ -72,15 +75,19 @@ private fun subtitleLanguageFromPath(path: String): String { } } -private fun isSubtitleLanguageCode(value: String): Boolean { - val parts = value.split('-') - return parts.size in 1..2 && parts.withIndex().all { (index, part) -> - part.length in (if (index == 0) 2..3 else 2..4) && part.all(Char::isLetter) - } +private fun subtitleFileName(path: String): String { + val pathWithoutUrlSuffix = if ("://" in path) path.substringBefore('?').substringBefore('#') else path + val encodedName = pathWithoutUrlSuffix.substringAfterLast('/').substringAfterLast('\\') + return runCatching { + URLDecoder.decode(encodedName.replace("+", "%2B"), StandardCharsets.UTF_8) + }.getOrDefault(encodedName) } private fun String.isSupportedExternalSubtitlePath(): Boolean = - substringAfterLast('.', "").lowercase() in SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS + subtitleFileName(this).substringAfterLast('.', "").lowercase() in SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS +private val SUBTITLE_LANGUAGE_SUFFIX = Regex( + "(?:^|[.\\s\\-\\[])([a-zA-Z]{2,3}(?:-[a-zA-Z]{2,4})?)\\]?$", +) private val SUPPORTED_EXTERNAL_SUBTITLE_EXTENSIONS = setOf("ass", "ssa", "srt", "vtt") private val EXTERNAL_SUBTITLE_SUFFIX_SEPARATORS = listOf(".", " ", "_", "-", "[") diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt index 8c9e371b..8cfe091d 100644 --- a/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/SubtitleTracksTest.kt @@ -1,6 +1,7 @@ package com.miruplay.tv.model import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -25,10 +26,38 @@ class SubtitleTracksTest { fun `external subtitle parses common language suffixes`() { assertEquals("zh-Hans", externalSubtitleTrackFromPath("Episode.zh-Hans.ass").language) assertEquals("zh-CN", externalSubtitleTrackFromPath("Episode.zh-cn.srt").language) + assertEquals("zh-CN", externalSubtitleTrackFromPath("Episode.zh_cn.srt").language) assertEquals("zh-Hant", externalSubtitleTrackFromPath("Episode_cht.srt").language) + assertEquals("zh-Hans", externalSubtitleTrackFromPath("Episode [SC].srt").language) assertEquals("en", externalSubtitleTrackFromPath("Episode.eng.vtt").language) } + @Test + fun `external subtitle decodes remote filename metadata and ignores url suffix`() { + val url = "https://dav.example/Show/Episode%20%5BSC%5D.ass?token=abc#fragment" + val track = externalSubtitleTrackFromPath(url) + + assertEquals("Episode [SC].ass", track.title) + assertEquals("zh-Hans", track.language) + assertEquals(SubtitleFormat.ASS, track.format) + assertEquals(url, track.path) + } + + @Test + fun `encoded Chinese subtitle title participates in preference detection`() { + val track = externalSubtitleTrackFromPath( + "https://dav.example/Show/Episode.%E7%AE%80%E4%B8%AD.srt?token=abc", + ) + + assertEquals( + 0, + preferredSubtitleTrackIndex( + listOf(track), + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + ), + ) + } + @Test fun `subtitle list accepts semicolon and newline separated paths`() { val tracks = buildExternalSubtitleTracks("D:/Anime/01.ass; D:/Anime/01.vtt\nD:/Anime/01.ssa") @@ -76,4 +105,59 @@ class SubtitleTracksTest { ).isEmpty(), ) } + + @Test + fun `preferred subtitle recognizes common Chinese aliases`() { + val tracks = buildExternalSubtitleTracks( + listOf( + "/Anime/Episode.eng.srt", + "/Anime/Episode.zh_cn.ass", + "/Anime/Episode.cht.srt", + ), + ) + + assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED)) + assertEquals(2, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_TRADITIONAL)) + assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE)) + assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.ENGLISH)) + } + + @Test + fun `preferred subtitle uses title when language metadata is absent`() { + val tracks = listOf( + SubtitleTrack( + language = "und", + title = "简体中文字幕", + isExternal = false, + path = "", + format = SubtitleFormat.ASS, + ), + ) + + assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED)) + assertNull(preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.JAPANESE)) + assertNull(preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.AUTO)) + } + + @Test + fun `preferred subtitle path moves the match first without dropping tracks`() { + assertEquals( + listOf("Episode.zh_cn.ass", "Episode.eng.srt", "Episode.jpn.srt"), + prioritizeSubtitlePaths( + listOf("Episode.eng.srt", "Episode.zh_cn.ass", "Episode.jpn.srt"), + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + ), + ) + } + + @Test + fun `specific Chinese preference favors exact script over generic Chinese`() { + val tracks = listOf( + externalSubtitleTrackFromPath("Episode.zh.srt"), + externalSubtitleTrackFromPath("Episode.zh-Hans.ass"), + ) + + assertEquals(1, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE_SIMPLIFIED)) + assertEquals(0, preferredSubtitleTrackIndex(tracks, SubtitleLanguagePreference.CHINESE)) + } } diff --git a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt index 22740586..f031c4f7 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.repository.PlaybackPreferencesRepository import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -27,6 +28,14 @@ class PlaybackPreferencesManager @Inject constructor( prefs.edit().putString(KEY_END_ACTION, value.storageValue).apply() } + var preferredSubtitleLanguage: SubtitleLanguagePreference + get() = SubtitleLanguagePreference.fromStorageValue( + prefs.getString(KEY_PREFERRED_SUBTITLE_LANGUAGE, null), + ) + set(value) { + prefs.edit().putString(KEY_PREFERRED_SUBTITLE_LANGUAGE, value.storageValue).apply() + } + var formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences get() { val stored = prefs.getString(KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES, null) @@ -53,6 +62,13 @@ class PlaybackPreferencesManager @Inject constructor( endAction = action } + override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference = + preferredSubtitleLanguage + + override suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) { + preferredSubtitleLanguage = preference + } + override suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences = formatAwareToneMappingPreferences @@ -62,6 +78,7 @@ class PlaybackPreferencesManager @Inject constructor( companion object { private const val KEY_END_ACTION = "end_action" + private const val KEY_PREFERRED_SUBTITLE_LANGUAGE = "preferred_subtitle_language" private const val KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES = "format_aware_tone_mapping_preferences" } } diff --git a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt index e7dadbf0..8f493e08 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt @@ -6,6 +6,7 @@ import com.miruplay.tv.model.FallbackBackendPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PeakDetectionStrategy import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.ToneMappingCurvePreset import com.miruplay.tv.model.ToneMappingRuleSet import com.miruplay.tv.model.VideoRenderRuleKey @@ -44,6 +45,18 @@ class PlaybackPreferencesManagerTest { assertTrue(preferences.rules.containsKey(VideoRenderRuleKey.DOLBY_VISION)) } + @Test + fun `manager persists preferred subtitle language`() = runBlocking { + assertEquals(SubtitleLanguagePreference.AUTO, manager.getPreferredSubtitleLanguage()) + + manager.setPreferredSubtitleLanguage(SubtitleLanguagePreference.CHINESE_SIMPLIFIED) + + assertEquals( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + manager.getPreferredSubtitleLanguage(), + ) + } + @Test fun `manager persists and reloads customized format-aware tone mapping preferences`() = runBlocking { val updated = FormatAwareToneMappingPreferences( diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanels.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanels.kt index 2dbcb68a..edf0f07d 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanels.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanels.kt @@ -63,6 +63,7 @@ import com.miruplay.tv.model.PlaybackTimingConventions import com.miruplay.tv.model.formatPlaybackPosition import com.miruplay.tv.model.mpvPlaybackSourceLine import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.mpvPlaybackStatusText import com.miruplay.tv.model.playbackBackToDetailsLabel import com.miruplay.tv.model.playbackCheckRuntimeActionLabel @@ -71,6 +72,9 @@ import com.miruplay.tv.model.playbackDiagnosticsTitleLabel import com.miruplay.tv.model.playbackEndActionLabel import com.miruplay.tv.model.playbackEndSettingsDescriptionLabel import com.miruplay.tv.model.playbackEndSettingsTitleLabel +import com.miruplay.tv.model.preferredSubtitleLanguageSettingsDescriptionLabel +import com.miruplay.tv.model.preferredSubtitleLanguageSettingsTitleLabel +import com.miruplay.tv.model.displayLabel import com.miruplay.tv.model.playbackExternalSubtitleLabel import com.miruplay.tv.model.playbackMediaTitle import com.miruplay.tv.model.playbackMpvExecutableFieldLabel @@ -111,6 +115,8 @@ internal fun PlaybackPanel( onRifeBackendChange: (RifeBackend) -> Unit, playbackEndAction: PlaybackEndAction, onPlaybackEndActionChange: (PlaybackEndAction) -> Unit, + preferredSubtitleLanguage: SubtitleLanguagePreference, + onPreferredSubtitleLanguageChange: (SubtitleLanguagePreference) -> Unit, playbackSpeed: Float, onPlaybackSpeedChange: (Float) -> Unit, isPlayerActive: Boolean, @@ -200,6 +206,8 @@ internal fun PlaybackPanel( onRifeBackendChange = onRifeBackendChange, playbackEndAction = playbackEndAction, onPlaybackEndActionChange = onPlaybackEndActionChange, + preferredSubtitleLanguage = preferredSubtitleLanguage, + onPreferredSubtitleLanguageChange = onPreferredSubtitleLanguageChange, playbackSpeed = playbackSpeed, onPlaybackSpeedChange = onPlaybackSpeedChange, focusVersion = settingsFocusVersion, @@ -692,6 +700,7 @@ internal enum class PlaybackSettingFocusTarget { StartSeconds, SubtitlePath, EndAction, + SubtitleLanguage, Speed, Fullscreen, KeepOpen, @@ -706,6 +715,7 @@ private val playbackSettingFocusableTargets = listOf( PlaybackSettingFocusTarget.StartSeconds, PlaybackSettingFocusTarget.SubtitlePath, PlaybackSettingFocusTarget.EndAction, + PlaybackSettingFocusTarget.SubtitleLanguage, PlaybackSettingFocusTarget.Speed, PlaybackSettingFocusTarget.Fullscreen, PlaybackSettingFocusTarget.KeepOpen, @@ -754,11 +764,16 @@ internal fun playbackSettingNavigationTarget( } PlaybackSettingFocusTarget.EndAction -> when (intent.verticalNavigationDelta()) { -1 -> PlaybackSettingFocusTarget.SubtitlePath + 1 -> PlaybackSettingFocusTarget.SubtitleLanguage + else -> null + } + PlaybackSettingFocusTarget.SubtitleLanguage -> when (intent.verticalNavigationDelta()) { + -1 -> PlaybackSettingFocusTarget.EndAction 1 -> PlaybackSettingFocusTarget.Speed else -> null } PlaybackSettingFocusTarget.Speed -> when (intent.verticalNavigationDelta()) { - -1 -> PlaybackSettingFocusTarget.EndAction + -1 -> PlaybackSettingFocusTarget.SubtitleLanguage 1 -> PlaybackSettingFocusTarget.Fullscreen else -> null } @@ -966,6 +981,8 @@ private fun PlaybackSettingsPanel( onRifeBackendChange: (RifeBackend) -> Unit, playbackEndAction: PlaybackEndAction, onPlaybackEndActionChange: (PlaybackEndAction) -> Unit, + preferredSubtitleLanguage: SubtitleLanguagePreference, + onPreferredSubtitleLanguageChange: (SubtitleLanguagePreference) -> Unit, playbackSpeed: Float, onPlaybackSpeedChange: (Float) -> Unit, focusVersion: Int = 0, @@ -1041,6 +1058,29 @@ private fun PlaybackSettingsPanel( onMove = ::movePlaybackSettingFocus, ), ) + Spacer(Modifier.height(MiruPlayUiMetrics.MEDIUM_GAP_DP.dp)) + Text( + preferredSubtitleLanguageSettingsTitleLabel(), + color = TextPrimary, + fontSize = MiruPlayUiMetrics.ITEM_TITLE_SP.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + Text( + preferredSubtitleLanguageSettingsDescriptionLabel(), + color = TextSecondary, + fontSize = MiruPlayUiMetrics.DETAIL_TEXT_SP.sp, + ) + Spacer(Modifier.height(MiruPlayUiMetrics.SMALL_GAP_DP.dp)) + SubtitleLanguagePreferencePicker( + selected = preferredSubtitleLanguage, + onSelected = onPreferredSubtitleLanguageChange, + modifier = Modifier.playbackSettingNavigation( + target = PlaybackSettingFocusTarget.SubtitleLanguage, + focusRequester = settingFocusRequesters.getValue(PlaybackSettingFocusTarget.SubtitleLanguage), + onMove = ::movePlaybackSettingFocus, + ), + ) Spacer(Modifier.height(MiruPlayUiMetrics.SMALL_GAP_DP.dp)) PlaybackSpeedPicker( selected = playbackSpeed, @@ -1390,6 +1430,55 @@ private fun PlaybackEndActionPicker( } } +@Composable +private fun SubtitleLanguagePreferencePicker( + selected: SubtitleLanguagePreference, + onSelected: (SubtitleLanguagePreference) -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + Row( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) AnimeRed else Color.White.copy(alpha = 0.18f), + shape = RoundedCornerShape(8.dp), + ) + .background(if (isFocused) CardBg.copy(alpha = 0.55f) else Color.Transparent) + .focusable(interactionSource = interactionSource) + .onPreviewKeyEvent { event -> + desktopConfirmOrNavigationIntentEvent( + key = event.key, + type = event.type, + onClick = { onSelected(selected) }, + onNavigationIntent = { intent -> + val delta = intent.horizontalNavigationDelta() + ?: return@desktopConfirmOrNavigationIntentEvent false + val index = SubtitleLanguagePreference.entries.indexOf(selected) + val next = SubtitleLanguagePreference.entries.getOrNull(index + delta) + ?: return@desktopConfirmOrNavigationIntentEvent false + onSelected(next) + true + }, + ) + } + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SubtitleLanguagePreference.entries.forEach { preference -> + PlaybackEndActionChoiceChip( + text = preference.displayLabel(), + selected = selected == preference, + onClick = { onSelected(preference) }, + widthDp = 105, + ) + } + } +} + @Composable private fun PlaybackEndActionChoiceChip( text: String, diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopWebControlService.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopWebControlService.kt index 7c647e9c..f4d858cb 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopWebControlService.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/DesktopWebControlService.kt @@ -9,7 +9,9 @@ import com.miruplay.tv.model.CloudDriveAutomationConfig import com.miruplay.tv.model.Episode import com.miruplay.tv.model.CloudDriveRssRunSummary import com.miruplay.tv.model.MediaSourceInfo +import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.ScanResult +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.completeStatus import com.miruplay.tv.repository.OtlpLogUploadConfig import com.miruplay.tv.repository.desktop.DesktopRepositories @@ -17,6 +19,8 @@ import com.miruplay.tv.sync.rss.CloudDriveRssActionCoordinator import com.miruplay.tv.sync.rss.DesktopCloudDriveRssAutomationEngine import com.miruplay.tv.webcontrol.PlayEpisodeRequest import com.miruplay.tv.webcontrol.PlaybackCommandRequest +import com.miruplay.tv.webcontrol.PlaybackSettingsDto +import com.miruplay.tv.webcontrol.PlaybackSettingsRequest import com.miruplay.tv.webcontrol.PlaybackStatusDto import com.miruplay.tv.webcontrol.executeWebControlPlaybackCommand import com.miruplay.tv.webcontrol.idleWebControlPlaybackStatus @@ -45,6 +49,7 @@ internal class DesktopWebControlService( }, private val onCloudDriveConfigSaved: suspend (CloudDriveAutomationConfig) -> Unit = {}, private val onLogUploadConfigSaved: suspend (OtlpLogUploadConfig) -> Unit = {}, + private val onPlaybackSettingsSaved: suspend (PlaybackEndAction, SubtitleLanguagePreference) -> Unit = { _, _ -> }, private val clock: () -> Long = System::currentTimeMillis, private val deviceName: String = "Windows", ) : SharedWebControlEndpointService( @@ -97,6 +102,38 @@ internal class DesktopWebControlService( override suspend fun playbackStatusResolved(): PlaybackStatusDto = playbackStatusProvider() + override suspend fun getPlaybackSettings(): PlaybackSettingsDto { + val endAction = repositories.playbackPreferences.getEndAction() + val preferredSubtitleLanguage = repositories.playbackPreferences.getPreferredSubtitleLanguage() + return PlaybackSettingsDto( + endAction = endAction.storageValue, + preferredSubtitleLanguage = preferredSubtitleLanguage.storageValue, + formatAwareToneMapping = repositories.playbackPreferences + .getFormatAwareToneMappingPreferences() + .normalized(), + ) + } + + override suspend fun savePlaybackSettings(request: PlaybackSettingsRequest): PlaybackSettingsDto { + request.endAction?.let { value -> + repositories.playbackPreferences.setEndAction(PlaybackEndAction.fromStorageValue(value)) + } + request.preferredSubtitleLanguage?.let { value -> + repositories.playbackPreferences.setPreferredSubtitleLanguage( + SubtitleLanguagePreference.fromStorageValue(value), + ) + } + request.formatAwareToneMapping?.let { preferences -> + repositories.playbackPreferences.setFormatAwareToneMappingPreferences(preferences.normalized()) + } + val saved = getPlaybackSettings() + onPlaybackSettingsSaved( + PlaybackEndAction.fromStorageValue(saved.endAction), + SubtitleLanguagePreference.fromStorageValue(saved.preferredSubtitleLanguage), + ) + return saved + } + private suspend fun rescanLinkedCloudDriveSource(reason: String) { val config = repositories.cloudDriveAutomation.getConfig().getOrNull() ?: return resolveAndRescanCloudRssLinkedSource( diff --git a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt index d57898d3..2dd08946 100644 --- a/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt +++ b/desktop-app/src/main/kotlin/com/miruplay/tv/desktop/MiruPlayDesktopComposeApp.kt @@ -61,6 +61,8 @@ import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceInfoConventions import com.miruplay.tv.model.MediaPathConventions import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference +import com.miruplay.tv.model.prioritizeSubtitlePaths import com.miruplay.tv.model.PlaybackProgressSession import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.PlaybackTimingConventions @@ -616,6 +618,7 @@ internal fun MiruPlayDesktopComposeApp( var fullscreen by remember { mutableStateOf(false) } var keepOpen by remember { mutableStateOf(false) } var playbackEndAction by remember { mutableStateOf(PlaybackEndAction.RETURN_TO_DETAIL) } + var preferredSubtitleLanguage by remember { mutableStateOf(SubtitleLanguagePreference.AUTO) } var playbackSpeed by remember { mutableStateOf(PLAYBACK_SPEED_NORMAL) } var rifeEnabled by remember { mutableStateOf(DEFAULT_DESKTOP_RIFE_ENABLED) } var rifeBackend by remember { mutableStateOf(RifeBackend.NVIDIA) } @@ -635,6 +638,13 @@ internal fun MiruPlayDesktopComposeApp( onLogUploadConfigSaved = { config -> logUploadAutoScheduler.syncWithConfig(config) }, + onPlaybackSettingsSaved = { endAction, subtitleLanguage -> + scope.launch { + playbackEndAction = endAction + preferredSubtitleLanguage = subtitleLanguage + } + Unit + }, playbackStatusProvider = { desktopWebControlPlaybackStatus( player = player, @@ -882,6 +892,7 @@ internal fun MiruPlayDesktopComposeApp( is Result.Error -> recentStatus = recents.error.toUserMessage() } playbackEndAction = settingsPreferenceActions.currentPlaybackEndAction() + preferredSubtitleLanguage = settingsPreferenceActions.currentPreferredSubtitleLanguage() applyScanPreferenceSnapshot(settingsPreferenceActions.currentScanPreferences()) when (val config = repositories.cloudDriveAutomation.getConfig()) { is Result.Success -> { @@ -1044,6 +1055,7 @@ internal fun MiruPlayDesktopComposeApp( ): DesktopPlaybackLaunchRequest { val playbackMediaSource = sourceOverride ?: activeSource val playbackSourceId = sourceIdOverride ?: activeSourceId + val launchSubtitlePreference = repositories.playbackPreferences.getPreferredSubtitleLanguage() val hasSubtitleOverride = subtitleOverrideKey == (playbackSourceId to path.trim()) val manualSubtitlePath = subtitlePath.takeIf { hasSubtitleOverride }.orEmpty() val indexedPath = episodeId @@ -1065,7 +1077,11 @@ internal fun MiruPlayDesktopComposeApp( mpvPath = mpvPath, configDir = configDir, mediaPath = path, - subtitlePath = if (hasSubtitleOverride) manualSubtitlePath else indexedSubtitlePaths.joinToString(";"), + subtitlePath = if (hasSubtitleOverride) { + manualSubtitlePath + } else { + prioritizeSubtitlePaths(indexedSubtitlePaths, launchSubtitlePreference).joinToString(";") + }, startSeconds = startPositionMs.coerceAtLeast(0L).let { position -> PlaybackTimingConventions.formatMpvStartSeconds(position) }, @@ -2639,6 +2655,13 @@ internal fun MiruPlayDesktopComposeApp( playbackEndAction = settingsPreferenceActions.setPlaybackEndAction(action) } }, + preferredSubtitleLanguage = preferredSubtitleLanguage, + onPreferredSubtitleLanguageChange = { preference -> + scope.launch { + preferredSubtitleLanguage = settingsPreferenceActions + .setPreferredSubtitleLanguage(preference) + } + }, playbackSpeed = playbackSpeed, onPlaybackSpeedChange = { speed -> scope.launch { diff --git a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanelTest.kt b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanelTest.kt index 4cd4bf53..cd3acd61 100644 --- a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanelTest.kt +++ b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopPlaybackPanelTest.kt @@ -421,11 +421,19 @@ class DesktopPlaybackPanelTest { playbackSettingNavigationTarget(PlaybackSettingFocusTarget.EndAction, Key.DirectionUp), ) assertEquals( - PlaybackSettingFocusTarget.Speed, + PlaybackSettingFocusTarget.SubtitleLanguage, playbackSettingNavigationTarget(PlaybackSettingFocusTarget.EndAction, Key.DirectionDown), ) assertEquals( PlaybackSettingFocusTarget.EndAction, + playbackSettingNavigationTarget(PlaybackSettingFocusTarget.SubtitleLanguage, Key.DirectionUp), + ) + assertEquals( + PlaybackSettingFocusTarget.Speed, + playbackSettingNavigationTarget(PlaybackSettingFocusTarget.SubtitleLanguage, Key.DirectionDown), + ) + assertEquals( + PlaybackSettingFocusTarget.SubtitleLanguage, playbackSettingNavigationTarget(PlaybackSettingFocusTarget.Speed, Key.DirectionUp), ) assertEquals( diff --git a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopWebControlServerTest.kt b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopWebControlServerTest.kt index 4913e146..8b92dcca 100644 --- a/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopWebControlServerTest.kt +++ b/desktop-app/src/test/kotlin/com/miruplay/tv/desktop/DesktopWebControlServerTest.kt @@ -11,6 +11,8 @@ import com.miruplay.tv.model.Anime import com.miruplay.tv.model.CloudDriveAutomationConfig import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaSourceInfoConventions +import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.repository.OtlpLogUploadConfig import com.miruplay.tv.model.RssSubscriptionInfo import com.miruplay.tv.repository.MediaIndexEntry @@ -97,6 +99,47 @@ class DesktopWebControlServerTest { } } + @Test + fun `desktop web control playback settings round trip and persist`() = runBlocking { + val storePath = Files.createTempDirectory("miruplay-web-control-store").resolve("store.json") + val port = freePort() + try { + val repositories = DesktopRepositories.fileBacked(storePath) + repositories.webControlAccess.webControlEnabled = true + val token = repositories.webControlAccess.accessToken + val server = DesktopWebControlServer( + webControlService = DesktopWebControlService(repositories, deviceName = "Windows Test"), + webControlAccess = repositories.webControlAccess, + port = port, + ) + server.startIfNeeded() + try { + val initial = request("http://127.0.0.1:$port/api/settings/playback?token=$token") + assertEquals(200, initial.code) + assertTrue(initial.body.contains("\"preferredSubtitleLanguage\":\"auto\"")) + + val saved = request( + url = "http://127.0.0.1:$port/api/settings/playback?token=$token", + method = "PUT", + body = """{"endAction":"play_next_episode","preferredSubtitleLanguage":"zh_hans"}""", + ) + assertEquals(200, saved.code) + assertTrue(saved.body.contains("\"preferredSubtitleLanguage\":\"zh_hans\"")) + } finally { + server.stopIfRunning() + } + + val reopened = DesktopRepositories.fileBacked(storePath) + assertEquals(PlaybackEndAction.PLAY_NEXT_EPISODE, reopened.playbackPreferences.getEndAction()) + assertEquals( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + reopened.playbackPreferences.getPreferredSubtitleLanguage(), + ) + } finally { + storePath.parent.toFile().deleteRecursively() + } + } + @Test fun `desktop web control exposes repository library and sanitized sources`() = runBlocking { val storePath = Files.createTempDirectory("miruplay-web-control-store").resolve("store.json") diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 65f1db67..72c9e3e0 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -30,6 +30,7 @@ import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.PlaybackSource import com.miruplay.tv.model.PlaybackState import com.miruplay.tv.model.SubtitleFormat +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.SubtitleTrack import com.miruplay.tv.model.ToneMappingRuleSet import com.miruplay.tv.model.VideoRenderRuleKey @@ -37,6 +38,7 @@ import com.miruplay.tv.model.VideoSignalDescriptor import com.miruplay.tv.model.VideoSignalKind import com.miruplay.tv.model.defaultToneMappingRuleSet import com.miruplay.tv.model.normalizeSupportedBackend +import com.miruplay.tv.model.preferredSubtitleTrackIndex import com.miruplay.tv.repository.PlaybackPreferencesRepository import `is`.xyz.mpv.MiruMpvSurfaceView import dagger.hilt.android.qualifiers.ApplicationContext @@ -105,6 +107,8 @@ class ExoPlaybackController @Inject constructor( private var selectedAudioTrackIndex: Int? = null private var currentSource: PlaybackSource? = null private var autoResumeSeekCalled = false + private var preferredSubtitleLanguage = SubtitleLanguagePreference.AUTO + private var subtitleSelectionWasManual = false private var playbackPreferences = FormatAwareToneMappingPreferences() private var containerSignalDescriptor: VideoSignalDescriptor? = null private var sessionState = PlaybackSessionState() @@ -143,6 +147,8 @@ class ExoPlaybackController @Inject constructor( playbackPreferences = playbackPreferencesRepository .getFormatAwareToneMappingPreferences() .normalized() + preferredSubtitleLanguage = playbackPreferencesRepository.getPreferredSubtitleLanguage() + subtitleSelectionWasManual = false _requestedRenderBackend.value = sessionState.effectiveRequestedBackend(playbackPreferences.defaultBackend) _sessionRuleOverrides.value = sessionState.ruleOverrides refreshRuntimeConfig(null) @@ -378,6 +384,7 @@ class ExoPlaybackController @Inject constructor( embeddedSubtitleTrackIds.clear() selectedSubtitleTrackIndex = null selectedAudioTrackIndex = null + subtitleSelectionWasManual = false containerSignalDescriptor = null _currentVideoSignalDescriptor.value = null PlaybackCodecSelectionState.decoderPreference = PlaybackDecoderPreference.DEFAULT @@ -414,9 +421,14 @@ class ExoPlaybackController @Inject constructor( } embeddedMpvView?.setSubtitleTrack(nativeTrackId) selectedSubtitleTrackIndex = trackIndex + subtitleSelectionWasManual = true } PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID -> Unit - else -> selectExoTrack(C.TRACK_TYPE_TEXT, trackIndex) + else -> selectExoTrack( + trackType = C.TRACK_TYPE_TEXT, + trackIndex = trackIndex, + markSubtitleSelectionManual = true, + ) } } @@ -889,7 +901,11 @@ class ExoPlaybackController @Inject constructor( } } - private suspend fun selectExoTrack(trackType: Int, trackIndex: Int?) = withContext(Dispatchers.Main) { + private suspend fun selectExoTrack( + trackType: Int, + trackIndex: Int?, + markSubtitleSelectionManual: Boolean = false, + ) = withContext(Dispatchers.Main) { val player = activeExoPlayer() if (trackType == C.TRACK_TYPE_TEXT && trackIndex == null) { player.trackSelectionParameters = player.trackSelectionParameters @@ -898,6 +914,7 @@ class ExoPlaybackController @Inject constructor( .clearOverridesOfType(trackType) .build() selectedSubtitleTrackIndex = null + if (markSubtitleSelectionManual) subtitleSelectionWasManual = true return@withContext } val target = when (trackType) { @@ -913,6 +930,7 @@ class ExoPlaybackController @Inject constructor( .build() if (trackType == C.TRACK_TYPE_TEXT) { selectedSubtitleTrackIndex = trackIndex + if (markSubtitleSelectionManual) subtitleSelectionWasManual = true } else { selectedAudioTrackIndex = trackIndex } @@ -964,11 +982,27 @@ class ExoPlaybackController @Inject constructor( } } } + applyPreferredExoSubtitleTrack() } catch (e: Exception) { MiruLog.w("ExoPlaybackController", "Failed to enumerate media tracks", e) } } + private fun applyPreferredExoSubtitleTrack() { + if (subtitleSelectionWasManual) return + val preferredIndex = preferredSubtitleTrackIndex(availableSubtitles, preferredSubtitleLanguage) ?: return + if (preferredIndex == selectedSubtitleTrackIndex) return + val target = exoSubtitleSelections.getOrNull(preferredIndex) ?: return + val player = activeExoPlayer() + player.trackSelectionParameters = player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .addOverride(TrackSelectionOverride(target.group.mediaTrackGroup, target.trackIndex)) + .build() + selectedSubtitleTrackIndex = preferredIndex + } + private fun refreshVideoSignalDescriptor(format: Format?) { val runtimeDescriptor = resolveVideoSignalDescriptor(format) val mergedDescriptor = mergeVideoSignalDescriptor( @@ -1322,6 +1356,14 @@ class ExoPlaybackController @Inject constructor( embeddedSubtitleTrackIds.add(track.id) if (track.selected) selectedSubtitleTrackIndex = index } + if (!subtitleSelectionWasManual) { + val preferredIndex = preferredSubtitleTrackIndex(availableSubtitles, preferredSubtitleLanguage) + val nativeTrackId = preferredIndex?.let(embeddedSubtitleTrackIds::getOrNull) + if (preferredIndex != null && preferredIndex != selectedSubtitleTrackIndex && nativeTrackId != null) { + view.setSubtitleTrack(nativeTrackId) + selectedSubtitleTrackIndex = preferredIndex + } + } } private fun recordPlaybackClockSample(snapshot: MiruMpvSurfaceView.StateSnapshot) { diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt index 0ab19ec0..08b76d56 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt @@ -2,10 +2,13 @@ package com.miruplay.tv.repository import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference interface PlaybackPreferencesRepository { suspend fun getEndAction(): PlaybackEndAction suspend fun setEndAction(action: PlaybackEndAction) + suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference + suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences suspend fun setFormatAwareToneMappingPreferences(preferences: FormatAwareToneMappingPreferences) } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinator.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinator.kt index f9d4373f..c8019ec2 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinator.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinator.kt @@ -2,6 +2,7 @@ package com.miruplay.tv.repository import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PosterWallArrangement +import com.miruplay.tv.model.SubtitleLanguagePreference data class ScanPreferenceActionSnapshot( val autoScanEnabled: Boolean = false, @@ -45,6 +46,14 @@ class SettingsPreferenceActionCoordinator( playbackPreferences.setEndAction(action) return playbackPreferences.getEndAction() } + + suspend fun currentPreferredSubtitleLanguage(): SubtitleLanguagePreference = + playbackPreferences.getPreferredSubtitleLanguage() + + suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference): SubtitleLanguagePreference { + playbackPreferences.setPreferredSubtitleLanguage(preference) + return playbackPreferences.getPreferredSubtitleLanguage() + } } fun ScanPreferencesSnapshot.toScanPreferenceActionSnapshot(): ScanPreferenceActionSnapshot { diff --git a/repository-api/src/test/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinatorTest.kt b/repository-api/src/test/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinatorTest.kt index 2b5fb0c9..f1d9fe94 100644 --- a/repository-api/src/test/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinatorTest.kt +++ b/repository-api/src/test/kotlin/com/miruplay/tv/repository/SettingsPreferenceActionCoordinatorTest.kt @@ -3,6 +3,7 @@ package com.miruplay.tv.repository import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PosterWallArrangement +import com.miruplay.tv.model.SubtitleLanguagePreference import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -64,6 +65,22 @@ class SettingsPreferenceActionCoordinatorTest { assertEquals(PlaybackEndAction.PLAY_NEXT_EPISODE, coordinator.currentPlaybackEndAction()) } + @Test + fun `preferred subtitle language setter persists and returns refreshed value`() = runBlocking { + val playbackPreferences = FakePlaybackPreferencesRepository() + val coordinator = coordinator(playbackPreferences = playbackPreferences) + + val preference = coordinator.setPreferredSubtitleLanguage( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + ) + + assertEquals(SubtitleLanguagePreference.CHINESE_SIMPLIFIED, preference) + assertEquals( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + coordinator.currentPreferredSubtitleLanguage(), + ) + } + private fun coordinator( scanPreferences: FakeScanPreferencesRepository = FakeScanPreferencesRepository(), playbackPreferences: FakePlaybackPreferencesRepository = FakePlaybackPreferencesRepository(), @@ -107,6 +124,7 @@ class SettingsPreferenceActionCoordinatorTest { ) : PlaybackPreferencesRepository { private var formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences = FormatAwareToneMappingPreferences() + private var preferredSubtitleLanguage = SubtitleLanguagePreference.AUTO override suspend fun getEndAction(): PlaybackEndAction = action @@ -115,6 +133,13 @@ class SettingsPreferenceActionCoordinatorTest { this.action = action } + override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference = + preferredSubtitleLanguage + + override suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) { + preferredSubtitleLanguage = preference + } + override suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences = formatAwareToneMappingPreferences diff --git a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoryStore.kt b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoryStore.kt index 40e52858..62aefcc6 100644 --- a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoryStore.kt +++ b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoryStore.kt @@ -5,6 +5,7 @@ import com.miruplay.tv.model.Anime import com.miruplay.tv.model.Episode import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.ProgressRecord import com.miruplay.tv.model.CloudDriveAutomationConfig import com.miruplay.tv.model.RssDownloadTaskInfo @@ -75,6 +76,7 @@ internal data class DesktopRepositoryState( val episodes: List = emptyList(), val indexBatchUndo: List = emptyList(), val playbackEndAction: PlaybackEndAction = PlaybackEndAction.RETURN_TO_DETAIL, + val preferredSubtitleLanguage: SubtitleLanguagePreference = SubtitleLanguagePreference.AUTO, val formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences = FormatAwareToneMappingPreferences(), val scanPreferences: ScanPreferencesSnapshot = ScanPreferencesSnapshot(), val cloudDriveConfig: CloudDriveAutomationConfig = CloudDriveAutomationConfig(), diff --git a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedPlaybackPreferencesRepository.kt b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedPlaybackPreferencesRepository.kt index 32b36491..42eca6cd 100644 --- a/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedPlaybackPreferencesRepository.kt +++ b/repository-desktop/src/main/kotlin/com/miruplay/tv/repository/desktop/FileBackedPlaybackPreferencesRepository.kt @@ -2,6 +2,7 @@ package com.miruplay.tv.repository.desktop import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.repository.PlaybackPreferencesRepository internal class FileBackedPlaybackPreferencesRepository( @@ -14,6 +15,13 @@ internal class FileBackedPlaybackPreferencesRepository( store.update { state -> state.copy(playbackEndAction = action) to Unit } } + override suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference = + store.read { state -> state.preferredSubtitleLanguage } + + override suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) { + store.update { state -> state.copy(preferredSubtitleLanguage = preference) to Unit } + } + override suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences = store.read { state -> state.formatAwareToneMappingPreferences.normalized() } diff --git a/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt b/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt index c6ce5cb4..e74b471e 100644 --- a/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt +++ b/repository-desktop/src/test/kotlin/com/miruplay/tv/repository/desktop/DesktopRepositoriesTest.kt @@ -7,6 +7,7 @@ import com.miruplay.tv.model.Episode import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.MediaSourceType import com.miruplay.tv.model.RssDownloadStatus @@ -151,6 +152,52 @@ class DesktopRepositoriesTest { } } + @Test + fun `legacy desktop store defaults subtitle preference without losing playback settings`() = runBlocking { + val storePath = tempStorePath() + try { + Files.writeString( + storePath, + """{"nextSourceId":9,"playbackEndAction":"PLAY_NEXT_EPISODE"}""", + ) + + val repositories = DesktopRepositories.fileBacked(storePath) + + assertEquals(PlaybackEndAction.PLAY_NEXT_EPISODE, repositories.playbackPreferences.getEndAction()) + assertEquals( + SubtitleLanguagePreference.AUTO, + repositories.playbackPreferences.getPreferredSubtitleLanguage(), + ) + } finally { + deleteTempStore(storePath) + } + } + + @Test + fun `preferred subtitle language is persisted`() = runBlocking { + val storePath = tempStorePath() + try { + val repositories = DesktopRepositories.fileBacked(storePath) + + assertEquals( + SubtitleLanguagePreference.AUTO, + repositories.playbackPreferences.getPreferredSubtitleLanguage(), + ) + + repositories.playbackPreferences.setPreferredSubtitleLanguage( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + ) + + val reopened = DesktopRepositories.fileBacked(storePath) + assertEquals( + SubtitleLanguagePreference.CHINESE_SIMPLIFIED, + reopened.playbackPreferences.getPreferredSubtitleLanguage(), + ) + } finally { + deleteTempStore(storePath) + } + } + @Test fun `format aware tone mapping preferences are persisted`() = runBlocking { val storePath = tempStorePath() diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt index e696ece6..20808035 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PhotoFilter import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save @@ -88,6 +89,7 @@ import com.miruplay.tv.repository.AppMode import com.miruplay.tv.repository.toMediaContentMode import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.normalizeSupportedBackend import com.miruplay.tv.model.supportedPlaybackRenderBackends import com.miruplay.tv.model.PosterWallArrangement @@ -176,6 +178,9 @@ import com.miruplay.tv.model.playbackEndReturnToDetailDetail import com.miruplay.tv.model.playbackEndSettingsDescriptionLabel import com.miruplay.tv.model.playbackEndSettingsTitleLabel import com.miruplay.tv.model.playbackEndMenuSummary +import com.miruplay.tv.model.preferredSubtitleLanguageSettingsDescriptionLabel +import com.miruplay.tv.model.preferredSubtitleLanguageSettingsTitleLabel +import com.miruplay.tv.model.displayLabel import com.miruplay.tv.model.pictureSettingsDescriptionLabel import com.miruplay.tv.model.pictureSettingsTitleLabel import com.miruplay.tv.model.playbackBackendLabel @@ -365,6 +370,7 @@ fun AddSourceScreen( val posterWallArrangement by viewModel.posterWallArrangement.collectAsStateWithLifecycle() val currentAppMode by viewModel.currentAppMode.collectAsStateWithLifecycle() val playbackEndAction by viewModel.playbackEndAction.collectAsStateWithLifecycle() + val preferredSubtitleLanguage by viewModel.preferredSubtitleLanguage.collectAsStateWithLifecycle() val formatAwareToneMappingPreferences by viewModel.formatAwareToneMappingPreferences.collectAsStateWithLifecycle() val savedTmdbToken by viewModel.tmdbToken.collectAsStateWithLifecycle() val webUiUrls by viewModel.webUiUrls.collectAsStateWithLifecycle() @@ -566,6 +572,7 @@ fun AddSourceScreen( mergeSameAnimeEnabled = mergeSameAnimeEnabled, posterWallArrangement = posterWallArrangement, playbackEndAction = playbackEndAction, + preferredSubtitleLanguage = preferredSubtitleLanguage, formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, cloudDriveEnabled = cloudEnabled, rssCount = rssSubscriptions.size, @@ -660,6 +667,8 @@ fun AddSourceScreen( onAppModeSelected = viewModel::setCurrentAppMode, playbackEndAction = playbackEndAction, onPlaybackEndActionSelected = viewModel::setPlaybackEndAction, + preferredSubtitleLanguage = preferredSubtitleLanguage, + onPreferredSubtitleLanguageSelected = viewModel::setPreferredSubtitleLanguage, formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, onPlaybackBackendSelected = viewModel::setDefaultPlaybackBackend, onToneMappingPresetSelected = viewModel::setToneMappingPreset, @@ -930,6 +939,7 @@ private fun SettingsMenuPanel( mergeSameAnimeEnabled: Boolean, posterWallArrangement: PosterWallArrangement, playbackEndAction: PlaybackEndAction, + preferredSubtitleLanguage: SubtitleLanguagePreference, formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, cloudDriveEnabled: Boolean, rssCount: Int, @@ -952,7 +962,7 @@ private fun SettingsMenuPanel( val menuSummaryInput = SettingsSectionMenuSummaryInput( webUiAddressCount = webUiAddressCount, sourceCount = sourcesCount, - playbackSummary = "${playbackEndAction.playbackEndMenuSummary()} · ${playbackBackendLabel(formatAwareToneMappingPreferences.defaultBackend)}", + playbackSummary = "${playbackEndAction.playbackEndMenuSummary()} · ${preferredSubtitleLanguage.displayLabel()}字幕 · ${playbackBackendLabel(formatAwareToneMappingPreferences.defaultBackend)}", cloudDriveEnabled = cloudDriveEnabled, rssCount = rssCount, proxyEnabled = proxyEnabled, @@ -1118,6 +1128,8 @@ private fun SettingsContent( onAppModeSelected: (AppMode) -> Unit, playbackEndAction: PlaybackEndAction, onPlaybackEndActionSelected: (PlaybackEndAction) -> Unit, + preferredSubtitleLanguage: SubtitleLanguagePreference, + onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit, formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit, onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit, @@ -1375,6 +1387,8 @@ private fun SettingsContent( PlaybackPanel( endAction = playbackEndAction, onEndActionSelected = onPlaybackEndActionSelected, + preferredSubtitleLanguage = preferredSubtitleLanguage, + onPreferredSubtitleLanguageSelected = onPreferredSubtitleLanguageSelected, formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, onPlaybackBackendSelected = onPlaybackBackendSelected, onToneMappingPresetSelected = onToneMappingPresetSelected, @@ -3110,6 +3124,8 @@ private fun ScanPanel( private fun PlaybackPanel( endAction: PlaybackEndAction, onEndActionSelected: (PlaybackEndAction) -> Unit, + preferredSubtitleLanguage: SubtitleLanguagePreference, + onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit, formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit, onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit, @@ -3162,6 +3178,49 @@ private fun PlaybackPanel( color = if (endAction == PlaybackEndAction.PLAY_NEXT_EPISODE) ProgressGreen else TextSecondary ) + Spacer(Modifier.height(24.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Language, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(26.dp) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = preferredSubtitleLanguageSettingsTitleLabel(), + style = TvTypography.subtitle, + color = TextPrimary, + ) + } + + Spacer(Modifier.height(6.dp)) + Text( + text = preferredSubtitleLanguageSettingsDescriptionLabel(), + style = TvTypography.body, + color = TextSecondary, + ) + + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SubtitleLanguagePreference.entries.forEach { preference -> + ScanOptionChip( + text = preference.displayLabel(), + icon = Icons.Filled.Language, + selected = preferredSubtitleLanguage == preference, + enabled = true, + onClick = { onPreferredSubtitleLanguageSelected(preference) }, + modifier = Modifier.width(120.dp), + ) + } + } + + StatusMessage( + icon = Icons.Filled.CheckCircle, + text = "当前优先:${preferredSubtitleLanguage.displayLabel()}", + color = if (preferredSubtitleLanguage == SubtitleLanguagePreference.AUTO) TextSecondary else ProgressGreen, + ) + Spacer(Modifier.height(24.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Icon( diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt index 71164c80..5904024f 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt @@ -17,6 +17,7 @@ import com.miruplay.tv.data.preferences.PlaybackPreferencesManager import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.PosterWallArrangement import com.miruplay.tv.model.ToneMappingProfilePreset import com.miruplay.tv.model.VideoRenderRuleKey @@ -164,6 +165,8 @@ class SettingsViewModel @Inject constructor( private val _playbackEndAction = MutableStateFlow(playbackPreferences.endAction) val playbackEndAction: StateFlow = _playbackEndAction.asStateFlow() + private val _preferredSubtitleLanguage = MutableStateFlow(playbackPreferences.preferredSubtitleLanguage) + val preferredSubtitleLanguage: StateFlow = _preferredSubtitleLanguage.asStateFlow() private val _formatAwareToneMappingPreferences = MutableStateFlow( playbackPreferences.formatAwareToneMappingPreferences.normalized() ) @@ -859,6 +862,11 @@ class SettingsViewModel @Inject constructor( _playbackEndAction.value = action } + fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference) { + playbackPreferences.preferredSubtitleLanguage = preference + _preferredSubtitleLanguage.value = preference + } + fun setDefaultPlaybackBackend(backend: PlaybackRenderBackend) { val updated = _formatAwareToneMappingPreferences.value.normalized().copy( defaultBackend = backend diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt index 0cbf2204..859d11ce 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsViewModelTokenTest.kt @@ -9,6 +9,7 @@ import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.CloudDriveAutomationConfig import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.PosterWallArrangement import com.miruplay.tv.repository.AppCredentialStore import com.miruplay.tv.repository.AppMode @@ -88,6 +89,7 @@ class SettingsViewModelTokenTest { every { scanPreferences.posterWallArrangement } returns PosterWallArrangement.TITLE every { playbackPreferences.endAction } returns PlaybackEndAction.RETURN_TO_DETAIL + every { playbackPreferences.preferredSubtitleLanguage } returns SubtitleLanguagePreference.AUTO every { playbackPreferences.formatAwareToneMappingPreferences } returns FormatAwareToneMappingPreferences() every { webControlPreferences.webControlEnabled } returns false diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt index 7b9c9797..98ff7ac9 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt @@ -334,13 +334,17 @@ data class ScanSettingsRequest( @Serializable data class PlaybackSettingsDto( val endAction: String, + val preferredSubtitleLanguage: String, val formatAwareToneMapping: FormatAwareToneMappingPreferences, val endActionOptions: List = listOf("return_to_detail", "play_next_episode"), + val preferredSubtitleLanguageOptions: List = + listOf("auto", "zh_hans", "zh_hant", "zh", "en", "ja"), ) @Serializable data class PlaybackSettingsRequest( val endAction: String? = null, + val preferredSubtitleLanguage: String? = null, val formatAwareToneMapping: FormatAwareToneMappingPreferences? = null, ) diff --git a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt index 53c6c88d..a9da19c8 100644 --- a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt +++ b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt @@ -52,10 +52,22 @@ class WebControlSettingsRouteTest { staticAssets = WebControlStaticAssets { null }, ) + val put = server.serve( + session( + method = NanoHTTPD.Method.PUT, + uri = "/api/settings/playback", + body = """{"preferredSubtitleLanguage":"zh_hans"}""", + ), + ) + assertEquals(NanoHTTPD.Response.Status.OK, put.status) + assertEquals("zh_hans", service.capturedPlayback?.preferredSubtitleLanguage) + val get = server.serve(session(method = NanoHTTPD.Method.GET, uri = "/api/settings/playback", body = "")) assertEquals(NanoHTTPD.Response.Status.OK, get.status) val body = get.bodyText() assertTrue(body.contains("\"endAction\":\"return_to_detail\"")) + assertTrue(body.contains("\"preferredSubtitleLanguage\":\"zh_hans\"")) + assertTrue(body.contains("\"preferredSubtitleLanguageOptions\"")) assertTrue(body.contains("\"formatAwareToneMapping\"")) } @@ -177,6 +189,7 @@ class WebControlSettingsRouteTest { private class SettingsStubService : EmptyWebControlEndpointService() { var capturedScan: ScanSettingsRequest? = null + var capturedPlayback: PlaybackSettingsRequest? = null var capturedTmdbToken: String? = null var capturedAppControlAction: String? = null var tmdbCleared = false @@ -198,9 +211,15 @@ class WebControlSettingsRouteTest { override suspend fun getPlaybackSettings(): PlaybackSettingsDto = PlaybackSettingsDto( endAction = "return_to_detail", + preferredSubtitleLanguage = "zh_hans", formatAwareToneMapping = FormatAwareToneMappingPreferences(), ) + override suspend fun savePlaybackSettings(request: PlaybackSettingsRequest): PlaybackSettingsDto { + capturedPlayback = request + return getPlaybackSettings() + } + override suspend fun getMetadataSettings(): MetadataSettingsDto = MetadataSettingsDto(bangumiTokenConfigured = false, tmdbTokenConfigured = true) diff --git a/web-control/frontend/src/App.vue b/web-control/frontend/src/App.vue index 5437d4de..a7126a2d 100644 --- a/web-control/frontend/src/App.vue +++ b/web-control/frontend/src/App.vue @@ -1080,6 +1080,16 @@ :options="playbackSettings.endActionOptions.map((value) => ({ value, label: endActionLabels[value] || value }))" /> + + + + +
保存设置 @@ -1627,11 +1637,14 @@ const scanForm = reactive({ }) const playbackSettings = reactive({ endAction: 'return_to_detail', + preferredSubtitleLanguage: 'auto', formatAwareToneMapping: null, - endActionOptions: ['return_to_detail', 'play_next_episode'] + endActionOptions: ['return_to_detail', 'play_next_episode'], + preferredSubtitleLanguageOptions: ['auto', 'zh_hans', 'zh_hant', 'zh', 'en', 'ja'] }) const playbackForm = reactive({ endAction: 'return_to_detail', + preferredSubtitleLanguage: 'auto', defaultBackend: '' }) const webControlAccess = reactive({ @@ -1699,6 +1712,14 @@ let cloudRunTimer = 0 const appModeLabels = { anime: '动画', drama: '剧集' } const posterWallArrangementLabels = { TITLE: '按标题', RELEASE_SEASON: '按新番季' } const endActionLabels = { return_to_detail: '返回详情页', play_next_episode: '播放下一集' } +const subtitleLanguageLabels = { + auto: '自动', + zh_hans: '简体中文', + zh_hant: '繁体中文', + zh: '中文', + en: '英语', + ja: '日语' +} const playbackBackendOptions = ['STANDARD_EXO', 'EXPERIMENTAL_MPV_EMBEDDED', 'EXPERIMENTAL_LIBVLC'] const playbackBackendLabels = { STANDARD_EXO: 'ExoPlayer(标准)', @@ -2934,9 +2955,12 @@ async function saveScanSettings() { function applyPlaybackSettings(data) { playbackSettings.endAction = data.endAction || 'return_to_detail' + playbackSettings.preferredSubtitleLanguage = data.preferredSubtitleLanguage || 'auto' playbackSettings.formatAwareToneMapping = data.formatAwareToneMapping || null playbackSettings.endActionOptions = data.endActionOptions || ['return_to_detail', 'play_next_episode'] + playbackSettings.preferredSubtitleLanguageOptions = data.preferredSubtitleLanguageOptions || ['auto', 'zh_hans', 'zh_hant', 'zh', 'en', 'ja'] playbackForm.endAction = playbackSettings.endAction + playbackForm.preferredSubtitleLanguage = playbackSettings.preferredSubtitleLanguage playbackForm.defaultBackend = playbackSettings.formatAwareToneMapping?.defaultBackend || '' } @@ -2955,6 +2979,7 @@ async function savePlaybackSettings() { const existing = playbackSettings.formatAwareToneMapping || {} const payload = { endAction: playbackForm.endAction, + preferredSubtitleLanguage: playbackForm.preferredSubtitleLanguage, formatAwareToneMapping: { ...existing, defaultBackend: playbackForm.defaultBackend } } applyPlaybackSettings(await api('/api/settings/playback', { diff --git a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt index 34176ab2..5adf9a00 100644 --- a/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt +++ b/web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt @@ -14,6 +14,7 @@ import com.miruplay.tv.mediasource.MediaSourceFactory import com.miruplay.tv.model.Episode import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.PlaybackEndAction +import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.ScanResult import com.miruplay.tv.model.buildToneMappingPreset @@ -690,9 +691,11 @@ class WebControlService @Inject constructor( override suspend fun getPlaybackSettings(): PlaybackSettingsDto = runOnIo { val endAction = playbackPreferencesRepository.getEndAction() + val preferredSubtitleLanguage = playbackPreferencesRepository.getPreferredSubtitleLanguage() val toneMapping = playbackPreferencesRepository.getFormatAwareToneMappingPreferences().normalized() PlaybackSettingsDto( endAction = endAction.storageValue, + preferredSubtitleLanguage = preferredSubtitleLanguage.storageValue, formatAwareToneMapping = toneMapping, ) } @@ -701,6 +704,11 @@ class WebControlService @Inject constructor( request.endAction?.let { value -> playbackPreferencesRepository.setEndAction(PlaybackEndAction.fromStorageValue(value)) } + request.preferredSubtitleLanguage?.let { value -> + playbackPreferencesRepository.setPreferredSubtitleLanguage( + SubtitleLanguagePreference.fromStorageValue(value), + ) + } request.formatAwareToneMapping?.let { prefs -> playbackPreferencesRepository.setFormatAwareToneMappingPreferences(prefs.normalized()) }