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 index 48088665..6f5c2348 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt @@ -186,9 +186,18 @@ val AvailableLanguageOptions: List = listOf( LanguagePreferenceOption("zu", Res.string.lang_zulu), ) -private val Iso639Aliases = mapOf( +private val LanguageCodeAliases = mapOf( + "pt-pt" to "pt", + "pt_br" to "pt-BR", + "pt-br" to "pt-BR", + "br" to "pt-BR", + "pob" to "pt-BR", "eng" to "en", "spa" to "es", + "es-419" to "es-419", + "es_419" to "es-419", + "es-la" to "es-419", + "es-lat" to "es-419", "fra" to "fr", "fre" to "fr", "deu" to "de", @@ -200,14 +209,170 @@ private val Iso639Aliases = mapOf( "kor" to "ko", "zho" to "zh", "chi" to "zh", + "zht" to "zh-TW", + "zhs" to "zh-CN", + "chi-tw" to "zh-TW", + "chi-cn" to "zh-CN", + "zh-tw" to "zh-TW", + "zh_tw" to "zh-TW", + "zh-cn" to "zh-CN", + "zh_cn" to "zh-CN", "ara" to "ar", "hin" to "hi", "nld" to "nl", "dut" to "nl", "pol" to "pl", "swe" to "sv", + "nor" to "no", + "dan" to "da", + "fin" to "fi", "tur" to "tr", + "ell" to "el", + "gre" to "el", "heb" to "he", + "tha" to "th", + "vie" to "vi", + "ind" to "id", + "msa" to "ms", + "may" to "ms", + "ces" to "cs", + "cze" to "cs", + "hun" to "hu", + "ron" to "ro", + "rum" to "ro", + "ukr" to "uk", + "bul" to "bg", + "hrv" to "hr", + "srp" to "sr", + "slk" to "sk", + "slo" to "sk", + "slv" to "sl", + "cat" to "ca", + "alb" to "sq", + "sqi" to "sq", + "bos" to "bs", + "mac" to "mk", + "mkd" to "mk", + "lav" to "lv", + "lit" to "lt", + "est" to "et", + "isl" to "is", + "ice" to "is", + "glg" to "gl", + "baq" to "eu", + "eus" to "eu", + "wel" to "cy", + "cym" to "cy", + "gle" to "ga", + "ben" to "bn", + "tam" to "ta", + "tel" to "te", + "mal" to "ml", + "kan" to "kn", + "mar" to "mr", + "pan" to "pa", + "guj" to "gu", + "urd" to "ur", + "fas" to "fa", + "per" to "fa", + "amh" to "am", + "swa" to "sw", + "zul" to "zu", + "afr" to "af", + "mlt" to "mt", + "bel" to "be", + "geo" to "ka", + "kat" to "ka", + "arm" to "hy", + "hye" to "hy", + "aze" to "az", + "kaz" to "kk", + "uzb" to "uz", + "mon" to "mn", + "khm" to "km", + "lao" to "lo", + "mya" to "my", + "bur" to "my", + "sin" to "si", + "nep" to "ne", + "tgl" to "tl", + "fil" to "tl", +) + +private val LanguageNameAliases = mapOf( + "afrikaans" to "af", + "albanian" to "sq", + "amharic" to "am", + "arabic" to "ar", + "armenian" to "hy", + "azerbaijani" to "az", + "basque" to "eu", + "belarusian" to "be", + "bengali" to "bn", + "bosnian" to "bs", + "bulgarian" to "bg", + "burmese" to "my", + "catalan" to "ca", + "chinese" to "zh", + "mandarin" to "zh", + "croatian" to "hr", + "czech" to "cs", + "danish" to "da", + "dutch" to "nl", + "english" to "en", + "estonian" to "et", + "filipino" to "tl", + "finnish" to "fi", + "french" to "fr", + "galician" to "gl", + "georgian" to "ka", + "german" to "de", + "greek" to "el", + "gujarati" to "gu", + "hebrew" to "he", + "hindi" to "hi", + "hungarian" to "hu", + "icelandic" to "is", + "indonesian" to "id", + "irish" to "ga", + "italian" to "it", + "japanese" to "ja", + "kannada" to "kn", + "kazakh" to "kk", + "khmer" to "km", + "korean" to "ko", + "lao" to "lo", + "latvian" to "lv", + "lithuanian" to "lt", + "macedonian" to "mk", + "malay" to "ms", + "malayalam" to "ml", + "maltese" to "mt", + "marathi" to "mr", + "mongolian" to "mn", + "nepali" to "ne", + "norwegian" to "no", + "persian" to "fa", + "polish" to "pl", + "punjabi" to "pa", + "romanian" to "ro", + "russian" to "ru", + "serbian" to "sr", + "sinhala" to "si", + "slovak" to "sk", + "slovenian" to "sl", + "swahili" to "sw", + "swedish" to "sv", + "tamil" to "ta", + "telugu" to "te", + "thai" to "th", + "turkish" to "tr", + "ukrainian" to "uk", + "urdu" to "ur", + "uzbek" to "uz", + "vietnamese" to "vi", + "welsh" to "cy", + "zulu" to "zu", ) fun normalizeLanguageCode(language: String?): String? { @@ -218,13 +383,55 @@ fun normalizeLanguageCode(language: String?): String? { ?.takeIf { it.isNotBlank() } ?: return null + val tokenized = raw + .replace('-', ' ') + .replace('.', ' ') + .replace('/', ' ') + .replace(Regex("\\s+"), " ") + .trim() + + fun containsAny(vararg values: String): Boolean = + values.any { value -> tokenized.contains(value) } + + if (containsAny("portuguese", "portugues")) { + return when { + containsAny("brazil", "brasil", "brazilian", "brasileiro", "pt br", "ptbr", "pob", "(br)") -> + "pt-br" + containsAny("portugal", "european", "europeu", "iberian", "pt pt", "ptpt") -> + "pt" + else -> "pt" + } + } + + if (containsAny("spanish", "espanol", "castellano")) { + return if (containsAny("latin", "latino", "latinoamerica", "latinoamericano", "lat am", "latam", "es 419", "es419", "(419)")) { + "es-419" + } else { + "es" + } + } + + LanguageCodeAliases[raw]?.let { return it.replace('_', '-').lowercase() } + LanguageNameAliases[tokenized]?.let { return it } + LanguageNameAliases.entries + .sortedByDescending { it.key.length } + .firstOrNull { (name, _) -> + tokenized == name || + tokenized.startsWith("$name ") || + tokenized.endsWith(" $name") || + tokenized.contains(" $name ") + } + ?.let { return it.value } + val primary = raw.substringBefore('-') - val canonicalPrimary = Iso639Aliases[primary] ?: primary + val primaryAlias = LanguageCodeAliases[primary]?.replace('_', '-')?.lowercase() val suffix = raw.substringAfter('-', "") return if (suffix.isBlank()) { - canonicalPrimary + primaryAlias ?: primary + } else if (primaryAlias != null && !primaryAlias.contains('-')) { + "$primaryAlias-$suffix" } else { - "$canonicalPrimary-$suffix" + primaryAlias ?: "$primary-$suffix" } } 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 9db6838d..50c727c1 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 @@ -39,6 +39,8 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.AddonResource +import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaVideo @@ -439,8 +441,24 @@ fun PlayerScreen( var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) } var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) } val subtitleStyle = playerSettingsUiState.subtitleStyle + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() + val activeAddonSubtitleType = contentType ?: parentMetaType + val addonSubtitleFetchKey = remember( + addonsUiState.addons, + activeAddonSubtitleType, + activeVideoId, + ) { + buildAddonSubtitleFetchKey( + addons = addonsUiState.addons, + type = activeAddonSubtitleType, + videoId = activeVideoId, + ) + } + var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) { + mutableStateOf(null) + } fun refreshTracks() { val ctrl = playerController ?: return @@ -1092,8 +1110,8 @@ fun PlayerScreen( } fun fetchAddonSubtitlesForActiveItem() { - val type = contentType ?: return - val videoId = activeVideoId ?: return + val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return + val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return SubtitleRepository.fetchAddonSubtitles(type, videoId) } @@ -1127,11 +1145,11 @@ fun PlayerScreen( playerController?.applySubtitleStyle(subtitleStyle) } - LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) { - if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect - if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) { - fetchAddonSubtitlesForActiveItem() - } + LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) { + val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect + if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect + autoFetchedAddonSubtitlesForKey = fetchKey + fetchAddonSubtitlesForActiveItem() } LaunchedEffect(playbackSnapshot.isLoading, playerController) { @@ -1924,6 +1942,47 @@ fun PlayerScreen( } } +private fun buildAddonSubtitleFetchKey( + addons: List, + type: String?, + videoId: String?, +): String? { + val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null + val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null + val compatibleSubtitleAddons = addons.mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + val supportsSubtitles = manifest.resources.any { resource -> + resource.isCompatibleSubtitleResource( + type = normalizedType, + videoId = normalizedVideoId, + ) + } + if (!supportsSubtitles) return@mapNotNull null + "${manifest.id}:${manifest.transportUrl}" + } + + if (compatibleSubtitleAddons.isEmpty()) return null + return buildString { + append(normalizedType) + append('|') + append(normalizedVideoId) + append('|') + append(compatibleSubtitleAddons.sorted().joinToString("|")) + } +} + +private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean { + val isSubtitleResource = name.equals("subtitles", ignoreCase = true) || + name.equals("subtitle", ignoreCase = true) + if (!isSubtitleResource) return false + + val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type + val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) } + if (!typeMatches) return false + + return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } +} + private fun findPreferredTrackIndex( tracks: List, targets: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index a6991e74..64d8c879 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -1,10 +1,13 @@ package com.nuvio.app.features.player import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,6 +18,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -35,8 +39,12 @@ object SubtitleRepository { private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + private var activeFetchJob: Job? = null + fun fetchAddonSubtitles(type: String, videoId: String) { - scope.launch { + activeFetchJob?.cancel() + activeFetchJob = scope.launch { + val requestType = canonicalSubtitleType(type) _isLoading.value = true _error.value = null _addonSubtitles.value = emptyList() @@ -46,17 +54,13 @@ object SubtitleRepository { for (addon in addons) { val manifest = addon.manifest ?: continue - val subtitleResource = manifest.resources.find { it.name == "subtitles" } ?: continue - if (!subtitleResource.types.contains(type)) continue - - val prefixMatch = subtitleResource.idPrefixes.isEmpty() || - subtitleResource.idPrefixes.any { videoId.startsWith(it) } - if (!prefixMatch) continue + val subtitleResource = manifest.resources.find { it.name.isSubtitleResourceName() } ?: continue + if (!subtitleResource.supportsSubtitleType(requestType, videoId)) continue val subtitleUrl = buildAddonResourceUrl( manifestUrl = manifest.transportUrl, resource = "subtitles", - type = type, + type = requestType, id = videoId, ) @@ -69,21 +73,23 @@ object SubtitleRepository { for (element in subtitlesArray) { val obj = element.jsonObject - val id = obj["id"]?.jsonPrimitive?.content + val id = obj.stringValue("id") ?: "${manifest.id}_${allSubs.size}" - val url = obj["url"]?.jsonPrimitive?.content ?: continue - val lang = obj["lang"]?.jsonPrimitive?.content ?: "unknown" + val url = obj.stringValue("url") ?: continue + val rawLang = obj.subtitleLanguage() ?: "unknown" + val normalizedLang = normalizeLanguageCode(rawLang) ?: rawLang allSubs.add( AddonSubtitle( id = id, url = url, - language = lang, - display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})", + language = normalizedLang, + display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})", ) ) } - } catch (_: Throwable) { + } catch (error: Throwable) { + if (error is CancellationException) throw error } } @@ -96,8 +102,35 @@ object SubtitleRepository { } fun clear() { + activeFetchJob?.cancel() _addonSubtitles.value = emptyList() _isLoading.value = false _error.value = null } } + +private fun canonicalSubtitleType(type: String): String = + if (type.equals("tv", ignoreCase = true)) "series" else type.lowercase() + +private fun String.isSubtitleResourceName(): Boolean = + equals("subtitles", ignoreCase = true) || equals("subtitle", ignoreCase = true) + +private fun AddonResource.supportsSubtitleType(type: String, videoId: String): Boolean { + val typeMatches = types.isEmpty() || types.any { it.equals(type, ignoreCase = true) } + if (!typeMatches) return false + return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } +} + +private fun JsonObject.subtitleLanguage(): String? = + stringValue("lang") + ?: stringValue("language") + ?: stringValue("languageCode") + ?: stringValue("locale") + ?: stringValue("label") + +private fun JsonObject.stringValue(name: String): String? = + this[name] + ?.jsonPrimitive + ?.contentOrNull + ?.trim() + ?.takeIf { it.isNotBlank() }