mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-28 11:53:00 +00:00
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:
parent
3b796018f7
commit
aac7fc9534
13 changed files with 932 additions and 12 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -327,6 +327,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
|
||||||
for (group in currentTracks.groups) {
|
for (group in currentTracks.groups) {
|
||||||
if (group.type != C.TRACK_TYPE_TEXT) continue
|
if (group.type != C.TRACK_TYPE_TEXT) continue
|
||||||
val format = group.mediaTrackGroup.getFormat(0)
|
val format = group.mediaTrackGroup.getFormat(0)
|
||||||
|
val hasForcedSelectionFlag = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0
|
||||||
tracks.add(
|
tracks.add(
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
index = idx,
|
index = idx,
|
||||||
|
|
@ -334,6 +335,12 @@ private fun ExoPlayer.extractSubtitleTracks(): List<SubtitleTrack> {
|
||||||
label = format.label ?: "",
|
label = format.label ?: "",
|
||||||
language = format.language,
|
language = format.language,
|
||||||
isSelected = group.isSelected,
|
isSelected = group.isSelected,
|
||||||
|
isForced = inferForcedSubtitleTrack(
|
||||||
|
label = format.label,
|
||||||
|
language = format.language,
|
||||||
|
trackId = format.id,
|
||||||
|
hasForcedSelectionFlag = hasForcedSelectionFlag,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
idx++
|
idx++
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
actual object PlayerSettingsStorage {
|
actual object PlayerSettingsStorage {
|
||||||
private const val preferencesName = "nuvio_player_settings"
|
private const val preferencesName = "nuvio_player_settings"
|
||||||
private const val showLoadingOverlayKey = "show_loading_overlay"
|
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
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
||||||
|
|
@ -30,4 +34,58 @@ actual object PlayerSettingsStorage {
|
||||||
?.putBoolean(ProfileScopedKey.of(showLoadingOverlayKey), enabled)
|
?.putBoolean(ProfileScopedKey.of(showLoadingOverlayKey), enabled)
|
||||||
?.apply()
|
?.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -164,6 +164,8 @@ fun PlayerScreen(
|
||||||
var selectedSubtitleIndex by remember { mutableStateOf(-1) }
|
var selectedSubtitleIndex by remember { mutableStateOf(-1) }
|
||||||
var selectedAddonSubtitleId by remember { mutableStateOf<String?>(null) }
|
var selectedAddonSubtitleId by remember { mutableStateOf<String?>(null) }
|
||||||
var useCustomSubtitles by remember { mutableStateOf(false) }
|
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 subtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
|
||||||
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
|
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
|
||||||
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
|
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
|
||||||
|
|
@ -179,6 +181,69 @@ fun PlayerScreen(
|
||||||
println("NuvioPlayer refreshTracks: useCustom=$useCustomSubtitles selectedAddonId=$selectedAddonSubtitleId selectedSubIdx=$selectedSubtitleIndex")
|
println("NuvioPlayer refreshTracks: useCustom=$useCustomSubtitles selectedAddonId=$selectedAddonSubtitleId selectedSubIdx=$selectedSubtitleIndex")
|
||||||
println("NuvioPlayer refreshTracks: found ${subtitleTracks.size} subtitle tracks, selectedTrack=${selectedSub?.index}")
|
println("NuvioPlayer refreshTracks: found ${subtitleTracks.size} subtitle tracks, selectedTrack=${selectedSub?.index}")
|
||||||
if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = 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")
|
println("NuvioPlayer refreshTracks: final selectedSubtitleIndex=$selectedSubtitleIndex")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,6 +300,8 @@ fun PlayerScreen(
|
||||||
initialLoadCompleted = false
|
initialLoadCompleted = false
|
||||||
lastProgressPersistEpochMs = 0L
|
lastProgressPersistEpochMs = 0L
|
||||||
previousIsPlaying = false
|
previousIsPlaying = false
|
||||||
|
preferredAudioSelectionApplied = false
|
||||||
|
preferredSubtitleSelectionApplied = false
|
||||||
SubtitleRepository.clear()
|
SubtitleRepository.clear()
|
||||||
WatchProgressRepository.ensureLoaded()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
data class PlayerSettingsUiState(
|
data class PlayerSettingsUiState(
|
||||||
val showLoadingOverlay: Boolean = true,
|
val showLoadingOverlay: Boolean = true,
|
||||||
|
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
|
||||||
|
val secondaryPreferredAudioLanguage: String? = null,
|
||||||
|
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
|
||||||
|
val secondaryPreferredSubtitleLanguage: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
object PlayerSettingsRepository {
|
object PlayerSettingsRepository {
|
||||||
|
|
@ -14,6 +18,10 @@ object PlayerSettingsRepository {
|
||||||
|
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
private var showLoadingOverlay = true
|
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() {
|
fun ensureLoaded() {
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
|
|
@ -27,6 +35,16 @@ object PlayerSettingsRepository {
|
||||||
private fun loadFromDisk() {
|
private fun loadFromDisk() {
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
showLoadingOverlay = PlayerSettingsStorage.loadShowLoadingOverlay() ?: 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()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,9 +56,49 @@ object PlayerSettingsRepository {
|
||||||
PlayerSettingsStorage.saveShowLoadingOverlay(enabled)
|
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() {
|
private fun publish() {
|
||||||
_uiState.value = PlayerSettingsUiState(
|
_uiState.value = PlayerSettingsUiState(
|
||||||
showLoadingOverlay = showLoadingOverlay,
|
showLoadingOverlay = showLoadingOverlay,
|
||||||
|
preferredAudioLanguage = preferredAudioLanguage,
|
||||||
|
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,12 @@ package com.nuvio.app.features.player
|
||||||
internal expect object PlayerSettingsStorage {
|
internal expect object PlayerSettingsStorage {
|
||||||
fun loadShowLoadingOverlay(): Boolean?
|
fun loadShowLoadingOverlay(): Boolean?
|
||||||
fun saveShowLoadingOverlay(enabled: 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?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ data class SubtitleTrack(
|
||||||
val label: String,
|
val label: String,
|
||||||
val language: String? = null,
|
val language: String? = null,
|
||||||
val isSelected: Boolean = false,
|
val isSelected: Boolean = false,
|
||||||
|
val isForced: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AddonSubtitle(
|
data class AddonSubtitle(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,79 @@
|
||||||
package com.nuvio.app.features.settings
|
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.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.PlayerSettingsRepository
|
||||||
|
import com.nuvio.app.features.player.SubtitleLanguageOption
|
||||||
|
import com.nuvio.app.features.player.languageLabelForCode
|
||||||
|
|
||||||
internal fun LazyListScope.playbackSettingsContent(
|
internal fun LazyListScope.playbackSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
showLoadingOverlay: Boolean,
|
showLoadingOverlay: Boolean,
|
||||||
|
preferredAudioLanguage: String,
|
||||||
|
secondaryPreferredAudioLanguage: String?,
|
||||||
|
preferredSubtitleLanguage: String,
|
||||||
|
secondaryPreferredSubtitleLanguage: String?,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
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(
|
SettingsSection(
|
||||||
title = "PLAYER",
|
title = "PLAYER",
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
|
|
@ -20,6 +86,213 @@ internal fun LazyListScope.playbackSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay,
|
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,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@ fun SettingsScreen(
|
||||||
page = page,
|
page = page,
|
||||||
onPageChange = { currentPage = it.name },
|
onPageChange = { currentPage = it.name },
|
||||||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||||
|
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||||
|
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||||
|
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||||
|
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||||
selectedTheme = selectedTheme,
|
selectedTheme = selectedTheme,
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
|
|
@ -92,6 +96,10 @@ fun SettingsScreen(
|
||||||
page = page,
|
page = page,
|
||||||
onPageChange = { currentPage = it.name },
|
onPageChange = { currentPage = it.name },
|
||||||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||||
|
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||||
|
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||||
|
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||||
|
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||||
selectedTheme = selectedTheme,
|
selectedTheme = selectedTheme,
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
|
|
@ -111,6 +119,10 @@ private fun MobileSettingsScreen(
|
||||||
page: SettingsPage,
|
page: SettingsPage,
|
||||||
onPageChange: (SettingsPage) -> Unit,
|
onPageChange: (SettingsPage) -> Unit,
|
||||||
showLoadingOverlay: Boolean,
|
showLoadingOverlay: Boolean,
|
||||||
|
preferredAudioLanguage: String,
|
||||||
|
secondaryPreferredAudioLanguage: String?,
|
||||||
|
preferredSubtitleLanguage: String,
|
||||||
|
secondaryPreferredSubtitleLanguage: String?,
|
||||||
selectedTheme: AppTheme,
|
selectedTheme: AppTheme,
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
|
|
@ -142,6 +154,10 @@ private fun MobileSettingsScreen(
|
||||||
SettingsPage.Playback -> playbackSettingsContent(
|
SettingsPage.Playback -> playbackSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
showLoadingOverlay = showLoadingOverlay,
|
showLoadingOverlay = showLoadingOverlay,
|
||||||
|
preferredAudioLanguage = preferredAudioLanguage,
|
||||||
|
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||||
)
|
)
|
||||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
|
|
@ -165,6 +181,10 @@ private fun TabletSettingsScreen(
|
||||||
page: SettingsPage,
|
page: SettingsPage,
|
||||||
onPageChange: (SettingsPage) -> Unit,
|
onPageChange: (SettingsPage) -> Unit,
|
||||||
showLoadingOverlay: Boolean,
|
showLoadingOverlay: Boolean,
|
||||||
|
preferredAudioLanguage: String,
|
||||||
|
secondaryPreferredAudioLanguage: String?,
|
||||||
|
preferredSubtitleLanguage: String,
|
||||||
|
secondaryPreferredSubtitleLanguage: String?,
|
||||||
selectedTheme: AppTheme,
|
selectedTheme: AppTheme,
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
|
|
@ -245,6 +265,10 @@ private fun TabletSettingsScreen(
|
||||||
SettingsPage.Playback -> playbackSettingsContent(
|
SettingsPage.Playback -> playbackSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
showLoadingOverlay = showLoadingOverlay,
|
showLoadingOverlay = showLoadingOverlay,
|
||||||
|
preferredAudioLanguage = preferredAudioLanguage,
|
||||||
|
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||||
|
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||||
|
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||||
)
|
)
|
||||||
SettingsPage.Appearance -> appearanceSettingsContent(
|
SettingsPage.Appearance -> appearanceSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,12 +83,20 @@ actual fun PlatformPlayerSurface(
|
||||||
override fun getSubtitleTracks(): List<SubtitleTrack> {
|
override fun getSubtitleTracks(): List<SubtitleTrack> {
|
||||||
val count = bridge.getSubtitleTrackCount()
|
val count = bridge.getSubtitleTrackCount()
|
||||||
val tracks = (0 until count).map { i ->
|
val tracks = (0 until count).map { i ->
|
||||||
|
val trackId = bridge.getSubtitleTrackId(i)
|
||||||
|
val trackLabel = bridge.getSubtitleTrackLabel(i)
|
||||||
|
val trackLanguage = bridge.getSubtitleTrackLang(i)
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
index = bridge.getSubtitleTrackIndex(i),
|
index = bridge.getSubtitleTrackIndex(i),
|
||||||
id = bridge.getSubtitleTrackId(i),
|
id = trackId,
|
||||||
label = bridge.getSubtitleTrackLabel(i),
|
label = trackLabel,
|
||||||
language = bridge.getSubtitleTrackLang(i),
|
language = trackLanguage,
|
||||||
isSelected = bridge.isSubtitleTrackSelected(i),
|
isSelected = bridge.isSubtitleTrackSelected(i),
|
||||||
|
isForced = inferForcedSubtitleTrack(
|
||||||
|
label = trackLabel,
|
||||||
|
language = trackLanguage,
|
||||||
|
trackId = trackId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" }
|
Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
actual object PlayerSettingsStorage {
|
actual object PlayerSettingsStorage {
|
||||||
private const val showLoadingOverlayKey = "show_loading_overlay"
|
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? {
|
actual fun loadShowLoadingOverlay(): Boolean? {
|
||||||
val defaults = NSUserDefaults.standardUserDefaults
|
val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
|
@ -19,4 +23,56 @@ actual object PlayerSettingsStorage {
|
||||||
actual fun saveShowLoadingOverlay(enabled: Boolean) {
|
actual fun saveShowLoadingOverlay(enabled: Boolean) {
|
||||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(showLoadingOverlayKey))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue