diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.android.kt new file mode 100644 index 00000000..0f3f4288 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.android.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.player + +import android.os.Build +import android.os.LocaleList +import java.util.Locale + +internal actual object DeviceLanguagePreferences { + actual fun preferredLanguageCodes(): List { + val languages = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val localeList = LocaleList.getDefault() + for (index in 0 until localeList.size()) { + val locale = localeList[index] ?: continue + appendLocaleCodes(languages, locale) + } + } else { + appendLocaleCodes(languages, Locale.getDefault()) + } + + if (languages.isEmpty()) { + appendLocaleCodes(languages, Locale.ENGLISH) + } + + return languages + .mapNotNull(::normalizeLanguageCode) + .distinct() + } + + private fun appendLocaleCodes(bucket: MutableList, locale: Locale) { + bucket += locale.toLanguageTag() + bucket += locale.language + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index b63f66e9..daaa815b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -327,6 +327,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List { for (group in currentTracks.groups) { if (group.type != C.TRACK_TYPE_TEXT) continue val format = group.mediaTrackGroup.getFormat(0) + val hasForcedSelectionFlag = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0 tracks.add( SubtitleTrack( index = idx, @@ -334,6 +335,12 @@ private fun ExoPlayer.extractSubtitleTracks(): List { label = format.label ?: "", language = format.language, isSelected = group.isSelected, + isForced = inferForcedSubtitleTrack( + label = format.label, + language = format.language, + trackId = format.id, + hasForcedSelectionFlag = hasForcedSelectionFlag, + ), ) ) idx++ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 8fad45f0..db84149c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -7,6 +7,10 @@ import com.nuvio.app.core.storage.ProfileScopedKey actual object PlayerSettingsStorage { private const val preferencesName = "nuvio_player_settings" private const val showLoadingOverlayKey = "show_loading_overlay" + private const val preferredAudioLanguageKey = "preferred_audio_language" + private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" + private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" + private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" private var preferences: SharedPreferences? = null @@ -30,4 +34,58 @@ actual object PlayerSettingsStorage { ?.putBoolean(ProfileScopedKey.of(showLoadingOverlayKey), enabled) ?.apply() } + + actual fun loadPreferredAudioLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null) + + actual fun savePreferredAudioLanguage(language: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(preferredAudioLanguageKey), language) + ?.apply() + } + + actual fun loadSecondaryPreferredAudioLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(secondaryPreferredAudioLanguageKey), null) + + actual fun saveSecondaryPreferredAudioLanguage(language: String?) { + preferences + ?.edit() + ?.apply { + val key = ProfileScopedKey.of(secondaryPreferredAudioLanguageKey) + if (language.isNullOrBlank()) { + remove(key) + } else { + putString(key, language) + } + } + ?.apply() + } + + actual fun loadPreferredSubtitleLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(preferredSubtitleLanguageKey), null) + + actual fun savePreferredSubtitleLanguage(language: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(preferredSubtitleLanguageKey), language) + ?.apply() + } + + actual fun loadSecondaryPreferredSubtitleLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(secondaryPreferredSubtitleLanguageKey), null) + + actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) { + preferences + ?.edit() + ?.apply { + val key = ProfileScopedKey.of(secondaryPreferredSubtitleLanguageKey) + if (language.isNullOrBlank()) { + remove(key) + } else { + putString(key, language) + } + } + ?.apply() + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt new file mode 100644 index 00000000..47746d49 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt @@ -0,0 +1,253 @@ +package com.nuvio.app.features.player + +data class LanguagePreferenceOption( + val code: String, + val label: String, +) + +object AudioLanguageOption { + const val DEFAULT = "default" + const val DEVICE = "device" +} + +object SubtitleLanguageOption { + const val NONE = "none" + const val DEVICE = "device" + const val FORCED = "forced" +} + +val AvailableLanguageOptions: List = listOf( + LanguagePreferenceOption("af", "Afrikaans"), + LanguagePreferenceOption("sq", "Albanian"), + LanguagePreferenceOption("am", "Amharic"), + LanguagePreferenceOption("ar", "Arabic"), + LanguagePreferenceOption("hy", "Armenian"), + LanguagePreferenceOption("az", "Azerbaijani"), + LanguagePreferenceOption("eu", "Basque"), + LanguagePreferenceOption("be", "Belarusian"), + LanguagePreferenceOption("bn", "Bengali"), + LanguagePreferenceOption("bs", "Bosnian"), + LanguagePreferenceOption("bg", "Bulgarian"), + LanguagePreferenceOption("my", "Burmese"), + LanguagePreferenceOption("ca", "Catalan"), + LanguagePreferenceOption("zh", "Chinese"), + LanguagePreferenceOption("zh-CN", "Chinese (Simplified)"), + LanguagePreferenceOption("zh-TW", "Chinese (Traditional)"), + LanguagePreferenceOption("hr", "Croatian"), + LanguagePreferenceOption("cs", "Czech"), + LanguagePreferenceOption("da", "Danish"), + LanguagePreferenceOption("nl", "Dutch"), + LanguagePreferenceOption("en", "English"), + LanguagePreferenceOption("et", "Estonian"), + LanguagePreferenceOption("tl", "Filipino"), + LanguagePreferenceOption("fi", "Finnish"), + LanguagePreferenceOption("fr", "French"), + LanguagePreferenceOption("gl", "Galician"), + LanguagePreferenceOption("ka", "Georgian"), + LanguagePreferenceOption("de", "German"), + LanguagePreferenceOption("el", "Greek"), + LanguagePreferenceOption("gu", "Gujarati"), + LanguagePreferenceOption("he", "Hebrew"), + LanguagePreferenceOption("hi", "Hindi"), + LanguagePreferenceOption("hu", "Hungarian"), + LanguagePreferenceOption("is", "Icelandic"), + LanguagePreferenceOption("id", "Indonesian"), + LanguagePreferenceOption("ga", "Irish"), + LanguagePreferenceOption("it", "Italian"), + LanguagePreferenceOption("ja", "Japanese"), + LanguagePreferenceOption("kn", "Kannada"), + LanguagePreferenceOption("kk", "Kazakh"), + LanguagePreferenceOption("km", "Khmer"), + LanguagePreferenceOption("ko", "Korean"), + LanguagePreferenceOption("lo", "Lao"), + LanguagePreferenceOption("lv", "Latvian"), + LanguagePreferenceOption("lt", "Lithuanian"), + LanguagePreferenceOption("mk", "Macedonian"), + LanguagePreferenceOption("ms", "Malay"), + LanguagePreferenceOption("ml", "Malayalam"), + LanguagePreferenceOption("mt", "Maltese"), + LanguagePreferenceOption("mr", "Marathi"), + LanguagePreferenceOption("mn", "Mongolian"), + LanguagePreferenceOption("ne", "Nepali"), + LanguagePreferenceOption("no", "Norwegian"), + LanguagePreferenceOption("pa", "Punjabi"), + LanguagePreferenceOption("fa", "Persian"), + LanguagePreferenceOption("pl", "Polish"), + LanguagePreferenceOption("pt", "Portuguese (Portugal)"), + LanguagePreferenceOption("pt-BR", "Portuguese (Brazil)"), + LanguagePreferenceOption("ro", "Romanian"), + LanguagePreferenceOption("ru", "Russian"), + LanguagePreferenceOption("sr", "Serbian"), + LanguagePreferenceOption("si", "Sinhala"), + LanguagePreferenceOption("sk", "Slovak"), + LanguagePreferenceOption("sl", "Slovenian"), + LanguagePreferenceOption("es", "Spanish"), + LanguagePreferenceOption("es-419", "Spanish (Latin America)"), + LanguagePreferenceOption("sw", "Swahili"), + LanguagePreferenceOption("sv", "Swedish"), + LanguagePreferenceOption("ta", "Tamil"), + LanguagePreferenceOption("te", "Telugu"), + LanguagePreferenceOption("th", "Thai"), + LanguagePreferenceOption("tr", "Turkish"), + LanguagePreferenceOption("uk", "Ukrainian"), + LanguagePreferenceOption("ur", "Urdu"), + LanguagePreferenceOption("uz", "Uzbek"), + LanguagePreferenceOption("vi", "Vietnamese"), + LanguagePreferenceOption("cy", "Welsh"), + LanguagePreferenceOption("zu", "Zulu"), +) + +private val Iso639Aliases = mapOf( + "eng" to "en", + "spa" to "es", + "fra" to "fr", + "fre" to "fr", + "deu" to "de", + "ger" to "de", + "ita" to "it", + "por" to "pt", + "rus" to "ru", + "jpn" to "ja", + "kor" to "ko", + "zho" to "zh", + "chi" to "zh", + "ara" to "ar", + "hin" to "hi", + "nld" to "nl", + "dut" to "nl", + "pol" to "pl", + "swe" to "sv", + "tur" to "tr", + "heb" to "he", +) + +fun normalizeLanguageCode(language: String?): String? { + val raw = language + ?.trim() + ?.replace('_', '-') + ?.lowercase() + ?.takeIf { it.isNotBlank() } + ?: return null + + val primary = raw.substringBefore('-') + val canonicalPrimary = Iso639Aliases[primary] ?: primary + val suffix = raw.substringAfter('-', "") + return if (suffix.isBlank()) { + canonicalPrimary + } else { + "$canonicalPrimary-$suffix" + } +} + +fun languageMatchesPreference(trackLanguage: String?, targetLanguage: String): Boolean { + val normalizedTrack = normalizeLanguageCode(trackLanguage) ?: return false + val normalizedTarget = normalizeLanguageCode(targetLanguage) ?: return false + if (normalizedTrack == normalizedTarget) return true + + val trackPrimary = normalizedTrack.substringBefore('-') + val targetPrimary = normalizedTarget.substringBefore('-') + return trackPrimary == targetPrimary +} + +fun languageLabelForCode(code: String?): String { + if (code.isNullOrBlank()) return "None" + if (code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true)) return "Forced" + return AvailableLanguageOptions.firstOrNull { + it.code.equals(code, ignoreCase = true) + }?.label ?: formatLanguage(code) +} + +fun resolvePreferredAudioLanguageTargets( + preferredAudioLanguage: String, + secondaryPreferredAudioLanguage: String?, + deviceLanguages: List, +): List { + fun normalize(language: String?): String? { + val normalized = normalizeLanguageCode(language) + return when (normalized) { + null, + AudioLanguageOption.DEFAULT, + AudioLanguageOption.DEVICE, + SubtitleLanguageOption.NONE, + SubtitleLanguageOption.FORCED, + -> null + else -> normalized + } + } + + val primary = normalizeLanguageCode(preferredAudioLanguage) ?: AudioLanguageOption.DEVICE + + return when (primary) { + AudioLanguageOption.DEFAULT -> listOfNotNull( + normalize(secondaryPreferredAudioLanguage), + ).distinct() + + AudioLanguageOption.DEVICE -> ( + deviceLanguages.mapNotNull(::normalize) + + listOfNotNull(normalize(secondaryPreferredAudioLanguage)) + ).distinct() + + else -> listOfNotNull( + normalize(preferredAudioLanguage), + normalize(secondaryPreferredAudioLanguage), + ).distinct() + } +} + +fun resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, + deviceLanguages: List, +): List { + fun normalize(language: String?): String? { + val normalized = normalizeLanguageCode(language) + return when (normalized) { + null, + SubtitleLanguageOption.NONE, + -> null + AudioLanguageOption.DEFAULT -> null + else -> normalized + } + } + + val primary = normalizeLanguageCode(preferredSubtitleLanguage) ?: SubtitleLanguageOption.NONE + + return when (primary) { + SubtitleLanguageOption.NONE -> listOfNotNull( + normalize(secondaryPreferredSubtitleLanguage), + ).distinct() + + SubtitleLanguageOption.DEVICE -> ( + deviceLanguages.mapNotNull(::normalize) + + listOfNotNull(normalize(secondaryPreferredSubtitleLanguage)) + ).distinct() + + else -> listOfNotNull( + normalize(preferredSubtitleLanguage), + normalize(secondaryPreferredSubtitleLanguage), + ).distinct() + } +} + +internal expect object DeviceLanguagePreferences { + fun preferredLanguageCodes(): List +} + +fun inferForcedSubtitleTrack( + label: String?, + language: String?, + trackId: String?, + hasForcedSelectionFlag: Boolean = false, +): Boolean { + if (hasForcedSelectionFlag) return true + + val normalizedLanguage = normalizeLanguageCode(language) + if (normalizedLanguage == SubtitleLanguageOption.FORCED) return true + + val text = listOfNotNull(label, language, trackId) + .joinToString(" ") + .lowercase() + + if ("forced" in text) return true + return text.contains("songs") && text.contains("sign") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 0eb7abd2..2ed5767e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -164,6 +164,8 @@ fun PlayerScreen( var selectedSubtitleIndex by remember { mutableStateOf(-1) } var selectedAddonSubtitleId by remember { mutableStateOf(null) } var useCustomSubtitles by remember { mutableStateOf(false) } + var preferredAudioSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) } + var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) } var subtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) } val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() @@ -179,6 +181,69 @@ fun PlayerScreen( println("NuvioPlayer refreshTracks: useCustom=$useCustomSubtitles selectedAddonId=$selectedAddonSubtitleId selectedSubIdx=$selectedSubtitleIndex") println("NuvioPlayer refreshTracks: found ${subtitleTracks.size} subtitle tracks, selectedTrack=${selectedSub?.index}") if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index + + if (!preferredAudioSelectionApplied) { + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + if (preferredAudioTargets.isEmpty()) { + preferredAudioSelectionApplied = true + } else if (audioTracks.isNotEmpty()) { + val preferredAudioIndex = findPreferredTrackIndex( + tracks = audioTracks, + targets = preferredAudioTargets, + language = { track -> track.language }, + ) + if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { + playerController?.selectAudioTrack(preferredAudioIndex) + selectedAudioIndex = preferredAudioIndex + } + preferredAudioSelectionApplied = true + } + } + + if (!preferredSubtitleSelectionApplied) { + val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + + if (preferredSubtitleTargets.isEmpty()) { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } else if (subtitleTracks.isNotEmpty()) { + val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( + tracks = subtitleTracks, + targets = preferredSubtitleTargets, + ) + if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { + playerController?.selectSubtitleTrack(preferredSubtitleIndex) + selectedSubtitleIndex = preferredSubtitleIndex + selectedAddonSubtitleId = null + useCustomSubtitles = false + } else if ( + preferredSubtitleIndex < 0 && + normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED + ) { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + } + preferredSubtitleSelectionApplied = true + } + } + println("NuvioPlayer refreshTracks: final selectedSubtitleIndex=$selectedSubtitleIndex") } @@ -235,6 +300,8 @@ fun PlayerScreen( initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false + preferredAudioSelectionApplied = false + preferredSubtitleSelectionApplied = false SubtitleRepository.clear() WatchProgressRepository.ensureLoaded() } @@ -509,3 +576,50 @@ fun PlayerScreen( } } } + +private fun findPreferredTrackIndex( + tracks: List, + targets: List, + language: (T) -> String?, +): Int { + if (targets.isEmpty()) return -1 + for (target in targets) { + val matchIndex = tracks.indexOfFirst { track -> + languageMatchesPreference( + trackLanguage = language(track), + targetLanguage = target, + ) + } + if (matchIndex >= 0) { + return matchIndex + } + } + return -1 +} + +private fun findPreferredSubtitleTrackIndex( + tracks: List, + targets: List, +): Int { + if (targets.isEmpty()) return -1 + + for ((targetPosition, target) in targets.withIndex()) { + val normalizedTarget = normalizeLanguageCode(target) ?: continue + if (normalizedTarget == SubtitleLanguageOption.FORCED) { + val forcedIndex = tracks.indexOfFirst { it.isForced } + if (forcedIndex >= 0) return forcedIndex + if (targetPosition == 0) return -1 + continue + } + + val matchIndex = tracks.indexOfFirst { track -> + languageMatchesPreference( + trackLanguage = track.language, + targetLanguage = normalizedTarget, + ) + } + if (matchIndex >= 0) return matchIndex + } + + return -1 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index d103f872..045bde97 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -6,6 +6,10 @@ import kotlinx.coroutines.flow.asStateFlow data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, + val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, + val secondaryPreferredAudioLanguage: String? = null, + val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE, + val secondaryPreferredSubtitleLanguage: String? = null, ) object PlayerSettingsRepository { @@ -14,6 +18,10 @@ object PlayerSettingsRepository { private var hasLoaded = false private var showLoadingOverlay = true + private var preferredAudioLanguage = AudioLanguageOption.DEVICE + private var secondaryPreferredAudioLanguage: String? = null + private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE + private var secondaryPreferredSubtitleLanguage: String? = null fun ensureLoaded() { if (hasLoaded) return @@ -27,6 +35,16 @@ object PlayerSettingsRepository { private fun loadFromDisk() { hasLoaded = true showLoadingOverlay = PlayerSettingsStorage.loadShowLoadingOverlay() ?: true + preferredAudioLanguage = + normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage()) + ?: AudioLanguageOption.DEVICE + secondaryPreferredAudioLanguage = + normalizeLanguageCode(PlayerSettingsStorage.loadSecondaryPreferredAudioLanguage()) + preferredSubtitleLanguage = + normalizeLanguageCode(PlayerSettingsStorage.loadPreferredSubtitleLanguage()) + ?: SubtitleLanguageOption.NONE + secondaryPreferredSubtitleLanguage = + normalizeLanguageCode(PlayerSettingsStorage.loadSecondaryPreferredSubtitleLanguage()) publish() } @@ -38,9 +56,49 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveShowLoadingOverlay(enabled) } + fun setPreferredAudioLanguage(language: String) { + ensureLoaded() + val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE + if (preferredAudioLanguage == normalized) return + preferredAudioLanguage = normalized + publish() + PlayerSettingsStorage.savePreferredAudioLanguage(normalized) + } + + fun setSecondaryPreferredAudioLanguage(language: String?) { + ensureLoaded() + val normalized = normalizeLanguageCode(language) + if (secondaryPreferredAudioLanguage == normalized) return + secondaryPreferredAudioLanguage = normalized + publish() + PlayerSettingsStorage.saveSecondaryPreferredAudioLanguage(normalized) + } + + fun setPreferredSubtitleLanguage(language: String) { + ensureLoaded() + val normalized = normalizeLanguageCode(language) ?: SubtitleLanguageOption.NONE + if (preferredSubtitleLanguage == normalized) return + preferredSubtitleLanguage = normalized + publish() + PlayerSettingsStorage.savePreferredSubtitleLanguage(normalized) + } + + fun setSecondaryPreferredSubtitleLanguage(language: String?) { + ensureLoaded() + val normalized = normalizeLanguageCode(language) + if (secondaryPreferredSubtitleLanguage == normalized) return + secondaryPreferredSubtitleLanguage = normalized + publish() + PlayerSettingsStorage.saveSecondaryPreferredSubtitleLanguage(normalized) + } + private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index d6fbcd01..23076f7f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -3,4 +3,12 @@ package com.nuvio.app.features.player internal expect object PlayerSettingsStorage { fun loadShowLoadingOverlay(): Boolean? fun saveShowLoadingOverlay(enabled: Boolean) + fun loadPreferredAudioLanguage(): String? + fun savePreferredAudioLanguage(language: String) + fun loadSecondaryPreferredAudioLanguage(): String? + fun saveSecondaryPreferredAudioLanguage(language: String?) + fun loadPreferredSubtitleLanguage(): String? + fun savePreferredSubtitleLanguage(language: String) + fun loadSecondaryPreferredSubtitleLanguage(): String? + fun saveSecondaryPreferredSubtitleLanguage(language: String?) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index d9f0729f..d409c351 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -17,6 +17,7 @@ data class SubtitleTrack( val label: String, val language: String? = null, val isSelected: Boolean = false, + val isForced: Boolean = false, ) data class AddonSubtitle( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 370d5a86..bab2e53b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -1,24 +1,297 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.player.AudioLanguageOption +import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.SubtitleLanguageOption +import com.nuvio.app.features.player.languageLabelForCode internal fun LazyListScope.playbackSettingsContent( isTablet: Boolean, showLoadingOverlay: Boolean, + preferredAudioLanguage: String, + secondaryPreferredAudioLanguage: String?, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, ) { item { - SettingsSection( - title = "PLAYER", + PlaybackSettingsSection( isTablet = isTablet, + showLoadingOverlay = showLoadingOverlay, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, + ) + } +} + +@Composable +private fun PlaybackSettingsSection( + isTablet: Boolean, + showLoadingOverlay: Boolean, + preferredAudioLanguage: String, + secondaryPreferredAudioLanguage: String?, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, +) { + var showPreferredAudioDialog by remember { mutableStateOf(false) } + var showSecondaryAudioDialog by remember { mutableStateOf(false) } + var showPreferredSubtitleDialog by remember { mutableStateOf(false) } + var showSecondarySubtitleDialog by remember { mutableStateOf(false) } + + SettingsSection( + title = "PLAYER", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = "Show Loading Overlay", + description = "Show the opening loading overlay while a stream starts playing.", + checked = showLoadingOverlay, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Preferred Audio Language", + description = when (preferredAudioLanguage) { + AudioLanguageOption.DEFAULT -> "Default" + AudioLanguageOption.DEVICE -> "Device Language" + else -> languageLabelForCode(preferredAudioLanguage) + }, + icon = Icons.Rounded.Language, + isTablet = isTablet, + onClick = { showPreferredAudioDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Secondary Audio Language", + description = languageLabelForCode(secondaryPreferredAudioLanguage), + icon = Icons.Rounded.Language, + isTablet = isTablet, + onClick = { showSecondaryAudioDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Preferred Subtitle Language", + description = when (preferredSubtitleLanguage) { + SubtitleLanguageOption.NONE -> "None" + SubtitleLanguageOption.DEVICE -> "Device Language" + SubtitleLanguageOption.FORCED -> "Forced" + else -> languageLabelForCode(preferredSubtitleLanguage) + }, + icon = Icons.Rounded.Subtitles, + isTablet = isTablet, + onClick = { showPreferredSubtitleDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Secondary Subtitle Language", + description = languageLabelForCode(secondaryPreferredSubtitleLanguage), + icon = Icons.Rounded.Subtitles, + isTablet = isTablet, + onClick = { showSecondarySubtitleDialog = true }, + ) + } + } + + if (showPreferredAudioDialog) { + LanguageSelectionDialog( + title = "Preferred Audio Language", + options = listOf( + LanguageSelectionOption(AudioLanguageOption.DEFAULT, "Default"), + LanguageSelectionOption(AudioLanguageOption.DEVICE, "Device Language"), + ) + AvailableLanguageOptions.map { option -> + LanguageSelectionOption(option.code, option.label) + }, + selectedValue = preferredAudioLanguage, + onSelect = { value -> + PlayerSettingsRepository.setPreferredAudioLanguage(value ?: AudioLanguageOption.DEVICE) + showPreferredAudioDialog = false + }, + onDismiss = { showPreferredAudioDialog = false }, + ) + } + + if (showSecondaryAudioDialog) { + LanguageSelectionDialog( + title = "Secondary Audio Language", + options = listOf( + LanguageSelectionOption(null, "None"), + ) + AvailableLanguageOptions.map { option -> + LanguageSelectionOption(option.code, option.label) + }, + selectedValue = secondaryPreferredAudioLanguage, + onSelect = { value -> + PlayerSettingsRepository.setSecondaryPreferredAudioLanguage(value) + showSecondaryAudioDialog = false + }, + onDismiss = { showSecondaryAudioDialog = false }, + ) + } + + if (showPreferredSubtitleDialog) { + LanguageSelectionDialog( + title = "Preferred Subtitle Language", + options = listOf( + LanguageSelectionOption(SubtitleLanguageOption.NONE, "None"), + LanguageSelectionOption(SubtitleLanguageOption.DEVICE, "Device Language"), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + ) + AvailableLanguageOptions.map { option -> + LanguageSelectionOption(option.code, option.label) + }, + selectedValue = preferredSubtitleLanguage, + onSelect = { value -> + PlayerSettingsRepository.setPreferredSubtitleLanguage(value ?: SubtitleLanguageOption.NONE) + showPreferredSubtitleDialog = false + }, + onDismiss = { showPreferredSubtitleDialog = false }, + ) + } + + if (showSecondarySubtitleDialog) { + LanguageSelectionDialog( + title = "Secondary Subtitle Language", + options = listOf( + LanguageSelectionOption(null, "None"), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + ) + AvailableLanguageOptions.map { option -> + LanguageSelectionOption(option.code, option.label) + }, + selectedValue = secondaryPreferredSubtitleLanguage, + onSelect = { value -> + PlayerSettingsRepository.setSecondaryPreferredSubtitleLanguage(value) + showSecondarySubtitleDialog = false + }, + onDismiss = { showSecondarySubtitleDialog = false }, + ) + } +} + +private data class LanguageSelectionOption( + val value: String?, + val label: String, +) + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LanguageSelectionDialog( + title: String, + options: List, + selectedValue: String?, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier + .fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, ) { - SettingsGroup(isTablet = isTablet) { - SettingsSwitchRow( - title = "Show Loading Overlay", - description = "Show the opening loading overlay while a stream starts playing.", - checked = showLoadingOverlay, - isTablet = isTablet, - onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(options) { option -> + val isSelected = option.value == selectedValue + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option.value) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Tap outside to close", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 8f69a62e..18dbc09a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -77,6 +77,10 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, selectedTheme = selectedTheme, onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, @@ -92,6 +96,10 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, selectedTheme = selectedTheme, onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, @@ -111,6 +119,10 @@ private fun MobileSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + preferredAudioLanguage: String, + secondaryPreferredAudioLanguage: String?, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, selectedTheme: AppTheme, onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, @@ -142,6 +154,10 @@ private fun MobileSettingsScreen( SettingsPage.Playback -> playbackSettingsContent( isTablet = false, showLoadingOverlay = showLoadingOverlay, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = false, @@ -165,6 +181,10 @@ private fun TabletSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + preferredAudioLanguage: String, + secondaryPreferredAudioLanguage: String?, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, selectedTheme: AppTheme, onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, @@ -245,6 +265,10 @@ private fun TabletSettingsScreen( SettingsPage.Playback -> playbackSettingsContent( isTablet = true, showLoadingOverlay = showLoadingOverlay, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = true, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.ios.kt new file mode 100644 index 00000000..91a9c136 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/DeviceLanguagePreferences.ios.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.player + +import platform.Foundation.NSUserDefaults + +internal actual object DeviceLanguagePreferences { + actual fun preferredLanguageCodes(): List { + val languages = mutableListOf() + + val preferred = NSUserDefaults.standardUserDefaults + .objectForKey("AppleLanguages") as? List<*> + + preferred.orEmpty().forEach { value -> + val code = value as? String ?: return@forEach + languages += code + languages += code.substringBefore('-') + } + + if (languages.isEmpty()) { + languages += "en" + } + + return languages + .mapNotNull(::normalizeLanguageCode) + .distinct() + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 1a4d1b67..94d847bb 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -83,12 +83,20 @@ actual fun PlatformPlayerSurface( override fun getSubtitleTracks(): List { val count = bridge.getSubtitleTrackCount() val tracks = (0 until count).map { i -> + val trackId = bridge.getSubtitleTrackId(i) + val trackLabel = bridge.getSubtitleTrackLabel(i) + val trackLanguage = bridge.getSubtitleTrackLang(i) SubtitleTrack( index = bridge.getSubtitleTrackIndex(i), - id = bridge.getSubtitleTrackId(i), - label = bridge.getSubtitleTrackLabel(i), - language = bridge.getSubtitleTrackLang(i), + id = trackId, + label = trackLabel, + language = trackLanguage, isSelected = bridge.isSubtitleTrackSelected(i), + isForced = inferForcedSubtitleTrack( + label = trackLabel, + language = trackLanguage, + trackId = trackId, + ), ) } Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index aa427282..316cc050 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -5,6 +5,10 @@ import platform.Foundation.NSUserDefaults actual object PlayerSettingsStorage { private const val showLoadingOverlayKey = "show_loading_overlay" + private const val preferredAudioLanguageKey = "preferred_audio_language" + private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" + private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" + private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" actual fun loadShowLoadingOverlay(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults @@ -19,4 +23,56 @@ actual object PlayerSettingsStorage { actual fun saveShowLoadingOverlay(enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(showLoadingOverlayKey)) } + + actual fun loadPreferredAudioLanguage(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(preferredAudioLanguageKey) + return defaults.stringForKey(key) + } + + actual fun savePreferredAudioLanguage(language: String) { + NSUserDefaults.standardUserDefaults.setObject(language, forKey = ProfileScopedKey.of(preferredAudioLanguageKey)) + } + + actual fun loadSecondaryPreferredAudioLanguage(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(secondaryPreferredAudioLanguageKey) + return defaults.stringForKey(key) + } + + actual fun saveSecondaryPreferredAudioLanguage(language: String?) { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(secondaryPreferredAudioLanguageKey) + if (language.isNullOrBlank()) { + defaults.removeObjectForKey(key) + } else { + defaults.setObject(language, forKey = key) + } + } + + actual fun loadPreferredSubtitleLanguage(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(preferredSubtitleLanguageKey) + return defaults.stringForKey(key) + } + + actual fun savePreferredSubtitleLanguage(language: String) { + NSUserDefaults.standardUserDefaults.setObject(language, forKey = ProfileScopedKey.of(preferredSubtitleLanguageKey)) + } + + actual fun loadSecondaryPreferredSubtitleLanguage(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(secondaryPreferredSubtitleLanguageKey) + return defaults.stringForKey(key) + } + + actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(secondaryPreferredSubtitleLanguageKey) + if (language.isNullOrBlank()) { + defaults.removeObjectForKey(key) + } else { + defaults.setObject(language, forKey = key) + } + } }