ref: adjust addon subtitle behaviour and language mapping

This commit is contained in:
tapframe 2026-05-13 13:59:28 +05:30
parent 32f937a4e0
commit 8b489c5f8e
3 changed files with 324 additions and 25 deletions

View file

@ -186,9 +186,18 @@ val AvailableLanguageOptions: List<LanguagePreferenceOption> = listOf(
LanguagePreferenceOption("zu", Res.string.lang_zulu), 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", "eng" to "en",
"spa" to "es", "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", "fra" to "fr",
"fre" to "fr", "fre" to "fr",
"deu" to "de", "deu" to "de",
@ -200,14 +209,170 @@ private val Iso639Aliases = mapOf(
"kor" to "ko", "kor" to "ko",
"zho" to "zh", "zho" to "zh",
"chi" 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", "ara" to "ar",
"hin" to "hi", "hin" to "hi",
"nld" to "nl", "nld" to "nl",
"dut" to "nl", "dut" to "nl",
"pol" to "pl", "pol" to "pl",
"swe" to "sv", "swe" to "sv",
"nor" to "no",
"dan" to "da",
"fin" to "fi",
"tur" to "tr", "tur" to "tr",
"ell" to "el",
"gre" to "el",
"heb" to "he", "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? { fun normalizeLanguageCode(language: String?): String? {
@ -218,13 +383,55 @@ fun normalizeLanguageCode(language: String?): String? {
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: return null ?: 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 primary = raw.substringBefore('-')
val canonicalPrimary = Iso639Aliases[primary] ?: primary val primaryAlias = LanguageCodeAliases[primary]?.replace('_', '-')?.lowercase()
val suffix = raw.substringAfter('-', "") val suffix = raw.substringAfter('-', "")
return if (suffix.isBlank()) { return if (suffix.isBlank()) {
canonicalPrimary primaryAlias ?: primary
} else if (primaryAlias != null && !primaryAlias.contains('-')) {
"$primaryAlias-$suffix"
} else { } else {
"$canonicalPrimary-$suffix" primaryAlias ?: "$primary-$suffix"
} }
} }

View file

@ -39,6 +39,8 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository 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.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
@ -439,8 +441,24 @@ fun PlayerScreen(
var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) } var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) }
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) } var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
val subtitleStyle = playerSettingsUiState.subtitleStyle val subtitleStyle = playerSettingsUiState.subtitleStyle
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.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<String?>(null)
}
fun refreshTracks() { fun refreshTracks() {
val ctrl = playerController ?: return val ctrl = playerController ?: return
@ -1092,8 +1110,8 @@ fun PlayerScreen(
} }
fun fetchAddonSubtitlesForActiveItem() { fun fetchAddonSubtitlesForActiveItem() {
val type = contentType ?: return val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
val videoId = activeVideoId ?: return val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
SubtitleRepository.fetchAddonSubtitles(type, videoId) SubtitleRepository.fetchAddonSubtitles(type, videoId)
} }
@ -1127,11 +1145,11 @@ fun PlayerScreen(
playerController?.applySubtitleStyle(subtitleStyle) playerController?.applySubtitleStyle(subtitleStyle)
} }
LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) { LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) { if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
fetchAddonSubtitlesForActiveItem() autoFetchedAddonSubtitlesForKey = fetchKey
} fetchAddonSubtitlesForActiveItem()
} }
LaunchedEffect(playbackSnapshot.isLoading, playerController) { LaunchedEffect(playbackSnapshot.isLoading, playerController) {
@ -1924,6 +1942,47 @@ fun PlayerScreen(
} }
} }
private fun buildAddonSubtitleFetchKey(
addons: List<ManagedAddon>,
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 <T> findPreferredTrackIndex( private fun <T> findPreferredTrackIndex(
tracks: List<T>, tracks: List<T>,
targets: List<String>, targets: List<String>,

View file

@ -1,10 +1,13 @@
package com.nuvio.app.features.player package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository 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.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -15,6 +18,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@ -35,8 +39,12 @@ object SubtitleRepository {
private val _error = MutableStateFlow<String?>(null) private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow() val error: StateFlow<String?> = _error.asStateFlow()
private var activeFetchJob: Job? = null
fun fetchAddonSubtitles(type: String, videoId: String) { fun fetchAddonSubtitles(type: String, videoId: String) {
scope.launch { activeFetchJob?.cancel()
activeFetchJob = scope.launch {
val requestType = canonicalSubtitleType(type)
_isLoading.value = true _isLoading.value = true
_error.value = null _error.value = null
_addonSubtitles.value = emptyList() _addonSubtitles.value = emptyList()
@ -46,17 +54,13 @@ object SubtitleRepository {
for (addon in addons) { for (addon in addons) {
val manifest = addon.manifest ?: continue val manifest = addon.manifest ?: continue
val subtitleResource = manifest.resources.find { it.name == "subtitles" } ?: continue val subtitleResource = manifest.resources.find { it.name.isSubtitleResourceName() } ?: continue
if (!subtitleResource.types.contains(type)) continue if (!subtitleResource.supportsSubtitleType(requestType, videoId)) continue
val prefixMatch = subtitleResource.idPrefixes.isEmpty() ||
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
if (!prefixMatch) continue
val subtitleUrl = buildAddonResourceUrl( val subtitleUrl = buildAddonResourceUrl(
manifestUrl = manifest.transportUrl, manifestUrl = manifest.transportUrl,
resource = "subtitles", resource = "subtitles",
type = type, type = requestType,
id = videoId, id = videoId,
) )
@ -69,21 +73,23 @@ object SubtitleRepository {
for (element in subtitlesArray) { for (element in subtitlesArray) {
val obj = element.jsonObject val obj = element.jsonObject
val id = obj["id"]?.jsonPrimitive?.content val id = obj.stringValue("id")
?: "${manifest.id}_${allSubs.size}" ?: "${manifest.id}_${allSubs.size}"
val url = obj["url"]?.jsonPrimitive?.content ?: continue val url = obj.stringValue("url") ?: continue
val lang = obj["lang"]?.jsonPrimitive?.content ?: "unknown" val rawLang = obj.subtitleLanguage() ?: "unknown"
val normalizedLang = normalizeLanguageCode(rawLang) ?: rawLang
allSubs.add( allSubs.add(
AddonSubtitle( AddonSubtitle(
id = id, id = id,
url = url, url = url,
language = lang, language = normalizedLang,
display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})", display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
) )
) )
} }
} catch (_: Throwable) { } catch (error: Throwable) {
if (error is CancellationException) throw error
} }
} }
@ -96,8 +102,35 @@ object SubtitleRepository {
} }
fun clear() { fun clear() {
activeFetchJob?.cancel()
_addonSubtitles.value = emptyList() _addonSubtitles.value = emptyList()
_isLoading.value = false _isLoading.value = false
_error.value = null _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() }