feat: Implement audio and subtitle language preferences

- Added `DeviceLanguagePreferences` for Android and iOS to retrieve preferred language codes.
- Enhanced `PlayerSettingsStorage` to save and load preferred audio and subtitle languages.
- Updated `PlayerSettingsRepository` to manage audio and subtitle language preferences.
- Modified `PlayerScreen` to apply preferred audio and subtitle selections.
- Introduced `PlaybackSettingsSection` in `PlaybackSettingsPage` for user to select audio and subtitle languages.
- Added language normalization and matching functions to support language preference logic.
- Updated `SubtitleTrack` model to include `isForced` property for subtitle tracks.
This commit is contained in:
tapframe 2026-03-29 15:01:08 +05:30
parent 3b796018f7
commit aac7fc9534
13 changed files with 932 additions and 12 deletions

View file

@ -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<String> {
val languages = mutableListOf<String>()
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<String>, locale: Locale) {
bucket += locale.toLanguageTag()
bucket += locale.language
}
}

View file

@ -327,6 +327,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
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<SubtitleTrack> {
label = format.label ?: "",
language = format.language,
isSelected = group.isSelected,
isForced = inferForcedSubtitleTrack(
label = format.label,
language = format.language,
trackId = format.id,
hasForcedSelectionFlag = hasForcedSelectionFlag,
),
)
)
idx++

View file

@ -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()
}
}

View file

@ -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<LanguagePreferenceOption> = 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<String>,
): List<String> {
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<String>,
): List<String> {
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<String>
}
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")
}

View file

@ -164,6 +164,8 @@ fun PlayerScreen(
var selectedSubtitleIndex by remember { mutableStateOf(-1) }
var selectedAddonSubtitleId by remember { mutableStateOf<String?>(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 <T> findPreferredTrackIndex(
tracks: List<T>,
targets: List<String>,
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<SubtitleTrack>,
targets: List<String>,
): 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
}

View file

@ -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,
)
}
}

View file

@ -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?)
}

View file

@ -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(

View file

@ -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<LanguageSelectionOption>,
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,
)
}
}

View file

@ -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,

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.player
import platform.Foundation.NSUserDefaults
internal actual object DeviceLanguagePreferences {
actual fun preferredLanguageCodes(): List<String> {
val languages = mutableListOf<String>()
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()
}
}

View file

@ -83,12 +83,20 @@ actual fun PlatformPlayerSurface(
override fun getSubtitleTracks(): List<SubtitleTrack> {
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" }

View file

@ -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)
}
}
}