From 4c59ab90a33f51e675c11a507daf620f28188077 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 16 May 2026 15:22:07 +0530 Subject: [PATCH 01/60] feat(plugin-runtime): implement robust, future-proof native crypto engine (AES, PBKDF2, Web Crypto) --- .../features/plugins/PluginCrypto.android.kt | 135 +++- .../features/plugins/runtime/PluginRuntime.kt | 175 ++++++ .../plugins/runtime/crypto/CryptoBridge.kt | 136 ++++ .../features/plugins/runtime/dom/DomBridge.kt | 118 ++++ .../plugins/runtime/host/HostApiRegistry.kt | 19 + .../plugins/runtime/host/HostFunctions.kt | 43 ++ .../features/plugins/runtime/js/JsBindings.kt | 595 ++++++++++++++++++ .../features/plugins/runtime/js/JsRuntime.kt | 16 + .../plugins/runtime/network/FetchBridge.kt | 103 +++ .../plugins/runtime/wasm/WasmBridge.kt | 16 + .../app/features/plugins/PluginCrypto.ios.kt | 273 +++++++- .../cinterop/commoncrypto_shim.h | 81 +++ 12 files changed, 1692 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index 8a4c66e90..475fd8b4f 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -1,14 +1,139 @@ package com.nuvio.app.features.plugins +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import java.security.MessageDigest -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec + +private val secureRandom = SecureRandom() + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + secureRandom.nextBytes(bytes) + return bytes +} + +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + return MessageDigest.getInstance(algorithm.uppercase()).digest(data) +} + +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val normalizedAlgo = when (algorithm.uppercase()) { + "SHA256" -> "PBKDF2WithHmacSHA256" + "SHA1" -> "PBKDF2WithHmacSHA1" + else -> "PBKDF2WithHmacSHA256" + } + val factory = SecretKeyFactory.getInstance(normalizedAlgo) + val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray() + val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits) + return factory.generateSecret(spec).encoded +} + +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.ENCRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val normalizedMode = when (mode.uppercase()) { + "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" + "AES-GCM", "GCM" -> "AES/GCM/NoPadding" + "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" + else -> "AES/CBC/PKCS5Padding" + } + + val cipher = Cipher.getInstance(normalizedMode) + val keySpec = SecretKeySpec(key, "AES") + + if (normalizedMode.contains("ECB")) { + cipher.init(Cipher.DECRYPT_MODE, keySpec) + } else if (normalizedMode.contains("GCM")) { + val gcmSpec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + } else { + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + } + + return cipher.doFinal(data) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val privKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initSign(privKey) + sig.update(data) + return sig.sign() +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) { + "RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA" + "ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA" + else -> "RSA" to "SHA256withRSA" + } + val factory = KeyFactory.getInstance(keyAlgo) + val pubKey = factory.generatePublic(X509EncodedKeySpec(publicKey)) + val sig = Signature.getInstance(sigAlgo) + sig.initVerify(pubKey) + sig.update(data) + return sig.verify(signature) +} internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) + val digest = pluginDigest(algorithm, data.encodeToByteArray()) return digest.joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt new file mode 100644 index 000000000..7349e11df --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -0,0 +1,175 @@ +package com.nuvio.app.features.plugins.runtime + +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge +import com.nuvio.app.features.plugins.runtime.dom.DomBridge +import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry +import com.nuvio.app.features.plugins.runtime.host.HostFunctions +import com.nuvio.app.features.plugins.runtime.js.JsBindings +import com.nuvio.app.features.plugins.runtime.js.JsRuntime +import com.nuvio.app.features.plugins.runtime.network.FetchBridge +import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val PLUGIN_TIMEOUT_MS = 60_000L + +internal object PluginRuntime { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap(), + ): List = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + executePluginInternal( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraperId, + scraperSettings = scraperSettings, + ) + } + } + + private suspend fun executePluginInternal( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map, + ): List { + val jsRuntime = JsRuntime() + var resultJson = "[]" + + val domBridge = DomBridge() + val hostRegistry = HostApiRegistry().apply { + addModule(HostFunctions(scraperId) { resultJson = it }) + addModule(FetchBridge()) + addModule(CryptoBridge()) + addModule(WasmBridge()) + addModule(domBridge) + } + + try { + jsRuntime.use { + hostRegistry.registerAll(this) + + val settingsJson = toJsonElement(scraperSettings).toString() + val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val seasonArg = season?.toString() ?: "undefined" + val episodeArg = episode?.toString() ?: "undefined" + val callCode = """ + (async function() { + try { + var getStreams = module.exports.getStreams || globalThis.getStreams; + if (!getStreams) { + console.error("getStreams function not found on module.exports or globalThis"); + __capture_result(JSON.stringify([])); + return; + } + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + evaluate(callCode) + } + + return parseJsonResults(resultJson) + } finally { + domBridge.clear() + } + } + + private fun parseJsonResults(rawJson: String): List { + return runCatching { + val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() + array.mapNotNull { element -> + val item = element as? JsonObject ?: return@mapNotNull null + val url = when (val urlValue = item["url"]) { + is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } + is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + else -> null + } ?: return@mapNotNull null + + val headers = (item["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + + PluginRuntimeResult( + title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", + name = item.stringOrNull("name"), + url = url, + quality = item.stringOrNull("quality"), + size = item.stringOrNull("size"), + language = item.stringOrNull("language"), + provider = item.stringOrNull("provider"), + type = item.stringOrNull("type"), + seeders = item["seeders"]?.jsonPrimitive?.intOrNull, + peers = item["peers"]?.jsonPrimitive?.intOrNull, + infoHash = item.stringOrNull("infoHash"), + headers = headers, + ) + }.filter { it.url.isNotBlank() } + }.getOrElse { emptyList() } + } + + private fun JsonObject.stringOrNull(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } + + private fun toJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Number -> JsonPrimitive(value.toDouble()) + is Map<*, *> -> JsonObject( + value.entries + .filter { it.key is String } + .associate { (it.key as String) to toJsonElement(it.value) }, + ) + is Iterable<*> -> JsonArray(value.map(::toJsonElement)) + else -> JsonPrimitive(value.toString()) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt new file mode 100644 index 000000000..0cbe87ce5 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt @@ -0,0 +1,136 @@ +package com.nuvio.app.features.plugins.runtime.crypto + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.plugins.runtime.host.HostModule +import com.nuvio.app.features.plugins.pluginDigestHex +import com.nuvio.app.features.plugins.pluginHmacHex +import com.nuvio.app.features.plugins.pluginBase64Encode +import com.nuvio.app.features.plugins.pluginBase64Decode +import com.nuvio.app.features.plugins.pluginUtf8ToHex +import com.nuvio.app.features.plugins.pluginHexToUtf8 +import com.nuvio.app.features.plugins.pluginGetRandomValues +import com.nuvio.app.features.plugins.pluginDigest +import com.nuvio.app.features.plugins.pluginPbkdf2 +import com.nuvio.app.features.plugins.pluginAesDecrypt +import com.nuvio.app.features.plugins.pluginAesEncrypt +import com.nuvio.app.features.plugins.pluginSign +import com.nuvio.app.features.plugins.pluginVerify + +internal class CryptoBridge : HostModule { + override fun register(runtime: QuickJs) { + // --- Binary-Safe Bridges (New) --- + + runtime.function("__crypto_get_random_values") { args -> + val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0 + runCatching { + pluginGetRandomValues(length) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_digest_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + runCatching { + pluginDigest(algorithm, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_pbkdf2_raw") { args -> + val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0) + val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000 + val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256 + val algorithm = args.getOrNull(4)?.toString() ?: "SHA256" + runCatching { + pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_encrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesEncrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_aes_decrypt_raw") { args -> + val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" + val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginAesDecrypt(mode, key, iv, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_sign_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + runCatching { + pluginSign(algorithm, privateKey, data) + }.getOrElse { ByteArray(0) } + } + + runtime.function("__crypto_verify_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "" + val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0) + val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) + runCatching { + pluginVerify(algorithm, publicKey, signature, data) + }.getOrDefault(false) + } + + // --- Legacy Hex/String Bridges (Backward Compatibility) --- + + runtime.function("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + runtime.function("__crypto_hmac_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = args.getOrNull(1)?.toString() ?: "" + val data = args.getOrNull(2)?.toString() ?: "" + runCatching { + pluginHmacHex(algorithm, key, data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + runtime.function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + runtime.function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt new file mode 100644 index 000000000..0ada370e4 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/dom/DomBridge.kt @@ -0,0 +1,118 @@ +package com.nuvio.app.features.plugins.runtime.dom + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.select.Elements +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlin.random.Random + +internal class DomBridge : HostModule { + private val documentCache = mutableMapOf() + private val elementCache = mutableMapOf() + private var idCounter = 0 + private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") + + override fun register(runtime: QuickJs) { + runtime.function("__cheerio_load") { args -> + val html = args.getOrNull(0)?.toString() ?: "" + val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" + documentCache[docId] = Ksoup.parse(html) + docId + } + + runtime.function("__cheerio_select") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + var selector = args.getOrNull(1)?.toString() ?: "" + val doc = documentCache[docId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = if (selector.isEmpty()) Elements() else doc.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_find") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + var selector = args.getOrNull(2)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = element.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:find:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + runtime.function("__cheerio_text") { args -> + val elementIds = args.getOrNull(1)?.toString() ?: "" + elementIds.split(",") + .filter { it.isNotEmpty() } + .mapNotNull { elementCache[it]?.text() } + .joinToString(" ") + } + + runtime.function("__cheerio_html") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + if (elementId.isEmpty()) { + documentCache[docId]?.html() ?: "" + } else { + elementCache[elementId]?.html() ?: "" + } + } + + runtime.function("__cheerio_inner_html") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + elementCache[elementId]?.html() ?: "" + } + + runtime.function("__cheerio_attr") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + val attrName = args.getOrNull(2)?.toString() ?: "" + val value = elementCache[elementId]?.attr(attrName) + if (value.isNullOrEmpty()) "__UNDEFINED__" else value + } + + runtime.function("__cheerio_next") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val next = element.nextElementSibling() ?: return@function "__NONE__" + val nextId = "$docId:next:${next.hashCode()}" + elementCache[nextId] = next + nextId + } + + runtime.function("__cheerio_prev") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val prev = element.previousElementSibling() ?: return@function "__NONE__" + val prevId = "$docId:prev:${prev.hashCode()}" + elementCache[prevId] = prev + prevId + } + } + + fun clear() { + documentCache.clear() + elementCache.clear() + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt new file mode 100644 index 000000000..c002258a1 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostApiRegistry.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.plugins.runtime.host + +import com.dokar.quickjs.QuickJs + +internal interface HostModule { + fun register(runtime: QuickJs) +} + +internal class HostApiRegistry { + private val modules = mutableListOf() + + fun addModule(module: HostModule) { + modules.add(module) + } + + fun registerAll(runtime: QuickJs) { + modules.forEach { it.register(runtime) } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt new file mode 100644 index 000000000..552fbac64 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/host/HostFunctions.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.features.plugins.runtime.host + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function + +internal class HostFunctions( + private val scraperId: String, + private val onResult: (String) -> Unit +) : HostModule { + private val log = Logger.withTag("PluginRuntime") + + override fun register(runtime: QuickJs) { + runtime.define("console") { + function("log") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("error") { args -> + log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("warn") { args -> + log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("info") { args -> + log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("debug") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + } + + runtime.function("__capture_result") { args -> + onResult(args.getOrNull(0)?.toString() ?: "[]") + null + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt new file mode 100644 index 000000000..0969bc702 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -0,0 +1,595 @@ +package com.nuvio.app.features.plugins.runtime.js + +internal object JsBindings { + fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + return """ + globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_SETTINGS = $settingsJson; + if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; + if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; + if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; + + ${fetchPolyfill()} + ${abortControllerPolyfill()} + ${base64Polyfill()} + ${urlPolyfill()} + ${cryptoPolyfill()} + ${cheerioPolyfill()} + ${requirePolyfill()} + ${arrayPolyfill()} + ${objectPolyfill()} + ${stringPolyfill()} + """.trimIndent() + } + + private fun fetchPolyfill() = """ + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + var followRedirects = options.redirect !== 'manual'; + var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); + var parsed = JSON.parse(result); + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[name.toLowerCase()] || null; + } + }, + text: function() { return Promise.resolve(parsed.body); }, + json: function() { + try { + if (parsed.body === null || parsed.body === undefined || parsed.body === '') { + return Promise.resolve(null); + } + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + return Promise.resolve(null); + } + } + }; + }; + """.trimIndent() + + private fun abortControllerPolyfill() = """ + if (typeof AbortSignal === 'undefined') { + var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; + AbortSignal.prototype.addEventListener = function(type, listener) { + if (type !== 'abort' || typeof listener !== 'function') return; + this._listeners.push(listener); + }; + AbortSignal.prototype.removeEventListener = function(type, listener) { + if (type !== 'abort') return; + this._listeners = this._listeners.filter(function(l) { return l !== listener; }); + }; + AbortSignal.prototype.dispatchEvent = function(event) { + if (!event || event.type !== 'abort') return true; + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i].call(this, event); } catch (e) {} + } + return true; + }; + globalThis.AbortSignal = AbortSignal; + } + + if (typeof AbortController === 'undefined') { + var AbortController = function() { this.signal = new AbortSignal(); }; + AbortController.prototype.abort = function(reason) { + if (this.signal.aborted) return; + this.signal.aborted = true; + this.signal.reason = reason; + this.signal.dispatchEvent({ type: 'abort' }); + }; + globalThis.AbortController = AbortController; + } + """.trimIndent() + + private fun base64Polyfill() = """ + if (typeof atob === 'undefined') { + globalThis.atob = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input).replace(/=+$/, ''); + if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); + var output = ''; + var bc = 0, bs, buffer, idx = 0; + while ((buffer = str.charAt(idx++))) { + buffer = chars.indexOf(buffer); + if (buffer === -1) continue; + bs = bc % 4 ? bs * 64 + buffer : buffer; + if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); + } + return output; + }; + } + + if (typeof btoa === 'undefined') { + globalThis.btoa = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input); + var output = ''; + for (var block, charCode, idx = 0, map = chars; + str.charAt(idx | 0) || (map = '=', idx % 1); + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { + charCode = str.charCodeAt(idx += 3 / 4); + if (charCode > 0xFF) throw new Error('InvalidCharacterError'); + block = (block << 8) | charCode; + } + return output; + }; + } + """.trimIndent() + + private fun urlPolyfill() = """ + var __native_parse_url = typeof __parse_url !== 'undefined' ? __parse_url : function(u) { return JSON.stringify({ protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '' }); }; + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = __native_parse_url(fullUrl); + var data = JSON.parse(parsed); + this.href = fullUrl; + this.protocol = data.protocol; + this.host = data.host; + this.hostname = data.hostname; + this.port = data.port; + this.pathname = data.pathname; + this.search = data.search; + this.hash = data.hash; + this.origin = data.protocol + '//' + data.host; + this.searchParams = new URLSearchParams(data.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (init && typeof init === 'object' && !Array.isArray(init)) { + Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); + } else if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + var parts = pair.split('='); + if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); + }); + } + }; + URLSearchParams.prototype.toString = function() { + var self = this; + return Object.keys(this._params).map(function(key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); + }).join('&'); + }; + URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; + URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; + URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; + URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; + URLSearchParams.prototype.values = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return self._params[k]; }); + }; + URLSearchParams.prototype.entries = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); + }; + URLSearchParams.prototype.forEach = function(callback) { + var self = this; + Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); + }; + URLSearchParams.prototype.getAll = function(key) { + return this._params.hasOwnProperty(key) ? [this._params[key]] : []; + }; + URLSearchParams.prototype.sort = function() { + var sorted = {}; + var self = this; + Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); + this._params = sorted; + }; + """.trimIndent() + + private fun cryptoPolyfill() = """ + function __hexToWords(hex) { + var words = []; + for (var i = 0; i < hex.length; i += 8) { + var chunk = hex.substring(i, i + 8); + while (chunk.length < 8) chunk += '0'; + words.push(parseInt(chunk, 16) | 0); + } + return words; + } + + function __wordsToHex(words, sigBytes) { + var hex = ''; + for (var i = 0; i < sigBytes; i++) { + var word = words[i >>> 2] || 0; + var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; + var part = byte.toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __wordArrayToHex(value) { + if (!value) return ''; + if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); + if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __wordsToHex(value.words, value.sigBytes); + } + return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : ''; + } + + function __buildWordArray(hex, utf8Override) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var wordArray = { + __hex: normalizedHex, + __utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''), + sigBytes: normalizedHex.length / 2, + words: __hexToWords(normalizedHex), + toString: function(encoder) { + if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; + if (encoder === CryptoJS.enc.Utf8) return this.__utf8; + if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : ''; + return this.__hex; + }, + clamp: function() { return this; }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : ''; + this.sigBytes = this.__hex.length / 2; + this.words = __hexToWords(this.__hex); + return this; + } + }; + return wordArray; + } + + function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); } + function __wordArrayFromUtf8(text) { + var utf8 = text == null ? '' : String(text); + var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : ''; + return __buildWordArray(hex, utf8); + } + function __wordArrayFromBase64(base64) { + var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : ''; + return __wordArrayFromUtf8(utf8); + } + + function __normalizeWordArrayInput(value) { + if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8; + if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : ''; + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : ''; + } + if (value == null) return ''; + return String(value); + } + + function __bufferToUint8(data) { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (typeof data === 'string') return new TextEncoder().encode(data); + return new Uint8Array(0); + } + + var CryptoJS = { + enc: { + Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } }, + Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } }, + Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } } + }, + lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } }, + mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, + pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, + algo: { SHA256: 'SHA256' }, + MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); }, + SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); }, + SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, + SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, + PBKDF2: function(pass, salt, options) { + var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); + var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); + var iter = options.iterations || 1000; + var kSize = options.keySize || (256/32); + var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; + var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); + return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + }, + AES: { + encrypt: function(message, key, options) { + var data = __bufferToUint8(__normalizeWordArrayInput(message)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + return { + ciphertext: wa, + toString: function() { return wa.toString(CryptoJS.enc.Base64); } + }; + }, + decrypt: function(cipher, key, options) { + var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher)); + var kBytes = __bufferToUint8(__wordArrayToHex(key)); + var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var mode = options.mode || 'AES-CBC'; + var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var plain = new TextDecoder().decode(resBytes); + return { toString: function(enc) { return plain; } }; + } + } + }; + globalThis.CryptoJS = CryptoJS; + + globalThis.crypto = { + subtle: { + digest: async function(algo, data) { + var bytes = __bufferToUint8(data); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); + return res.buffer; + }, + importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, + deriveBits: async function(params, key, len) { + var pBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(params.salt); + var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); + return res.buffer; + }, + encrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + decrypt: async function(params, key, data) { + var kBytes = __bufferToUint8(key._raw); + var ivBytes = __bufferToUint8(params.iv || ''); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + sign: async function(algo, key, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var dBytes = __bufferToUint8(data); + var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); + return res.buffer; + }, + verify: async function(algo, key, sig, data) { + var algoName = typeof algo === 'string' ? algo : (algo.name || ''); + var kBytes = __bufferToUint8(key._raw); + var sBytes = __bufferToUint8(sig); + var dBytes = __bufferToUint8(data); + return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; + } + }, + getRandomValues: function(arr) { + + var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); + for (var i = 0; i < arr.length; i++) arr[i] = bytes[i]; + return arr; + } + }; + + // WebAssembly placeholder + globalThis.WebAssembly = { + instantiate: async function(bufferSource, importObject) { + console.warn("WebAssembly.instantiate called (placeholder)"); + return { instance: { exports: {} }, module: {} }; + } + }; + """.trimIndent() + + private fun cheerioPolyfill() = """ + var cheerio = { + load: function(html) { + var docId = __cheerio_load(html); + var $ = function(selector, context) { + if (selector && selector._elementIds) return selector; + if (context && context._elementIds && context._elementIds.length > 0) { + var allIds = []; + for (var i = 0; i < context._elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + } + return createCheerioWrapper(docId, selector); + }; + $.html = function(el) { + if (el && el._elementIds && el._elementIds.length > 0) { + return __cheerio_html(docId, el._elementIds[0]); + } + return __cheerio_html(docId, ''); + }; + return $; + } + }; + + function createCheerioWrapper(docId, selector) { + var elementIds; + if (typeof selector === 'string') { + var idsJson = __cheerio_select(docId, selector); + elementIds = JSON.parse(idsJson); + } else { + elementIds = []; + } + return createCheerioWrapperFromIds(docId, elementIds); + } + + function createCheerioWrapperFromIds(docId, ids) { + var wrapper = { + _docId: docId, + _elementIds: ids, + length: ids.length, + each: function(callback) { + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + find: function(sel) { + var allIds = []; + for (var i = 0; i < ids.length; i++) { + var childIdsJson = __cheerio_find(docId, ids[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + text: function() { + if (ids.length === 0) return ''; + return __cheerio_text(docId, ids.join(',')); + }, + html: function() { + if (ids.length === 0) return ''; + return __cheerio_inner_html(docId, ids[0]); + }, + attr: function(name) { + if (ids.length === 0) return undefined; + var val = __cheerio_attr(docId, ids[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, + last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, + next: function() { + var nextIds = []; + for (var i = 0; i < ids.length; i++) { + var nextId = __cheerio_next(docId, ids[i]); + if (nextId && nextId !== '__NONE__') nextIds.push(nextId); + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + prev: function() { + var prevIds = []; + for (var i = 0; i < ids.length; i++) { + var prevId = __cheerio_prev(docId, ids[i]); + if (prevId && prevId !== '__NONE__') prevIds.push(prevId); + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + eq: function(index) { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return createCheerioWrapperFromIds(docId, []); + }, + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return undefined; + } + return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); + }, + map: function(callback) { + var results = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) results.push(result); + } + return { + length: results.length, + get: function(index) { return typeof index === 'number' ? results[index] : results; }, + toArray: function() { return results; } + }; + }, + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) filteredIds.push(ids[i]); + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + children: function(sel) { return this.find(sel || '*'); }, + parent: function() { return createCheerioWrapperFromIds(docId, []); }, + toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } + }; + return wrapper; + } + """.trimIndent() + + private fun requirePolyfill() = """ + var require = function(moduleName) { + if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { + return cheerio; + } + if (moduleName === 'crypto-js') { + return CryptoJS; + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + """.trimIndent() + + private fun arrayPolyfill() = """ + if (!Array.prototype.flat) { + Array.prototype.flat = function(depth) { + depth = depth === undefined ? 1 : Math.floor(depth); + if (depth < 1) return Array.prototype.slice.call(this); + return (function flatten(arr, d) { + return d > 0 + ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) + : arr.slice(); + })(this, depth); + }; + } + + if (!Array.prototype.flatMap) { + Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; + } + """.trimIndent() + + private fun objectPolyfill() = """ + if (!Object.entries) { + Object.entries = function(obj) { + var result = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); + } + return result; + }; + } + + if (!Object.fromEntries) { + Object.fromEntries = function(entries) { + var result = {}; + for (var i = 0; i < entries.length; i++) { + result[entries[i][0]] = entries[i][1]; + } + return result; + }; + } + """.trimIndent() + + private fun stringPolyfill() = """ + if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(search, replace) { + if (search instanceof RegExp) { + if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); + return this.replace(search, replace); + } + return this.split(search).join(replace); + }; + } + """.trimIndent() +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt new file mode 100644 index 000000000..704da56c2 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsRuntime.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.js + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.quickJs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal class JsRuntime( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + suspend fun use(block: suspend QuickJs.() -> T): T { + return quickJs(dispatcher) { + block() + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt new file mode 100644 index 000000000..5dd6d78be --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -0,0 +1,103 @@ +package com.nuvio.app.features.plugins.runtime.network + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +private const val MAX_FETCH_BODY_CHARS = 256 * 1024 +private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 +private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" + +internal class FetchBridge : HostModule { + private val log = Logger.withTag("PluginRuntime") + private val json = Json { ignoreUnknownKeys = true } + + override fun register(runtime: QuickJs) { + runtime.function("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + val followRedirects = args.getOrNull(4) as? Boolean ?: true + try { + performNativeFetch(url, method, headersJson, body, followRedirects) + } catch (t: Throwable) { + log.e(t) { "Fetch bridge error for $method $url" } + JsonObject( + mapOf( + "ok" to JsonPrimitive(false), + "status" to JsonPrimitive(0), + "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), + "url" to JsonPrimitive(url), + "body" to JsonPrimitive(""), + "headers" to JsonObject(emptyMap()), + ), + ).toString() + } + } + } + + private fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + followRedirects: Boolean, + ): String { + val headers = parseHeaders(headersJson).toMutableMap() + if (!headers.containsKey("User-Agent")) { + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + val response = runBlocking { + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + followRedirects = followRedirects, + ) + } + + val responseHeaders = response.headers.mapValues { (_, value) -> + truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) + } + val result = JsonObject( + mapOf( + "ok" to JsonPrimitive(response.status in 200..299), + "status" to JsonPrimitive(response.status), + "statusText" to JsonPrimitive(response.statusText), + "url" to JsonPrimitive(response.url), + "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), + ), + ) + return result.toString() + } + + private fun parseHeaders(headersJson: String): Map { + return runCatching { + val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) + obj.entries + .mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + .toMap() + }.getOrDefault(emptyMap()) + } + + private fun truncateString(value: String, maxChars: Int): String { + if (value.length <= maxChars) return value + val end = maxChars - FETCH_TRUNCATION_SUFFIX.length + if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) + return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt new file mode 100644 index 000000000..bc1e8d163 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.plugins.runtime.wasm + +import com.dokar.quickjs.QuickJs +import com.nuvio.app.features.plugins.runtime.host.HostModule + +/** + * Lightweight WASM Helpers bridge. + * For now, this is a placeholder for running small WASM modules. + * In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js. + */ +internal class WasmBridge : HostModule { + override fun register(runtime: QuickJs) { + // Placeholder for WASM instantiation bridge + // runtime.function("__native_wasm_instantiate") { ... } + } +} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index f581b27c6..dc1186951 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.plugins import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 @@ -17,6 +26,226 @@ import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256 import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512 +import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF +import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1 +import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256 +import com.nuvio.app.features.plugins.cryptointerop.CCCrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode +import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt +import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding +import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess +import platform.Security.SecRandomCopyBytes +import platform.Security.kSecRandomDefault + +internal fun pluginGetRandomValues(length: Int): ByteArray { + val bytes = ByteArray(length) + @OptIn(ExperimentalForeignApi::class) + SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + return bytes +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { + val normalized = algorithm.uppercase() + val output = ByteArray( + when (normalized) { + "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() + "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() + "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() + else -> error("Unsupported digest algorithm: $algorithm") + }, + ) + + data.usePinned { pinnedData -> + output.usePinned { pinnedOutput -> + val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0).reinterpret() + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr) + } + } + } + + return output +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginPbkdf2( + password: ByteArray, + salt: ByteArray, + iterations: Int, + keySizeBits: Int, + algorithm: String, +): ByteArray { + val prf = when (algorithm.uppercase()) { + "SHA256" -> kCCPRFHmacAlgSHA256 + "SHA1" -> kCCPRFHmacAlgSHA1 + else -> kCCPRFHmacAlgSHA256 + } + + val derivedKeyLen = keySizeBits / 8 + val derivedKey = ByteArray(derivedKeyLen) + + password.usePinned { pinnedPassword -> + salt.usePinned { pinnedSalt -> + derivedKey.usePinned { pinnedDerivedKey -> + val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret() else null + val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret() else null + val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret() + + val status = CCKeyDerivationPBKDF( + algorithm = kCCPBKDF2, + password = passwordPtr, + passwordLen = password.size.toULong(), + salt = saltPtr, + saltLen = salt.size.toULong(), + prf = prf, + rounds = iterations.toUInt(), + derivedKey = derivedKeyPtr, + derivedKeyLen = derivedKeyLen.toULong() + ) + + require(status == kCCSuccess) { "PBKDF2 failed with status: $status" } + } + } + } + + return derivedKey +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesEncrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCEncrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt Encrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginAesDecrypt( + mode: String, + key: ByteArray, + iv: ByteArray, + data: ByteArray, +): ByteArray { + val isGcm = mode.uppercase().contains("GCM") + if (isGcm) { + throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS") + } + val isEcb = mode.uppercase().contains("ECB") + + val dataOutAvailable = data.size + 16 // AES block size + val dataOut = ByteArray(dataOutAvailable) + + var finalData: ByteArray? = null + + memScoped { + val dataOutMoved = alloc() + + val options = if (isEcb) { + kCCOptionPKCS7Padding or kCCOptionECBMode + } else { + kCCOptionPKCS7Padding + } + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + dataOut.usePinned { pinnedDataOut -> + val status = CCCrypt( + op = kCCDecrypt, + alg = kCCAlgorithmAES, + options = options, + key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null, + keyLength = key.size.toULong(), + iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null, + dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null, + dataInLength = data.size.toULong(), + dataOut = pinnedDataOut.addressOf(0), + dataOutAvailable = dataOutAvailable.toULong(), + dataOutMoved = dataOutMoved.ptr + ) + + if (status == kCCSuccess) { + finalData = dataOut.copyOf(dataOutMoved.value.toInt()) + } else { + error("CCCrypt failed with status: $status") + } + } + } + } + } + } + + return finalData ?: ByteArray(0) +} + +internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray { + throw UnsupportedOperationException("Asymmetric signing is currently implemented natively only on Android") +} + +internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean { + throw UnsupportedOperationException("Asymmetric verification is currently implemented natively only on Android") +} private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> byte.toString(16).padStart(2, '0') @@ -36,11 +265,18 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { }, ) - when (normalized) { - "MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0)) - "SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0)) + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val dataPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + when (normalized) { + "MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr) + "SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr) + "SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr) + "SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr) + } + } } return output.toHex() @@ -61,14 +297,25 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String } val output = UByteArray(outputSize) - CCHmac( - alg, - keyBytes.refTo(0), - keyBytes.size.toULong(), - input.refTo(0), - input.size.toULong(), - output.refTo(0), - ) + + keyBytes.usePinned { pinnedKey -> + input.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null + val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null + val outputPtr = pinnedOutput.addressOf(0) + + CCHmac( + alg, + keyPtr, + keyBytes.size.toULong(), + inputPtr, + input.size.toULong(), + outputPtr, + ) + } + } + } return output.toHex() } diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index cc394555f..b2620dc11 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -31,3 +31,84 @@ void CCHmac( size_t dataLength, void *macOut ); + +typedef uint32_t CCPBKDFAlgorithm; +enum { + kCCPBKDF2 = 2, +}; + +typedef uint32_t CCPseudoRandomAlgorithm; +enum { + kCCPRFHmacAlgSHA1 = 1, + kCCPRFHmacAlgSHA224 = 2, + kCCPRFHmacAlgSHA256 = 3, + kCCPRFHmacAlgSHA384 = 4, + kCCPRFHmacAlgSHA512 = 5, +}; + +int CCKeyDerivationPBKDF( + CCPBKDFAlgorithm algorithm, + const char *password, + size_t passwordLen, + const uint8_t *salt, + size_t saltLen, + CCPseudoRandomAlgorithm prf, + uint32_t rounds, + uint8_t *derivedKey, + size_t derivedKeyLen +); + +typedef int32_t CCCryptorStatus; +enum { + kCCSuccess = 0, + kCCParamError = -4300, + kCCBufferTooSmall = -4301, + kCCMemoryFailure = -4302, + kCCAlignmentError = -4303, + kCCDecodeError = -4304, + kCCUnimplemented = -4305, + kCCOverflow = -4306, + kCCRNGFailure = -4307, + kCCUnspecifiedError = -4308, + kCCCallSequenceError = -4309, + kCCKeySizeError = -4310, + kCCInvalidKey = -4311, +}; + +typedef uint32_t CCOperation; +enum { + kCCEncrypt = 0, + kCCDecrypt = 1, +}; + +typedef uint32_t CCAlgorithm; +enum { + kCCAlgorithmAES128 = 0, + kCCAlgorithmAES = 0, + kCCAlgorithmDES = 1, + kCCAlgorithm3DES = 2, + kCCAlgorithmCAST = 3, + kCCAlgorithmRC4 = 4, + kCCAlgorithmRC2 = 5, + kCCAlgorithmBlowfish = 6, +}; + +typedef uint32_t CCOptions; +enum { + kCCOptionPKCS7Padding = 1, + kCCOptionECBMode = 2, +}; + +CCCryptorStatus CCCrypt( + CCOperation op, + CCAlgorithm alg, + CCOptions options, + const void *key, + size_t keyLength, + const void *iv, + const void *dataIn, + size_t dataInLength, + void *dataOut, + size_t dataOutAvailable, + size_t *dataOutMoved +); From e76119ee44ef5c6062a05c3db89cd297a345b66b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 16 May 2026 18:25:49 +0530 Subject: [PATCH 02/60] feat: complete modular plugin runtime transition with url bridge and enhanced polyfills --- .../features/plugins/PluginCrypto.android.kt | 10 +- .../app/features/plugins/PluginRepository.kt | 1 + .../app/features/plugins/PluginRuntime.kt | 991 ------------------ .../features/plugins/runtime/PluginRuntime.kt | 2 + .../features/plugins/runtime/js/JsBindings.kt | 27 + .../plugins/runtime/network/UrlBridge.kt | 53 + .../plugins/runtime/wasm/WasmBridge.kt | 4 +- .../app/features/plugins/PluginCrypto.ios.kt | 10 +- 8 files changed, 99 insertions(+), 999 deletions(-) delete mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index 475fd8b4f..b1d4fedc5 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -171,11 +171,11 @@ internal fun pluginUtf8ToHex(value: String): String = byte.toUByte().toString(16).padStart(2, '0') } -internal fun pluginHexToUtf8(hex: String): String { +internal fun pluginHexToByteArray(hex: String): ByteArray { val normalized = hex.trim().lowercase() .replace(" ", "") .removePrefix("0x") - if (normalized.isEmpty()) return "" + if (normalized.isEmpty()) return ByteArray(0) val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" val out = ByteArray(evenHex.length / 2) @@ -183,5 +183,9 @@ internal fun pluginHexToUtf8(hex: String): String { val part = evenHex.substring(index * 2, index * 2 + 2) out[index] = part.toInt(16).toByte() } - return out.decodeToString() + return out +} + +internal fun pluginHexToUtf8(hex: String): String { + return pluginHexToByteArray(hex).decodeToString() } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 32e0562fd..ec2741e74 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.plugins.runtime.PluginRuntime import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.query.Order import io.github.jan.supabase.postgrest.rpc diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt deleted file mode 100644 index 8d792b5e1..000000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ /dev/null @@ -1,991 +0,0 @@ -package com.nuvio.app.features.plugins - -import co.touchlab.kermit.Logger -import com.dokar.quickjs.binding.define -import com.dokar.quickjs.binding.function -import com.dokar.quickjs.quickJs -import com.fleeksoft.ksoup.Ksoup -import com.fleeksoft.ksoup.nodes.Document -import com.fleeksoft.ksoup.nodes.Element -import com.fleeksoft.ksoup.select.Elements -import com.nuvio.app.features.addons.httpRequestRaw -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlin.random.Random - -private const val PLUGIN_TIMEOUT_MS = 60_000L -private const val MAX_FETCH_BODY_CHARS = 256 * 1024 -private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 -private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" - -internal object PluginRuntime { - private val log = Logger.withTag("PluginRuntime") - private val json = Json { - ignoreUnknownKeys = true - } - - private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") - - suspend fun executePlugin( - code: String, - tmdbId: String, - mediaType: String, - season: Int?, - episode: Int?, - scraperId: String, - scraperSettings: Map = emptyMap(), - ): List = withContext(Dispatchers.Default) { - withTimeout(PLUGIN_TIMEOUT_MS) { - executePluginInternal( - code = code, - tmdbId = tmdbId, - mediaType = mediaType, - season = season, - episode = episode, - scraperId = scraperId, - scraperSettings = scraperSettings, - ) - } - } - - private suspend fun executePluginInternal( - code: String, - tmdbId: String, - mediaType: String, - season: Int?, - episode: Int?, - scraperId: String, - scraperSettings: Map, - ): List { - val documentCache = mutableMapOf() - val elementCache = mutableMapOf() - var idCounter = 0 - var resultJson = "[]" - - try { - quickJs(Dispatchers.Default) { - define("console") { - function("log") { args -> - log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("error") { args -> - log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("warn") { args -> - log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("info") { args -> - log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - function("debug") { args -> - log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } - null - } - } - - function("__native_fetch") { args -> - val url = args.getOrNull(0)?.toString() ?: "" - val method = args.getOrNull(1)?.toString() ?: "GET" - val headersJson = args.getOrNull(2)?.toString() ?: "{}" - val body = args.getOrNull(3)?.toString() ?: "" - val followRedirects = args.getOrNull(4) as? Boolean ?: true - try { - performNativeFetch(url, method, headersJson, body, followRedirects) - } catch (t: Throwable) { - log.e(t) { "Fetch bridge error for $method $url" } - JsonObject( - mapOf( - "ok" to JsonPrimitive(false), - "status" to JsonPrimitive(0), - "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), - "url" to JsonPrimitive(url), - "body" to JsonPrimitive(""), - "headers" to JsonObject(emptyMap()), - ), - ).toString() - } - } - - function("__crypto_digest_hex") { args -> - val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val data = args.getOrNull(1)?.toString() ?: "" - runCatching { - pluginDigestHex(algorithm, data) - }.getOrDefault("") - } - - function("__crypto_hmac_hex") { args -> - val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val key = args.getOrNull(1)?.toString() ?: "" - val data = args.getOrNull(2)?.toString() ?: "" - runCatching { - pluginHmacHex(algorithm, key, data) - }.getOrDefault("") - } - - function("__crypto_base64_encode") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginBase64Encode(data) - }.getOrDefault("") - } - - function("__crypto_base64_decode") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginBase64Decode(data) - }.getOrDefault("") - } - - function("__crypto_utf8_to_hex") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginUtf8ToHex(data) - }.getOrDefault("") - } - - function("__crypto_hex_to_utf8") { args -> - val data = args.getOrNull(0)?.toString() ?: "" - runCatching { - pluginHexToUtf8(data) - }.getOrDefault("") - } - - function("__parse_url") { args -> - parseUrl(args.getOrNull(0)?.toString() ?: "") - } - - function("__cheerio_load") { args -> - val html = args.getOrNull(0)?.toString() ?: "" - val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" - documentCache[docId] = Ksoup.parse(html) - docId - } - - function("__cheerio_select") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - var selector = args.getOrNull(1)?.toString() ?: "" - val doc = documentCache[docId] ?: return@function "[]" - try { - selector = selector.replace(containsRegex, ":contains($1)") - val elements = if (selector.isEmpty()) Elements() else doc.select(selector) - val ids = elements.mapIndexed { index, el -> - val id = "$docId:$index:${el.hashCode()}" - elementCache[id] = el - id - } - "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" - } catch (_: Exception) { - "[]" - } - } - - function("__cheerio_find") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - var selector = args.getOrNull(2)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "[]" - try { - selector = selector.replace(containsRegex, ":contains($1)") - val elements = element.select(selector) - val ids = elements.mapIndexed { index, el -> - val id = "$docId:find:$index:${el.hashCode()}" - elementCache[id] = el - id - } - "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" - } catch (_: Exception) { - "[]" - } - } - - function("__cheerio_text") { args -> - val elementIds = args.getOrNull(1)?.toString() ?: "" - elementIds.split(",") - .filter { it.isNotEmpty() } - .mapNotNull { elementCache[it]?.text() } - .joinToString(" ") - } - - function("__cheerio_html") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - if (elementId.isEmpty()) { - documentCache[docId]?.html() ?: "" - } else { - elementCache[elementId]?.html() ?: "" - } - } - - function("__cheerio_inner_html") { args -> - val elementId = args.getOrNull(1)?.toString() ?: "" - elementCache[elementId]?.html() ?: "" - } - - function("__cheerio_attr") { args -> - val elementId = args.getOrNull(1)?.toString() ?: "" - val attrName = args.getOrNull(2)?.toString() ?: "" - val value = elementCache[elementId]?.attr(attrName) - if (value.isNullOrEmpty()) "__UNDEFINED__" else value - } - - function("__cheerio_next") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "__NONE__" - val next = element.nextElementSibling() ?: return@function "__NONE__" - val nextId = "$docId:next:${next.hashCode()}" - elementCache[nextId] = next - nextId - } - - function("__cheerio_prev") { args -> - val docId = args.getOrNull(0)?.toString() ?: "" - val elementId = args.getOrNull(1)?.toString() ?: "" - val element = elementCache[elementId] ?: return@function "__NONE__" - val prev = element.previousElementSibling() ?: return@function "__NONE__" - val prevId = "$docId:prev:${prev.hashCode()}" - elementCache[prevId] = prev - prevId - } - - function("__capture_result") { args -> - resultJson = args.getOrNull(0)?.toString() ?: "[]" - null - } - - val settingsJson = toJsonElement(scraperSettings).toString() - val polyfillCode = buildPolyfillCode(scraperId, settingsJson) - evaluate(polyfillCode) - - val wrappedCode = """ - var module = { exports: {} }; - var exports = module.exports; - (function() { - $code - })(); - """.trimIndent() - evaluate(wrappedCode) - - val seasonArg = season?.toString() ?: "undefined" - val episodeArg = episode?.toString() ?: "undefined" - val callCode = """ - (async function() { - try { - var getStreams = module.exports.getStreams || globalThis.getStreams; - if (!getStreams) { - console.error("getStreams function not found on module.exports or globalThis"); - __capture_result(JSON.stringify([])); - return; - } - var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); - __capture_result(JSON.stringify(result || [])); - } catch (e) { - console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); - __capture_result(JSON.stringify([])); - } - })(); - """.trimIndent() - evaluate(callCode) - } - - return parseJsonResults(resultJson) - } finally { - documentCache.clear() - elementCache.clear() - } - } - - private fun performNativeFetch( - url: String, - method: String, - headersJson: String, - body: String, - followRedirects: Boolean, - ): String { - return try { - val headers = parseHeaders(headersJson).toMutableMap() - if (!headers.containsKey("User-Agent")) { - headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - } - - val response = runBlocking { - httpRequestRaw( - method = method, - url = url, - headers = headers, - body = body, - followRedirects = followRedirects, - ) - } - - val responseHeaders = response.headers.mapValues { (_, value) -> - truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) - } - val result = JsonObject( - mapOf( - "ok" to JsonPrimitive(response.status in 200..299), - "status" to JsonPrimitive(response.status), - "statusText" to JsonPrimitive(response.statusText), - "url" to JsonPrimitive(response.url), - "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), - "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), - ), - ) - result.toString() - } catch (error: Throwable) { - log.e(error) { "Fetch error for $method $url" } - JsonObject( - mapOf( - "ok" to JsonPrimitive(false), - "status" to JsonPrimitive(0), - "statusText" to JsonPrimitive(error.message ?: "Fetch failed"), - "url" to JsonPrimitive(url), - "body" to JsonPrimitive(""), - "headers" to JsonObject(emptyMap()), - ), - ) - .toString() - } - } - - private fun parseHeaders(headersJson: String): Map { - return runCatching { - val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) - obj.entries - .mapNotNull { (key, value) -> - value.jsonPrimitive.contentOrNull?.let { key to it } - } - .toMap() - }.getOrDefault(emptyMap()) - } - - private fun parseUrl(urlString: String): String { - return try { - val parsed = io.ktor.http.Url(urlString) - JsonObject( - mapOf( - "protocol" to JsonPrimitive("${parsed.protocol.name}:"), - "host" to JsonPrimitive( - if (parsed.port != parsed.protocol.defaultPort) { - "${parsed.host}:${parsed.port}" - } else { - parsed.host - }, - ), - "hostname" to JsonPrimitive(parsed.host), - "port" to JsonPrimitive( - if (parsed.port != parsed.protocol.defaultPort) parsed.port.toString() else "", - ), - "pathname" to JsonPrimitive(parsed.encodedPath.ifBlank { "/" }), - "search" to JsonPrimitive(parsed.encodedQuery?.let { "?$it" } ?: ""), - "hash" to JsonPrimitive(parsed.encodedFragment?.let { "#$it" } ?: ""), - ), - ).toString() - } catch (_: Exception) { - JsonObject( - mapOf( - "protocol" to JsonPrimitive(""), - "host" to JsonPrimitive(""), - "hostname" to JsonPrimitive(""), - "port" to JsonPrimitive(""), - "pathname" to JsonPrimitive("/"), - "search" to JsonPrimitive(""), - "hash" to JsonPrimitive(""), - ), - ).toString() - } - } - - private fun truncateString(value: String, maxChars: Int): String { - if (value.length <= maxChars) return value - val end = maxChars - FETCH_TRUNCATION_SUFFIX.length - if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) - return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX - } - - private fun parseJsonResults(rawJson: String): List { - return runCatching { - val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() - array.mapNotNull { element -> - val item = element as? JsonObject ?: return@mapNotNull null - val url = when (val urlValue = item["url"]) { - is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } - is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } - else -> null - } ?: return@mapNotNull null - - val headers = (item["headers"] as? JsonObject) - ?.mapNotNull { (key, value) -> - value.jsonPrimitive.contentOrNull?.let { key to it } - } - ?.toMap() - ?.takeIf { it.isNotEmpty() } - - PluginRuntimeResult( - title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", - name = item.stringOrNull("name"), - url = url, - quality = item.stringOrNull("quality"), - size = item.stringOrNull("size"), - language = item.stringOrNull("language"), - provider = item.stringOrNull("provider"), - type = item.stringOrNull("type"), - seeders = item["seeders"]?.jsonPrimitive?.intOrNull, - peers = item["peers"]?.jsonPrimitive?.intOrNull, - infoHash = item.stringOrNull("infoHash"), - headers = headers, - ) - }.filter { it.url.isNotBlank() } - }.getOrElse { error -> - log.e(error) { "Failed to parse plugin result json" } - emptyList() - } - } - - private fun JsonObject.stringOrNull(key: String): String? = - this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } - - private fun toJsonElement(value: Any?): JsonElement = when (value) { - null -> JsonNull - is JsonElement -> value - is String -> JsonPrimitive(value) - is Boolean -> JsonPrimitive(value) - is Int -> JsonPrimitive(value) - is Long -> JsonPrimitive(value) - is Float -> JsonPrimitive(value) - is Double -> JsonPrimitive(value) - is Number -> JsonPrimitive(value.toDouble()) - is Map<*, *> -> JsonObject( - value.entries - .filter { it.key is String } - .associate { (it.key as String) to toJsonElement(it.value) }, - ) - is Iterable<*> -> JsonArray(value.map(::toJsonElement)) - else -> JsonPrimitive(value.toString()) - } - - private fun buildPolyfillCode(scraperId: String, settingsJson: String): String { - return """ - globalThis.SCRAPER_ID = "$scraperId"; - globalThis.SCRAPER_SETTINGS = $settingsJson; - if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; - if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; - if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; - - var fetch = async function(url, options) { - options = options || {}; - var method = (options.method || 'GET').toUpperCase(); - var headers = options.headers || {}; - var body = options.body || ''; - var followRedirects = options.redirect !== 'manual'; - var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); - var parsed = JSON.parse(result); - return { - ok: parsed.ok, - status: parsed.status, - statusText: parsed.statusText, - url: parsed.url, - headers: { - get: function(name) { - return parsed.headers[name.toLowerCase()] || null; - } - }, - text: function() { return Promise.resolve(parsed.body); }, - json: function() { - try { - if (parsed.body === null || parsed.body === undefined || parsed.body === '') { - return Promise.resolve(null); - } - return Promise.resolve(JSON.parse(parsed.body)); - } catch (e) { - return Promise.resolve(null); - } - } - }; - }; - - if (typeof AbortSignal === 'undefined') { - var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; - AbortSignal.prototype.addEventListener = function(type, listener) { - if (type !== 'abort' || typeof listener !== 'function') return; - this._listeners.push(listener); - }; - AbortSignal.prototype.removeEventListener = function(type, listener) { - if (type !== 'abort') return; - this._listeners = this._listeners.filter(function(l) { return l !== listener; }); - }; - AbortSignal.prototype.dispatchEvent = function(event) { - if (!event || event.type !== 'abort') return true; - for (var i = 0; i < this._listeners.length; i++) { - try { this._listeners[i].call(this, event); } catch (e) {} - } - return true; - }; - globalThis.AbortSignal = AbortSignal; - } - - if (typeof AbortController === 'undefined') { - var AbortController = function() { this.signal = new AbortSignal(); }; - AbortController.prototype.abort = function(reason) { - if (this.signal.aborted) return; - this.signal.aborted = true; - this.signal.reason = reason; - this.signal.dispatchEvent({ type: 'abort' }); - }; - globalThis.AbortController = AbortController; - } - - if (typeof atob === 'undefined') { - globalThis.atob = function(input) { - var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - var str = String(input).replace(/=+$/, ''); - if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); - var output = ''; - var bc = 0, bs, buffer, idx = 0; - while ((buffer = str.charAt(idx++))) { - buffer = chars.indexOf(buffer); - if (buffer === -1) continue; - bs = bc % 4 ? bs * 64 + buffer : buffer; - if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); - } - return output; - }; - } - - if (typeof btoa === 'undefined') { - globalThis.btoa = function(input) { - var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - var str = String(input); - var output = ''; - for (var block, charCode, idx = 0, map = chars; - str.charAt(idx | 0) || (map = '=', idx % 1); - output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { - charCode = str.charCodeAt(idx += 3 / 4); - if (charCode > 0xFF) throw new Error('InvalidCharacterError'); - block = (block << 8) | charCode; - } - return output; - }; - } - - var URL = function(urlString, base) { - var fullUrl = urlString; - if (base && !/^https?:\/\//i.test(urlString)) { - var b = typeof base === 'string' ? base : base.href; - if (urlString.charAt(0) === '/') { - var m = b.match(/^(https?:\/\/[^\/]+)/); - fullUrl = m ? m[1] + urlString : urlString; - } else { - fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; - } - } - var parsed = __parse_url(fullUrl); - var data = JSON.parse(parsed); - this.href = fullUrl; - this.protocol = data.protocol; - this.host = data.host; - this.hostname = data.hostname; - this.port = data.port; - this.pathname = data.pathname; - this.search = data.search; - this.hash = data.hash; - this.origin = data.protocol + '//' + data.host; - this.searchParams = new URLSearchParams(data.search || ''); - }; - URL.prototype.toString = function() { return this.href; }; - - var URLSearchParams = function(init) { - this._params = {}; - var self = this; - if (init && typeof init === 'object' && !Array.isArray(init)) { - Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); - } else if (typeof init === 'string') { - init.replace(/^\?/, '').split('&').forEach(function(pair) { - var parts = pair.split('='); - if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); - }); - } - }; - URLSearchParams.prototype.toString = function() { - var self = this; - return Object.keys(this._params).map(function(key) { - return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); - }).join('&'); - }; - URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; - URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; - URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; - URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; - URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; - URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; - URLSearchParams.prototype.values = function() { - var self = this; - return Object.keys(this._params).map(function(k) { return self._params[k]; }); - }; - URLSearchParams.prototype.entries = function() { - var self = this; - return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); - }; - URLSearchParams.prototype.forEach = function(callback) { - var self = this; - Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); - }; - URLSearchParams.prototype.getAll = function(key) { - return this._params.hasOwnProperty(key) ? [this._params[key]] : []; - }; - URLSearchParams.prototype.sort = function() { - var sorted = {}; - var self = this; - Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); - this._params = sorted; - }; - - function __hexToWords(hex) { - var words = []; - for (var i = 0; i < hex.length; i += 8) { - var chunk = hex.substring(i, i + 8); - while (chunk.length < 8) chunk += '0'; - words.push(parseInt(chunk, 16) | 0); - } - return words; - } - - function __wordsToHex(words, sigBytes) { - var hex = ''; - for (var i = 0; i < sigBytes; i++) { - var word = words[i >>> 2] || 0; - var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; - var part = byte.toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __wordArrayToHex(value) { - if (!value) return ''; - if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); - if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordsToHex(value.words, value.sigBytes); - } - return __crypto_utf8_to_hex(String(value)); - } - - function __buildWordArray(hex, utf8Override) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var wordArray = { - __hex: normalizedHex, - __utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex), - sigBytes: normalizedHex.length / 2, - words: __hexToWords(normalizedHex), - toString: function(encoder) { - if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; - if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8); - return this.__hex; - }, - clamp: function() { - return this; - }, - concat: function(other) { - var otherHex = __wordArrayToHex(other); - this.__hex += otherHex; - this.__utf8 = __crypto_hex_to_utf8(this.__hex); - this.sigBytes = this.__hex.length / 2; - this.words = __hexToWords(this.__hex); - return this; - } - }; - return wordArray; - } - - function __wordArrayFromHex(hex) { - return __buildWordArray(hex, undefined); - } - - function __wordArrayFromUtf8(text) { - var utf8 = text == null ? '' : String(text); - return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8); - } - - function __wordArrayFromBase64(base64) { - return __wordArrayFromUtf8(__crypto_base64_decode(base64 || '')); - } - - function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && typeof value.__utf8 === 'string') { - return value.__utf8; - } - if (value && typeof value === 'object' && typeof value.__hex === 'string') { - return __crypto_hex_to_utf8(value.__hex); - } - if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)); - } - if (value == null) return ''; - return String(value); - } - - function __cryptoHashWordArray(algorithm, message) { - var utf8 = __normalizeWordArrayInput(message); - var hex = __crypto_digest_hex(algorithm, utf8); - return __wordArrayFromHex(hex); - } - - function __cryptoHmacWordArray(algorithm, message, key) { - var utf8Message = __normalizeWordArrayInput(message); - var utf8Key = __normalizeWordArrayInput(key); - var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message); - return __wordArrayFromHex(hex); - } - - var CryptoJS = { - enc: { - Hex: { - stringify: function(wordArray) { - return __wordArrayToHex(wordArray); - }, - parse: function(hexStr) { - return __wordArrayFromHex(hexStr || ''); - } - }, - Utf8: { - stringify: function(wordArray) { - if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8; - if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex); - return __normalizeWordArrayInput(wordArray); - }, - parse: function(text) { - return __wordArrayFromUtf8(text); - } - }, - Base64: { - stringify: function(wordArray) { - if (wordArray && typeof wordArray.__utf8 === 'string') { - return __crypto_base64_encode(wordArray.__utf8); - } - return __crypto_base64_encode(__normalizeWordArrayInput(wordArray)); - }, - parse: function(base64) { - return __wordArrayFromBase64(base64); - } - } - }, - MD5: function(message) { return __cryptoHashWordArray('MD5', message); }, - SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); }, - SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); }, - SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); }, - HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); }, - HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); }, - HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); }, - HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); } - }; - globalThis.CryptoJS = CryptoJS; - - var cheerio = { - load: function(html) { - var docId = __cheerio_load(html); - var $ = function(selector, context) { - if (selector && selector._elementIds) return selector; - if (context && context._elementIds && context._elementIds.length > 0) { - var allIds = []; - for (var i = 0; i < context._elementIds.length; i++) { - var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); - var childIds = JSON.parse(childIdsJson); - allIds = allIds.concat(childIds); - } - return createCheerioWrapperFromIds(docId, allIds); - } - return createCheerioWrapper(docId, selector); - }; - $.html = function(el) { - if (el && el._elementIds && el._elementIds.length > 0) { - return __cheerio_html(docId, el._elementIds[0]); - } - return __cheerio_html(docId, ''); - }; - return $; - } - }; - - function createCheerioWrapper(docId, selector) { - var elementIds; - if (typeof selector === 'string') { - var idsJson = __cheerio_select(docId, selector); - elementIds = JSON.parse(idsJson); - } else { - elementIds = []; - } - return createCheerioWrapperFromIds(docId, elementIds); - } - - function createCheerioWrapperFromIds(docId, ids) { - var wrapper = { - _docId: docId, - _elementIds: ids, - length: ids.length, - each: function(callback) { - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - callback.call(elWrapper, i, elWrapper); - } - return wrapper; - }, - find: function(sel) { - var allIds = []; - for (var i = 0; i < ids.length; i++) { - var childIdsJson = __cheerio_find(docId, ids[i], sel); - var childIds = JSON.parse(childIdsJson); - allIds = allIds.concat(childIds); - } - return createCheerioWrapperFromIds(docId, allIds); - }, - text: function() { - if (ids.length === 0) return ''; - return __cheerio_text(docId, ids.join(',')); - }, - html: function() { - if (ids.length === 0) return ''; - return __cheerio_inner_html(docId, ids[0]); - }, - attr: function(name) { - if (ids.length === 0) return undefined; - var val = __cheerio_attr(docId, ids[0], name); - return val === '__UNDEFINED__' ? undefined : val; - }, - first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, - last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, - next: function() { - var nextIds = []; - for (var i = 0; i < ids.length; i++) { - var nextId = __cheerio_next(docId, ids[i]); - if (nextId && nextId !== '__NONE__') nextIds.push(nextId); - } - return createCheerioWrapperFromIds(docId, nextIds); - }, - prev: function() { - var prevIds = []; - for (var i = 0; i < ids.length; i++) { - var prevId = __cheerio_prev(docId, ids[i]); - if (prevId && prevId !== '__NONE__') prevIds.push(prevId); - } - return createCheerioWrapperFromIds(docId, prevIds); - }, - eq: function(index) { - if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); - return createCheerioWrapperFromIds(docId, []); - }, - get: function(index) { - if (typeof index === 'number') { - if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); - return undefined; - } - return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); - }, - map: function(callback) { - var results = []; - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - var result = callback.call(elWrapper, i, elWrapper); - if (result !== undefined && result !== null) results.push(result); - } - return { - length: results.length, - get: function(index) { return typeof index === 'number' ? results[index] : results; }, - toArray: function() { return results; } - }; - }, - filter: function(selectorOrCallback) { - if (typeof selectorOrCallback === 'function') { - var filteredIds = []; - for (var i = 0; i < ids.length; i++) { - var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); - var result = selectorOrCallback.call(elWrapper, i, elWrapper); - if (result) filteredIds.push(ids[i]); - } - return createCheerioWrapperFromIds(docId, filteredIds); - } - return wrapper; - }, - children: function(sel) { return this.find(sel || '*'); }, - parent: function() { return createCheerioWrapperFromIds(docId, []); }, - toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } - }; - return wrapper; - } - - var require = function(moduleName) { - if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { - return cheerio; - } - if (moduleName === 'crypto-js') { - return CryptoJS; - } - throw new Error("Module '" + moduleName + "' is not available"); - }; - - if (!Array.prototype.flat) { - Array.prototype.flat = function(depth) { - depth = depth === undefined ? 1 : Math.floor(depth); - if (depth < 1) return Array.prototype.slice.call(this); - return (function flatten(arr, d) { - return d > 0 - ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) - : arr.slice(); - })(this, depth); - }; - } - - if (!Array.prototype.flatMap) { - Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; - } - - if (!Object.entries) { - Object.entries = function(obj) { - var result = []; - for (var key in obj) { - if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); - } - return result; - }; - } - - if (!Object.fromEntries) { - Object.fromEntries = function(entries) { - var result = {}; - for (var i = 0; i < entries.length; i++) { - result[entries[i][0]] = entries[i][1]; - } - return result; - }; - } - - if (!String.prototype.replaceAll) { - String.prototype.replaceAll = function(search, replace) { - if (search instanceof RegExp) { - if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); - return this.replace(search, replace); - } - return this.split(search).join(replace); - }; - } - """.trimIndent() - } -} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 7349e11df..040a1c8d3 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.plugins.runtime.host.HostFunctions import com.nuvio.app.features.plugins.runtime.js.JsBindings import com.nuvio.app.features.plugins.runtime.js.JsRuntime import com.nuvio.app.features.plugins.runtime.network.FetchBridge +import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,6 +66,7 @@ internal object PluginRuntime { val hostRegistry = HostApiRegistry().apply { addModule(HostFunctions(scraperId) { resultJson = it }) addModule(FetchBridge()) + addModule(UrlBridge()) addModule(CryptoBridge()) addModule(WasmBridge()) addModule(domBridge) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 0969bc702..81b45bd6d 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -14,6 +14,7 @@ internal object JsBindings { ${base64Polyfill()} ${urlPolyfill()} ${cryptoPolyfill()} + ${textEncoderPolyfill()} ${cheerioPolyfill()} ${requirePolyfill()} ${arrayPolyfill()} @@ -395,6 +396,32 @@ internal object JsBindings { }; """.trimIndent() + private fun textEncoderPolyfill() = """ + if (typeof TextEncoder === 'undefined') { + globalThis.TextEncoder = function() {}; + TextEncoder.prototype.encode = function(str) { + var hex = __crypto_utf8_to_hex(str); + var bytes = new Uint8Array(hex.length / 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + }; + } + if (typeof TextDecoder === 'undefined') { + globalThis.TextDecoder = function() {}; + TextDecoder.prototype.decode = function(data) { + var bytes = data; + if (data instanceof ArrayBuffer) bytes = new Uint8Array(data); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return __crypto_hex_to_utf8(hex); + }; + } + """.trimIndent() + private fun cheerioPolyfill() = """ var cheerio = { load: function(html) { diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt new file mode 100644 index 000000000..092cf1e24 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/UrlBridge.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.plugins.runtime.network + +import com.dokar.quickjs.QuickJs +import com.dokar.quickjs.binding.function +import com.nuvio.app.features.plugins.runtime.host.HostModule +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal class UrlBridge : HostModule { + override fun register(runtime: QuickJs) { + runtime.function("__parse_url") { args -> + val urlString = args.getOrNull(0)?.toString() ?: "" + parseUrl(urlString) + } + } + + private fun parseUrl(urlString: String): String { + return try { + val parsed = io.ktor.http.Url(urlString) + JsonObject( + mapOf( + "protocol" to JsonPrimitive("${parsed.protocol.name}:"), + "host" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) { + "${parsed.host}:${parsed.port}" + } else { + parsed.host + }, + ), + "hostname" to JsonPrimitive(parsed.host), + "port" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) parsed.port.toString() else "", + ), + "pathname" to JsonPrimitive(parsed.encodedPath.ifBlank { "/" }), + "search" to JsonPrimitive(parsed.encodedQuery?.let { "?$it" } ?: ""), + "hash" to JsonPrimitive(parsed.encodedFragment?.let { "#$it" } ?: ""), + ), + ).toString() + } catch (_: Exception) { + JsonObject( + mapOf( + "protocol" to JsonPrimitive(""), + "host" to JsonPrimitive(""), + "hostname" to JsonPrimitive(""), + "port" to JsonPrimitive(""), + "pathname" to JsonPrimitive("/"), + "search" to JsonPrimitive(""), + "hash" to JsonPrimitive(""), + ), + ).toString() + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt index bc1e8d163..6ef7a189a 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/wasm/WasmBridge.kt @@ -5,8 +5,8 @@ import com.nuvio.app.features.plugins.runtime.host.HostModule /** * Lightweight WASM Helpers bridge. - * For now, this is a placeholder for running small WASM modules. - * In the future, this could integrate a lightweight WASM interpreter like Chasm or wasm-interp.js. + * TODO: In the future, this will integrate a lightweight WASM interpreter like Chasm or wasm-interp.js + * to support advanced extraction logic (e.g. FlixCloud). */ internal class WasmBridge : HostModule { override fun register(runtime: QuickJs) { diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index dc1186951..d61a00e10 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -336,11 +336,11 @@ internal fun pluginUtf8ToHex(value: String): String = byte.toUByte().toString(16).padStart(2, '0') } -internal fun pluginHexToUtf8(hex: String): String { +internal fun pluginHexToByteArray(hex: String): ByteArray { val normalized = hex.trim().lowercase() .replace(" ", "") .removePrefix("0x") - if (normalized.isEmpty()) return "" + if (normalized.isEmpty()) return ByteArray(0) val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" val out = ByteArray(evenHex.length / 2) @@ -348,5 +348,9 @@ internal fun pluginHexToUtf8(hex: String): String { val part = evenHex.substring(index * 2, index * 2 + 2) out[index] = part.toInt(16).toByte() } - return out.decodeToString() + return out +} + +internal fun pluginHexToUtf8(hex: String): String { + return pluginHexToByteArray(hex).decodeToString() } From 405f00ab5bf59800c70edd9c2537e1a020750a61 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 09:21:56 +0530 Subject: [PATCH 03/60] fix: harden plugin runtime bridges --- .../features/plugins/runtime/PluginRuntime.kt | 9 +- .../features/plugins/runtime/js/JsBindings.kt | 103 +++++++++++++++--- .../plugins/runtime/network/FetchBridge.kt | 8 +- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 040a1c8d3..6c680a9b7 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -77,7 +77,10 @@ internal object PluginRuntime { hostRegistry.registerAll(this) val settingsJson = toJsonElement(scraperSettings).toString() - val polyfillCode = JsBindings.buildPolyfillCode(scraperId, settingsJson) + val polyfillCode = JsBindings.buildPolyfillCode( + scraperIdJson = JsonPrimitive(scraperId).toString(), + settingsJson = settingsJson, + ) evaluate(polyfillCode) val wrappedCode = """ @@ -89,6 +92,8 @@ internal object PluginRuntime { """.trimIndent() evaluate(wrappedCode) + val tmdbIdArg = JsonPrimitive(tmdbId).toString() + val mediaTypeArg = JsonPrimitive(mediaType).toString() val seasonArg = season?.toString() ?: "undefined" val episodeArg = episode?.toString() ?: "undefined" val callCode = """ @@ -100,7 +105,7 @@ internal object PluginRuntime { __capture_result(JSON.stringify([])); return; } - var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + var result = await getStreams($tmdbIdArg, $mediaTypeArg, $seasonArg, $episodeArg); __capture_result(JSON.stringify(result || [])); } catch (e) { console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 81b45bd6d..66c70e866 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -1,9 +1,9 @@ package com.nuvio.app.features.plugins.runtime.js internal object JsBindings { - fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + fun buildPolyfillCode(scraperIdJson: String, settingsJson: String): String { return """ - globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_ID = $scraperIdJson; globalThis.SCRAPER_SETTINGS = $settingsJson; if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; @@ -24,10 +24,27 @@ internal object JsBindings { } private fun fetchPolyfill() = """ + function __normalize_fetch_headers(headers) { + var out = {}; + if (!headers) return out; + if (typeof headers.forEach === 'function') { + headers.forEach(function(value, key) { out[key] = String(value); }); + return out; + } + if (Array.isArray(headers)) { + headers.forEach(function(pair) { + if (pair && pair.length >= 2) out[pair[0]] = String(pair[1]); + }); + return out; + } + Object.keys(headers).forEach(function(key) { out[key] = String(headers[key]); }); + return out; + } + var fetch = async function(url, options) { options = options || {}; var method = (options.method || 'GET').toUpperCase(); - var headers = options.headers || {}; + var headers = __normalize_fetch_headers(options.headers); var body = options.body || ''; var followRedirects = options.redirect !== 'manual'; var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); @@ -223,6 +240,41 @@ internal object JsBindings { return hex; } + function __bytesToHex(bytes) { + bytes = __toUint8Array(bytes); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + var part = bytes[i].toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __hexToBytes(hex) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var bytes = new Uint8Array(normalizedHex.length / 2); + for (var i = 0; i < normalizedHex.length; i += 2) { + bytes[i / 2] = parseInt(normalizedHex.substring(i, i + 2), 16) || 0; + } + return bytes; + } + + function __binaryStringToBytes(value) { + var text = value == null ? '' : String(value); + var bytes = new Uint8Array(text.length); + for (var i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; + return bytes; + } + + function __bytesToBinaryString(bytes) { + bytes = __toUint8Array(bytes); + var out = ''; + for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; + } + function __wordArrayToHex(value) { if (!value) return ''; if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); @@ -243,7 +295,7 @@ internal object JsBindings { toString: function(encoder) { if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return typeof __crypto_base64_encode !== 'undefined' ? __crypto_base64_encode(this.__utf8) : ''; + if (encoder === CryptoJS.enc.Base64) return btoa(__bytesToBinaryString(__hexToBytes(this.__hex))); return this.__hex; }, clamp: function() { return this; }, @@ -266,8 +318,11 @@ internal object JsBindings { return __buildWordArray(hex, utf8); } function __wordArrayFromBase64(base64) { - var utf8 = typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(base64 || '') : ''; - return __wordArrayFromUtf8(utf8); + return __buildWordArray(__bytesToHex(__binaryStringToBytes(atob(base64 || ''))), undefined); + } + + function __wordArrayToBytes(value) { + return __hexToBytes(__wordArrayToHex(value)); } function __normalizeWordArrayInput(value) { @@ -280,6 +335,13 @@ internal object JsBindings { return String(value); } + function __toUint8Array(data) { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (data && typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); + return new Uint8Array(0); + } + function __bufferToUint8(data) { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); @@ -302,31 +364,36 @@ internal object JsBindings { SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { + options = options || {}; var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); - return __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + return __wordArrayFromHex(__bytesToHex(resBytes)); }, AES: { encrypt: function(message, key, options) { + options = options || {}; var data = __bufferToUint8(__normalizeWordArrayInput(message)); - var kBytes = __bufferToUint8(__wordArrayToHex(key)); - var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + var kBytes = __wordArrayToBytes(key); + var ivBytes = __wordArrayToBytes(options.iv || ''); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); - var wa = __wordArrayFromHex(__wordsToHex(Array.from(resBytes), resBytes.length)); + var wa = __wordArrayFromHex(__bytesToHex(resBytes)); return { ciphertext: wa, toString: function() { return wa.toString(CryptoJS.enc.Base64); } }; }, decrypt: function(cipher, key, options) { - var data = typeof cipher === 'string' ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher) : '') : (cipher.ciphertext ? __bufferToUint8(typeof __crypto_base64_decode !== 'undefined' ? __crypto_base64_decode(cipher.ciphertext.toString(CryptoJS.enc.Base64)) : '') : __bufferToUint8(cipher)); - var kBytes = __bufferToUint8(__wordArrayToHex(key)); - var ivBytes = __bufferToUint8(__wordArrayToHex(options.iv || '')); + options = options || {}; + var data = typeof cipher === 'string' + ? __binaryStringToBytes(atob(cipher)) + : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __bufferToUint8(cipher)); + var kBytes = __wordArrayToBytes(key); + var ivBytes = __wordArrayToBytes(options.iv || ''); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); @@ -341,35 +408,35 @@ internal object JsBindings { digest: async function(algo, data) { var bytes = __bufferToUint8(data); var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { var pBytes = __bufferToUint8(key._raw); var sBytes = __bufferToUint8(params.salt); var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { var kBytes = __bufferToUint8(key._raw); var ivBytes = __bufferToUint8(params.iv || ''); var dBytes = __bufferToUint8(data); var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { var kBytes = __bufferToUint8(key._raw); var ivBytes = __bufferToUint8(params.iv || ''); var dBytes = __bufferToUint8(data); var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); var kBytes = __bufferToUint8(key._raw); var dBytes = __bufferToUint8(data); var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); - return res.buffer; + return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt index 5dd6d78be..22216c58c 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/network/FetchBridge.kt @@ -12,7 +12,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive -private const val MAX_FETCH_BODY_CHARS = 256 * 1024 private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" @@ -67,16 +66,15 @@ internal class FetchBridge : HostModule { ) } - val responseHeaders = response.headers.mapValues { (_, value) -> - truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) - } + val responseHeaders = response.headers.mapKeys { (key, _) -> key.lowercase() } + .mapValues { (_, value) -> truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) } val result = JsonObject( mapOf( "ok" to JsonPrimitive(response.status in 200..299), "status" to JsonPrimitive(response.status), "statusText" to JsonPrimitive(response.statusText), "url" to JsonPrimitive(response.url), - "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "body" to JsonPrimitive(response.body), "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), ), ) From a5493a84a7e3dc8ed14549cbe14b76acca565e7a Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 10:40:44 +0530 Subject: [PATCH 04/60] feat(plugins): implement declarative plugin settings system with multi-platform storage --- .../plugins/PluginPlatform.android.kt | 10 + .../app/features/plugins/PluginModels.kt | 3 + .../app/features/plugins/PluginRepository.kt | 7 +- .../features/plugins/PluginSettingsDialog.kt | 204 ++++++++++++++++++ .../features/plugins/PluginsSettingsScreen.kt | 46 +++- .../features/plugins/runtime/PluginRuntime.kt | 63 +++++- .../features/plugins/PluginPlatform.ios.kt | 10 + 7 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index 6e77db321..525b6c6f8 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -22,6 +22,16 @@ internal object PluginStorage { ?.putString("${pluginsStateKey}_$profileId", payload) ?.apply() } + + fun loadScraperSettings(scraperId: String): String? = + preferences?.getString("settings_${scraperId}", null) + + fun saveScraperSettings(scraperId: String, payload: String) { + preferences + ?.edit() + ?.putString("settings_${scraperId}", payload) + ?.apply() + } } internal fun currentPluginPlatform(): String = "android" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index afca79884..e069a5fbf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -21,6 +21,7 @@ data class PluginManifestScraper( val filename: String, @SerialName("supportedTypes") val supportedTypes: List = listOf("movie", "tv"), val enabled: Boolean = true, + val hasSettings: Boolean = false, val logo: String? = null, @SerialName("contentLanguage") val contentLanguage: List? = null, @SerialName("supportedPlatforms") val supportedPlatforms: List? = null, @@ -52,6 +53,7 @@ data class PluginScraper( val supportedTypes: List, val enabled: Boolean, val manifestEnabled: Boolean, + val hasSettings: Boolean = false, val logo: String? = null, val contentLanguage: List = emptyList(), val formats: List? = null, @@ -119,6 +121,7 @@ internal data class StoredPluginScraper( val supportedTypes: List, val enabled: Boolean, val manifestEnabled: Boolean, + val hasSettings: Boolean = false, val logo: String? = null, val contentLanguage: List = emptyList(), val formats: List? = null, diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index ec2741e74..25d405b84 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -329,7 +329,6 @@ actual object PluginRepository { season = season, episode = episode, scraperId = scraper.id, - scraperSettings = emptyMap(), ) } } @@ -383,6 +382,7 @@ actual object PluginRepository { supportedTypes = info.supportedTypes, enabled = enabled, manifestEnabled = info.enabled, + hasSettings = info.hasSettings, logo = info.logo, contentLanguage = info.contentLanguage ?: emptyList(), formats = info.formats ?: info.supportedFormats, @@ -476,12 +476,12 @@ actual object PluginRepository { supportedTypes = scraper.supportedTypes, enabled = scraper.enabled, manifestEnabled = scraper.manifestEnabled, + hasSettings = scraper.hasSettings, logo = scraper.logo, contentLanguage = scraper.contentLanguage, formats = scraper.formats, code = scraper.code, - ) - }, + ) }, ) PluginStorage.saveState(currentProfileId, json.encodeToString(payload)) } @@ -543,6 +543,7 @@ actual object PluginRepository { supportedTypes = it.supportedTypes, enabled = it.enabled, manifestEnabled = it.manifestEnabled, + hasSettings = it.hasSettings, logo = it.logo, contentLanguage = it.contentLanguage, formats = it.formats, diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt new file mode 100644 index 000000000..cb5a98f08 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginSettingsDialog.kt @@ -0,0 +1,204 @@ +package com.nuvio.app.features.plugins + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.nuvio.app.core.ui.NuvioInputField +import com.nuvio.app.core.ui.NuvioPrimaryButton +import kotlinx.serialization.json.* + +@Composable +fun PluginSettingsDialog( + scraperId: String, + scraperName: String, + layoutJson: String, + onDismiss: () -> Unit +) { + val json = remember { Json { ignoreUnknownKeys = true } } + val layout = remember(layoutJson) { + runCatching { json.parseToJsonElement(layoutJson).jsonArray }.getOrElse { JsonArray(emptyList()) } + } + + val savedSettingsJson = remember(scraperId) { PluginStorage.loadScraperSettings(scraperId) ?: "{}" } + val initialSettings = remember(savedSettingsJson) { + runCatching { json.parseToJsonElement(savedSettingsJson).jsonObject }.getOrElse { JsonObject(emptyMap()) } + } + + val currentSettings = remember { mutableStateMapOf().apply { putAll(initialSettings) } } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "$scraperName Settings", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + layout.forEach { element -> + val field = element.jsonObject + val type = field["type"]?.jsonPrimitive?.content ?: "info" + val key = field["key"]?.jsonPrimitive?.content ?: "" + val label = field["label"]?.jsonPrimitive?.content ?: "" + val description = field["description"]?.jsonPrimitive?.content + + when (type) { + "header" -> { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(top = 8.dp) + ) + } + "info" -> { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + "text" -> { + val value = currentSettings[key]?.jsonPrimitive?.content ?: "" + val isPassword = field["isPassword"]?.jsonPrimitive?.boolean ?: false + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + NuvioInputField( + value = value, + onValueChange = { currentSettings[key] = JsonPrimitive(it) }, + placeholder = field["placeholder"]?.jsonPrimitive?.content ?: "" + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + "select" -> { + val options = field["options"]?.jsonArray ?: JsonArray(emptyList()) + val defaultValue = field["defaultValue"]?.jsonPrimitive?.content ?: "" + val currentValue = currentSettings[key]?.jsonPrimitive?.content ?: defaultValue + + var expanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val selectedLabel = options.find { + it.jsonObject["value"]?.jsonPrimitive?.content == currentValue + }?.jsonObject?.get("label")?.jsonPrimitive?.content ?: currentValue + + Text(text = selectedLabel.ifBlank { "Select option" }) + Icon(Icons.Rounded.ArrowDropDown, contentDescription = null) + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + options.forEach { optionElement -> + val option = optionElement.jsonObject + val optionLabel = option["label"]?.jsonPrimitive?.content ?: "" + val optionValue = option["value"]?.jsonPrimitive?.content ?: "" + DropdownMenuItem( + text = { Text(optionLabel) }, + onClick = { + currentSettings[key] = JsonPrimitive(optionValue) + expanded = false + } + ) + } + } + } + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + "toggle" -> { + val value = currentSettings[key]?.jsonPrimitive?.boolean ?: field["defaultValue"]?.jsonPrimitive?.boolean ?: false + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Switch( + checked = value, + onCheckedChange = { currentSettings[key] = JsonPrimitive(it) } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + NuvioPrimaryButton( + text = "Save", + onClick = { + val result = JsonObject(currentSettings.toMap()) + PluginStorage.saveScraperSettings(scraperId, result.toString()) + onDismiss() + }, + modifier = Modifier.width(100.dp) + ) + } + } + } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 71b7e4e37..95a6fee11 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -12,8 +12,10 @@ import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -63,6 +65,9 @@ fun PluginsSettingsPageContent( var testingScraperId by remember { mutableStateOf(null) } val testResults = remember { mutableStateMapOf>() } + var configuringScraper by remember { mutableStateOf(null) } + var configuringLayout by remember { mutableStateOf(null) } + val sortedRepos = remember(uiState.repositories) { uiState.repositories.sortedBy { it.name.lowercase() } } @@ -350,11 +355,30 @@ fun PluginsSettingsPageContent( ) } } - Switch( - checked = scraper.enabled, - onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) }, - enabled = scraper.manifestEnabled, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (scraper.hasSettings) { + IconButton(onClick = { + coroutineScope.launch { + val layout = PluginRuntime.getPluginSettingsLayout(scraper.code, scraper.id) + if (layout != null) { + configuringScraper = scraper + configuringLayout = layout + } + } + }) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = "Provider settings", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Switch( + checked = scraper.enabled, + onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) }, + enabled = scraper.manifestEnabled, + ) + } } Spacer(modifier = Modifier.height(10.dp)) @@ -439,6 +463,18 @@ fun PluginsSettingsPageContent( } } } + + if (configuringScraper != null && configuringLayout != null) { + PluginSettingsDialog( + scraperId = configuringScraper!!.id, + scraperName = configuringScraper!!.name, + layoutJson = configuringLayout!!, + onDismiss = { + configuringScraper = null + configuringLayout = null + } + ) + } } private fun String.fallbackRepositoryLabel(): String { diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 6c680a9b7..1fc3e9095 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -35,8 +35,12 @@ internal object PluginRuntime { season: Int?, episode: Int?, scraperId: String, - scraperSettings: Map = emptyMap(), ): List = withContext(Dispatchers.Default) { + val scraperSettingsJson = PluginStorage.loadScraperSettings(scraperId) ?: "{}" + val scraperSettingsMap = runCatching { + json.decodeFromString>(scraperSettingsJson) + }.getOrElse { emptyMap() } + withTimeout(PLUGIN_TIMEOUT_MS) { executePluginInternal( code = code, @@ -45,11 +49,62 @@ internal object PluginRuntime { season = season, episode = episode, scraperId = scraperId, - scraperSettings = scraperSettings, + scraperSettings = scraperSettingsMap, ) } } + suspend fun getPluginSettingsLayout( + code: String, + scraperId: String, + ): String? = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + val jsRuntime = JsRuntime() + var resultJson: String? = null + + try { + jsRuntime.use { + val polyfillCode = JsBindings.buildPolyfillCode( + scraperIdJson = JsonPrimitive(scraperId).toString(), + settingsJson = "{}" + ) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val callCode = """ + (async function() { + try { + var onSettings = module.exports.onSettings || globalThis.onSettings; + if (onSettings) { + var layout = await onSettings(); + globalThis.__settings_layout_result = JSON.stringify(layout || []); + } else { + globalThis.__settings_layout_result = null; + } + } catch (e) { + console.error("onSettings error:", e); + globalThis.__settings_layout_result = null; + } + })(); + """.trimIndent() + evaluate(callCode) + resultJson = evaluate("globalThis.__settings_layout_result") + } + resultJson + } catch (e: Exception) { + null + } + } + } + private suspend fun executePluginInternal( code: String, tmdbId: String, @@ -57,7 +112,7 @@ internal object PluginRuntime { season: Int?, episode: Int?, scraperId: String, - scraperSettings: Map, + scraperSettings: Map, ): List { val jsRuntime = JsRuntime() var resultJson = "[]" @@ -76,7 +131,7 @@ internal object PluginRuntime { jsRuntime.use { hostRegistry.registerAll(this) - val settingsJson = toJsonElement(scraperSettings).toString() + val settingsJson = JsonObject(scraperSettings).toString() val polyfillCode = JsBindings.buildPolyfillCode( scraperIdJson = JsonPrimitive(scraperId).toString(), settingsJson = settingsJson, diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index a30ad428e..1a8ddaac6 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -15,6 +15,16 @@ internal object PluginStorage { forKey = "${pluginsStateKey}_$profileId", ) } + + fun loadScraperSettings(scraperId: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}") + + fun saveScraperSettings(scraperId: String, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "settings_${scraperId}", + ) + } } internal fun currentPluginPlatform(): String = "ios" From a44aaf4ef7f3468790a3bec1e44076e63de5e3a4 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 10:54:46 +0530 Subject: [PATCH 05/60] fix(plugins): add missing imports and resolve compilation errors --- .../com/nuvio/app/features/plugins/PluginsSettingsScreen.kt | 1 + .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 95a6fee11..9204e8d66 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -41,6 +41,7 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.plugins.runtime.PluginRuntime import kotlinx.coroutines.launch @Composable diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 1fc3e9095..8afe6e064 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.plugins.runtime import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge import com.nuvio.app.features.plugins.runtime.dom.DomBridge import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry From 835473115fed4c7a5ca1025eec6081f8b36727ca Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 11:27:12 +0530 Subject: [PATCH 06/60] fix(plugins): improve settings layout fetching with native capture function --- .../features/plugins/runtime/PluginRuntime.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 8afe6e064..9e19114fa 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -83,21 +83,28 @@ internal object PluginRuntime { val callCode = """ (async function() { try { - var onSettings = module.exports.onSettings || globalThis.onSettings; - if (onSettings) { + var onSettings = (typeof module !== 'undefined' && module.exports && module.exports.onSettings) || globalThis.onSettings; + if (typeof onSettings === 'function') { var layout = await onSettings(); - globalThis.__settings_layout_result = JSON.stringify(layout || []); + __capture_settings_result(JSON.stringify(layout || [])); } else { - globalThis.__settings_layout_result = null; + __capture_settings_result("[]"); } } catch (e) { console.error("onSettings error:", e); - globalThis.__settings_layout_result = null; + __capture_settings_result("[]"); } })(); """.trimIndent() + + var captureResult: String? = null + jsRuntime.function("__capture_settings_result") { args -> + captureResult = args.getOrNull(0)?.toString() + null + } + evaluate(callCode) - resultJson = evaluate("globalThis.__settings_layout_result") + resultJson = captureResult } resultJson } catch (e: Exception) { From 3415696df8945d3fcc2dc6dc5764b81a04a4178b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 11:46:28 +0530 Subject: [PATCH 07/60] fix(plugins): resolve unresolved reference and type inference errors in PluginRuntime --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 9e19114fa..cfd72e18e 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry import com.nuvio.app.features.plugins.runtime.host.HostFunctions import com.nuvio.app.features.plugins.runtime.js.JsBindings import com.nuvio.app.features.plugins.runtime.js.JsRuntime +import com.dokar.quickjs.binding.function import com.nuvio.app.features.plugins.runtime.network.FetchBridge import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge @@ -98,7 +99,7 @@ internal object PluginRuntime { """.trimIndent() var captureResult: String? = null - jsRuntime.function("__capture_settings_result") { args -> + jsRuntime.function("__capture_settings_result") { args: Array -> captureResult = args.getOrNull(0)?.toString() null } From 422151bc23ee53a463126e8c9156bab6ad1969ab Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 12:00:34 +0530 Subject: [PATCH 08/60] fix(plugins): fix receiver type mismatch in getPluginSettingsLayout --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index cfd72e18e..9d1da1486 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -99,7 +99,7 @@ internal object PluginRuntime { """.trimIndent() var captureResult: String? = null - jsRuntime.function("__capture_settings_result") { args: Array -> + function("__capture_settings_result") { args: Array -> captureResult = args.getOrNull(0)?.toString() null } From a024e12bceb1f7e36d0996d676e4811b88ffb68b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 12:57:50 +0530 Subject: [PATCH 09/60] fix(plugins): ensure runtime waits for async settings and stream results --- .../features/plugins/runtime/PluginRuntime.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 9d1da1486..3da6404be 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -12,6 +12,7 @@ import com.dokar.quickjs.binding.function import com.nuvio.app.features.plugins.runtime.network.FetchBridge import com.nuvio.app.features.plugins.runtime.network.UrlBridge import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -62,7 +63,7 @@ internal object PluginRuntime { ): String? = withContext(Dispatchers.Default) { withTimeout(PLUGIN_TIMEOUT_MS) { val jsRuntime = JsRuntime() - var resultJson: String? = null + val deferred = CompletableDeferred() try { jsRuntime.use { @@ -98,16 +99,14 @@ internal object PluginRuntime { })(); """.trimIndent() - var captureResult: String? = null function("__capture_settings_result") { args: Array -> - captureResult = args.getOrNull(0)?.toString() + deferred.complete(args.getOrNull(0)?.toString()) null } evaluate(callCode) - resultJson = captureResult + deferred.await() } - resultJson } catch (e: Exception) { null } @@ -124,11 +123,11 @@ internal object PluginRuntime { scraperSettings: Map, ): List { val jsRuntime = JsRuntime() - var resultJson = "[]" + val deferred = CompletableDeferred() val domBridge = DomBridge() val hostRegistry = HostApiRegistry().apply { - addModule(HostFunctions(scraperId) { resultJson = it }) + addModule(HostFunctions(scraperId) { deferred.complete(it) }) addModule(FetchBridge()) addModule(UrlBridge()) addModule(CryptoBridge()) @@ -178,9 +177,10 @@ internal object PluginRuntime { })(); """.trimIndent() evaluate(callCode) + + val resultJson = deferred.await() + return parseJsonResults(resultJson) } - - return parseJsonResults(resultJson) } finally { domBridge.clear() } From 5c063405a0a344a68f1639d0f4dd5245398ed3af Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 13:13:32 +0530 Subject: [PATCH 10/60] fix(plugins): remove prohibited return from inside jsRuntime.use block --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 3da6404be..fb4b71101 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -178,9 +178,9 @@ internal object PluginRuntime { """.trimIndent() evaluate(callCode) - val resultJson = deferred.await() - return parseJsonResults(resultJson) + deferred.await() } + return parseJsonResults(resultJson) } finally { domBridge.clear() } From 5ee2ad6d8a7b2442d6fd421228d395149266cc7a Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 18 May 2026 13:28:21 +0530 Subject: [PATCH 11/60] fix(plugins): move return outside jsRuntime.use block --- .../com/nuvio/app/features/plugins/runtime/PluginRuntime.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index fb4b71101..5c884568a 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -180,7 +180,9 @@ internal object PluginRuntime { deferred.await() } - return parseJsonResults(resultJson) + + // Result is captured inside use block, but returned outside to satisfy compiler + return parseJsonResults(deferred.await()) } finally { domBridge.clear() } From 9b372ea11823d71da3628402b85db2ad57260df2 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 20 May 2026 18:25:31 +0530 Subject: [PATCH 12/60] feat(plugins): add support for plugin subtitles with header injection and format detection --- .../features/player/PlayerEngine.android.kt | 106 ++++++++++++++++-- .../commonMain/kotlin/com/nuvio/app/App.kt | 9 +- .../nuvio/app/features/player/PlayerEngine.kt | 1 + .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 4 + .../player/PlayerStreamsRepository.kt | 8 ++ .../app/features/plugins/PluginModels.kt | 9 ++ .../app/features/streams/StreamModels.kt | 9 ++ .../app/features/streams/StreamsRepository.kt | 8 ++ .../features/player/PlayerLaunchStoreTest.kt | 1 + .../features/plugins/runtime/PluginRuntime.kt | 20 ++++ .../app/features/player/NuvioPlayerBridge.kt | 7 +- .../app/features/player/PlayerEngine.ios.kt | 17 ++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 43 ++++++- 14 files changed, 224 insertions(+), 19 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 62ebd5213..c44ac0023 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -35,6 +35,9 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -68,6 +71,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -146,12 +150,26 @@ actual fun PlatformPlayerSurface( .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE) - val dataSourceFactory = PlatformPlaybackDataSourceFactory.create( - context = context, - defaultRequestHeaders = sanitizedSourceHeaders, + val baseNetworkFactory = if (useYoutubeChunkedPlayback) { + YoutubeChunkedDataSourceFactory(defaultRequestHeaders = sanitizedSourceHeaders) + } else { + PlayerPlaybackNetworking.createHttpDataSourceFactory(sanitizedSourceHeaders) + } + + val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory( + upstreamFactory = baseNetworkFactory, + externalSubtitles = externalSubtitles + ) + + val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory) + val dataSourceFactory = if (sanitizedSourceResponseHeaders.isEmpty()) { + baseFactory + } else { + ResponseHeaderOverridingDataSourceFactory( + upstreamFactory = baseFactory, defaultResponseHeaders = sanitizedSourceResponseHeaders, - useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, ) + } val player = if (useLibass) { ExoPlayer.Builder(context) @@ -179,13 +197,33 @@ actual fun PlatformPlayerSurface( } player.apply { + val mediaItemBuilder = MediaItem.Builder() + .setUri(Uri.parse(sourceUrl)) + .setMediaId(sourceUrl) + + val subtitleConfigs = externalSubtitles.mapNotNull { subtitle -> + val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers) + MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url)) + .setMimeType(mimeType) + .setLanguage(subtitle.language) + .setLabel(subtitle.name ?: subtitle.language) + .setRoleFlags(C.ROLE_FLAG_SUBTITLE) + .build() + } + + if (subtitleConfigs.isNotEmpty()) { + mediaItemBuilder.setSubtitleConfigurations(subtitleConfigs) + } + + val mediaItem = mediaItemBuilder.build() + if (!sourceAudioUrl.isNullOrBlank()) { val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) - val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl)) + val videoSource = msf.createMediaSource(mediaItem) val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl)) setMediaSource(MergingMediaSource(videoSource, audioSource)) } else { - setMediaItem(MediaItem.fromUri(sourceUrl)) + setMediaItem(mediaItem) } fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) } prepare() @@ -709,15 +747,15 @@ private fun ExoPlayer.logCurrentTracks(context: String) { Log.d(TAG, "--- end logCurrentTracks ---") } -private fun resolveSubtitleMimeType(url: String): String { - probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) -> +private fun resolveSubtitleMimeType(url: String, headers: Map? = null): String { + probeSubtitleHeaders(url, headers)?.let { (contentType, contentDisposition) -> mapSubtitleMime(contentType)?.let { return it } filenameFromContentDisposition(contentDisposition)?.let(::guessSubtitleMime)?.let { return it } } return guessSubtitleMime(url) } -private fun probeSubtitleHeaders(url: String): Pair? { +private fun probeSubtitleHeaders(url: String, headers: Map? = null): Pair? { val methods = listOf("HEAD", "GET") methods.forEach { method -> runCatching { @@ -727,6 +765,9 @@ private fun probeSubtitleHeaders(url: String): Pair? { readTimeout = 5_000 instanceFollowRedirects = true setRequestProperty("Accept", "*/*") + headers?.forEach { (key, value) -> + setRequestProperty(key, value) + } } try { connection.responseCode @@ -781,3 +822,50 @@ private fun guessSubtitleMime(url: String): String { else -> MimeTypes.TEXT_VTT } } + +private class SubtitleRequestHeaderDataSourceFactory( + private val upstreamFactory: DataSource.Factory, + private val externalSubtitles: List, +) : DataSource.Factory { + override fun createDataSource(): DataSource = + SubtitleRequestHeaderDataSource( + upstream = upstreamFactory.createDataSource(), + externalSubtitles = externalSubtitles, + ) +} + +private class SubtitleRequestHeaderDataSource( + private val upstream: DataSource, + private val externalSubtitles: List, +) : DataSource { + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val url = dataSpec.uri.toString() + val subtitle = externalSubtitles.find { it.url == url } + val headers = subtitle?.headers + + return if (headers.isNullOrEmpty()) { + upstream.open(dataSpec) + } else { + val mergedHeaders = dataSpec.httpRequestHeaders.toMutableMap() + headers.forEach { (key, value) -> + mergedHeaders[key] = value + } + upstream.open(dataSpec.buildUpon().setHttpRequestHeaders(mergedHeaders).build()) + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> = upstream.responseHeaders + + override fun close() { + upstream.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118a..3ee87ef09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -872,6 +872,7 @@ private fun MainAppContent( sourceUrl = localSourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = logo, poster = poster, background = background, @@ -1457,6 +1458,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + externalSubtitles = emptyList(), logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1563,6 +1565,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1582,8 +1585,7 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, - ) - StreamsRepository.consumeAutoPlay() + ) StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) @@ -1673,6 +1675,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + externalSubtitles = stream.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1803,6 +1806,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + externalSubtitles = launch.externalSubtitles, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1908,6 +1912,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + externalSubtitles = emptyList(), logo = item.logo, poster = item.poster, background = item.background, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ac0be69f0..87773ddc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -56,6 +56,7 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 773a276dc..e43c0df67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -13,6 +13,7 @@ data class PlayerLaunch( val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val externalSubtitles: List = emptyList(), val logo: String? = null, val poster: String? = null, val background: String? = null, 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 476b0a772..d3f4483ef 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 @@ -126,6 +126,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + externalSubtitles: List = emptyList(), providerName: String, streamTitle: String, streamSubtitle: String?, @@ -144,6 +145,8 @@ fun PlayerScreen( videoId: String? = null, parentMetaId: String, parentMetaType: String, + parentMetaLogo: String? = null, + parentMetaPoster: String? = null, providerAddonId: String? = null, initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, @@ -1713,6 +1716,7 @@ fun PlayerScreen( sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, + externalSubtitles = externalSubtitles, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 013460c35..ff990846b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -401,5 +401,13 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + com.nuvio.app.features.streams.StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index e069a5fbf..98769a634 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -78,6 +78,15 @@ data class PluginRuntimeResult( val peers: Int? = null, val infoHash: String? = null, val headers: Map? = null, + val subtitles: List? = null, +) + +@Serializable +data class PluginSubtitleResult( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null ) data class PluginsUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0b3d8b244..67682bc3a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -4,6 +4,14 @@ import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString +@Serializable +data class StreamSubtitle( + val url: String, + val language: String, + val name: String? = null, + val headers: Map? = null +) + data class StreamItem( val name: String? = null, val title: String? = null, @@ -18,6 +26,7 @@ data class StreamItem( val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, + val externalSubtitles: List = emptyList(), ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 2fc87a24e..a6787f53c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -665,6 +665,14 @@ private fun PluginRuntimeResult.toStreamItem( proxyHeaders = StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt index 4128f45cd..f0033b183 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt @@ -11,6 +11,7 @@ class PlayerLaunchStoreTest { val launch = PlayerLaunch( title = "Title", sourceUrl = "https://example.com/video.m3u8?token=a/b:c", + externalSubtitles = emptyList(), streamTitle = "Source", providerName = "Provider", parentMetaId = "tt1234567", diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt index 5c884568a..3fe3e7283 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/PluginRuntime.kt @@ -206,6 +206,25 @@ internal object PluginRuntime { ?.toMap() ?.takeIf { it.isNotEmpty() } + val subtitles = (item["subtitles"] as? JsonArray)?.mapNotNull { subElement -> + val subObj = subElement as? JsonObject ?: return@mapNotNull null + val subUrl = subObj["url"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val subLang = subObj["language"]?.jsonPrimitive?.contentOrNull ?: "Unknown" + val subName = subObj["name"]?.jsonPrimitive?.contentOrNull + val subHeaders = (subObj["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + com.nuvio.app.features.plugins.PluginSubtitleResult( + url = subUrl, + language = subLang, + name = subName, + headers = subHeaders + ) + }?.takeIf { it.isNotEmpty() } + PluginRuntimeResult( title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", name = item.stringOrNull("name"), @@ -219,6 +238,7 @@ internal object PluginRuntime { peers = item["peers"]?.jsonPrimitive?.intOrNull, infoHash = item.stringOrNull("infoHash"), headers = headers, + subtitles = subtitles, ) }.filter { it.url.isNotBlank() } }.getOrElse { emptyList() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 9012a96c9..311442abe 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -9,7 +9,12 @@ import platform.UIKit.UIViewController interface NuvioPlayerBridge { fun createPlayerViewController(): UIViewController fun loadFile(url: String) - fun loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) + fun loadFileWithAudio( + videoUrl: String, + audioUrl: String?, + headersJson: String?, + subtitlesJson: String? = null + ) fun play() fun pause() fun seekTo(positionMs: Long) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 733bf1629..0eaba0361 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -26,6 +26,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + externalSubtitles: List, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -222,12 +223,13 @@ actual fun PlatformPlayerSurface( } // Load file and set initial state - LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders, externalSubtitles) { bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( - sourceUrl, - sourceAudioUrl, - encodePlaybackHeadersForBridge(sourceHeaders), + videoUrl = sourceUrl, + audioUrl = sourceAudioUrl, + headersJson = encodePlaybackHeadersForBridge(sourceHeaders), + subtitlesJson = encodeExternalSubtitlesForBridge(externalSubtitles), ) if (playWhenReady) { bridge.play() @@ -339,6 +341,13 @@ private fun Int.toHexByte(): String { } } +private fun encodeExternalSubtitlesForBridge(subtitles: List): String? { + if (subtitles.isEmpty()) return null + return runCatching { + Json.encodeToString(subtitles) + }.getOrNull() +} + private fun encodePlaybackHeadersForBridge(headers: Map): String? { val sanitized = sanitizePlaybackHeaders(headers) if (sanitized.isEmpty()) { diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index afcdc6019..7cd949517 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -16,13 +16,41 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { } func loadFile(url: String) { playerVC?.loadFile(url) } - func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) { + func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) { playerVC?.loadFile( videoUrl, audioUrl: audioUrl, - requestHeaders: parseRequestHeaders(headersJson) + requestHeaders: parseRequestHeaders(headersJson), + subtitles: parseSubtitles(subtitlesJson) ) } + + private func parseSubtitles(_ json: String?) -> [PluginSubtitle] { + guard + let json, + let data = json.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + return raw.compactMap { dict in + guard let url = dict["url"] as? String else { return nil } + return PluginSubtitle( + url: url, + language: dict["language"] as? String ?? "Unknown", + name: dict["name"] as? String, + headers: dict["headers"] as? [String: String] + ) + } + } +} + +struct PluginSubtitle { + val url: String + val language: String + val name: String? + val headers: [String: String]? +} func play() { playerVC?.playPlayback() } func pause() { playerVC?.pausePlayback() } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } @@ -172,6 +200,7 @@ private struct PendingLoadRequest { let urlString: String let audioUrl: String? let requestHeaders: [String: String] + let subtitles: [PluginSubtitle] let queuedAtUptime: TimeInterval } @@ -357,11 +386,12 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API - func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:], subtitles: [PluginSubtitle] = []) { let request = PendingLoadRequest( urlString: urlString, audioUrl: audioUrl, requestHeaders: requestHeaders, + subtitles: subtitles, queuedAtUptime: ProcessInfo.processInfo.systemUptime ) @@ -409,6 +439,13 @@ final class MPVPlayerViewController: UIViewController { self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } + + // Add external subtitles + for subtitle in request.subtitles { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false) + } + } } private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool { From dd2be50d17e54cd8a50164e3a2e51055372d42ea Mon Sep 17 00:00:00 2001 From: Joseph Alves Date: Wed, 20 May 2026 11:44:22 -0300 Subject: [PATCH 13/60] Add pt-BR --- composeApp/src/androidMain/res/xml/locale_config.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 6bb55c30b..9fe7cd591 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -8,6 +8,7 @@ + From 83e40b2782b83d701a7771ddc633770a05f01855 Mon Sep 17 00:00:00 2001 From: Joseph Alves Date: Wed, 20 May 2026 11:46:03 -0300 Subject: [PATCH 14/60] Add pt-BR --- .../kotlin/com/nuvio/app/features/settings/AppLanguage.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index 8e989c5e5..05abde857 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -9,6 +9,7 @@ import nuvio.composeapp.generated.resources.lang_greek import nuvio.composeapp.generated.resources.lang_indonesian import nuvio.composeapp.generated.resources.lang_italian import nuvio.composeapp.generated.resources.lang_polish +import nuvio.composeapp.generated.resources.lang_portuguese_brazil import nuvio.composeapp.generated.resources.lang_portuguese_portugal import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_turkish @@ -27,6 +28,7 @@ enum class AppLanguage( INDONESIAN("id", Res.string.lang_indonesian), ITALIAN("it", Res.string.lang_italian), POLISH("pl", Res.string.lang_polish), + PORTUGUESE BRAZIL("pt-BR", Res.string.lang_portuguese_brazil), PORTUGUESE("pt", Res.string.lang_portuguese_portugal), SPANISH("es", Res.string.lang_spanish), TURKISH("tr", Res.string.lang_turkish), From 127ec3cf4af24e505432ec7247f899262a4799af Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 20 May 2026 21:53:47 +0530 Subject: [PATCH 15/60] fix: resolve compilation errors introduced by subtitle implementation --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 2 ++ composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 3 ++- .../kotlin/com/nuvio/app/features/streams/StreamModels.kt | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index c44ac0023..a97ae1360 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -37,12 +37,14 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MergingMediaSource +import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ed2785548..d4496702a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1586,7 +1586,8 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = launch.resumePositionMs ?: 0L, initialProgressFraction = launch.resumeProgressFraction, - ) StreamsRepository.consumeAutoPlay() + ) + StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 67682bc3a..90f45de05 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.streams import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString From 695da820e9fffba8a04042d3f8f1e9d44ef018a2 Mon Sep 17 00:00:00 2001 From: Joseph Alves Date: Wed, 20 May 2026 15:14:00 -0300 Subject: [PATCH 16/60] Add pt-BR --- .../composeResources/values-pt-BR/strings.xml‎ | 1344 +++++++++++++++++ 1 file changed, 1344 insertions(+) create mode 100644 composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ diff --git a/composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ b/composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ new file mode 100644 index 000000000..49fb2960d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ @@ -0,0 +1,1344 @@ + + Fontes de dados, agradecimentos e licenças da plataforma + Reconhecimento aberto e créditos do projeto + Voltar + Cancelar + Fechar + Excluir + Concluído + Editar + Importar + Próximo + OK + Reproduzir + Anterior + Remover + Reordenar + Redefinir para Padrão + Retomar + Tentar novamente + Salvar + Salvando… + Validar + Instalando + Addons + Ativo + %1$d catálogos + Configurável + Atualizando + %1$d recursos + Indisponível + Configurar addon + Excluir addon + Adicione uma URL de manifesto para começar a carregar catálogos, metadados, streams ou legendas no Nuvio. + Nenhum addon instalado ainda. + Insira uma URL de addon. + URL do Addon + Instalar Addon + Carregando detalhes do manifesto... + Validando a URL do manifesto e carregando detalhes do addon antes da instalação. + Verificando Addon + Falha na Instalação + %1$s foi validado e adicionado com sucesso. + Addon Instalado + Mover addon para baixo + Mover addon para cima + Ativo + Addons + Catálogos + Atualizar addon + Adicionar Addon + Addons Instalados + Visão Geral + %1$d regras de ID + Versão %1$s + Selecionado + Copiar JSON + %1$d coleção(ões), %2$d pasta(s) + Excluir "%1$s"? Isso não pode ser desfeito. + Excluir Coleção + Adicionar Catálogo + Adicionar Pasta + Todos os gêneros + Adicione catálogos dos seus addons instalados para definir o que esta pasta exibe. + Nenhuma fonte de catálogo ainda + Escolher + Emoji + URL da Imagem + Nenhuma + Capa + Criar Coleção + Concluído + Editar Coleção + Editar Pasta + Defina a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor principal de coleções. + Adicione uma para começar. + Nenhuma pasta ainda + Pastas + Filtro de Gênero + Mostrar apenas a imagem de capa + Ocultar Título + Nova Pasta + Mostrar esta coleção acima de todos os catálogos regulares da página inicial. Múltiplas coleções fixadas seguem a ordem de criação da coleção. + Fixar Acima dos Catálogos + URL da imagem de backdrop (opcional) + Nome da pasta + URL do GIF animado (reproduz apenas ao focar) + Nome da coleção + Salvar Alterações + Salvar + Aparência + Básico + Fontes de Catálogo + Escolha os catálogos de addon que esta pasta deve agregar. + Selecionar Catálogos + Selecionar gênero + %1$d selecionado(s) + %1$d catálogos + %1$d selecionado(s) + Pôster + Quadrado + Largo + Combinar todos os catálogos em uma única aba + Mostrar Aba \"Todos\" + Reproduzir o GIF configurado em vez da capa estática quando disponível. + Mostrar GIF Quando Configurado + %1$d fonte(s) · %2$s + Formato do Tile + Linhas + Abas + Modo de Visualização + Fontes TMDB + Lista Pública + Produção + Emissora + Coleção + Pessoa + Diretor + Personalizado + Escolha uma fonte pronta. Você pode editar ou removê-la após adicionar. + Cole a URL de uma lista pública do TMDB ou apenas o número da URL. + Busque pelo nome do estúdio, ou cole um ID/URL de empresa do TMDB e adicione diretamente. + Insira um ID de emissora. Emissoras comuns estão disponíveis em Predefinições e filtros rápidos. + Busque o nome de uma coleção de filmes ou cole o ID da coleção do TMDB. + Insira um ID ou URL de pessoa do TMDB para construir uma linha a partir dos créditos de elenco. + Insira um ID ou URL de pessoa do TMDB para construir uma linha a partir dos créditos de diretor. + Construa uma linha dinâmica do TMDB usando filtros opcionais. Deixe campos vazios quando não precisar desse filtro. + Lista pública do TMDB + ID da Emissora + ID da Coleção + ID da Pessoa + Nome da empresa de produção, ID ou URL + ID ou URL do TMDB + https://www.themoviedb.org/list/8504994 ou 8504994 + 213 para Netflix, 49 para HBO, 2739 para Disney+ + 10 para Coleção Star Wars + Marvel Studios, 420, ou URL da empresa + 31 para Tom Hanks, ou URL da pessoa + Exemplos: Marvel Studios, 420, ou https://www.themoviedb.org/company/420. + Exemplo: Coleção Star Wars, Coleção Harry Potter, ou uma URL de coleção. + Exemplos de IDs: Netflix 213, HBO 49, Disney+ 2739. + Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994. + Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31. + Título de exibição + Exibido como nome da linha/aba. Se em branco, o Nuvio cria um a partir da fonte. + Filmes da Marvel, Originais Netflix, Pixar + Filmes do Tom Hanks, Atores Favoritos + Filmes do Christopher Nolan, Diretores Favoritos + Melhores Filmes de Ação, Dramas Coreanos, Animação 2024 + Resultados da Busca + Coleção do TMDB + Empresa do TMDB %1$d + Coleção do TMDB %1$d + Tipo + Filmes + Séries + Ambos + Ordenar + Filtros + Deixe campos vazios quando não precisar desse filtro. + Gêneros rápidos + Idiomas rápidos + Países rápidos + Palavras-chave rápidas + Estúdios rápidos + Emissoras rápidas + IDs de Gênero + Use números de gênero do TMDB. Separe múltiplos com vírgulas para AND, ou pipes para OR. + Data de lançamento ou exibição de + Data de lançamento ou exibição até + Use YYYY-MM-DD, por exemplo 2024-01-01. + Classificação mínima + Classificação máxima + Classificação do TMDB de 0 a 10. Exemplo: 7,0. + Votos mínimos + Use isso para evitar títulos obscuros com poucos votos. Exemplo: 100. + Idioma original + Use códigos de idioma de duas letras, por exemplo en, ko, ja, hi. + País de origem + Use códigos de país de duas letras, por exemplo US, KR, JP, IN. + IDs de Palavras-chave + Use números de palavra-chave do TMDB. Chips rápidos preenchem exemplos comuns. + 9715 para super-herói + IDs de Empresa + Use IDs de estúdio/empresa. Chips rápidos preenchem exemplos comuns. + 420 para Marvel Studios + IDs de Emissoras + Apenas para séries. Use IDs de emissoras como Netflix 213 ou HBO 49. + 213 para Netflix + Ano + Use um ano de quatro dígitos, por exemplo 2024. + Predefinições + Buscar + Adicionar Fonte + Adicionar Lista do Trakt + Editar Lista do Trakt + Listas do Trakt + Lista do Trakt + Buscar título, URL do Trakt, ou ID da lista + Use uma URL de lista pública do Trakt ou ID numérico da lista, ou busque por nome. + Fim de Semana, Vencedores de Prêmios + Resultados da Busca + Listas em Alta + Listas Populares + Direção + Ascendente + Descendente + Ordem da Lista + Adicionados Recentemente + Título + Lançados + Duração + Populares + Porcentagem + Votos + Ação + Aventura + Animação + Comédia + Terror + Ficção Científica + Drama + Crime + Reality + Inglês + Coreano + Japonês + Hindi + Espanhol + Estados Unidos + Coreia + Japão + Índia + Reino Unido + Super-herói + Baseado em Romance + Viagem no Tempo + Espaço + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Popular + Mais Bem Avaliados + Recentes + Mais Votados + Região de exibição + Código de país ISO 3166-1 onde o título está disponível. Exemplo: US, BR. + Regiões de exibição rápidas + IDs de provedores de exibição + Use IDs de provedores de exibição do TMDB. Separe múltiplos com vírgulas para E, ou pipes para OU. + 8|337|350 + Provedores de exibição rápidos + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + Lista do TMDB + Coleção de Filmes do TMDB + Produção + Emissora + Pessoa + Diretor + TMDB Discover + Crie uma para organizar seus catálogos. + Nenhuma coleção ainda + %1$d pasta(s) + Nenhum item encontrado + Pasta não encontrada + Coleções + Importar Coleções + JSON + Cole seu JSON de coleções abaixo. + Importar + Nova Coleção + Fixado + Todos + Suas Coleções + Feito com ❤️ por Tapframe e amigos + Versão %1$s (%2$s) + Desativado + Ativado + Pausar + Recarregar + Já tem uma conta? + Continuar sem conta + Criar Conta + Não tem uma conta? + E-mail + ou + Senha + Faça login para acessar sua biblioteca e progresso + Entrar + Cadastre-se para sincronizar seus dados entre dispositivos + Cadastrar + Seus dados serão armazenados apenas localmente + Assista tudo, em qualquer lugar + Bem-vindo de Volta + Biblioteca + Biblioteca do Trakt + Início + Biblioteca + Perfil + Busca + Faixas de Áudio + Áudio + Incorporado + Deslocamento Inferior + Fechar player + Cor + Reproduzindo agora + E%1$d + T%1$dE%2$d + T%1$dE%2$d • %3$s + Episódios + Tamanho da Fonte + %1$dsp + Bloquear controles do player + Nenhuma faixa de áudio disponível + Nenhum episódio disponível + Nenhuma stream encontrada + Nenhuma + Contorno + Episódios + Fontes + Streams + Erro de reprodução + Reproduzindo + Toque para buscar legendas + Voltar + Redefinir Padrões + Preencher + Ajustar + Zoom + Retroceder 10 segundos + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avançar 10 segundos + Fontes + Estilo + Legendas + Legendas + Brilho %1$s + Volume %1$s + Silenciado + Baixado + Exibe + A definir + Toque para desbloquear + Faixa %1$d + Desbloquear controles do player + Você está assistindo + Adicionar Perfil + Limpar busca + Descobrir + Addons instalados falharam ao retornar resultados de busca válidos. + Busca falhou + Instale e valide pelo menos um addon antes de buscar. + Nenhum addon ativo + Catálogos pesquisáveis instalados não retornaram correspondências para esta consulta. + Nenhum resultado encontrado + Seus addons instalados não expõem busca de catálogo. + Nenhum catálogo pesquisável + Buscar filmes, séries... + Buscas Recentes + Remover busca recente + Sobre + Geral + Conta + Addons + Layout + Conteúdo & Descoberta + Continuar Assistindo + Debrid + Layout da Página Inicial + Integrações + Licenças & Atribuições + Classificações do MDBList + Página de Detalhes + Notificações + Reprodução + Plugins + Estilo do Card de Pôster + Configurações + Apoiadores & Contribuidores + Enriquecimento do TMDB + Trakt + SOBRE + Conta e status de sincronização + CONTA + Estrutura da página inicial e estilos de pôster + Baixar a versão mais recente + Verificar atualizações + Gerenciar addons e fontes de descoberta. + Gerencie seus filmes e episódios baixados. + Downloads + GERAL + Gerenciar integrações disponíveis + Gerencie alertas de lançamento de episódios e envie uma notificação de teste. + Mude para um perfil diferente. + Alternar Perfil + Abrir tela de conexão do Trakt + Nenhuma configuração encontrada. + Buscar configurações... + RESULTADOS + LICENÇA DO APP + DADOS & SERVIÇOS + LICENÇA DE REPRODUÇÃO + Nuvio Mobile + Código-fonte e termos de licença estão disponíveis no repositório do projeto. + Licenciado sob a GNU General Public License v3.0. + The Movie Database (TMDB) + O Nuvio usa a API do TMDB para metadados de filmes e séries, artes, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto usa a API do TMDB, mas não é endossado ou certificado pelo TMDB. + Conjuntos de Dados Não Comerciais do IMDb + O Nuvio usa os Conjuntos de Dados Não Comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informações cortesia do IMDb (https://www.imdb.com). Usado com permissão. Dados do IMDb são para uso pessoal e não comercial sob os termos do IMDb. + Trakt + O Nuvio conecta-se ao Trakt para autenticação de conta, histórico de assistidos, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado ou endossado pelo Trakt. + MDBList + O Nuvio usa o MDBList para classificações e dados de provedores externos de pontuação. O Nuvio não é afiliado ou endossado pelo MDBList. + IntroDB + O Nuvio usa a API do IntroDB para timestamps de intro, recapitulação, créditos e prévia fornecidos pela comunidade, usados pelos controles de pular. O Nuvio não é afiliado ou endossado pelo IntroDB. + MPVKit + Usado para reprodução em builds para iOS. + O código-fonte do MPVKit isolado é licenciado sob LGPL v3.0. Os bundles do MPVKit, incluindo bibliotecas libmpv e FFmpeg, também são licenciados sob LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Usado para reprodução em builds para Android. + Licenciado sob a Apache License, Version 2.0. + Carregando suas listas do Trakt… + Escolha onde salvar este título no Trakt + Doar + Ir para detalhes + Remover + Começar do início + Reproduzir + %1$d/10 + Avaliação + Spoiler + Nenhuma avaliação do Trakt disponível ainda. + %1$d curtidas + Este comentário contém spoilers. + Este comentário contém spoilers e foi ocultado. + Comentários + Trailer + %1$s (%2$d) + Trailers + Nenhum episódio concluído + Nenhum download ainda + %1$d episódio(s) baixado(s) + Ativo + Filmes + Séries + Mostrar Downloads + Concluído • %1$s + Baixando • %1$s + Falhou + Pausado • %1$s + Assistido + Temporada %1$d + Especiais + Continue de onde parou + Adicionar à biblioteca + Marcar como não assistido + Marcar como assistido + Remover da biblioteca + Ver Todos + Reproduzir manualmente + Logo de %1$s + Conta + Excluir Conta + Isso excluirá permanentemente sua conta e todos os dados associados. + Esta ação não pode ser desfeita. Todos os seus dados, perfis e histórico de sincronização serão removidos permanentemente. + Excluir Conta? + E-mail + Não conectado + Sair + Você será retornado à tela de login. + Sair? + Status + Anônimo + Conectado + Preto AMOLED + Usar fundos pretos puros para telas OLED. + Idioma do App + Escolher Idioma + Configurações para a seção Continuar Assistindo. + Liquid Glass + Usar a barra de abas nativa do iPhone no iOS 26 e posterior. A alternância instantânea de perfil pela barra de abas não está disponível enquanto isso estiver ativado. + Ajuste largura do card e raio dos cantos. + EXIBIÇÃO + PÁGINA INICIAL + TEMA + Coleção • %1$s + Nome de Exibição + Instale um addon com catálogos compatíveis com board para configurar as linhas da Página Inicial. + Nenhum catálogo da página inicial + Fonte do hero + Oculto + Manter Página Inicial focada + %1$s • Limite atingido (máx %2$d) + Nenhuma fonte de hero selecionada + Não no hero + Remova o fixar no topo da coleção para mover + Fixado + Fixado no topo + Reordenar + CATÁLOGOS + CATÁLOGOS & COLEÇÕES + COLEÇÕES + Layout da Página Inicial + Catálogos do Hero + %1$d de %2$d selecionado(s) + Mostrar Seção Hero + Exibir carrossel hero no topo da página inicial. + Ocultar Conteúdo Não Lançado + Ocultar filmes e séries que ainda não foram lançados. + Ocultar Linha Sublinhada do Catálogo + Remover a linha de destaque abaixo dos títulos de catálogo e coleção em todo o app. + %1$d de %2$d catálogos visíveis • %3$d fontes de hero selecionadas + Abra um catálogo apenas quando precisar renomeá-lo ou reordená-lo. + Visível + Ocultar valor + Player, legendas e reprodução automática + Raio dos Cantos + Estilo do Card de Pôster + Largura + Personalizado + Ajuste largura do card e raio dos cantos. + Ocultar rótulos + Pôsteres em Paisagem + Pré-visualização ao Vivo + %1$s (%2$s) + Raio dos cantos: %1$ddp + Altura: %1$ddp + Largura: %1$ddp + Clássico + Pílula + Arredondado + Nítido + Sutil + Equilibrado + Conforto + Compacto + Denso + Grande + Padrão + Mostrar valor + Mostrar um popup para continuar de onde parou ao abrir o app após sair do player. + Prompt de retomada ao iniciar + Desfocar thumbnails do próximo episódio em Continuar Assistindo para evitar spoilers. + Desfocar Não Assistidos em Continuar Assistindo + Incluir episódios futuros em Continuar Assistindo antes de serem exibidos. + Mostrar Episódios Próximos Não Exibidos + ORDEM DE CLASSIFICAÇÃO + Ordem de Classificação + Padrão + Classificar todos os itens por recenticidade + Estilo Streaming + Itens lançados primeiro, futuros no final + Estilo do Card de Pôster + AO INICIAR + COMPORTAMENTO DO PRÓXIMO + VISIBILIDADE + Exibir a prateleira Continuar Assistindo na tela inicial. + Mostrar Continuar Assistindo + Pôster + Card de pôster com foco na arte + Largo + Card horizontal com informações densas + Mostrar próximo episódio com base no episódio assistido mais avançado. Desative para reassistir e usar o episódio assistido mais recentemente. + Próximo a Partir do Episódio Mais Avançado + Preferir thumbnails de episódios quando disponíveis. + Preferir Thumbnails de Episódios em Continuar Assistindo + PÁGINA INICIAL + FONTES + Instale, remova, atualize e ordene suas fontes de conteúdo. + Instale repositórios de scraper JavaScript e teste provedores internamente. + Ajuste layout da página inicial, visibilidade de conteúdo e comportamento de pôsteres + Configurações para as telas de detalhes e episódios. + Crie agrupamentos personalizados de catálogos com pastas exibidas na Página Inicial. + Integrações + Controles de enriquecimento de metadados + Provedores externos de classificações + Fontes de contas em nuvem experimentais + Debrid + O suporte a Debrid é experimental e pode ser mantido, alterado ou removido posteriormente. + Ativar fontes + Exibir resultados reproduzíveis de contas conectadas. + Adicione uma chave de API primeiro. + Conta + Conecte sua conta do Torbox. + Chave de API do Torbox + Insira sua chave de API do Torbox. + Insira a chave de API do Torbox + Não definido + Reprodução Instantânea + Preparar links + Resolver as primeiras fontes antes do início da reprodução. + Fontes a preparar + Use uma contagem menor quando possível. Os serviços de Debrid podem limitar a taxa de quantos links podem ser resolvidos em um período de tempo. Abrir um filme ou episódio pode contar para esses limites mesmo se você não pressionar Assistir, porque os links são preparados antecipadamente. + 1 fonte + %1$d fontes + Formatação + Modelo de nome + Controla como os nomes das fontes aparecem. + Modelo de descrição + Controla os metadados exibidos abaixo de cada fonte. + Redefinir formatação + Restaurar formatação padrão de fontes. + Chave de API validada. + Não foi possível validar esta chave de API. + Adicione sua chave de API do MDBList abaixo antes de ativar as classificações. + Necessária para buscar classificações do MDBList + Chave de API + Chave de API + Ativar Classificações do MDBList + Buscar classificações de provedores externos na tela de detalhes de metadados + Chave de API + Provedores externos de classificações + Classificações do MDBList + Ações + Controles de reprodução e salvamento. + Elenco + Lista do elenco principal. + Fundo Cinematográfico + Backdrop desfocado atrás do conteúdo, similar à tela de stream. + Coleção + Coleção relacionada ou rail de franquia. + Comentários + Avaliações do Trakt + Detalhes + Duração, status, lançamento, idioma e informações relacionadas. + Cards de Episódio + Escolha como os episódios são renderizados na tela de metadados. + Horizontal + Cards de linha estilo backdrop + Lista + Cards empilhados com foco em detalhes + Episódios + Temporadas e lista de episódios para séries. + Desfocar Episódios Não Assistidos + Desfocar thumbnails de episódios até serem assistidos para evitar spoilers. + Grupo %1$d + Mais como este + Backdrops de recomendação do TMDB na página de detalhes + Nenhum + Visão Geral + Sinopse, classificações, gêneros e créditos principais. + Produção + Estúdios e emissoras. + APARÊNCIA + SEÇÕES + Grupo de Aba %1$d + Layout de Abas + Agrupe seções em abas como no app de TV. Atribua até 3 seções por grupo de abas. + Trailers + Rail de trailer e atalhos de reprodução. + Notificações estão atualmente desativadas no Nuvio. + Alertas de lançamento de episódios + Agende notificações locais quando um novo episódio de uma série salva se tornar disponível. + Notificações do sistema estão desativadas para o Nuvio. Ative-as para receber alertas e testar notificações. + %1$d alertas de lançamento estão atualmente agendados neste dispositivo. + ALERTAS + TESTE + Enviar Notificação de Teste + Enviando Notificação de Teste... + Enviar uma notificação de teste local para %1$s. + Salve uma série na sua biblioteca primeiro para testar notificações. + Notificação de teste + Comunidade + Veja as pessoas que constroem e apoiam o Nuvio em Mobile, TV e Web. + API de apoiadores não está configurada. Adicione DONATIONS_BASE_URL ao local.properties. + Contribuidores + Apoiadores + Abrir no GitHub + Perfil do GitHub indisponível + Nenhuma mensagem anexada. + Carregando contribuidores... + Carregando apoiadores... + Não foi possível carregar contribuidores + Não foi possível carregar apoiadores + Nenhum contribuidor encontrado. + Nenhum apoiador encontrado. + Não foi possível carregar contribuidores. + Não foi possível carregar apoiadores. + Não foi possível carregar contribuidores no momento. + Não foi possível carregar apoiadores no momento. + %1$d commits no total + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + %1$s %2$s, %3$s + Todos os addons instalados + Todos os plugins ativados + Addons Permitidos + Plugins Permitidos + Anime Skip + Client ID do AnimeSkip + Insira seu client ID da API do AnimeSkip. Obtenha um em anime-skip.com. + Ativar Envio de Intro + Mostrar um botão para enviar timestamps de intro/outro para o banco de dados da comunidade. + Chave de API do IntroDB + Insira sua chave de API do IntroDB para enviar timestamps. Necessário para envio. + Também buscar no AnimeSkip por timestamps de pular (requer client ID). + Reproduzir Próximo Episódio Automaticamente + Iniciar próximo episódio automaticamente quando o prompt aparecer. + Apenas decodificadores do dispositivo + Preferir decodificadores do app (FFmpeg) + Preferir decodificadores do dispositivo + Prioridade do Decodificador + Toque fora para fechar + Toque fora para salvar & fechar + %1$d dia + %1$d dias + %1$d hora + %1$d horas + Usar libass para legendas ASS/SSA + Experimental: renderização avançada de ASS/SSA (estilos, posicionamento, animações) + Player Externo + App de Player Externo + Abrir nova reprodução com o app de vídeo padrão do Android ou seletor do sistema. + Abrir nova reprodução com o player instalado selecionado. + Nenhum player externo compatível instalado + Segurar para Velocidade + Segurar para Acelerar + Pressione e segure em qualquer lugar na superfície do player para aumentar temporariamente a velocidade de reprodução. + Padrão regex inválido + Duração do Cache do Último Link + DV7 - Fallback HEVC + Mapear Dolby Vision Profile 7 para HEVC padrão em dispositivos sem suporte de hardware para DV + Minutos de Limite + Fallback quando não houver timestamp de encerramento. + %1$s min + Nenhum item disponível + Não definido + Padrão (arquivo de mídia) + Idioma do dispositivo + Forçada + Nenhuma + Preferir Grupo de Maratona (Próximo Episódio) + Tentar o mesmo perfil de fonte primeiro (mesmo addon/grupo de qualidade) antes das regras normais de reprodução automática. + Reutilizar Grupo de Maratona + Lembrar e reutilizar o último grupo de maratona (binge group) entre sessões (Continuar Assistindo, Detalhes, etc.). + Idioma de Áudio Preferido + Idioma Preferido + Predefinições + Corresponde contra nome/título/descrição/addon/URL da stream. Exemplo: 4K|2160p|Remux + Padrão Regex + Nenhum padrão definido. Exemplo: 4K|2160p|Remux + Qualquer 1080p+ + AVC / x264 + Qualidade BluRay + Dolby Atmos / DTS + Inglês + HDR / Dolby Vision + HEVC / x265 + Sem CAM/TS + Sem REMUX/HDR + 1080p Padrão + 4K / Remux + 720p / Menor + Fontes WEB + Modo de Renderização Libass + Cues Padrão + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL (Recomendado) + Reutilizar Último Link + Reproduzir automaticamente sua última stream funcional para este mesmo filme/episódio enquanto o cache estiver válido + Segundo Idioma de Áudio + Segundo Idioma Preferido + DECODIFICADOR + PRÓXIMO EPISÓDIO + PLAYER + PULAR SEGMENTOS + REPRODUÇÃO AUTOMÁTICA DE STREAM + SELEÇÃO DE STREAM + LEGENDA E ÁUDIO + RENDERIZAÇÃO DE LEGENDA + %1$d selecionado(s) + Overlay de Carregamento + Mostrar tela de carregamento até o primeiro quadro de vídeo aparecer. + Pular Intro + Usar introdb.app para detectar intros e recapitulações. + Escopo de Fonte para Reprodução Automática + Todos os addons instalados + Reprodução automática considera apenas streams vindas de seus addons instalados. + Todas as fontes + Reprodução automática pode usar tanto addons instalados quanto plugins ativados. + Apenas plugins ativados + Reprodução automática considera apenas streams vindas de plugins ativados. + Apenas addons instalados + Reprodução automática considera apenas streams vindas de seus addons instalados. + Seleção Automática de Stream + Reproduzir primeira fonte automaticamente + Reproduzir a primeira fonte disponível automaticamente. + Manual (escolher stream) + Sempre mostrar lista de fontes e permitir que eu escolha. + Reproduzir correspondência regex automaticamente + Reproduzir primeira fonte cujo texto corresponda ao seu padrão regex. + Tempo Limite para Seleção de Stream + Tempo de espera para addons antes de selecionar. + Minutos de Limite + Modo de Limite para Próximo Episódio + Minutos antes do fim + Porcentagem + Porcentagem de Limite + Fallback quando não houver timestamp de encerramento. + %1$s% + Instantâneo + %1$ss + Ilimitado + Reprodução em Túnel + Sincronização de áudio/vídeo em nível de hardware. Pode melhorar a reprodução em alguns dispositivos Android TV + Adicione sua própria chave de API do TMDB abaixo antes de ativar o enriquecimento. + Chave de API + Ativar Enriquecimento do TMDB + Usar o TMDB como fonte de metadados para aprimorar dados de addons + Insira sua chave de API TMDB v3. + Código de idioma + Arte + Imagens de logo e backdrop do TMDB + Informações Básicas + Descrição, gêneros e classificação do TMDB + Coleções + Coleções de filmes do TMDB em ordem de lançamento + Créditos + Elenco com fotos, diretor e roteirista do TMDB + Detalhes + Duração, status, país e idioma do TMDB + Episódios + Títulos de episódios, sinopses, thumbnails e duração do TMDB + Mais como Este + Backdrops de recomendação do TMDB na página de detalhes + Emissoras + Emissoras com logos do TMDB + Produções + Empresas de produção do TMDB + Pôsteres de temporada + Usar pôsteres de temporada do TMDB no seletor de temporadas da tela de metadados para séries. + Trailers + Candidatos a trailer dos vídeos do TMDB para a seção de trailer de detalhes + Chave de API pessoal + Idioma + Idioma dos metadados do TMDB para título, logo e campos ativados + CREDENCIAIS + LOCALIZAÇÃO + MÓDULOS + Enriquecimento do TMDB + Após a aprovação, você será redirecionado de volta automaticamente. + AUTENTICAÇÃO + Comentários + Mostrar avaliações do Trakt nas páginas de metadados + Conectar Trakt + Conectado como %1$s + Usuário Trakt + Desconectar + Falha ao abrir navegador + RECURSOS + Conclua o login do Trakt no seu navegador + Sincronize sua watchlist, progresso de exibição, continuar assistindo, scrobbles e listas pessoais com o Trakt. + Credenciais do Trakt ausentes em local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Abrir Login do Trakt + Suas ações de Salvar agora podem ter como alvo a watchlist e listas pessoais do Trakt. + Faça login com o Trakt para ativar o salvamento baseado em listas e o modo de biblioteca do Trakt. + Fonte da Biblioteca + Escolha qual biblioteca usar para salvar e visualizar sua coleção + Fonte da Biblioteca + Escolha onde salvar e gerenciar seus itens da biblioteca + Trakt + Biblioteca do Nuvio + Biblioteca do Trakt selecionada + Biblioteca do Nuvio selecionada + Progresso de Exibição + Escolha qual fonte de progresso controla retomar e continuar assistindo + Progresso de Exibição + Escolha se retomar e continuar assistindo devem usar Trakt ou Nuvio Sync enquanto o scrobble do Trakt permanece ativo. + Trakt + Nuvio Sync + Fonte de progresso de exibição definida como Trakt + Fonte de progresso de exibição definida como Nuvio Sync + Janela de Continuar Assistindo + Histórico do Trakt considerado para continuar assistindo + Janela de Continuar Assistindo + Escolha quanto da atividade do Trakt deve aparecer em continuar assistindo. + Todo o histórico + %1$d dias + Pontuação do Público + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Desconhecido + Âmbar + Carmesim + Esmeralda + Oceano + Rosa + Violeta + Branco + Próximo Episódio + Buscando fonte… + Reproduzindo via %1$s em %2$d… + Thumbnail do próximo episódio + Não exibido + Pular + Pular Intro + Pular Outro + Pular Recapitulação + Nenhuma legenda encontrada + Africâner + Albanês + Amárico + Árabe + Armênio + Azerbaijano + Basco + Bielorrusso + Bengali + Bósnio + Búlgaro + Birmanês + Catalão + Chinês + Chinês (Simplificado) + Chinês (Tradicional) + Croata + Tcheco + Dinamarquês + Holandês + Inglês + Estoniano + Filipino + Finlandês + Francês + Galego + Georgiano + Alemão + Grego + Guzerate + Hebraico + Hindi + Húngaro + Islandês + Indonésio + Irlandês + Italiano + Japonês + Canarim + Cazaque + Khmer + Coreano + Lao + Letão + Lituano + Macedônio + Malaio + Malaiala + Maltês + Marati + Mongol + Nepalês + Norueguês + Persa + Polonês + Português (Portugal) + Português (Brasil) + Punjabi + Romeno + Russo + Sérvio + Cingalês + Eslovaco + Esloveno + Espanhol + Espanhol (América Latina) + Suaíli + Sueco + Tâmil + Télugo + Tailandês + Turco + Ucraniano + Urdu + Uzbeque + Vietnamita + Galês + Zulu + Limpar + Continuar + Ignorar + Instalar + Depois + Não + Atualizar + Sim + Deseja sair do app? + Sair do app + Este catálogo não retornou nenhum item. + Nenhum título encontrado + Verifique sua conexão Wi-Fi ou dados móveis e tente novamente. + Diretor + Falha ao carregar + Mais como Este + Temporadas + Este addon retornou vídeos para a série, mas nenhum incluiu números de temporada ou episódio. + Este addon não forneceu metadados de episódio para esta série. + Episódios ainda não foram publicados por este addon. + Seu dispositivo está online, mas o Nuvio não pôde alcançar os servidores necessários. + Mostrar Menos + Mostrar Mais ▾ + Roteirista + Todos os Gêneros + Catálogo + %1$s • %2$s + O catálogo selecionado falhou ao retornar itens de descoberta. + Não foi possível carregar Descobrir + Addons instalados não expõem catálogos compatíveis com board para descoberta. + Nenhum catálogo de descoberta + O catálogo e filtros selecionados não retornaram nenhum item. + Nenhum título encontrado + Instale e valide pelo menos um addon antes de navegar pelos catálogos de descoberta. + Selecionar Catálogo + Selecionar Gênero + Selecionar Tipo + Tipo + Marcar anteriores como não assistidos + Marcar anteriores como assistidos + Marcar %1$s como não assistido + Marcar %1$s como assistido + Marcar como não assistido + Marcar como assistido + A seguir + %1$s assistido + Instale e valide pelo menos um addon antes de carregar linhas de catálogo na Página Inicial. + Addons instalados não expõem atualmente catálogos compatíveis com board sem extras obrigatórios. + Nenhuma linha da página inicial disponível + Ver Detalhes + Controles de reprodução e salvamento. + Ações + Lista do elenco principal. + Coleção relacionada ou rail de franquia. + Coleção + Seção de comentários do Trakt. + Duração, status, lançamento, idioma e informações relacionadas. + Detalhes + Temporadas e lista de episódios para séries. + Rail de recomendação. + Mais como Este + Sinopse, classificações, gêneros e créditos principais. + Visão Geral + Estúdios e emissoras. + Produção + Rail de trailer e atalhos de reprodução. + Online novamente + Não foi possível alcançar os servidores + Sem conexão com a internet + (idade %1$d) + Nascido %1$s%2$s + Falecido %1$s + Conhecido por: %1$s + Mais Recentes + Não foi possível carregar detalhes para %1$s + Populares + Algo deu errado + Próximos + Apagar + Cancelar + Insira o PIN + Insira o PIN para %1$s + Esqueceu o PIN? + PIN incorreto + Bloqueado. Tente novamente em %1$ds + Opções de avatar aparecerão aqui quando o catálogo carregar. + Avatar: %1$s + Insira uma URL de imagem http:// ou https:// válida. + Escolher um avatar + Escolha um avatar abaixo. + Criar Perfil + URL de avatar personalizada selecionada. + URL de avatar personalizada + Cole um link de imagem, ou deixe vazio para usar o catálogo de avatar integrado. + https://example.com/avatar.png + Todos os dados de "%1$s" serão excluídos permanentemente. + Excluir Perfil + Adicionar Perfil + Editar Perfil + Insira o PIN atual + Insira o novo PIN + Perfil %1$d + Carregando avatares... + Gerenciar Perfis + Nome do perfil + Novo perfil + Addons primários desativados + Addons primários ativados + Remover PIN para %1$s + Remover Bloqueio por PIN + Salvando... + Segurança + Adicione um PIN se quiser que este perfil seja bloqueado antes de alternar para ele. + Este perfil está protegido com um PIN. + Selecione um avatar para este perfil. + Definir Bloqueio por PIN + Perfil sem nome + Usar Addons Primários + Compartilhar a configuração de addon do perfil principal em vez de gerenciar uma lista separada. + Quem está assistindo? + Baixado + Retomar + Scrapers ativos + Verificando mais addons… + Copiar link da stream + Baixar arquivo + Abrir em player externo + Abrir em player interno + Os addons de stream instalados falharam ao retornar uma resposta de stream válida. + Não foi possível carregar streams + Instale um addon primeiro para carregar streams para este título. + Seus addons instalados não fornecem streams para este tipo de título. + Nenhum addon de stream disponível + Nenhum dos seus addons instalados retornou streams para este título. + T%1$d E%2$d + Episódio + T%1$dE%2$d - %3$s + Buscando… + Buscando fonte… + Buscando streams… + Link da stream copiado + Nenhum link direto de stream disponível + Nenhum metadado disponível + Atualizar streams + Retomar de %1$d% + Retomar de %1$s + TAMANHO %1$s + Streams de torrent não são suportados + Adicione uma chave de API do Debrid nas Configurações. + Este resultado do Debrid expirou. Atualizando streams. + Não foi possível resolver esta stream do Debrid. + Não foi possível abrir o player externo + Escolha um player externo nas configurações primeiro + Nenhum player externo está disponível + Fechar trailer + Não foi possível reproduzir o trailer + Falha ao carregar listas do Trakt + Falha ao atualizar listas do Trakt + %1$s • %2$s + Falha na verificação de atualização + Download falhou + Baixando %1$d% + Não foi possível iniciar a instalação + Você está usando a versão mais recente. + Ative instalações de apps para o Nuvio, então volte e continue. + Baixando atualização... + Nenhuma atualização encontrada. + Uma nova versão está pronta para instalar. + Atualizações no app não estão disponíveis nesta build. + Preparando download + Notas de lançamento + Permitir instalações para continuar + Atualização disponível + Status da atualização + Esse addon já está instalado. + Insira uma URL de addon válida + Não foi possível carregar o manifesto + Nuvio + Falha na exclusão da conta + Falha no login + Falha ao sair + Falha no cadastro + Não foi possível carregar itens do catálogo. + A Seguir + A Seguir • T%1$dE%2$d + Logo de %1$s + Falha ao carregar comentários + Não foi possível carregar detalhes de nenhum addon. + Emissoras + Nenhum addon fornece metadados para este conteúdo. + Download falhou + Mostra progresso e controles de download em tempo real. + Downloads + Download concluído + Baixando %1$s • %2$s + Baixando %1$s • %2$s / %3$s + Download falhou + Pausado %1$s + Remover + Remover %1$s de %2$s? + Remover %1$s da sua biblioteca? + Remover da Biblioteca? + Filme + Alertas quando um novo episódio de uma série salva é lançado. + Pré-visualização de alerta de lançamento de episódio. + Falha ao enviar notificação de teste. + Notificação de teste enviada para %1$s. + Não foi possível reproduzir esta stream. + O PIN deste perfil mudou. Conecte-se uma vez para atualizar o bloqueio neste dispositivo. + Não foi possível remover o bloqueio por PIN. Tente novamente. + Conecte-se à internet para remover o bloqueio por PIN. + Este PIN ainda não pode ser verificado offline neste dispositivo. Conecte-se uma vez e desbloqueie online primeiro. + Não foi possível definir o PIN. Tente novamente. + Conecte-se à internet para definir um PIN. + Este perfil usa addons primários. + Falha ao carregar %1$s + Stream + Incorporada + Autorização negada + Conclua o login do Trakt no seu navegador + Callback do Trakt inválido + Estado de callback do Trakt inválido + Resposta de token do Trakt inválida + Falha ao carregar biblioteca do Trakt + Lista %1$d + Trakt não retornou um código de autorização + Credenciais do Trakt ausentes + Falha ao carregar progresso do Trakt + Falha ao concluir login do Trakt + Usuário Trakt + Watchlist + Trailer + Desconhecido + Addon + Salvo + Reproduzir %1$s + Retomar %1$s + JSON está vazio. + Coleção %1$d tem id em branco. + Coleção '%1$s' tem título em branco. + Pasta %1$d em '%2$s' tem id em branco. + Pasta '%1$s' em '%2$s' tem título em branco. + Fonte %1$d na pasta '%2$s' tem campos em branco. + Fonte %1$d na pasta '%2$s' está sem ID de lista do Trakt. + JSON inválido: %1$s + Addon não encontrado: %1$s + Janeiro + Fevereiro + Março + Abril + Maio + Junho + Julho + Agosto + Setembro + Outubro + Novembro + Dezembro + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + Empresa de Produção + Emissora + Não foi possível carregar %1$s + Populares + Recentes + %1$s • %2$s + Mais Bem Avaliados + Classificação + Detalhes do Filme + Idioma Original + País de Origem + Informações de Lançamento + Duração + Pôsteres + Texto + Mostrar Detalhes + Status + Vídeos + ARQUIVO + Nenhum link direto de stream disponível + Download anterior substituído + Download iniciado + Formato de stream não suportado para downloads + Corpo da resposta vazio + Solicitação falhou com HTTP %1$d + Sistema de download não está inicializado + Solicitação de download falhou + %1$s - %2$s + Títulos salvos aparecerão aqui após você tocar em Salvar em uma tela de detalhes. + Sua biblioteca está vazia + Não foi possível carregar a biblioteca + Outro + Biblioteca + Conecte o Trakt e salve títulos na sua watchlist ou listas pessoais. + Sua biblioteca do Trakt está vazia + Não foi possível carregar biblioteca do Trakt + Biblioteca do Trakt + Anime + Canais + Filmes + Séries + TV + %1$s já está disponível + %1$s • %2$s já está disponível + Um novo episódio já está disponível + %1$s já está disponível + Lançamentos de Episódios + Álcool/Drogas + Assustador + Nudez + Linguagem Obscena + Leve + Moderado + Grave + Violência + Criador + Diretor + Roteirista + Pontuação do Público + Nenhuma stream de trailer reproduzível encontrada. + Temporada %1$d - %2$s + B + KB + MB + GB + From 73c97bfeb708365f020e72d1cae2e810132942dd Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 21 May 2026 15:56:42 +0530 Subject: [PATCH 17/60] feat(crypto): implement AES-GCM for iOS and enhance PBKDF2/Base64 support --- .../features/plugins/PluginCrypto.android.kt | 63 +++++- .../app/features/plugins/PluginCrypto.ios.kt | 213 +++++++++++++++--- .../cinterop/commoncrypto_shim.h | 74 ++++++ 3 files changed, 304 insertions(+), 46 deletions(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index b1d4fedc5..ca9d08b7f 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -35,15 +35,54 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val normalizedAlgo = when (algorithm.uppercase()) { - "SHA256" -> "PBKDF2WithHmacSHA256" - "SHA1" -> "PBKDF2WithHmacSHA1" - else -> "PBKDF2WithHmacSHA256" + val prfAlgo = when (algorithm.uppercase()) { + "SHA256", "HMACSHA256" -> "HmacSHA256" + "SHA1", "HMACSHA1" -> "HmacSHA1" + "SHA512", "HMACSHA512" -> "HmacSHA512" + "MD5", "HMACMD5" -> "HmacMD5" + else -> "HmacSHA256" } - val factory = SecretKeyFactory.getInstance(normalizedAlgo) - val passChars = password.map { (it.toInt() and 0xFF).toChar() }.toCharArray() - val spec = PBEKeySpec(passChars, salt, iterations, keySizeBits) - return factory.generateSecret(spec).encoded + val mac = Mac.getInstance(prfAlgo) + mac.init(SecretKeySpec(password, prfAlgo)) + + val hLen = mac.macLength + val dkLen = keySizeBits / 8 + val dk = ByteArray(dkLen) + + val blocks = (dkLen + hLen - 1) / hLen + val u = ByteArray(hLen) + val t = ByteArray(hLen) + + val blockIndexBytes = ByteArray(4) + + for (i in 1..blocks) { + mac.reset() + mac.update(salt) + blockIndexBytes[0] = (i ushr 24).toByte() + blockIndexBytes[1] = (i ushr 16).toByte() + blockIndexBytes[2] = (i ushr 8).toByte() + blockIndexBytes[3] = i.toByte() + mac.update(blockIndexBytes) + + val u1 = mac.doFinal() + u1.copyInto(t) + u1.copyInto(u) + + for (j in 2..iterations) { + mac.reset() + val uj = mac.doFinal(u) + uj.copyInto(u) + for (k in 0 until hLen) { + t[k] = (t[k].toInt() xor uj[k].toInt()).toByte() + } + } + + val offset = (i - 1) * hLen + val len = minOf(hLen, dkLen - offset) + t.copyInto(dk, destinationOffset = offset, startIndex = 0, endIndex = len) + } + + return dk } internal fun pluginAesEncrypt( @@ -161,7 +200,13 @@ internal fun pluginBase64Encode(data: String): String = @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + // Robust URL-safe base64 decoding fallback + normalized = normalized.replace("-", "+").replace("_", "/") + val padNeeded = (4 - (normalized.length % 4)) % 4 + if (padNeeded > 0) { + normalized += "=".repeat(padNeeded) + } val decoded = Base64.decode(normalized) return decoded.decodeToString() } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index d61a00e10..cfb647651 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -13,30 +13,7 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.value import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 -import com.nuvio.app.features.plugins.cryptointerop.CC_MD5_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512 -import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512_DIGEST_LENGTH -import com.nuvio.app.features.plugins.cryptointerop.CCHmac -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256 -import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512 -import com.nuvio.app.features.plugins.cryptointerop.CCKeyDerivationPBKDF -import com.nuvio.app.features.plugins.cryptointerop.kCCPBKDF2 -import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA1 -import com.nuvio.app.features.plugins.cryptointerop.kCCPRFHmacAlgSHA256 -import com.nuvio.app.features.plugins.cryptointerop.CCCrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCDecrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCAlgorithmAES -import com.nuvio.app.features.plugins.cryptointerop.kCCOptionECBMode -import com.nuvio.app.features.plugins.cryptointerop.kCCEncrypt -import com.nuvio.app.features.plugins.cryptointerop.kCCOptionPKCS7Padding -import com.nuvio.app.features.plugins.cryptointerop.kCCSuccess +import com.nuvio.app.features.plugins.cryptointerop.* import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault @@ -86,8 +63,10 @@ internal fun pluginPbkdf2( algorithm: String, ): ByteArray { val prf = when (algorithm.uppercase()) { - "SHA256" -> kCCPRFHmacAlgSHA256 - "SHA1" -> kCCPRFHmacAlgSHA1 + "SHA256", "HMACSHA256" -> kCCPRFHmacAlgSHA256 + "SHA1", "HMACSHA1" -> kCCPRFHmacAlgSHA1 + "SHA384", "HMACSHA384" -> kCCPRFHmacAlgSHA384 + "SHA512", "HMACSHA512" -> kCCPRFHmacAlgSHA512 else -> kCCPRFHmacAlgSHA256 } @@ -130,9 +109,82 @@ internal fun pluginAesEncrypt( ): ByteArray { val isGcm = mode.uppercase().contains("GCM") if (isGcm) { - throw UnsupportedOperationException("AES-GCM Encrypt is not yet implemented on iOS") + var encryptedData: ByteArray? = null + memScoped { + val cryptorRefVar = alloc() + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + data.usePinned { pinnedData -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null + val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null + + val status = CCCryptorCreateWithMode( + op = kCCEncrypt, + mode = kCCModeGCM, + alg = kCCAlgorithmAES, + padding = ccNoPadding, + iv = ivPtr, + key = keyPtr, + keyLength = key.size.toULong(), + tweak = null, + tweakLength = 0UL, + numRounds = 0, + options = 0U, + cryptorRef = cryptorRefVar.ptr + ) + + if (status != kCCSuccess) { + error("CCCryptorCreateWithMode failed with status: $status") + } + + val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null") + + try { + val cipherTextBytes = ByteArray(data.size) + cipherTextBytes.usePinned { pinnedCipher -> + val cipherPtr = if (data.isNotEmpty()) pinnedCipher.addressOf(0) else null + val cryptStatus = CCCryptorGCMEncrypt( + cryptorRef = cryptorRef, + dataIn = dataPtr, + dataInLength = data.size.toULong(), + dataOut = cipherPtr + ) + if (cryptStatus != kCCSuccess) { + error("CCCryptorGCMEncrypt failed with status: $cryptStatus") + } + } + + val tagBytes = ByteArray(16) + val tagLengthVar = alloc() + tagLengthVar.value = 16UL + + tagBytes.usePinned { pinnedTag -> + val tagPtr = pinnedTag.addressOf(0) + val finalStatus = CCCryptorGCMFinal( + cryptorRef = cryptorRef, + tag = tagPtr, + tagLength = tagLengthVar.ptr + ) + if (finalStatus != kCCSuccess) { + error("CCCryptorGCMFinal failed with status: $finalStatus") + } + } + + encryptedData = cipherTextBytes + tagBytes + } finally { + CCCryptorRelease(cryptorRef) + } + } + } + } + } + return encryptedData ?: ByteArray(0) } + val isEcb = mode.uppercase().contains("ECB") + val isNoPadding = mode.uppercase().contains("NOPADDING") val dataOutAvailable = data.size + 16 // AES block size val dataOut = ByteArray(dataOutAvailable) @@ -142,10 +194,12 @@ internal fun pluginAesEncrypt( memScoped { val dataOutMoved = alloc() - val options = if (isEcb) { - kCCOptionPKCS7Padding or kCCOptionECBMode - } else { - kCCOptionPKCS7Padding + var options = 0U + if (isEcb) { + options = options or kCCOptionECBMode + } + if (!isNoPadding) { + options = options or kCCOptionPKCS7Padding } key.usePinned { pinnedKey -> @@ -189,9 +243,87 @@ internal fun pluginAesDecrypt( ): ByteArray { val isGcm = mode.uppercase().contains("GCM") if (isGcm) { - throw UnsupportedOperationException("AES-GCM Decrypt is not yet implemented on iOS") + require(data.size >= 16) { "Data too short for GCM decryption" } + val ciphertextLen = data.size - 16 + val ciphertext = data.copyOfRange(0, ciphertextLen) + val tagBytes = data.copyOfRange(ciphertextLen, data.size) + + var decryptedData: ByteArray? = null + + memScoped { + val cryptorRefVar = alloc() + + key.usePinned { pinnedKey -> + iv.usePinned { pinnedIv -> + ciphertext.usePinned { pinnedCipher -> + tagBytes.usePinned { pinnedTag -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null + val cipherPtr = if (ciphertext.isNotEmpty()) pinnedCipher.addressOf(0) else null + val tagPtr = pinnedTag.addressOf(0) + + val status = CCCryptorCreateWithMode( + op = kCCDecrypt, + mode = kCCModeGCM, + alg = kCCAlgorithmAES, + padding = ccNoPadding, + iv = ivPtr, + key = keyPtr, + keyLength = key.size.toULong(), + tweak = null, + tweakLength = 0UL, + numRounds = 0, + options = 0U, + cryptorRef = cryptorRefVar.ptr + ) + + if (status != kCCSuccess) { + error("CCCryptorCreateWithMode failed with status: $status") + } + + val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null") + + try { + val plainTextBytes = ByteArray(ciphertextLen) + plainTextBytes.usePinned { pinnedPlain -> + val plainPtr = if (ciphertextLen > 0) pinnedPlain.addressOf(0) else null + val cryptStatus = CCCryptorGCMDecrypt( + cryptorRef = cryptorRef, + dataIn = cipherPtr, + dataInLength = ciphertextLen.toULong(), + dataOut = plainPtr + ) + if (cryptStatus != kCCSuccess) { + error("CCCryptorGCMDecrypt failed with status: $cryptStatus") + } + } + + val tagLengthVar = alloc() + tagLengthVar.value = 16UL + + val finalStatus = CCCryptorGCMFinal( + cryptorRef = cryptorRef, + tag = tagPtr, + tagLength = tagLengthVar.ptr + ) + if (finalStatus != kCCSuccess) { + error("CCCryptorGCMFinal failed with status: $finalStatus (tag verification failed)") + } + + decryptedData = plainTextBytes + } finally { + CCCryptorRelease(cryptorRef) + } + } + } + } + } + } + return decryptedData ?: ByteArray(0) } + val isEcb = mode.uppercase().contains("ECB") + val isNoPadding = mode.uppercase().contains("NOPADDING") val dataOutAvailable = data.size + 16 // AES block size val dataOut = ByteArray(dataOutAvailable) @@ -201,10 +333,12 @@ internal fun pluginAesDecrypt( memScoped { val dataOutMoved = alloc() - val options = if (isEcb) { - kCCOptionPKCS7Padding or kCCOptionECBMode - } else { - kCCOptionPKCS7Padding + var options = 0U + if (isEcb) { + options = options or kCCOptionECBMode + } + if (!isNoPadding) { + options = options or kCCOptionPKCS7Padding } key.usePinned { pinnedKey -> @@ -326,7 +460,12 @@ internal fun pluginBase64Encode(data: String): String = @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + normalized = normalized.replace("-", "+").replace("_", "/") + val padNeeded = (4 - (normalized.length % 4)) % 4 + if (padNeeded > 0) { + normalized += "=".repeat(padNeeded) + } val decoded = Base64.decode(normalized) return decoded.decodeToString() } diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index b2620dc11..255a0fd71 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -112,3 +112,77 @@ CCCryptorStatus CCCrypt( size_t dataOutAvailable, size_t *dataOutMoved ); + +typedef uint32_t CCMode; +enum { + kCCModeECB = 1, + kCCModeCBC = 2, + kCCModeCFB = 3, + kCCModeOFB = 4, + kCCModeCFB8 = 5, + kCCModeCTR = 6, + kCCModeF8 = 7, + kCCModeLRW = 8, + kCCModeOFB8 = 9, + kCCModeXTS = 10, + kCCModeRC4 = 11, + kCCModeCFB128 = 12, + kCCModeGCM = 13, + kCCModeCCM = 14, +}; + +typedef uint32_t CCPadding; +enum { + ccNoPadding = 0, + ccPKCS7Padding = 1, +}; + +typedef uint32_t CCModeOptions; + +typedef struct _CCCryptor *CCCryptorRef; + +CCCryptorStatus CCCryptorCreateWithMode( + CCOperation op, + CCMode mode, + CCAlgorithm alg, + CCPadding padding, + const void *iv, + const void *key, + size_t keyLength, + const void *tweak, + size_t tweakLength, + int numRounds, + CCModeOptions options, + CCCryptorRef *cryptorRef +); + +CCCryptorStatus CCCryptorGCMAddAAD( + CCCryptorRef cryptorRef, + const void *aData, + size_t aDataLen +); + +CCCryptorStatus CCCryptorGCMEncrypt( + CCCryptorRef cryptorRef, + const void *dataIn, + size_t dataInLength, + void *dataOut +); + +CCCryptorStatus CCCryptorGCMDecrypt( + CCCryptorRef cryptorRef, + const void *dataIn, + size_t dataInLength, + void *dataOut +); + +CCCryptorStatus CCCryptorGCMFinal( + CCCryptorRef cryptorRef, + void *tag, + size_t *tagLength +); + +CCCryptorStatus CCCryptorRelease( + CCCryptorRef cryptorRef +); + From 0a0aa00a0f349a56141fdea75887f37467bdedbd Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 21 May 2026 22:09:44 +0530 Subject: [PATCH 18/60] fix(plugins): use proper bitwise WordArray implementation for CryptoJS polyfill --- .../features/plugins/runtime/js/JsBindings.kt | 312 ++++++++++-------- 1 file changed, 166 insertions(+), 146 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 66c70e866..f6f1ac1a8 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -218,121 +218,81 @@ internal object JsBindings { """.trimIndent() private fun cryptoPolyfill() = """ - function __hexToWords(hex) { - var words = []; - for (var i = 0; i < hex.length; i += 8) { - var chunk = hex.substring(i, i + 8); - while (chunk.length < 8) chunk += '0'; - words.push(parseInt(chunk, 16) | 0); - } - return words; - } - - function __wordsToHex(words, sigBytes) { - var hex = ''; - for (var i = 0; i < sigBytes; i++) { - var word = words[i >>> 2] || 0; - var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; - var part = byte.toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __bytesToHex(bytes) { - bytes = __toUint8Array(bytes); - var hex = ''; - for (var i = 0; i < bytes.length; i++) { - var part = bytes[i].toString(16); - if (part.length < 2) part = '0' + part; - hex += part; - } - return hex; - } - - function __hexToBytes(hex) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var bytes = new Uint8Array(normalizedHex.length / 2); - for (var i = 0; i < normalizedHex.length; i += 2) { - bytes[i / 2] = parseInt(normalizedHex.substring(i, i + 2), 16) || 0; - } - return bytes; - } - - function __binaryStringToBytes(value) { - var text = value == null ? '' : String(value); - var bytes = new Uint8Array(text.length); - for (var i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; - return bytes; - } - - function __bytesToBinaryString(bytes) { - bytes = __toUint8Array(bytes); - var out = ''; - for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); - return out; - } - - function __wordArrayToHex(value) { - if (!value) return ''; - if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); - if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordsToHex(value.words, value.sigBytes); - } - return typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(String(value)) : ''; - } - - function __buildWordArray(hex, utf8Override) { - var normalizedHex = (hex || '').toLowerCase(); - if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; - var wordArray = { - __hex: normalizedHex, - __utf8: utf8Override !== undefined ? utf8Override : (typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(normalizedHex) : ''), - sigBytes: normalizedHex.length / 2, - words: __hexToWords(normalizedHex), - toString: function(encoder) { - if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; - if (encoder === CryptoJS.enc.Utf8) return this.__utf8; - if (encoder === CryptoJS.enc.Base64) return btoa(__bytesToBinaryString(__hexToBytes(this.__hex))); - return this.__hex; - }, - clamp: function() { return this; }, - concat: function(other) { - var otherHex = __wordArrayToHex(other); - this.__hex += otherHex; - this.__utf8 = typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(this.__hex) : ''; - this.sigBytes = this.__hex.length / 2; - this.words = __hexToWords(this.__hex); - return this; + var WordArray = { + init: function(words, sigBytes) { + words = this.words = words || []; + if (sigBytes != undefined) { + this.sigBytes = sigBytes; + } else { + this.sigBytes = words.length * 4; } - }; - return wordArray; + }, + toString: function(encoder) { + return (encoder || CryptoJS.enc.Hex).stringify(this); + }, + concat: function(wordArray) { + var thisWords = this.words; + var thatWords = wordArray.words; + var thisSigBytes = this.sigBytes; + var thatSigBytes = wordArray.sigBytes; + + this.clamp(); + + if (thisSigBytes % 4) { + for (var i = 0; i < thatSigBytes; i++) { + var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); + } + } else { + for (var j = 0; j < thatSigBytes; j += 4) { + thisWords[(thisSigBytes + j) >>> 2] = thatWords[j >>> 2]; + } + } + this.sigBytes += thatSigBytes; + return this; + }, + clamp: function() { + var words = this.words; + var sigBytes = this.sigBytes; + words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + words.length = Math.ceil(sigBytes / 4); + return this; + }, + clone: function() { + var clone = Object.create(WordArray); + clone.init(this.words.slice(0), this.sigBytes); + return clone; + } + }; + + function __wordArrayCreate(words, sigBytes) { + var wa = Object.create(WordArray); + wa.init(words, sigBytes); + return wa; } - function __wordArrayFromHex(hex) { return __buildWordArray(hex, undefined); } - function __wordArrayFromUtf8(text) { - var utf8 = text == null ? '' : String(text); - var hex = typeof __crypto_utf8_to_hex !== 'undefined' ? __crypto_utf8_to_hex(utf8) : ''; - return __buildWordArray(hex, utf8); - } - function __wordArrayFromBase64(base64) { - return __buildWordArray(__bytesToHex(__binaryStringToBytes(atob(base64 || ''))), undefined); + function __wordArrayToBytes(wordArray) { + var bytes = new Uint8Array(wordArray.sigBytes); + for (var i = 0; i < wordArray.sigBytes; i++) { + bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + } + return bytes; } - function __wordArrayToBytes(value) { - return __hexToBytes(__wordArrayToHex(value)); + function __bytesToWordArray(bytes) { + var words = []; + for (var i = 0; i < bytes.length; i++) { + words[i >>> 2] |= (bytes[i] & 0xff) << (24 - (i % 4) * 8); + } + return __wordArrayCreate(words, bytes.length); } function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && typeof value.__utf8 === 'string') return value.__utf8; - if (value && typeof value === 'object' && typeof value.__hex === 'string') return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(value.__hex) : ''; if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return typeof __crypto_hex_to_utf8 !== 'undefined' ? __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)) : ''; + return __wordArrayToBytes(value); } - if (value == null) return ''; - return String(value); + if (typeof value === 'string') return new TextEncoder().encode(value); + return __toUint8Array(value); } function __toUint8Array(data) { @@ -342,46 +302,106 @@ internal object JsBindings { return new Uint8Array(0); } - function __bufferToUint8(data) { - if (data instanceof Uint8Array) return data; - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (typeof data === 'string') return new TextEncoder().encode(data); - return new Uint8Array(0); - } - var CryptoJS = { enc: { - Hex: { stringify: function(wa) { return __wordArrayToHex(wa); }, parse: function(s) { return __wordArrayFromHex(s); } }, - Utf8: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Utf8); }, parse: function(s) { return __wordArrayFromUtf8(s); } }, - Base64: { stringify: function(wa) { return wa.toString(CryptoJS.enc.Base64); }, parse: function(s) { return __wordArrayFromBase64(s); } } + Hex: { + stringify: function(wordArray) { + var words = wordArray.words; + var sigBytes = wordArray.sigBytes; + var hexChars = []; + for (var i = 0; i < sigBytes; i++) { + var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + var hexStr = bite.toString(16); + if (hexStr.length < 2) hexStr = '0' + hexStr; + hexChars.push(hexStr); + } + return hexChars.join(''); + }, + parse: function(hexStr) { + var hexStrLength = hexStr.length; + var words = []; + for (var i = 0; i < hexStrLength; i += 2) { + words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4); + } + return __wordArrayCreate(words, hexStrLength / 2); + } + }, + Utf8: { + stringify: function(wordArray) { + return new TextDecoder('utf-8').decode(__wordArrayToBytes(wordArray)); + }, + parse: function(utf8Str) { + return __bytesToWordArray(new TextEncoder().encode(String(utf8Str))); + } + }, + Base64: { + stringify: function(wordArray) { + var bytes = __wordArrayToBytes(wordArray); + var binaryStr = ''; + for (var j = 0; j < bytes.length; j++) { + binaryStr += String.fromCharCode(bytes[j]); + } + return btoa(binaryStr); + }, + parse: function(base64Str) { + var binaryStr = atob(String(base64Str || '')); + var bytes = new Uint8Array(binaryStr.length); + for (var i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) & 0xff; + } + return __bytesToWordArray(bytes); + } + } + }, + lib: { + WordArray: { + create: function(words, sigBytes) { + return __wordArrayCreate(words, sigBytes); + } + } }, - lib: { WordArray: { create: function(words, sigBytes) { return __buildWordArray(__wordsToHex(words, sigBytes), undefined); } } }, mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, algo: { SHA256: 'SHA256' }, - MD5: function(m) { return __wordArrayFromHex(__crypto_digest_hex('MD5', __normalizeWordArrayInput(m))); }, - SHA1: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA1', __normalizeWordArrayInput(m))); }, - SHA256: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA256', __normalizeWordArrayInput(m))); }, - SHA512: function(m) { return __wordArrayFromHex(__crypto_digest_hex('SHA512', __normalizeWordArrayInput(m))); }, + MD5: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA1: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA256: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, + SHA512: function(m) { + var bytes = __normalizeWordArrayInput(m); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes) : new Uint8Array(0); + return __bytesToWordArray(res); + }, PBKDF2: function(pass, salt, options) { options = options || {}; - var pBytes = __bufferToUint8(__normalizeWordArrayInput(pass)); - var sBytes = __bufferToUint8(__normalizeWordArrayInput(salt)); + var pBytes = __normalizeWordArrayInput(pass); + var sBytes = __normalizeWordArrayInput(salt); var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); - return __wordArrayFromHex(__bytesToHex(resBytes)); + return __bytesToWordArray(resBytes); }, AES: { encrypt: function(message, key, options) { options = options || {}; - var data = __bufferToUint8(__normalizeWordArrayInput(message)); + var data = __normalizeWordArrayInput(message); var kBytes = __wordArrayToBytes(key); - var ivBytes = __wordArrayToBytes(options.iv || ''); + var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); - var wa = __wordArrayFromHex(__bytesToHex(resBytes)); + var wa = __bytesToWordArray(resBytes); return { ciphertext: wa, toString: function() { return wa.toString(CryptoJS.enc.Base64); } @@ -390,10 +410,10 @@ internal object JsBindings { decrypt: function(cipher, key, options) { options = options || {}; var data = typeof cipher === 'string' - ? __binaryStringToBytes(atob(cipher)) - : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __bufferToUint8(cipher)); + ? new Uint8Array(Array.from(atob(cipher), c => c.charCodeAt(0))) + : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __toUint8Array(cipher)); var kBytes = __wordArrayToBytes(key); - var ivBytes = __wordArrayToBytes(options.iv || ''); + var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); @@ -406,50 +426,50 @@ internal object JsBindings { globalThis.crypto = { subtle: { digest: async function(algo, data) { - var bytes = __bufferToUint8(data); + var bytes = __toUint8Array(data); var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { - var pBytes = __bufferToUint8(key._raw); - var sBytes = __bufferToUint8(params.salt); + var pBytes = __toUint8Array(key._raw); + var sBytes = __toUint8Array(params.salt); var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { - var kBytes = __bufferToUint8(key._raw); - var ivBytes = __bufferToUint8(params.iv || ''); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); + var dBytes = __toUint8Array(data); var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { - var kBytes = __bufferToUint8(key._raw); - var ivBytes = __bufferToUint8(params.iv || ''); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); + var dBytes = __toUint8Array(data); var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __bufferToUint8(key._raw); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var dBytes = __toUint8Array(data); var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __bufferToUint8(key._raw); - var sBytes = __bufferToUint8(sig); - var dBytes = __bufferToUint8(data); + var kBytes = __toUint8Array(key._raw); + var sBytes = __toUint8Array(sig); + var dBytes = __toUint8Array(data); return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; } }, getRandomValues: function(arr) { - - var bytes = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); - for (var i = 0; i < arr.length; i++) arr[i] = bytes[i]; + if (!arr || !arr.length) return arr; + var res = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); + for (var i = 0; i < arr.length; i++) arr[i] = res[i]; return arr; } }; From e664606adc71c686a4752467a238fa12e1338b08 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 10:32:20 +0530 Subject: [PATCH 19/60] fix(plugins): pass underlying ArrayBuffers to JNI to resolve typed array conversion bug --- .../features/plugins/runtime/js/JsBindings.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index f6f1ac1a8..c4774b7e4 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -365,22 +365,22 @@ internal object JsBindings { algo: { SHA256: 'SHA256' }, MD5: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA1: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA256: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, SHA512: function(m) { var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes.buffer) : new Uint8Array(0); return __bytesToWordArray(res); }, PBKDF2: function(pass, salt, options) { @@ -390,7 +390,7 @@ internal object JsBindings { var iter = options.iterations || 1000; var kSize = options.keySize || (256/32); var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; - var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, iter, kSize * 32, algo) : new Uint8Array(0); + var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, iter, kSize * 32, algo) : new Uint8Array(0); return __bytesToWordArray(resBytes); }, AES: { @@ -400,7 +400,7 @@ internal object JsBindings { var kBytes = __wordArrayToBytes(key); var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); var wa = __bytesToWordArray(resBytes); return { ciphertext: wa, @@ -415,7 +415,7 @@ internal object JsBindings { var kBytes = __wordArrayToBytes(key); var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes, ivBytes, data) : new Uint8Array(0); + var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); var plain = new TextDecoder().decode(resBytes); return { toString: function(enc) { return plain; } }; } @@ -427,35 +427,35 @@ internal object JsBindings { subtle: { digest: async function(algo, data) { var bytes = __toUint8Array(data); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes) : new Uint8Array(0); + var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { var pBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(params.salt); - var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes, sBytes, params.iterations, len, params.hash) : new Uint8Array(0); + var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, params.iterations, len, params.hash) : new Uint8Array(0); return __toUint8Array(res).buffer; }, encrypt: async function(params, key, data) { var kBytes = __toUint8Array(key._raw); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, decrypt: async function(params, key, data) { var kBytes = __toUint8Array(key._raw); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes, ivBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, sign: async function(algo, key, data) { var algoName = typeof algo === 'string' ? algo : (algo.name || ''); var kBytes = __toUint8Array(key._raw); var dBytes = __toUint8Array(data); - var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes, dBytes) : new Uint8Array(0); + var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes.buffer, dBytes.buffer) : new Uint8Array(0); return __toUint8Array(res).buffer; }, verify: async function(algo, key, sig, data) { @@ -463,7 +463,7 @@ internal object JsBindings { var kBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(sig); var dBytes = __toUint8Array(data); - return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes, sBytes, dBytes) : false; + return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes.buffer, sBytes.buffer, dBytes.buffer) : false; } }, getRandomValues: function(arr) { From 566966f3f0eacb09e3475306d780634f93fa2978 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 13:51:05 +0530 Subject: [PATCH 20/60] fix(plugins): repair crypto polyfills --- .../features/plugins/PluginCrypto.android.kt | 101 ++-- .../plugins/runtime/crypto/CryptoBridge.kt | 87 ++- .../features/plugins/runtime/js/JsBindings.kt | 502 +++++++++++++----- .../app/features/plugins/PluginCrypto.ios.kt | 116 ++-- 4 files changed, 576 insertions(+), 230 deletions(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt index ca9d08b7f..b899e1544 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -8,10 +8,8 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.X509EncodedKeySpec import javax.crypto.Cipher import javax.crypto.Mac -import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -19,13 +17,14 @@ import kotlin.io.encoding.ExperimentalEncodingApi private val secureRandom = SecureRandom() internal fun pluginGetRandomValues(length: Int): ByteArray { + require(length >= 0) { "Random byte length must be non-negative" } val bytes = ByteArray(length) secureRandom.nextBytes(bytes) return bytes } internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { - return MessageDigest.getInstance(algorithm.uppercase()).digest(data) + return MessageDigest.getInstance(normalizeDigestAlgorithm(algorithm)).digest(data) } internal fun pluginPbkdf2( @@ -35,13 +34,10 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val prfAlgo = when (algorithm.uppercase()) { - "SHA256", "HMACSHA256" -> "HmacSHA256" - "SHA1", "HMACSHA1" -> "HmacSHA1" - "SHA512", "HMACSHA512" -> "HmacSHA512" - "MD5", "HMACMD5" -> "HmacMD5" - else -> "HmacSHA256" - } + require(iterations > 0) { "PBKDF2 iterations must be positive" } + require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" } + + val prfAlgo = normalizeHmacAlgorithm(algorithm) val mac = Mac.getInstance(prfAlgo) mac.init(SecretKeySpec(password, prfAlgo)) @@ -91,13 +87,11 @@ internal fun pluginAesEncrypt( iv: ByteArray, data: ByteArray, ): ByteArray { - val normalizedMode = when (mode.uppercase()) { - "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" - "AES-GCM", "GCM" -> "AES/GCM/NoPadding" - "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" - else -> "AES/CBC/PKCS5Padding" + val normalizedMode = normalizeAesTransformation(mode) + requireValidAesKey(key) + if (!normalizedMode.contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } } - val cipher = Cipher.getInstance(normalizedMode) val keySpec = SecretKeySpec(key, "AES") @@ -120,13 +114,11 @@ internal fun pluginAesDecrypt( iv: ByteArray, data: ByteArray, ): ByteArray { - val normalizedMode = when (mode.uppercase()) { - "AES-CBC", "CBC" -> "AES/CBC/PKCS5Padding" - "AES-GCM", "GCM" -> "AES/GCM/NoPadding" - "AES-ECB", "ECB" -> "AES/ECB/PKCS5Padding" - else -> "AES/CBC/PKCS5Padding" + val normalizedMode = normalizeAesTransformation(mode) + requireValidAesKey(key) + if (!normalizedMode.contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } } - val cipher = Cipher.getInstance(normalizedMode) val keySpec = SecretKeySpec(key, "AES") @@ -178,22 +170,67 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { } } -internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = when (algorithm.uppercase()) { - "SHA1" -> "HmacSHA1" - "SHA256" -> "HmacSHA256" - "SHA512" -> "HmacSHA512" - "MD5" -> "HmacMD5" - else -> error("Unsupported HMAC algorithm: $algorithm") - } +internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray { + val normalized = normalizeHmacAlgorithm(algorithm) val mac = Mac.getInstance(normalized) - mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) - val digest = mac.doFinal(data.encodeToByteArray()) + mac.init(SecretKeySpec(key, normalized)) + return mac.doFinal(data) +} + +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val digest = pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray()) return digest.joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } } +private fun normalizeDigestAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken()) { + "MD5" -> "MD5" + "SHA1" -> "SHA-1" + "SHA256" -> "SHA-256" + "SHA384" -> "SHA-384" + "SHA512" -> "SHA-512" + else -> error("Unsupported digest algorithm: $algorithm") + } +} + +private fun normalizeHmacAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { + "MD5" -> "HmacMD5" + "SHA1" -> "HmacSHA1" + "SHA256" -> "HmacSHA256" + "SHA384" -> "HmacSHA384" + "SHA512" -> "HmacSHA512" + else -> error("Unsupported HMAC algorithm: $algorithm") + } +} + +private fun normalizeAesTransformation(mode: String): String { + val normalized = mode.normalizedAlgorithmToken() + val noPadding = normalized.contains("NOPADDING") + val padding = if (noPadding) "NoPadding" else "PKCS5Padding" + return when { + normalized.contains("GCM") -> "AES/GCM/NoPadding" + normalized.contains("ECB") -> "AES/ECB/$padding" + normalized.contains("CBC") -> "AES/CBC/$padding" + else -> "AES/CBC/$padding" + } +} + +private fun requireValidAesKey(key: ByteArray) { + require(key.size == 16 || key.size == 24 || key.size == 32) { + "AES key must be 16, 24, or 32 bytes" + } +} + +private fun String.normalizedAlgorithmToken(): String = + uppercase() + .replace("-", "") + .replace("_", "") + .replace("/", "") + .replace(" ", "") + @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Encode(data: String): String = Base64.encode(data.encodeToByteArray()) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt index 0cbe87ce5..a6754c67e 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/crypto/CryptoBridge.kt @@ -9,8 +9,10 @@ import com.nuvio.app.features.plugins.pluginBase64Encode import com.nuvio.app.features.plugins.pluginBase64Decode import com.nuvio.app.features.plugins.pluginUtf8ToHex import com.nuvio.app.features.plugins.pluginHexToUtf8 +import com.nuvio.app.features.plugins.pluginHexToByteArray import com.nuvio.app.features.plugins.pluginGetRandomValues import com.nuvio.app.features.plugins.pluginDigest +import com.nuvio.app.features.plugins.pluginHmac import com.nuvio.app.features.plugins.pluginPbkdf2 import com.nuvio.app.features.plugins.pluginAesDecrypt import com.nuvio.app.features.plugins.pluginAesEncrypt @@ -19,71 +21,63 @@ import com.nuvio.app.features.plugins.pluginVerify internal class CryptoBridge : HostModule { override fun register(runtime: QuickJs) { - // --- Binary-Safe Bridges (New) --- - - runtime.function("__crypto_get_random_values") { args -> + // Hex transport keeps binary data stable across the QuickJS/native bridge. + runtime.function("__crypto_get_random_values_hex") { args -> val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0 - runCatching { - pluginGetRandomValues(length) - }.getOrElse { ByteArray(0) } + pluginGetRandomValues(length).toHexString() } - runtime.function("__crypto_digest_raw") { args -> + runtime.function("__crypto_digest_hex_raw") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" - val data = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - runCatching { - pluginDigest(algorithm, data) - }.getOrElse { ByteArray(0) } + val data = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + pluginDigest(algorithm, data).toHexString() } - runtime.function("__crypto_pbkdf2_raw") { args -> - val password = args.getOrNull(0) as? ByteArray ?: ByteArray(0) - val salt = args.getOrNull(1) as? ByteArray ?: ByteArray(0) + runtime.function("__crypto_hmac_hex_raw") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + pluginHmac(algorithm, key, data).toHexString() + } + + runtime.function("__crypto_pbkdf2_hex") { args -> + val password = pluginHexToByteArray(args.getOrNull(0)?.toString() ?: "") + val salt = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000 val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256 val algorithm = args.getOrNull(4)?.toString() ?: "SHA256" - runCatching { - pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm) - }.getOrElse { ByteArray(0) } + pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm).toHexString() } - runtime.function("__crypto_aes_encrypt_raw") { args -> + runtime.function("__crypto_aes_encrypt_hex") { args -> val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" - val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginAesEncrypt(mode, key, iv, data) - }.getOrElse { ByteArray(0) } + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginAesEncrypt(mode, key, iv, data).toHexString() } - runtime.function("__crypto_aes_decrypt_raw") { args -> + runtime.function("__crypto_aes_decrypt_hex") { args -> val mode = args.getOrNull(0)?.toString() ?: "AES-CBC" - val key = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val iv = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginAesDecrypt(mode, key, iv, data) - }.getOrElse { ByteArray(0) } + val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginAesDecrypt(mode, key, iv, data).toHexString() } - runtime.function("__crypto_sign_raw") { args -> + runtime.function("__crypto_sign_hex") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "" - val privateKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - runCatching { - pluginSign(algorithm, privateKey, data) - }.getOrElse { ByteArray(0) } + val privateKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + pluginSign(algorithm, privateKey, data).toHexString() } - runtime.function("__crypto_verify_raw") { args -> + runtime.function("__crypto_verify_hex") { args -> val algorithm = args.getOrNull(0)?.toString() ?: "" - val publicKey = args.getOrNull(1) as? ByteArray ?: ByteArray(0) - val signature = args.getOrNull(2) as? ByteArray ?: ByteArray(0) - val data = args.getOrNull(3) as? ByteArray ?: ByteArray(0) - runCatching { - pluginVerify(algorithm, publicKey, signature, data) - }.getOrDefault(false) + val publicKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "") + val signature = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "") + val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "") + pluginVerify(algorithm, publicKey, signature, data) } // --- Legacy Hex/String Bridges (Backward Compatibility) --- @@ -134,3 +128,8 @@ internal class CryptoBridge : HostModule { } } } + +private fun ByteArray.toHexString(): String = + joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index c4774b7e4..5b987ca51 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -220,12 +220,8 @@ internal object JsBindings { private fun cryptoPolyfill() = """ var WordArray = { init: function(words, sigBytes) { - words = this.words = words || []; - if (sigBytes != undefined) { - this.sigBytes = sigBytes; - } else { - this.sigBytes = words.length * 4; - } + this.words = words || []; + this.sigBytes = sigBytes != undefined ? sigBytes : this.words.length * 4; }, toString: function(encoder) { return (encoder || CryptoJS.enc.Hex).stringify(this); @@ -238,15 +234,9 @@ internal object JsBindings { this.clamp(); - if (thisSigBytes % 4) { - for (var i = 0; i < thatSigBytes; i++) { - var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; - thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); - } - } else { - for (var j = 0; j < thatSigBytes; j += 4) { - thisWords[(thisSigBytes + j) >>> 2] = thatWords[j >>> 2]; - } + for (var i = 0; i < thatSigBytes; i++) { + var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8); } this.sigBytes += thatSigBytes; return this; @@ -254,24 +244,52 @@ internal object JsBindings { clamp: function() { var words = this.words; var sigBytes = this.sigBytes; - words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + if (sigBytes % 4) { + words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8); + } words.length = Math.ceil(sigBytes / 4); return this; }, clone: function() { - var clone = Object.create(WordArray); - clone.init(this.words.slice(0), this.sigBytes); - return clone; + return __wordArrayCreate(this.words.slice(0), this.sigBytes); } }; - + function __wordArrayCreate(words, sigBytes) { var wa = Object.create(WordArray); wa.init(words, sigBytes); return wa; } + function __isWordArray(value) { + return value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number'; + } + + function __copyUint8Array(bytes) { + bytes = __toUint8Array(bytes); + var copy = new Uint8Array(bytes.length); + copy.set(bytes); + return copy; + } + + function __toUint8Array(data) { + if (!data) return new Uint8Array(0); + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength); + } + if (Array.isArray(data)) return new Uint8Array(data); + if (typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); + return new Uint8Array(0); + } + + function __bytesToArrayBuffer(bytes) { + return __copyUint8Array(bytes).buffer; + } + function __wordArrayToBytes(wordArray) { + if (!__isWordArray(wordArray)) return typeof wordArray === 'string' ? new TextEncoder().encode(wordArray) : __toUint8Array(wordArray); var bytes = new Uint8Array(wordArray.sigBytes); for (var i = 0; i < wordArray.sigBytes; i++) { bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; @@ -280,6 +298,7 @@ internal object JsBindings { } function __bytesToWordArray(bytes) { + bytes = __toUint8Array(bytes); var words = []; for (var i = 0; i < bytes.length; i++) { words[i >>> 2] |= (bytes[i] & 0xff) << (24 - (i % 4) * 8); @@ -288,42 +307,146 @@ internal object JsBindings { } function __normalizeWordArrayInput(value) { - if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { - return __wordArrayToBytes(value); - } + if (__isWordArray(value)) return __wordArrayToBytes(value); if (typeof value === 'string') return new TextEncoder().encode(value); return __toUint8Array(value); } - function __toUint8Array(data) { - if (data instanceof Uint8Array) return data; - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (data && typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data)); - return new Uint8Array(0); + function __bytesToHex(bytes) { + bytes = __toUint8Array(bytes); + var out = []; + for (var i = 0; i < bytes.length; i++) { + var hex = bytes[i].toString(16); + out.push(hex.length < 2 ? '0' + hex : hex); + } + return out.join(''); + } + + function __hexToBytes(hex) { + hex = String(hex || '').replace(/[^0-9a-fA-F]/g, ''); + if (hex.length % 2) hex = '0' + hex; + var bytes = new Uint8Array(hex.length / 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) & 0xff; + } + return bytes; + } + + function __concatBytes() { + var total = 0; + var parts = []; + for (var i = 0; i < arguments.length; i++) { + var part = __toUint8Array(arguments[i]); + parts.push(part); + total += part.length; + } + var out = new Uint8Array(total); + var offset = 0; + for (var j = 0; j < parts.length; j++) { + out.set(parts[j], offset); + offset += parts[j].length; + } + return out; + } + + function __normalizeHashName(hash) { + var name = hash && hash.name ? hash.name : hash; + name = String(name || 'SHA-256').toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (name === 'SHA1' || name === 'SHA256' || name === 'SHA384' || name === 'SHA512' || name === 'MD5') return name; + throw new Error('Unsupported hash algorithm: ' + name); + } + + function __normalizeAlgorithmName(algo) { + var name = algo && algo.name ? algo.name : algo; + name = String(name || '').toUpperCase(); + if (name.indexOf('AES-GCM') >= 0) return 'AES-GCM'; + if (name.indexOf('AES-CBC') >= 0) return 'AES-CBC'; + if (name.indexOf('AES-ECB') >= 0 || name === 'ECB') return 'AES-ECB'; + if (name.indexOf('PBKDF2') >= 0) return 'PBKDF2'; + if (name.indexOf('HMAC') >= 0) return 'HMAC'; + if (name.indexOf('RSASSA-PKCS1') >= 0) return 'RSASSA-PKCS1-V1_5'; + if (name.indexOf('ECDSA') >= 0) return 'ECDSA'; + return name; + } + + function __aesModeName(mode, padding) { + var normalized = __normalizeAlgorithmName(mode || 'AES-CBC'); + if (padding === CryptoJS.pad.NoPadding || padding === 'NoPadding') normalized += '-NoPadding'; + return normalized; + } + + function __nativeDigestBytes(hash, dataBytes) { + if (typeof __crypto_digest_hex_raw === 'undefined') throw new Error('Native digest bridge is unavailable'); + return __hexToBytes(__crypto_digest_hex_raw(__normalizeHashName(hash), __bytesToHex(dataBytes))); + } + + function __nativeHmacBytes(hash, keyBytes, dataBytes) { + if (typeof __crypto_hmac_hex_raw === 'undefined') throw new Error('Native HMAC bridge is unavailable'); + return __hexToBytes(__crypto_hmac_hex_raw(__normalizeHashName(hash), __bytesToHex(keyBytes), __bytesToHex(dataBytes))); + } + + function __nativePbkdf2Bytes(passwordBytes, saltBytes, iterations, keySizeBits, hash) { + if (typeof __crypto_pbkdf2_hex === 'undefined') throw new Error('Native PBKDF2 bridge is unavailable'); + return __hexToBytes(__crypto_pbkdf2_hex(__bytesToHex(passwordBytes), __bytesToHex(saltBytes), iterations, keySizeBits, __normalizeHashName(hash))); + } + + function __nativeAesBytes(encrypt, mode, keyBytes, ivBytes, dataBytes) { + var fn = encrypt ? __crypto_aes_encrypt_hex : __crypto_aes_decrypt_hex; + if (typeof fn === 'undefined') throw new Error('Native AES bridge is unavailable'); + return __hexToBytes(fn(mode, __bytesToHex(keyBytes), __bytesToHex(ivBytes), __bytesToHex(dataBytes))); + } + + function __evpKdf(passwordBytes, saltBytes, keySizeBytes, ivSizeBytes) { + var targetSize = keySizeBytes + ivSizeBytes; + var derived = new Uint8Array(targetSize); + var block = new Uint8Array(0); + var offset = 0; + while (offset < targetSize) { + block = __nativeDigestBytes('MD5', __concatBytes(block, passwordBytes, saltBytes || new Uint8Array(0))); + var take = Math.min(block.length, targetSize - offset); + derived.set(block.subarray(0, take), offset); + offset += take; + } + return { + key: derived.subarray(0, keySizeBytes), + iv: derived.subarray(keySizeBytes, keySizeBytes + ivSizeBytes) + }; + } + + function __opensslSaltHeader() { + return new Uint8Array([83, 97, 108, 116, 101, 100, 95, 95]); + } + + function __hasOpenSslSaltHeader(bytes) { + var header = __opensslSaltHeader(); + if (!bytes || bytes.length < 16) return false; + for (var i = 0; i < header.length; i++) { + if (bytes[i] !== header[i]) return false; + } + return true; + } + + function __makeCipherParams(ciphertext, key, iv, salt, mode) { + return { + ciphertext: __bytesToWordArray(ciphertext), + key: key ? __bytesToWordArray(key) : undefined, + iv: iv ? __bytesToWordArray(iv) : undefined, + salt: salt ? __bytesToWordArray(salt) : undefined, + mode: mode, + toString: function(formatter) { + return (formatter || CryptoJS.format.OpenSSL).stringify(this); + } + }; } var CryptoJS = { enc: { Hex: { stringify: function(wordArray) { - var words = wordArray.words; - var sigBytes = wordArray.sigBytes; - var hexChars = []; - for (var i = 0; i < sigBytes; i++) { - var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; - var hexStr = bite.toString(16); - if (hexStr.length < 2) hexStr = '0' + hexStr; - hexChars.push(hexStr); - } - return hexChars.join(''); + return __bytesToHex(__wordArrayToBytes(wordArray)); }, parse: function(hexStr) { - var hexStrLength = hexStr.length; - var words = []; - for (var i = 0; i < hexStrLength; i += 2) { - words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4); - } - return __wordArrayCreate(words, hexStrLength / 2); + return __bytesToWordArray(__hexToBytes(hexStr)); } }, Utf8: { @@ -334,143 +457,282 @@ internal object JsBindings { return __bytesToWordArray(new TextEncoder().encode(String(utf8Str))); } }, + Latin1: { + stringify: function(wordArray) { + var bytes = __wordArrayToBytes(wordArray); + var out = ''; + for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; + }, + parse: function(str) { + str = String(str || ''); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff; + return __bytesToWordArray(bytes); + } + }, Base64: { stringify: function(wordArray) { var bytes = __wordArrayToBytes(wordArray); var binaryStr = ''; - for (var j = 0; j < bytes.length; j++) { - binaryStr += String.fromCharCode(bytes[j]); - } + for (var j = 0; j < bytes.length; j++) binaryStr += String.fromCharCode(bytes[j]); return btoa(binaryStr); }, parse: function(base64Str) { var binaryStr = atob(String(base64Str || '')); var bytes = new Uint8Array(binaryStr.length); - for (var i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i) & 0xff; - } + for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i) & 0xff; return __bytesToWordArray(bytes); } + }, + Base64url: { + stringify: function(wordArray) { + return CryptoJS.enc.Base64.stringify(wordArray).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + }, + parse: function(str) { + str = String(str || '').replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + return CryptoJS.enc.Base64.parse(str); + } } }, lib: { WordArray: { create: function(words, sigBytes) { + if (words == null) return __wordArrayCreate([], sigBytes || 0); + if (__isWordArray(words)) return words.clone(); + if (typeof words === 'string') return CryptoJS.enc.Utf8.parse(words); + if (words instanceof ArrayBuffer || (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(words))) { + var bytes = __toUint8Array(words); + return __bytesToWordArray(sigBytes != undefined ? bytes.subarray(0, sigBytes) : bytes); + } return __wordArrayCreate(words, sigBytes); + }, + random: function(nBytes) { + var bytes = new Uint8Array(nBytes || 0); + globalThis.crypto.getRandomValues(bytes); + return __bytesToWordArray(bytes); + } + }, + CipherParams: { + create: function(params) { + params = params || {}; + params.toString = params.toString || function(formatter) { + return (formatter || CryptoJS.format.OpenSSL).stringify(this); + }; + return params; + } + } + }, + format: { + OpenSSL: { + stringify: function(cipherParams) { + var cipherBytes = __wordArrayToBytes(cipherParams.ciphertext); + var out = cipherParams.salt + ? __concatBytes(__opensslSaltHeader(), __wordArrayToBytes(cipherParams.salt), cipherBytes) + : cipherBytes; + return CryptoJS.enc.Base64.stringify(__bytesToWordArray(out)); + }, + parse: function(str) { + var bytes = __wordArrayToBytes(CryptoJS.enc.Base64.parse(str)); + if (__hasOpenSslSaltHeader(bytes)) { + return CryptoJS.lib.CipherParams.create({ + salt: __bytesToWordArray(bytes.subarray(8, 16)), + ciphertext: __bytesToWordArray(bytes.subarray(16)) + }); + } + return CryptoJS.lib.CipherParams.create({ ciphertext: __bytesToWordArray(bytes) }); } } }, mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' }, pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' }, - algo: { SHA256: 'SHA256' }, - MD5: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('MD5', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA1: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA1', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA256: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA256', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, - SHA512: function(m) { - var bytes = __normalizeWordArrayInput(m); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw('SHA512', bytes.buffer) : new Uint8Array(0); - return __bytesToWordArray(res); - }, + algo: { MD5: 'MD5', SHA1: 'SHA1', SHA256: 'SHA256', SHA384: 'SHA384', SHA512: 'SHA512', AES: 'AES' }, + MD5: function(m) { return __bytesToWordArray(__nativeDigestBytes('MD5', __normalizeWordArrayInput(m))); }, + SHA1: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA1', __normalizeWordArrayInput(m))); }, + SHA256: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA256', __normalizeWordArrayInput(m))); }, + SHA512: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA512', __normalizeWordArrayInput(m))); }, + HmacMD5: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('MD5', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA1: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA1', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA256: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA256', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA512: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA512', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { options = options || {}; var pBytes = __normalizeWordArrayInput(pass); var sBytes = __normalizeWordArrayInput(salt); var iter = options.iterations || 1000; - var kSize = options.keySize || (256/32); - var algo = options.hasher === CryptoJS.algo.SHA256 ? 'SHA256' : 'SHA1'; - var resBytes = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, iter, kSize * 32, algo) : new Uint8Array(0); - return __bytesToWordArray(resBytes); + var kSize = options.keySize || 8; + var algo = options.hasher || 'SHA1'; + return __bytesToWordArray(__nativePbkdf2Bytes(pBytes, sBytes, iter, kSize * 32, algo)); }, AES: { encrypt: function(message, key, options) { options = options || {}; var data = __normalizeWordArrayInput(message); - var kBytes = __wordArrayToBytes(key); - var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); - var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); - var wa = __bytesToWordArray(resBytes); - return { - ciphertext: wa, - toString: function() { return wa.toString(CryptoJS.enc.Base64); } - }; + var kBytes; + var ivBytes; + var saltBytes; + var isPassphrase = typeof key === 'string'; + if (isPassphrase) { + saltBytes = options.salt ? __wordArrayToBytes(options.salt) : __wordArrayToBytes(CryptoJS.lib.WordArray.random(8)); + var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16); + kBytes = derived.key; + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv; + } else { + kBytes = __wordArrayToBytes(key); + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); + } + var mode = __aesModeName(options.mode || 'AES-CBC', options.padding); + var resBytes = __nativeAesBytes(true, mode, kBytes, ivBytes, data); + return __makeCipherParams(resBytes, kBytes, ivBytes, saltBytes, mode); }, decrypt: function(cipher, key, options) { options = options || {}; - var data = typeof cipher === 'string' - ? new Uint8Array(Array.from(atob(cipher), c => c.charCodeAt(0))) - : (cipher.ciphertext ? __wordArrayToBytes(cipher.ciphertext) : __toUint8Array(cipher)); - var kBytes = __wordArrayToBytes(key); - var ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); - var mode = options.mode || 'AES-CBC'; - var resBytes = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(mode, kBytes.buffer, ivBytes.buffer, data.buffer) : new Uint8Array(0); - var plain = new TextDecoder().decode(resBytes); - return { toString: function(enc) { return plain; } }; + var cipherParams = typeof cipher === 'string' ? CryptoJS.format.OpenSSL.parse(cipher) : cipher; + var data = cipherParams.ciphertext ? __wordArrayToBytes(cipherParams.ciphertext) : __toUint8Array(cipherParams); + var kBytes; + var ivBytes; + var isPassphrase = typeof key === 'string'; + if (isPassphrase) { + var saltBytes = options.salt ? __wordArrayToBytes(options.salt) : (cipherParams.salt ? __wordArrayToBytes(cipherParams.salt) : new Uint8Array(0)); + var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16); + kBytes = derived.key; + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv; + } else { + kBytes = __wordArrayToBytes(key); + ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0); + } + var mode = __aesModeName(options.mode || 'AES-CBC', options.padding); + return __bytesToWordArray(__nativeAesBytes(false, mode, kBytes, ivBytes, data)); } } }; globalThis.CryptoJS = CryptoJS; + function __makeCryptoKey(type, algorithm, extractable, usages, rawBytes) { + return { + type: type, + extractable: !!extractable, + algorithm: algorithm, + usages: usages || [], + _raw: __copyUint8Array(rawBytes) + }; + } + + function __webCryptoAlgorithm(algo) { + var name = __normalizeAlgorithmName(algo); + var out = { name: name }; + if (algo && typeof algo === 'object' && algo.length) out.length = algo.length; + if (algo && typeof algo === 'object' && algo.hash) out.hash = { name: __normalizeHashName(algo.hash) }; + return out; + } + + function __signatureAlgorithmName(algo, key) { + var name = __normalizeAlgorithmName(algo || (key && key.algorithm)); + var hash = algo && algo.hash ? __normalizeHashName(algo.hash) : (key && key.algorithm && key.algorithm.hash ? key.algorithm.hash.name : 'SHA256'); + if (name === 'RSASSA-PKCS1-V1_5') return 'RSASSA-PKCS1-V1_5-' + hash; + if (name === 'ECDSA') return 'ECDSA-' + hash; + return name; + } + globalThis.crypto = { subtle: { digest: async function(algo, data) { - var bytes = __toUint8Array(data); - var res = typeof __crypto_digest_raw !== 'undefined' ? __crypto_digest_raw(algo.name || algo, bytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeDigestBytes(algo, __toUint8Array(data))); + }, + importKey: async function(fmt, data, algo, extractable, usages) { + fmt = String(fmt || 'raw').toLowerCase(); + if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt); + var algorithm = __webCryptoAlgorithm(algo || {}); + var type = fmt === 'spki' ? 'public' : (fmt === 'pkcs8' ? 'private' : 'secret'); + return __makeCryptoKey(type, algorithm, extractable, usages || [], __toUint8Array(data)); + }, + exportKey: async function(fmt, key) { + fmt = String(fmt || 'raw').toLowerCase(); + if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt); + return __bytesToArrayBuffer(key._raw); + }, + generateKey: async function(algo, extractable, usages) { + var algorithm = __webCryptoAlgorithm(algo || {}); + if (algorithm.name !== 'AES-CBC' && algorithm.name !== 'AES-GCM' && algorithm.name !== 'HMAC') { + throw new Error('Unsupported generateKey algorithm: ' + algorithm.name); + } + var length = algorithm.length || 256; + var bytes = new Uint8Array(length / 8); + globalThis.crypto.getRandomValues(bytes); + return __makeCryptoKey('secret', algorithm, extractable, usages || [], bytes); }, - importKey: async function(fmt, data, algo, ext, use) { return { _raw: data, _algo: algo }; }, deriveBits: async function(params, key, len) { + if (__normalizeAlgorithmName(params) !== 'PBKDF2') throw new Error('Only PBKDF2 deriveBits is supported'); var pBytes = __toUint8Array(key._raw); var sBytes = __toUint8Array(params.salt); - var res = typeof __crypto_pbkdf2_raw !== 'undefined' ? __crypto_pbkdf2_raw(pBytes.buffer, sBytes.buffer, params.iterations, len, params.hash) : new Uint8Array(0); - return __toUint8Array(res).buffer; + var hash = params.hash || 'SHA-256'; + return __bytesToArrayBuffer(__nativePbkdf2Bytes(pBytes, sBytes, params.iterations || 1000, len, hash)); + }, + deriveKey: async function(params, key, derivedKeyAlgo, extractable, usages) { + var algorithm = __webCryptoAlgorithm(derivedKeyAlgo || {}); + var length = algorithm.length || 256; + var raw = await globalThis.crypto.subtle.deriveBits(params, key, length); + return __makeCryptoKey('secret', algorithm, extractable, usages || [], new Uint8Array(raw)); }, encrypt: async function(params, key, data) { - var kBytes = __toUint8Array(key._raw); + var mode = __normalizeAlgorithmName(params); + if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported encrypt algorithm: ' + mode); + if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported'); + if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported'); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_encrypt_raw !== 'undefined' ? __crypto_aes_encrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeAesBytes(true, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data))); }, decrypt: async function(params, key, data) { - var kBytes = __toUint8Array(key._raw); + var mode = __normalizeAlgorithmName(params); + if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported decrypt algorithm: ' + mode); + if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported'); + if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported'); var ivBytes = __toUint8Array(params.iv || new Uint8Array(0)); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_aes_decrypt_raw !== 'undefined' ? __crypto_aes_decrypt_raw(params.name, kBytes.buffer, ivBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + return __bytesToArrayBuffer(__nativeAesBytes(false, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data))); }, sign: async function(algo, key, data) { - var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __toUint8Array(key._raw); - var dBytes = __toUint8Array(data); - var res = typeof __crypto_sign_raw !== 'undefined' ? __crypto_sign_raw(algoName, kBytes.buffer, dBytes.buffer) : new Uint8Array(0); - return __toUint8Array(res).buffer; + if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') { + var hash = (algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256'; + return __bytesToArrayBuffer(__nativeHmacBytes(hash, __toUint8Array(key._raw), __toUint8Array(data))); + } + if (typeof __crypto_sign_hex === 'undefined') throw new Error('Native signature bridge is unavailable'); + var sigHex = __crypto_sign_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(data))); + return __bytesToArrayBuffer(__hexToBytes(sigHex)); }, verify: async function(algo, key, sig, data) { - var algoName = typeof algo === 'string' ? algo : (algo.name || ''); - var kBytes = __toUint8Array(key._raw); - var sBytes = __toUint8Array(sig); - var dBytes = __toUint8Array(data); - return typeof __crypto_verify_raw !== 'undefined' ? __crypto_verify_raw(algoName, kBytes.buffer, sBytes.buffer, dBytes.buffer) : false; + if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') { + var expected = __nativeHmacBytes((algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256', __toUint8Array(key._raw), __toUint8Array(data)); + var actual = __toUint8Array(sig); + if (expected.length !== actual.length) return false; + var diff = 0; + for (var i = 0; i < expected.length; i++) diff |= expected[i] ^ actual[i]; + return diff === 0; + } + if (typeof __crypto_verify_hex === 'undefined') throw new Error('Native signature bridge is unavailable'); + return __crypto_verify_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(sig)), __bytesToHex(__toUint8Array(data))); } }, getRandomValues: function(arr) { - if (!arr || !arr.length) return arr; - var res = typeof __crypto_get_random_values !== 'undefined' ? __crypto_get_random_values(arr.length) : new Uint8Array(arr.length); - for (var i = 0; i < arr.length; i++) arr[i] = res[i]; + if (!arr) return arr; + var byteLength = arr.byteLength != undefined ? arr.byteLength : arr.length; + if (!byteLength) return arr; + if (typeof __crypto_get_random_values_hex === 'undefined') throw new Error('Native random bridge is unavailable'); + var random = __hexToBytes(__crypto_get_random_values_hex(byteLength)); + if (arr.buffer && arr.byteLength != undefined) { + new Uint8Array(arr.buffer, arr.byteOffset || 0, arr.byteLength).set(random); + } else { + for (var i = 0; i < arr.length; i++) arr[i] = random[i] || 0; + } return arr; + }, + randomUUID: function() { + var b = new Uint8Array(16); + globalThis.crypto.getRandomValues(b); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + var h = __bytesToHex(b); + return h.substr(0, 8) + '-' + h.substr(8, 4) + '-' + h.substr(12, 4) + '-' + h.substr(16, 4) + '-' + h.substr(20); } }; diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index cfb647651..1811ca98c 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -18,6 +18,8 @@ import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault internal fun pluginGetRandomValues(length: Int): ByteArray { + require(length >= 0) { "Random byte length must be non-negative" } + if (length == 0) return ByteArray(0) val bytes = ByteArray(length) @OptIn(ExperimentalForeignApi::class) SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) @@ -26,7 +28,7 @@ internal fun pluginGetRandomValues(length: Int): ByteArray { @OptIn(ExperimentalForeignApi::class) internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { - val normalized = algorithm.uppercase() + val normalized = normalizeDigestAlgorithm(algorithm) val output = ByteArray( when (normalized) { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() @@ -62,13 +64,10 @@ internal fun pluginPbkdf2( keySizeBits: Int, algorithm: String, ): ByteArray { - val prf = when (algorithm.uppercase()) { - "SHA256", "HMACSHA256" -> kCCPRFHmacAlgSHA256 - "SHA1", "HMACSHA1" -> kCCPRFHmacAlgSHA1 - "SHA384", "HMACSHA384" -> kCCPRFHmacAlgSHA384 - "SHA512", "HMACSHA512" -> kCCPRFHmacAlgSHA512 - else -> kCCPRFHmacAlgSHA256 - } + require(iterations > 0) { "PBKDF2 iterations must be positive" } + require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" } + + val prf = normalizePbkdf2Prf(algorithm) val derivedKeyLen = keySizeBits / 8 val derivedKey = ByteArray(derivedKeyLen) @@ -107,6 +106,11 @@ internal fun pluginAesEncrypt( iv: ByteArray, data: ByteArray, ): ByteArray { + requireValidAesKey(key) + if (!mode.uppercase().contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } + } + val isGcm = mode.uppercase().contains("GCM") if (isGcm) { var encryptedData: ByteArray? = null @@ -241,6 +245,11 @@ internal fun pluginAesDecrypt( iv: ByteArray, data: ByteArray, ): ByteArray { + requireValidAesKey(key) + if (!mode.uppercase().contains("ECB")) { + require(iv.isNotEmpty()) { "AES mode $mode requires an IV" } + } + val isGcm = mode.uppercase().contains("GCM") if (isGcm) { require(data.size >= 16) { "Data too short for GCM decryption" } @@ -387,7 +396,7 @@ private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> @OptIn(ExperimentalForeignApi::class) internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() + val normalized = normalizeDigestAlgorithm(algorithm) val input = data.encodeToByteArray() val output = UByteArray( when (normalized) { @@ -417,12 +426,57 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { } @OptIn(ExperimentalForeignApi::class) -internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = algorithm.uppercase() - val keyBytes = key.encodeToByteArray() - val input = data.encodeToByteArray() +internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray { + val (alg, outputSize) = normalizeHmacAlgorithm(algorithm) + val output = ByteArray(outputSize) - val (alg, outputSize) = when (normalized) { + key.usePinned { pinnedKey -> + data.usePinned { pinnedInput -> + output.usePinned { pinnedOutput -> + val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null + val inputPtr = if (data.isNotEmpty()) pinnedInput.addressOf(0) else null + + CCHmac( + alg, + keyPtr, + key.size.toULong(), + inputPtr, + data.size.toULong(), + pinnedOutput.addressOf(0).reinterpret(), + ) + } + } + } + + return output +} + +@OptIn(ExperimentalForeignApi::class) +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + return pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray()).toHex() +} + +private fun normalizeDigestAlgorithm(algorithm: String): String { + return when (algorithm.normalizedAlgorithmToken()) { + "MD5" -> "MD5" + "SHA1" -> "SHA1" + "SHA256" -> "SHA256" + "SHA512" -> "SHA512" + else -> error("Unsupported digest algorithm: $algorithm") + } +} + +private fun normalizePbkdf2Prf(algorithm: String) = + when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { + "SHA1" -> kCCPRFHmacAlgSHA1 + "SHA256" -> kCCPRFHmacAlgSHA256 + "SHA384" -> kCCPRFHmacAlgSHA384 + "SHA512" -> kCCPRFHmacAlgSHA512 + else -> error("Unsupported PBKDF2 hash algorithm: $algorithm") + } + +private fun normalizeHmacAlgorithm(algorithm: String) = + when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt() @@ -430,29 +484,23 @@ internal fun pluginHmacHex(algorithm: String, key: String, data: String): String else -> error("Unsupported HMAC algorithm: $algorithm") } - val output = UByteArray(outputSize) - - keyBytes.usePinned { pinnedKey -> - input.usePinned { pinnedInput -> - output.usePinned { pinnedOutput -> - val keyPtr = if (keyBytes.isNotEmpty()) pinnedKey.addressOf(0) else null - val inputPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null - val outputPtr = pinnedOutput.addressOf(0) +private fun requireValidAesKey(key: ByteArray) { + require(key.size == 16 || key.size == 24 || key.size == 32) { + "AES key must be 16, 24, or 32 bytes" + } +} - CCHmac( - alg, - keyPtr, - keyBytes.size.toULong(), - inputPtr, - input.size.toULong(), - outputPtr, - ) - } - } +private fun ByteArray.toHex(): String = + joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') } - return output.toHex() -} +private fun String.normalizedAlgorithmToken(): String = + uppercase() + .replace("-", "") + .replace("_", "") + .replace("/", "") + .replace(" ", "") @OptIn(ExperimentalEncodingApi::class) internal fun pluginBase64Encode(data: String): String = From 700181572fcb0228572fb7c000cfbf659c31f132 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 14:53:17 +0530 Subject: [PATCH 21/60] fix(plugins/crypto): validate ios entropy status and support sha384/hmac-sha384 --- .../com/nuvio/app/features/plugins/PluginCrypto.ios.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index 1811ca98c..c3f3a181a 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -22,7 +22,8 @@ internal fun pluginGetRandomValues(length: Int): ByteArray { if (length == 0) return ByteArray(0) val bytes = ByteArray(length) @OptIn(ExperimentalForeignApi::class) - SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + val status = SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0)) + require(status == 0) { "Failed to generate secure random bytes: status $status" } return bytes } @@ -34,6 +35,7 @@ internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported digest algorithm: $algorithm") }, @@ -48,6 +50,7 @@ internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray { "MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr) "SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr) "SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr) + "SHA384" -> CC_SHA384(dataPtr, data.size.toUInt(), outputPtr) "SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr) } } @@ -403,6 +406,7 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { "MD5" -> CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported digest algorithm: $algorithm") }, @@ -417,6 +421,7 @@ internal fun pluginDigestHex(algorithm: String, data: String): String { "MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr) "SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr) "SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr) + "SHA384" -> CC_SHA384(dataPtr, input.size.toUInt(), outputPtr) "SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr) } } @@ -461,6 +466,7 @@ private fun normalizeDigestAlgorithm(algorithm: String): String { "MD5" -> "MD5" "SHA1" -> "SHA1" "SHA256" -> "SHA256" + "SHA384" -> "SHA384" "SHA512" -> "SHA512" else -> error("Unsupported digest algorithm: $algorithm") } @@ -480,6 +486,7 @@ private fun normalizeHmacAlgorithm(algorithm: String) = "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() "SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt() "SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt() + "SHA384" -> kCCHmacAlgSHA384 to CC_SHA384_DIGEST_LENGTH.toInt() "SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt() else -> error("Unsupported HMAC algorithm: $algorithm") } From a1574820bca64da2156d95513ef238a9c0fb6da5 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Tue, 2 Jun 2026 14:53:50 +0530 Subject: [PATCH 22/60] fix(plugins/crypto): expose SHA384 and HmacSHA384 shortcuts in JS polyfills --- .../com/nuvio/app/features/plugins/runtime/js/JsBindings.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt index 5b987ca51..5e2390489 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/runtime/js/JsBindings.kt @@ -551,10 +551,12 @@ internal object JsBindings { MD5: function(m) { return __bytesToWordArray(__nativeDigestBytes('MD5', __normalizeWordArrayInput(m))); }, SHA1: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA1', __normalizeWordArrayInput(m))); }, SHA256: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA256', __normalizeWordArrayInput(m))); }, + SHA384: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA384', __normalizeWordArrayInput(m))); }, SHA512: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA512', __normalizeWordArrayInput(m))); }, HmacMD5: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('MD5', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA1: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA1', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA256: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA256', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, + HmacSHA384: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA384', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, HmacSHA512: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA512', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); }, PBKDF2: function(pass, salt, options) { options = options || {}; From 4c527a8d8d378ce787d1237ac7c4d71bc0ceb0ee Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 6 Jun 2026 00:04:46 +0530 Subject: [PATCH 23/60] feat(player): improve plugin subtitle handling --- .../player/PlayerSubtitleCueParser.kt | 162 +++++++++++++++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 37 +++- 2 files changed, 186 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 67b52638d..9e1b03817 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -11,10 +11,39 @@ object PlayerSubtitleCueParser { .trim() if (normalized.isBlank()) return emptyList() - return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) { - parseWebVtt(normalized) - } else { - parseSrt(normalized) + return when (detectSubtitleFormat(sourceUrl, normalized)) { + SubtitleFormatHint.WebVtt -> parseWebVtt(normalized) + SubtitleFormatHint.Ass -> parseAss(normalized) + SubtitleFormatHint.Ttml -> parseTtml(normalized) + SubtitleFormatHint.Srt -> parseSrt(normalized) + } + } + + private enum class SubtitleFormatHint { + Srt, + WebVtt, + Ass, + Ttml, + } + + private fun detectSubtitleFormat(sourceUrl: String?, text: String): SubtitleFormatHint { + val sourcePath = sourceUrl + ?.substringBefore('?') + ?.substringBefore('#') + ?.lowercase() + .orEmpty() + val sample = text.take(4_096).lowercase() + + return when { + sourcePath.endsWith(".vtt") || sourcePath.endsWith(".webvtt") || text.startsWith("WEBVTT") -> + SubtitleFormatHint.WebVtt + sourcePath.endsWith(".ass") || sourcePath.endsWith(".ssa") || + (sample.contains("[events]") && sample.contains("dialogue:")) -> + SubtitleFormatHint.Ass + sourcePath.endsWith(".ttml") || sourcePath.endsWith(".dfxp") || sourcePath.endsWith(".xml") || + Regex("""]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) -> + SubtitleFormatHint.Ttml + else -> SubtitleFormatHint.Srt } } @@ -53,6 +82,68 @@ object PlayerSubtitleCueParser { } .sortedBy { it.startTimeMs } + private fun parseAss(text: String): List { + var inEventsSection = false + var formatFields: List? = null + + return text.lines() + .mapNotNull { rawLine -> + val line = rawLine.trim() + when { + line.equals("[Events]", ignoreCase = true) -> { + inEventsSection = true + null + } + line.startsWith("[") && line.endsWith("]") -> { + inEventsSection = false + null + } + inEventsSection && line.startsWith("Format:", ignoreCase = true) -> { + formatFields = line.substringAfter(':') + .split(',') + .map { it.trim() } + null + } + inEventsSection && line.startsWith("Dialogue:", ignoreCase = true) -> + parseAssDialogue(line.substringAfter(':'), formatFields) + else -> null + } + } + .sortedBy { it.startTimeMs } + } + + private fun parseAssDialogue(payload: String, formatFields: List?): SubtitleSyncCue? { + val fields = formatFields.orEmpty() + val parts = payload + .split(',', limit = fields.ifEmpty { defaultAssFormatFields }.size) + .map { it.trim() } + val startIndex = fields.indexOfField("Start").takeIf { it >= 0 } ?: 1 + val textIndex = fields.indexOfField("Text").takeIf { it >= 0 } ?: 9 + + if (parts.size <= startIndex || parts.size <= textIndex) return null + val start = parseTimestamp(parts[startIndex]) ?: return null + val body = parts[textIndex] + .cleanAssCueText() + .cleanSubtitleCueText() + return if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + + private fun parseTtml(text: String): List = + Regex("""]*)>(.*?)

""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + .findAll(text) + .mapNotNull { match -> + val attrs = match.groupValues[1] + val startRaw = attrs.attributeValue("begin") + ?: attrs.attributeValue("start") + ?: return@mapNotNull null + val start = parseTtmlTimestamp(startRaw) ?: return@mapNotNull null + val body = match.groupValues[2] + .replace(Regex("""""", RegexOption.IGNORE_CASE), " ") + .cleanSubtitleCueText() + if (body.isBlank()) null else SubtitleSyncCue(start, body) + } + .sortedBy { it.startTimeMs } + private fun parseCueStart(timingLine: String): Long? { val startPart = timingLine.substringBefore("-->").trim() return parseTimestamp(startPart) @@ -76,12 +167,75 @@ object PlayerSubtitleCueParser { return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis) } + private fun parseTtmlTimestamp(raw: String): Long? { + val cleaned = raw.trim().substringBefore(' ') + if (cleaned.isBlank()) return null + + parseClockTimeWithFrames(cleaned)?.let { return it } + parseTimestamp(cleaned)?.let { return it } + + val match = Regex("""^([0-9]+(?:\.[0-9]+)?)(ms|h|m|s)$""", RegexOption.IGNORE_CASE) + .matchEntire(cleaned) + ?: return null + val value = match.groupValues[1].toDoubleOrNull() ?: return null + val multiplier = when (match.groupValues[2].lowercase()) { + "h" -> 3_600_000.0 + "m" -> 60_000.0 + "s" -> 1_000.0 + "ms" -> 1.0 + else -> return null + } + return max(0L, (value * multiplier).toLong()) + } + + private fun parseClockTimeWithFrames(raw: String): Long? { + val parts = raw.split(':') + if (parts.size != 4) return null + + val hours = parts[0].toLongOrNull() ?: return null + val minutes = parts[1].toLongOrNull() ?: return null + val seconds = parts[2].toLongOrNull() ?: return null + val frames = parts[3].substringBefore('.').toLongOrNull() ?: return null + return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + frames * 1_000L / 30L) + } + + private val defaultAssFormatFields = listOf( + "Layer", + "Start", + "End", + "Style", + "Name", + "MarginL", + "MarginR", + "MarginV", + "Effect", + "Text", + ) + + private fun List.indexOfField(name: String): Int = + indexOfFirst { it.equals(name, ignoreCase = true) } + + private fun String.attributeValue(name: String): String? = + Regex("""\b${Regex.escape(name)}\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + + private fun String.cleanAssCueText(): String = + replace(Regex("""\{[^}]*}"""), "") + .replace("\\N", " ") + .replace("\\n", " ") + .replace("\\h", " ") + private fun String.cleanSubtitleCueText(): String = replace(Regex("<[^>]+>"), "") .replace(" ", " ") .replace("&", "&") .replace("<", "<") .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") .replace(Regex("\\s+"), " ") .trim() } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index c053d781e..7eb3126dc 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -43,14 +43,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } } -} -struct PluginSubtitle { - val url: String - val language: String - val name: String? - val headers: [String: String]? -} func play() { playerVC?.playPlayback() } func pause() { playerVC?.pausePlayback() } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } @@ -198,6 +191,13 @@ struct PluginSubtitle { } } +struct PluginSubtitle { + let url: String + let language: String + let name: String? + let headers: [String: String]? +} + // MARK: - Track Info struct TrackInfo { @@ -453,10 +453,9 @@ final class MPVPlayerViewController: UIViewController { } } - // Add external subtitles for subtitle in request.subtitles { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.command("sub-add", args: [subtitle.url, "auto", subtitle.name ?? subtitle.language, subtitle.language], checkForErrors: false) + self?.addSubtitle(subtitle, mode: "auto") } } } @@ -598,6 +597,26 @@ final class MPVPlayerViewController: UIViewController { command("sub-add", args: [url, "select"]) } + private func addSubtitle(_ subtitle: PluginSubtitle, mode: String) { + guard mpv != nil else { return } + let subtitleHeaders = sanitizeRequestHeaders(subtitle.headers ?? [:]) + let previousHeaders = activeRequestHeaders + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders.merging(subtitleHeaders) { _, subtitleValue in subtitleValue }) + } + + command( + "sub-add", + args: [subtitle.url, mode, subtitle.name ?? subtitle.language, subtitle.language], + checkForErrors: false + ) + + if !subtitleHeaders.isEmpty { + applyRequestHeaders(previousHeaders) + } + } + func removeExternalSubtitles() { guard mpv != nil else { return } let count = getInt("track-list/count") From 8ebe7ccda372aefadd5c402e6f7f31bb390e3077 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 6 Jun 2026 00:22:46 +0530 Subject: [PATCH 24/60] fix(player): resolve return type mismatch in parseTtml --- .../com/nuvio/app/features/player/PlayerSubtitleCueParser.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 9e1b03817..72a9bc502 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -143,6 +143,7 @@ object PlayerSubtitleCueParser { if (body.isBlank()) null else SubtitleSyncCue(start, body) } .sortedBy { it.startTimeMs } + .toList() private fun parseCueStart(timingLine: String): Long? { val startPart = timingLine.substringBefore("-->").trim() From 3a87ecce0f64102588cf9d2f94f35e3059aedeb1 Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Sat, 6 Jun 2026 00:04:26 +0200 Subject: [PATCH 25/60] feat(ios): rapid profile switch for liquid glass --- composeApp/proguard-rules.pro | 4 + .../app/core/ui/NativeTabBridge.android.kt | 9 + .../composeResources/values-cs/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-id/strings.xml | 2 +- .../composeResources/values-it/strings.xml | 2 +- .../composeResources/values-nb/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../composeResources/values-tr/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../commonMain/kotlin/com/nuvio/app/App.kt | 54 ++++- .../com/nuvio/app/core/ui/NativeTabBridge.kt | 28 +++ .../features/profiles/ProfileSwitcherTab.kt | 185 +++++++++++++++++- .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 25 +++ iosApp/Configuration/Version.xcconfig | 4 +- iosApp/iosApp/ContentView.swift | 151 +++++++++++++- iosApp/iosApp/Player/MPVPlayerBridge.swift | 6 + 18 files changed, 463 insertions(+), 21 deletions(-) diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index bbe05c0c8..29e2b9b63 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -29,6 +29,10 @@ -keep class com.nuvio.app.features.streams.StreamsScreenKt { *; } -keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; } +# Avoid R8 producing verifier-invalid bytecode for the large player composable. +-keep class com.nuvio.app.features.player.PlayerScreenKt { *; } +-keep class com.nuvio.app.features.player.PlayerScreenKt$* { *; } + # QuickJS plugin runtime is dynamic; keep runtime and app plugin classes. -keep class com.dokar.quickjs.** { *; } -keep class com.nuvio.app.features.plugins.** { *; } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index c7c556c50..900c3489b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -10,9 +10,18 @@ internal actual fun publishNativeSelectedTab(tabName: String) = Unit internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) = Unit + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) = Unit + +internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index 5e186e5f4..298527b02 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -453,7 +453,7 @@ Vyberte jazyk Nastavení pro sekci Pokračovat ve sledování. Tekuté sklo (Liquid Glass) - Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Okamžité přepínání profilů z lišty panelů není při zapnutí dostupné. + Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Vyladit šířku karty a poloměr rohů. ZOBRAZENÍ DOMŮ diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a09f31754..95e83d86a 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -506,7 +506,7 @@ Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. Liquid Glass - Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d’onglets n’est pas disponible quand cette option est activée. + Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Ajuste la largeur des cartes et le rayon des coins. AFFICHAGE ACCUEIL diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index e442e971d..35a923d9f 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -508,7 +508,7 @@ Pilih Bahasa Pengaturan untuk bagian Lanjutkan Menonton. Liquid Glass - Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif. + Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Sesuaikan lebar kartu dan radius sudut. TAMPILAN BERANDA diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index c72739585..d96e8195f 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1240,7 +1240,7 @@ Utilizzato per la riproduzione sulle build Android. Rilasciato sotto la licenza Apache License, Versione 2.0. Liquid Glass - Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Il cambio rapido del profilo dalla barra dei pannelli non è disponibile quando questa opzione è attiva. + Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Nascondi contenuti non rilasciati Nascondi i film e le serie TV che non sono ancora stati rilasciati. Nascondi sottolineatura catalogo diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 253a32cc2..ad7cea8c3 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -492,7 +492,7 @@ Velg språk Innstillinger for Fortsett å se-seksjonen. Liquid Glass - Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på. + Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Juster kortbredde og hjørneradius. VISNING HJEM diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3e487c29c..1374aebbe 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -508,7 +508,7 @@ Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. Liquid Glass - Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 725e8cc2e..74c572aee 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -498,7 +498,7 @@ Escolher idioma Definições para a secção Continuar a Ver. Liquid Glass - Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo. + Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. Ajusta a largura dos cartões e o raio dos cantos. ECRÃ ECRÃ INICIAL diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 9d54c44b1..1340bde98 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -498,7 +498,7 @@ Dil seç İzlemeye Devam Et rafını göster, gizle ve stilini ayarla. Liquid Glass - iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Bu özellik açıkken sekme çubuğundan anlık profil geçişi yapılamaz. + iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla. EKRAN ANA SAYFA diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index fd8226bfc..5eb9014a5 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -526,7 +526,7 @@ Choose Language Settings for the Continue Watching section. Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. + Use the native iPhone tab bar on iOS 26 and later. Tune card width and corner radius. DISPLAY HOME diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 444573b93..36fd8e3a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -158,6 +158,7 @@ import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile +import com.nuvio.app.features.profiles.NativeProfileSwitcherPopup import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen @@ -592,6 +593,7 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + var nativeProfileSwitcherVisible by remember { mutableStateOf(false) } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -644,6 +646,10 @@ private fun MainAppContent( val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) + val nativeTabHomeTitle = stringResource(Res.string.compose_nav_home) + val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) + val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) + val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -675,6 +681,28 @@ private fun MainAppContent( } } + LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + NativeTabBridge.profileTabLongPresses.collectLatest { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + nativeProfileSwitcherVisible = true + } + } + } + + LaunchedEffect( + nativeTabHomeTitle, + nativeTabSearchTitle, + nativeTabLibraryTitle, + nativeTabProfileTitle, + ) { + NativeTabBridge.publishTabTitles( + home = nativeTabHomeTitle, + search = nativeTabSearchTitle, + library = nativeTabLibraryTitle, + profile = nativeTabProfileTitle, + ) + } + LaunchedEffect(selectedTab) { NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) if (selectedTab != AppScreenTab.Search) { @@ -682,16 +710,20 @@ private fun MainAppContent( } } + var profileSwitchLoading by remember { mutableStateOf(false) } + DisposableEffect( navController, liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled, initialHomeReady, + profileSwitchLoading, ) { fun publishNativeTabVisibilityForCurrentRoute() { val visible = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady && + !profileSwitchLoading && navController.currentDestination?.hasRoute() == true NativeTabBridge.publishTabBarVisible(visible) } @@ -801,7 +833,6 @@ private fun MainAppContent( SyncManager.requestForegroundPull(activeProfileId, force = true) } } - var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf() } @@ -1283,7 +1314,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> + nativeProfileSwitcherVisible = false profileSwitchLoading = true + NativeTabBridge.publishTabBarVisible(false) selectedTab = AppScreenTab.Home ProfileRepository.selectProfile(profile.profileIndex) com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) @@ -1436,7 +1469,24 @@ private fun MainAppContent( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, + onAddProfileRequested = { + nativeProfileSwitcherVisible = false + onSwitchProfile() + }, + ) + } + + if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) { + NativeProfileSwitcherPopup( + visible = nativeProfileSwitcherVisible, + isSwitchingProfile = profileSwitchLoading, + onDismissRequest = { nativeProfileSwitcherVisible = false }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = { + nativeProfileSwitcherVisible = false + onSwitchProfile() + }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index aa426d022..18299c1fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -20,11 +20,17 @@ internal enum class NativeNavigationTab { internal object NativeTabBridge { private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() + private val _profileTabLongPresses = MutableSharedFlow(extraBufferCapacity = 1) + val profileTabLongPresses: SharedFlow = _profileTabLongPresses.asSharedFlow() fun requestTab(tabName: String) { _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } + fun requestProfileTabLongPress() { + _profileTabLongPresses.tryEmit(Unit) + } + fun publishSelectedTab(tab: NativeNavigationTab) { publishNativeSelectedTab(tab.name) } @@ -41,6 +47,15 @@ internal object NativeTabBridge { publishNativeTabAccentColor(hexColor) } + fun publishTabTitles( + home: String, + search: String, + library: String, + profile: String, + ) { + publishNativeTabTitles(home, search, library, profile) + } + fun publishProfileTabIcon( name: String?, avatarColorHex: String?, @@ -60,6 +75,10 @@ fun nativeTabSelect(tabName: String) { NativeTabBridge.requestTab(tabName) } +fun nativeProfileTabLongPress() { + NativeTabBridge.requestProfileTabLongPress() +} + internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) @@ -70,9 +89,18 @@ internal expect fun publishNativeSelectedTab(tabName: String) internal expect fun publishNativeTabAccentColor(hexColor: String) +internal expect fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) + internal expect fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) + +internal expect fun notifyNativeProfileSwitcherPopupDismissed() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index a399c8220..cf477d8e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -18,8 +18,10 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -70,6 +72,7 @@ import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.isIos +import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -139,8 +142,8 @@ fun ProfileSwitcherTab( if (profile.pinEnabled) { pinProfile = profile } else { - onProfileSelected(profile) showPopup = false + onProfileSelected(profile) } } @@ -335,6 +338,186 @@ fun ProfileSwitcherTab( } } +@Composable +fun NativeProfileSwitcherPopup( + visible: Boolean, + isSwitchingProfile: Boolean, + onDismissRequest: () -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val activeProfile = profileState.activeProfile + val profiles = profileState.profiles + val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + + var showPopup by remember { mutableStateOf(false) } + var popupVisible by remember { mutableStateOf(false) } + var pinProfile by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + AvatarRepository.fetchAvatars() + AvatarRepository.refreshAvatars() + } + + LaunchedEffect(visible, isSwitchingProfile, profiles.isNotEmpty()) { + if (visible && !isSwitchingProfile) { + if (profiles.isNotEmpty()) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showPopup = true + } else { + onDismissRequest() + showPopup = false + } + } else { + showPopup = false + } + } + + fun chooseProfile(profile: NuvioProfile) { + if (profile.pinEnabled) { + pinProfile = profile + } else { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + } + } + + val popupAlpha = remember { Animatable(0f) } + val popupScale = remember { Animatable(0.5f) } + val popupTranslateY = remember { Animatable(40f) } + + LaunchedEffect(showPopup) { + if (showPopup) { + popupVisible = true + launch { popupAlpha.animateTo(1f, tween(220, easing = FastOutSlowInEasing)) } + launch { + popupScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + launch { + popupTranslateY.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + } else if (popupVisible) { + notifyNativeProfileSwitcherPopupDismissed() + launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } + launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } + launch { + popupTranslateY.animateTo(30f, tween(200, easing = FastOutSlowInEasing)) + popupVisible = false + pinProfile = null + } + } + } + + BoxWithConstraints(modifier = modifier) { + val anchorWidth = maxWidth / 4 + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxHeight() + .width(anchorWidth), + ) { + if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, with(density) { -84.dp.roundToPx() }), + properties = PopupProperties(focusable = true), + onDismissRequest = onDismissRequest, + ) { + Box( + modifier = Modifier + .imePadding() + .graphicsLayer { + alpha = popupAlpha.value + scaleX = popupScale.value + scaleY = popupScale.value + translationY = popupTranslateY.value + } + .shadow(16.dp, RoundedCornerShape(28.dp)) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + RoundedCornerShape(28.dp), + ) + .padding(16.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + profiles.forEachIndexed { index, profile -> + PopupProfileBubble( + profile = profile, + avatars = avatars, + isActive = profile.profileIndex == activeProfile?.profileIndex, + isSelected = pinProfile?.profileIndex == profile.profileIndex, + delayMs = index * 50, + onBoundsChanged = {}, + onClick = { chooseProfile(profile) }, + ) + } + + if (profiles.size < 4) { + PopupAddProfileBubble( + delayMs = profiles.size * 50, + onClick = { + showPopup = false + onDismissRequest() + onAddProfileRequested() + }, + ) + } + } + + AnimatedVisibility( + visible = pinProfile != null, + enter = expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + fadeIn(tween(200)), + exit = shrinkVertically(tween(150)) + fadeOut(tween(100)), + ) { + pinProfile?.let { profile -> + InlinePinEntry( + profileName = profile.name, + onVerified = { + showPopup = false + onDismissRequest() + onProfileSelected(profile) + }, + onCancel = { pinProfile = null }, + verifyPin = { pin -> + ProfileRepository.verifyPin(profile.profileIndex, pin) + }, + ) + } + } + } + } + } + } + } + } +} + @Composable private fun PopupAddProfileBubble( delayMs: Int, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 1b72da7c2..5fad29aab 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -9,10 +9,15 @@ private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTab private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor" +private const val nativeTabTitleHomeKey = "NuvioNativeTabTitleHome" +private const val nativeTabTitleSearchKey = "NuvioNativeTabTitleSearch" +private const val nativeTabTitleLibraryKey = "NuvioNativeTabTitleLibrary" +private const val nativeTabTitleProfileKey = "NuvioNativeTabTitleProfile" private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" +private const val nativeProfileSwitcherPopupDismissedNotification = "NuvioNativeProfileSwitcherPopupDismissed" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -38,6 +43,19 @@ internal actual fun publishNativeTabAccentColor(hexColor: String) { notifyNativeTabChromeChanged() } +internal actual fun publishNativeTabTitles( + home: String, + search: String, + library: String, + profile: String, +) { + publishString(nativeTabTitleHomeKey, home) + publishString(nativeTabTitleSearchKey, search) + publishString(nativeTabTitleLibraryKey, library) + publishString(nativeTabTitleProfileKey, profile) + notifyNativeTabChromeChanged() +} + internal actual fun publishNativeProfileTabIcon( name: String?, avatarColorHex: String?, @@ -51,6 +69,13 @@ internal actual fun publishNativeProfileTabIcon( notifyNativeTabChromeChanged() } +internal actual fun notifyNativeProfileSwitcherPopupDismissed() { + NSNotificationCenter.defaultCenter.postNotificationName( + nativeProfileSwitcherPopupDismissedNotification, + null, + ) +} + private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 1a9a5661d..92fd04ca2 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=73 -MARKETING_VERSION=0.2.2 +CURRENT_PROJECT_VERSION=74 +MARKETING_VERSION=0.2.3 diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 14f5664a0..ac5db8145 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -253,7 +253,7 @@ private enum NuvioNativeTabIcon { } } -final class RootComposeViewController: UIViewController, UITabBarDelegate { +final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGestureRecognizerDelegate { private enum NativeTab: String, CaseIterable { case home = "Home" case search = "Search" @@ -269,7 +269,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } - var title: String { + var titleKey: String { + switch self { + case .home: return "NuvioNativeTabTitleHome" + case .search: return "NuvioNativeTabTitleSearch" + case .library: return "NuvioNativeTabTitleLibrary" + case .settings: return "NuvioNativeTabTitleProfile" + } + } + + var fallbackTitle: String { switch self { case .home: return "Home" case .search: return "Search" @@ -278,6 +287,10 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + func localizedTitle(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: titleKey)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? fallbackTitle + } + var iconImage: UIImage { switch self { case .home: return NuvioNativeTabIcon.home @@ -301,6 +314,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" + private static let nativeProfileSwitcherPopupDismissedNotification = Notification.Name("NuvioNativeProfileSwitcherPopupDismissed") private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") private let contentController: UIViewController @@ -309,6 +323,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { private var tabBarHeightConstraint: NSLayoutConstraint? private var userDefaultsObserver: NSObjectProtocol? private var tabChromeObserver: NSObjectProtocol? + private var profileSwitcherPopupObserver: NSObjectProtocol? + private var profileLongPressRecognizer: UILongPressGestureRecognizer? + private var suppressNextProfileSelection = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -355,6 +372,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { if let tabChromeObserver { NotificationCenter.default.removeObserver(tabChromeObserver) } + if let profileSwitcherPopupObserver { + NotificationCenter.default.removeObserver(profileSwitcherPopupObserver) + } profileAvatarImageTask?.cancel() } @@ -365,8 +385,26 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let tab = NativeTab(tag: item.tag) else { return } - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + if tab == .settings && suppressNextProfileSelection { + suppressNextProfileSelection = false + restoreNativeTabFocus(to: currentNativeSelectedTab) + return + } + selectNativeTab(tab) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === profileLongPressRecognizer + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if gestureRecognizer === profileLongPressRecognizer { + return nativeTab(at: touch.location(in: tabBar)) == .settings + } + return true } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -441,13 +479,19 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { tabBar.translatesAutoresizingMaskIntoConstraints = false tabBar.items = NativeTab.allCases.map { tab in let item = UITabBarItem( - title: tab.title, + title: tab.localizedTitle(), image: tab.iconImage, selectedImage: tab.iconImage ) item.tag = tab.tag return item } + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleNativeProfileTabLongPress(_:))) + longPressRecognizer.delegate = self + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + tabBar.addGestureRecognizer(longPressRecognizer) + profileLongPressRecognizer = longPressRecognizer tabBar.selectedItem = tabBar.items?.first applyNativeTabBarAppearance() tabBar.alpha = 0 @@ -480,6 +524,14 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { ) { [weak self] _ in self?.syncNativeTabChrome(animated: true) } + + profileSwitcherPopupObserver = NotificationCenter.default.addObserver( + forName: Self.nativeProfileSwitcherPopupDismissedNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.suppressNextProfileSelection = false + } } private var tabBarHeight: CGFloat { @@ -525,9 +577,70 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } private func syncSelectedNativeTab() { + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == currentNativeSelectedTab.tag }) + } + + @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began else { return } + + suppressNextProfileSelection = true + let tabToRestore = currentNativeSelectedTab + cancelNativeTabTracking() + DispatchQueue.main.async { [weak self] in + self?.restoreNativeTabFocus(to: tabToRestore) + NativeTabBridgeKt.nativeProfileTabLongPress() + } + } + + private var currentNativeSelectedTab: NativeTab { let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue - let selectedTab = NativeTab(rawValue: rawValue) ?? .home - tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag }) + return NativeTab(rawValue: rawValue) ?? .home + } + + private func nativeTab(at point: CGPoint) -> NativeTab? { + guard tabBar.bounds.contains(point), tabBar.bounds.width > 0 else { return nil } + let tabs = NativeTab.allCases + let rawIndex = Int((point.x / tabBar.bounds.width) * CGFloat(tabs.count)) + let clampedIndex = min(max(rawIndex, 0), tabs.count - 1) + return tabs[clampedIndex] + } + + private func selectNativeTab(_ tab: NativeTab) { + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) + UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) + NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + } + + private func restoreNativeTabFocus(to tab: NativeTab) { + guard let item = tabBar.items?.first(where: { $0.tag == tab.tag }) else { return } + UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) + NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + + tabBar.cancelControlTrackingRecursively() + + let restoreDuration: TimeInterval = 0.24 + UIView.animate( + withDuration: restoreDuration, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] + ) { + self.tabBar.selectedItem = item + self.tabBar.layoutIfNeeded() + } + } + + private func cancelNativeTabTracking() { + tabBar.cancelControlTrackingRecursively() + tabBar.gestureRecognizers?.forEach { recognizer in + guard recognizer !== profileLongPressRecognizer else { return } + recognizer.isEnabled = false + recognizer.isEnabled = true + } + tabBar.isUserInteractionEnabled = false + DispatchQueue.main.async { [weak self] in + self?.tabBar.cancelControlTrackingRecursively() + self?.tabBar.isUserInteractionEnabled = true + } } private func applyNativeTabBarAppearance() { @@ -535,6 +648,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1) + updateNativeTabTitles() refreshProfileAvatarImageIfNeeded() updateNativeTabImages(accent: accent) @@ -566,6 +680,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate { } } + private func updateNativeTabTitles() { + tabBar.items?.forEach { item in + guard let tab = NativeTab(tag: item.tag) else { return } + item.title = tab.localizedTitle() + } + } + private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { guard tab == .settings else { return tab.iconImage @@ -630,6 +751,22 @@ private extension UIColor { } } +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} + +private extension UIView { + func cancelControlTrackingRecursively() { + if let control = self as? UIControl { + control.cancelTracking(with: nil) + control.isHighlighted = false + } + subviews.forEach { $0.cancelControlTrackingRecursively() } + } +} + struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 41d492574..563a692e4 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -60,6 +60,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } + func setMuted(muted: Bool) { playerVC?.setMuted(muted) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } // Audio tracks @@ -517,6 +518,11 @@ final class MPVPlayerViewController: UIViewController { mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s) } + func setMuted(_ muted: Bool) { + guard mpv != nil else { return } + setFlag("mute", muted) + } + func setResize(_ mode: Int) { guard mpv != nil else { return } switch mode { From 5b65ee20cb27007dadfddb484cca8b0f109d5901 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Mon, 8 Jun 2026 10:32:18 +0530 Subject: [PATCH 26/60] Fix iOS compilation errors: add CC_SHA384 and fix size_tVar types --- .../com/nuvio/app/features/plugins/PluginCrypto.ios.kt | 10 ++++++---- .../src/nativeInterop/cinterop/commoncrypto_shim.h | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index c3f3a181a..5e5b205eb 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -164,7 +164,7 @@ internal fun pluginAesEncrypt( } val tagBytes = ByteArray(16) - val tagLengthVar = alloc() + val tagLengthVar = alloc() tagLengthVar.value = 16UL tagBytes.usePinned { pinnedTag -> @@ -199,7 +199,7 @@ internal fun pluginAesEncrypt( var finalData: ByteArray? = null memScoped { - val dataOutMoved = alloc() + val dataOutMoved = alloc() var options = 0U if (isEcb) { @@ -310,7 +310,7 @@ internal fun pluginAesDecrypt( } } - val tagLengthVar = alloc() + val tagLengthVar = alloc() tagLengthVar.value = 16UL val finalStatus = CCCryptorGCMFinal( @@ -343,7 +343,7 @@ internal fun pluginAesDecrypt( var finalData: ByteArray? = null memScoped { - val dataOutMoved = alloc() + val dataOutMoved = alloc() var options = 0U if (isEcb) { @@ -472,6 +472,7 @@ private fun normalizeDigestAlgorithm(algorithm: String): String { } } +@OptIn(ExperimentalForeignApi::class) private fun normalizePbkdf2Prf(algorithm: String) = when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "SHA1" -> kCCPRFHmacAlgSHA1 @@ -481,6 +482,7 @@ private fun normalizePbkdf2Prf(algorithm: String) = else -> error("Unsupported PBKDF2 hash algorithm: $algorithm") } +@OptIn(ExperimentalForeignApi::class) private fun normalizeHmacAlgorithm(algorithm: String) = when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) { "MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt() diff --git a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h index 255a0fd71..c9b762128 100644 --- a/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h +++ b/composeApp/src/nativeInterop/cinterop/commoncrypto_shim.h @@ -7,6 +7,7 @@ enum { CC_MD5_DIGEST_LENGTH = 16, CC_SHA1_DIGEST_LENGTH = 20, CC_SHA256_DIGEST_LENGTH = 32, + CC_SHA384_DIGEST_LENGTH = 48, CC_SHA512_DIGEST_LENGTH = 64, }; @@ -21,6 +22,7 @@ typedef enum { unsigned char *CC_MD5(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA1(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md); +unsigned char *CC_SHA384(const void *data, CC_LONG len, unsigned char *md); unsigned char *CC_SHA512(const void *data, CC_LONG len, unsigned char *md); void CCHmac( @@ -48,7 +50,7 @@ enum { int CCKeyDerivationPBKDF( CCPBKDFAlgorithm algorithm, - const char *password, + const void *password, size_t passwordLen, const uint8_t *salt, size_t saltLen, From 18d004ffe0232e7c6028d2cd11039136e7dc59de Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Thu, 18 Jun 2026 14:59:22 +0200 Subject: [PATCH 27/60] Add native liquid glass profile tab handling --- .../app/core/ui/NativeTabBridge.android.kt | 2 - .../commonMain/kotlin/com/nuvio/app/App.kt | 10 +- .../com/nuvio/app/core/ui/NativeTabBridge.kt | 2 - .../features/profiles/ProfileSwitcherTab.kt | 15 +- .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 8 - iosApp/iosApp/ContentView.swift | 188 ++++++++++-------- 6 files changed, 116 insertions(+), 109 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index 900c3489b..8e860fa35 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -23,5 +23,3 @@ internal actual fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) = Unit - -internal actual fun notifyNativeProfileSwitcherPopupDismissed() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 755ffd9cd..21b046131 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -103,6 +103,7 @@ import com.nuvio.app.core.ui.NativeTabBridge import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository @@ -1422,6 +1423,11 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val nativeTabSafeBottomPadding = nuvioBottomNavigationBarInsets() + .asPaddingValues() + .calculateBottomPadding() + val nativeProfileTabAnchorBottomPadding = + nativeTabSafeBottomPadding + NuvioTokens.Space.s10 val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> nativeProfileSwitcherVisible = false @@ -1596,7 +1602,9 @@ private fun MainAppContent( nativeProfileSwitcherVisible = false onSwitchProfile() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(bottom = nativeProfileTabAnchorBottomPadding), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index 18299c1fa..2b9d48160 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -102,5 +102,3 @@ internal expect fun publishNativeProfileTabIcon( avatarImageUrl: String?, avatarBackgroundColorHex: String?, ) - -internal expect fun notifyNativeProfileSwitcherPopupDismissed() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index ba179788b..5e9b47952 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -72,7 +72,6 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos -import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -352,6 +351,7 @@ fun NativeProfileSwitcherPopup( val activeProfile = profileState.activeProfile val profiles = profileState.profiles val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val tokens = MaterialTheme.nuvio val haptic = LocalHapticFeedback.current val density = LocalDensity.current @@ -415,7 +415,6 @@ fun NativeProfileSwitcherPopup( ) } } else if (popupVisible) { - notifyNativeProfileSwitcherPopupDismissed() launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } launch { @@ -437,7 +436,7 @@ fun NativeProfileSwitcherPopup( if (popupVisible && profiles.isNotEmpty() && !isSwitchingProfile) { Popup( alignment = Alignment.BottomCenter, - offset = IntOffset(0, with(density) { -84.dp.roundToPx() }), + offset = IntOffset(0, with(density) { -NuvioTokens.Space.s64.roundToPx() }), properties = PopupProperties(focusable = true), onDismissRequest = onDismissRequest, ) { @@ -450,16 +449,16 @@ fun NativeProfileSwitcherPopup( scaleY = popupScale.value translationY = popupTranslateY.value } - .shadow(16.dp, RoundedCornerShape(28.dp)) + .shadow(tokens.elevation.overlay, tokens.shapes.sheet) .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(28.dp), + tokens.colors.surfaceSheet, + tokens.shapes.sheet, ) - .padding(16.dp), + .padding(tokens.spacing.sheetPadding), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { profiles.forEachIndexed { index, profile -> diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 5fad29aab..6a3c7e7f6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -17,7 +17,6 @@ private const val nativeProfileNameKey = "NuvioNativeProfileName" private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" -private const val nativeProfileSwitcherPopupDismissedNotification = "NuvioNativeProfileSwitcherPopupDismissed" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -69,13 +68,6 @@ internal actual fun publishNativeProfileTabIcon( notifyNativeTabChromeChanged() } -internal actual fun notifyNativeProfileSwitcherPopupDismissed() { - NSNotificationCenter.defaultCenter.postNotificationName( - nativeProfileSwitcherPopupDismissedNotification, - null, - ) -} - private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ac5db8145..c8fb0338b 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -253,7 +253,7 @@ private enum NuvioNativeTabIcon { } } -final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGestureRecognizerDelegate { +final class RootComposeViewController: UIViewController, UITabBarDelegate { private enum NativeTab: String, CaseIterable { case home = "Home" case search = "Search" @@ -314,18 +314,17 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL" private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" - private static let nativeProfileSwitcherPopupDismissedNotification = Notification.Name("NuvioNativeProfileSwitcherPopupDismissed") private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") private let contentController: UIViewController private let tabBar = UITabBar() + private let profileTabTouchOverlay = UIControl() private var contentBottomToViewBottom: NSLayoutConstraint? private var tabBarHeightConstraint: NSLayoutConstraint? private var userDefaultsObserver: NSObjectProtocol? private var tabChromeObserver: NSObjectProtocol? - private var profileSwitcherPopupObserver: NSObjectProtocol? - private var profileLongPressRecognizer: UILongPressGestureRecognizer? - private var suppressNextProfileSelection = false + private var profileTouchRestoreTab: NativeTab? + private var profileLongPressHandled = false private var profileAvatarImageURL: String? private var profileAvatarImageTask: URLSessionDataTask? private var profileAvatarImage: UIImage? @@ -372,9 +371,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes if let tabChromeObserver { NotificationCenter.default.removeObserver(tabChromeObserver) } - if let profileSwitcherPopupObserver { - NotificationCenter.default.removeObserver(profileSwitcherPopupObserver) - } profileAvatarImageTask?.cancel() } @@ -383,30 +379,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes updateTabBarHeight() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateProfileTabTouchOverlayFrame() + } + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let tab = NativeTab(tag: item.tag) else { return } - if tab == .settings && suppressNextProfileSelection { - suppressNextProfileSelection = false - restoreNativeTabFocus(to: currentNativeSelectedTab) - return - } selectNativeTab(tab) } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - gestureRecognizer === profileLongPressRecognizer - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if gestureRecognizer === profileLongPressRecognizer { - return nativeTab(at: touch.location(in: tabBar)) == .settings - } - return true - } - override var childForHomeIndicatorAutoHidden: UIViewController? { immersiveController(in: contentController) ?? contentController } @@ -486,18 +468,13 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes item.tag = tab.tag return item } - let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleNativeProfileTabLongPress(_:))) - longPressRecognizer.delegate = self - longPressRecognizer.minimumPressDuration = 0.45 - longPressRecognizer.cancelsTouchesInView = true - tabBar.addGestureRecognizer(longPressRecognizer) - profileLongPressRecognizer = longPressRecognizer tabBar.selectedItem = tabBar.items?.first applyNativeTabBarAppearance() tabBar.alpha = 0 tabBar.isHidden = true view.addSubview(tabBar) + configureProfileTabTouchOverlay() let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) tabBarHeightConstraint = heightConstraint NSLayoutConstraint.activate([ @@ -524,14 +501,6 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes ) { [weak self] _ in self?.syncNativeTabChrome(animated: true) } - - profileSwitcherPopupObserver = NotificationCenter.default.addObserver( - forName: Self.nativeProfileSwitcherPopupDismissedNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.suppressNextProfileSelection = false - } } private var tabBarHeight: CGFloat { @@ -540,6 +509,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes private func updateTabBarHeight() { tabBarHeightConstraint?.constant = tabBarHeight + updateProfileTabTouchOverlayFrame() } private func syncNativeTabChrome(animated: Bool) { @@ -551,15 +521,18 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes contentBottomToViewBottom?.isActive = true if visible { tabBar.isHidden = false + profileTabTouchOverlay.isHidden = false } let changes = { self.tabBar.alpha = visible ? 1 : 0 + self.profileTabTouchOverlay.alpha = visible ? 1 : 0 self.view.layoutIfNeeded() } let completion: (Bool) -> Void = { _ in self.tabBar.isHidden = !visible + self.profileTabTouchOverlay.isHidden = !visible } if animated && view.window != nil { @@ -583,13 +556,33 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes @objc private func handleNativeProfileTabLongPress(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began else { return } - suppressNextProfileSelection = true - let tabToRestore = currentNativeSelectedTab - cancelNativeTabTracking() - DispatchQueue.main.async { [weak self] in - self?.restoreNativeTabFocus(to: tabToRestore) + profileLongPressHandled = true + DispatchQueue.main.async { NativeTabBridgeKt.nativeProfileTabLongPress() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.restoreProfileTabTouchIfNeeded() + } + } + + @objc private func handleNativeProfileTabTouchDown() { + profileTouchRestoreTab = currentNativeSelectedTab + profileLongPressHandled = false + } + + @objc private func handleNativeProfileTabTap() { + if profileLongPressHandled { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() + return + } + profileTouchRestoreTab = nil + selectNativeTab(.settings) + } + + @objc private func handleNativeProfileTabTouchCancel() { + profileLongPressHandled = false + restoreProfileTabTouchIfNeeded() } private var currentNativeSelectedTab: NativeTab { @@ -597,50 +590,78 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes return NativeTab(rawValue: rawValue) ?? .home } - private func nativeTab(at point: CGPoint) -> NativeTab? { - guard tabBar.bounds.contains(point), tabBar.bounds.width > 0 else { return nil } - let tabs = NativeTab.allCases - let rawIndex = Int((point.x / tabBar.bounds.width) * CGFloat(tabs.count)) - let clampedIndex = min(max(rawIndex, 0), tabs.count - 1) - return tabs[clampedIndex] - } - private func selectNativeTab(_ tab: NativeTab) { tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) } - private func restoreNativeTabFocus(to tab: NativeTab) { - guard let item = tabBar.items?.first(where: { $0.tag == tab.tag }) else { return } - UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) - NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) + private func configureProfileTabTouchOverlay() { + profileTabTouchOverlay.backgroundColor = .clear + profileTabTouchOverlay.isOpaque = false + profileTabTouchOverlay.isExclusiveTouch = true + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() + profileTabTouchOverlay.accessibilityTraits = .button + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchDown), + for: .touchDown + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTap), + for: .touchUpInside + ) + profileTabTouchOverlay.addTarget( + self, + action: #selector(handleNativeProfileTabTouchCancel), + for: [.touchCancel, .touchUpOutside] + ) - tabBar.cancelControlTrackingRecursively() + let longPressRecognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleNativeProfileTabLongPress(_:)) + ) + longPressRecognizer.minimumPressDuration = 0.45 + longPressRecognizer.cancelsTouchesInView = true + profileTabTouchOverlay.addGestureRecognizer(longPressRecognizer) - let restoreDuration: TimeInterval = 0.24 - UIView.animate( - withDuration: restoreDuration, - delay: 0, - options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] - ) { - self.tabBar.selectedItem = item - self.tabBar.layoutIfNeeded() - } + profileTabTouchOverlay.alpha = 0 + profileTabTouchOverlay.isHidden = true + view.addSubview(profileTabTouchOverlay) + updateProfileTabTouchOverlayFrame() } - private func cancelNativeTabTracking() { - tabBar.cancelControlTrackingRecursively() - tabBar.gestureRecognizers?.forEach { recognizer in - guard recognizer !== profileLongPressRecognizer else { return } - recognizer.isEnabled = false - recognizer.isEnabled = true + private func restoreProfileTabTouchIfNeeded() { + let tab = profileTouchRestoreTab ?? currentNativeSelectedTab + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == tab.tag }) + profileTouchRestoreTab = nil + } + + private func updateProfileTabTouchOverlayFrame() { + let tabCount = CGFloat(NativeTab.allCases.count) + guard tabCount > 0, tabBar.bounds.width > 0 else { + profileTabTouchOverlay.frame = .zero + return } - tabBar.isUserInteractionEnabled = false - DispatchQueue.main.async { [weak self] in - self?.tabBar.cancelControlTrackingRecursively() - self?.tabBar.isUserInteractionEnabled = true + + let itemWidth = tabBar.bounds.width / tabCount + let settingsIndex = CGFloat(NativeTab.settings.tag) + let visualIndex: CGFloat + if tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft { + visualIndex = tabCount - 1 - settingsIndex + } else { + visualIndex = settingsIndex } + let overlayFrameInTabBar = CGRect( + x: itemWidth * visualIndex, + y: 0, + width: itemWidth, + height: tabBar.bounds.height + ) + profileTabTouchOverlay.frame = tabBar.convert(overlayFrameInTabBar, to: view) + profileTabTouchOverlay.alpha = tabBar.alpha + view.bringSubviewToFront(profileTabTouchOverlay) } private func applyNativeTabBarAppearance() { @@ -685,6 +706,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate, UIGes guard let tab = NativeTab(tag: item.tag) else { return } item.title = tab.localizedTitle() } + profileTabTouchOverlay.accessibilityLabel = NativeTab.settings.localizedTitle() } private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { @@ -757,16 +779,6 @@ private extension String { } } -private extension UIView { - func cancelControlTrackingRecursively() { - if let control = self as? UIControl { - control.cancelTracking(with: nil) - control.isHighlighted = false - } - subviews.forEach { $0.cancelControlTrackingRecursively() } - } -} - struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { // Register MPV player bridge before Compose initializes From 5fd0ab6ea26ae2cf8c94aa274f8c3ceb63953b9b Mon Sep 17 00:00:00 2001 From: paregi12 Date: Fri, 19 Jun 2026 08:57:41 +0530 Subject: [PATCH 28/60] fix(streams): resolve redeclaration and unresolved reference build errors --- .../player/PlayerStreamsRepository.kt | 43 ------ .../app/features/streams/StreamsRepository.kt | 141 ------------------ 2 files changed, 184 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6f734ac56..81ec1c1e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -508,47 +508,4 @@ private fun StreamsUiState.streamDiagnostics(): String { private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = "addon:$manifestId:$manifestUrl" -private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem { - val subtitleParts = listOfNotNull( - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - addonName = scraper.name, - addonId = "plugin:${scraper.id}", - behaviorHints = if (requestHeaders.isEmpty()) { - com.nuvio.app.features.streams.StreamBehaviorHints() - } else { - com.nuvio.app.features.streams.StreamBehaviorHints( - notWebReady = true, - proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), - ) - }, - externalSubtitles = subtitles?.map { - com.nuvio.app.features.streams.StreamSubtitle( - url = it.url, - language = it.language, - name = it.name, - headers = it.headers - ) - } ?: emptyList() - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 223b1ddeb..60ac5b364 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -799,145 +799,4 @@ object StreamsRepository { _uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) } } } -private data class InstalledStreamAddonTarget( - val addonName: String, - val addonId: String, - val manifest: com.nuvio.app.features.addons.AddonManifest, -) -private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = - "addon:$manifestId:$manifestUrl" - -private data class PluginProviderGroup( - val addonId: String, - val addonName: String, - val scrapers: List, -) - -private sealed interface StreamLoadCompletion { - data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class PluginScraper( - val addonId: String, - val streams: List, - val error: String?, - ) : StreamLoadCompletion -} - -private fun List.toPluginProviderGroups( - repositories: List, - groupByRepository: Boolean, -): List { - if (!groupByRepository) { - return map { scraper -> - PluginProviderGroup( - addonId = "plugin:${scraper.id}", - addonName = scraper.name, - scrapers = listOf(scraper), - ) - } - } - - val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } - return groupBy { it.repositoryUrl } - .map { (repositoryUrl, scrapers) -> - PluginProviderGroup( - addonId = "plugin-repo:${repositoryUrl.lowercase()}", - addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, - scrapers = scrapers.sortedBy { it.name.lowercase() }, - ) - } - .sortedBy { it.addonName.lowercase() } -} - -private fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { - if (anyLoading || any { it.streams.isNotEmpty() }) { - return null - } - - return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { - StreamsEmptyStateReason.StreamFetchFailed - } else { - StreamsEmptyStateReason.NoStreamsFound - } -} - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } - -private fun PluginRuntimeResult.toStreamItem( - scraper: PluginScraper, - addonName: String = scraper.name, - addonId: String = "plugin:${scraper.id}", - includeScraperNameInSubtitle: Boolean = false, -): StreamItem { - val subtitleParts = listOfNotNull( - scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - sourceName = scraper.name, - addonName = addonName, - addonId = addonId, - behaviorHints = if (requestHeaders.isEmpty()) { - StreamBehaviorHints() - } else { - StreamBehaviorHints( - notWebReady = true, - proxyHeaders = StreamProxyHeaders(request = requestHeaders), - ) - }, - externalSubtitles = subtitles?.map { - StreamSubtitle( - url = it.url, - language = it.language, - name = it.name, - headers = it.headers - ) - } ?: emptyList() - ) -} - -private fun List.sortedForGroupedDisplay(): List = - sortedWith( - compareBy( - { it.sourceName.orEmpty().lowercase() }, - { it.streamLabel.lowercase() }, - { it.streamSubtitle.orEmpty().lowercase() }, - ), - ) - -private fun String.fallbackRepositoryLabel(): String { - val withoutQuery = substringBefore("?") - val withoutManifest = withoutQuery.removeSuffix("/manifest.json") - val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') - return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { - runBlocking { getString(Res.string.streams_plugin_repository_fallback) } - } - } -} From adf5d18462338754f503b86b33f633232488aaf2 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Fri, 19 Jun 2026 10:00:39 +0530 Subject: [PATCH 29/60] fix(plugins): map plugin subtitles to StreamItem.externalSubtitles --- .../com/nuvio/app/features/streams/StreamFetchSupport.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt index 84d67e887..5bfa30be3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt @@ -124,6 +124,14 @@ internal fun PluginRuntimeResult.toStreamItem( proxyHeaders = StreamProxyHeaders(request = requestHeaders), ) }, + externalSubtitles = subtitles?.map { + StreamSubtitle( + url = it.url, + language = it.language, + name = it.name, + headers = it.headers + ) + } ?: emptyList() ) } From cb8072b470bbfc36aed489ccc04136396be35d9d Mon Sep 17 00:00:00 2001 From: Joe00011 <152079313+Joe00011@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:44:55 +0900 Subject: [PATCH 30/60] Add Japanese locale to locale_config.xml --- composeApp/src/androidMain/res/xml/locale_config.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 530bb0477..a0cb2d584 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -12,4 +12,5 @@ + From f5b32acd424525c2842a47f0fc49b330dfa63bec Mon Sep 17 00:00:00 2001 From: Joe00011 <152079313+Joe00011@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:51:28 +0900 Subject: [PATCH 31/60] Add Japanese language support to AppLanguage enum --- .../kotlin/com/nuvio/app/features/settings/AppLanguage.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index 85bcf497a..5d54c88af 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.lang_portuguese_portugal import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_turkish import nuvio.composeapp.generated.resources.lang_norwegian +import nuvio.composeapp.generated.resources.lang_japanese import nuvio.composeapp.generated.resources.settings_appearance_app_language_device import org.jetbrains.compose.resources.StringResource @@ -33,6 +34,7 @@ enum class AppLanguage( SPANISH("es", Res.string.lang_spanish), TURKISH("tr", Res.string.lang_turkish), NORWEGIAN("nb", Res.string.lang_norwegian), + JAPANESE("ja", Res.string.lang_japanese), ; companion object { From f4a2ce89636d63c13362cf3a7b653454d54add1e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:57:29 +0530 Subject: [PATCH 32/60] Add wide cast and production layouts --- .../features/details/PersonDetailScreen.kt | 431 ++++++++++++++++-- .../details/TmdbEntityBrowseScreen.kt | 319 +++++++++++-- 2 files changed, 675 insertions(+), 75 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index f209f7f2f..08f323bd6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -6,12 +6,15 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -257,68 +260,356 @@ private fun PersonDetailContent( .background(accentGradient), ) - AnimatedVisibility( - visible = true, - enter = fadeIn(), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 48.dp), + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val useWideLayout = maxWidth >= PERSON_DETAIL_WIDE_LAYOUT_MIN_WIDTH + AnimatedVisibility( + visible = true, + enter = fadeIn(), ) { - HeroSection( - person = person, - collapseProgress = collapseProgress, - fallbackProfilePhoto = initialProfilePhoto, - avatarTransitionKey = avatarTransitionKey, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - - if (popularCredits.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - DetailPosterRailSection( - title = stringResource(Res.string.person_popular), - items = popularCredits, + if (useWideLayout) { + WidePersonDetailContent( + person = person, + popularCredits = popularCredits, + latestCredits = latestCredits, + upcomingCredits = upcomingCredits, watchedKeys = watchedKeys, - headerHorizontalPadding = 20.dp, - onPosterClick = onOpenMeta, + onOpenMeta = onOpenMeta, + fallbackProfilePhoto = initialProfilePhoto, + avatarTransitionKey = avatarTransitionKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) - } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 48.dp), + ) { + HeroSection( + person = person, + collapseProgress = collapseProgress, + fallbackProfilePhoto = initialProfilePhoto, + avatarTransitionKey = avatarTransitionKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) - if (latestCredits.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - DetailPosterRailSection( - title = stringResource(Res.string.person_latest), - items = latestCredits, - watchedKeys = watchedKeys, - headerHorizontalPadding = 20.dp, - onPosterClick = onOpenMeta, - ) - } + if (popularCredits.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailPosterRailSection( + title = stringResource(Res.string.person_popular), + items = popularCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + } - if (upcomingCredits.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - DetailPosterRailSection( - title = stringResource(Res.string.person_upcoming), - items = upcomingCredits, - watchedKeys = watchedKeys, - headerHorizontalPadding = 20.dp, - onPosterClick = onOpenMeta, - ) - } + if (latestCredits.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailPosterRailSection( + title = stringResource(Res.string.person_latest), + items = latestCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + } - Spacer(modifier = Modifier.height(32.dp)) + if (upcomingCredits.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailPosterRailSection( + title = stringResource(Res.string.person_upcoming), + items = upcomingCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } } } } } +private val PERSON_DETAIL_WIDE_LAYOUT_MIN_WIDTH = 900.dp +private val PERSON_DETAIL_WIDE_SIDEBAR_WIDTH = 392.dp + private const val HERO_COLLAPSE_SCROLL_RANGE = 220f private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56 +@Composable +@OptIn(ExperimentalSharedTransitionApi::class) +private fun WidePersonDetailContent( + person: PersonDetail, + popularCredits: List, + latestCredits: List, + upcomingCredits: List, + watchedKeys: Set, + onOpenMeta: (MetaPreview) -> Unit, + fallbackProfilePhoto: String?, + avatarTransitionKey: String, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, +) { + Row( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 34.dp), + ) { + PersonIdentitySidebar( + person = person, + fallbackProfilePhoto = fallbackProfilePhoto, + avatarTransitionKey = avatarTransitionKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier + .width(PERSON_DETAIL_WIDE_SIDEBAR_WIDTH) + .fillMaxHeight(), + ) + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 40.dp, bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(34.dp), + ) { + if (popularCredits.isNotEmpty()) { + DetailPosterRailSection( + title = stringResource(Res.string.person_popular), + items = popularCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 0.dp, + onPosterClick = onOpenMeta, + ) + } + + if (latestCredits.isNotEmpty()) { + DetailPosterRailSection( + title = stringResource(Res.string.person_latest), + items = latestCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 0.dp, + onPosterClick = onOpenMeta, + ) + } + + if (upcomingCredits.isNotEmpty()) { + DetailPosterRailSection( + title = stringResource(Res.string.person_upcoming), + items = upcomingCredits, + watchedKeys = watchedKeys, + headerHorizontalPadding = 0.dp, + onPosterClick = onOpenMeta, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalSharedTransitionApi::class) +private fun PersonIdentitySidebar( + person: PersonDetail, + fallbackProfilePhoto: String?, + avatarTransitionKey: String, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, + modifier: Modifier = Modifier, +) { + val accentColor = MaterialTheme.colorScheme.primary + val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto + val platformContext = LocalPlatformContext.current + val avatarRequest = if (!avatarUrl.isNullOrBlank()) { + remember(platformContext, avatarUrl, avatarTransitionKey) { + ImageRequest.Builder(platformContext) + .data(avatarUrl) + .memoryCacheKey(avatarTransitionKey) + .placeholderMemoryCacheKey(avatarTransitionKey) + .diskCacheKey(avatarUrl) + .build() + } + } else { + null + } + val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = avatarTransitionKey), + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } else { + Modifier + } + val credits = remember(person.movieCredits, person.tvCredits) { + (person.movieCredits + person.tvCredits).distinctBy { it.id } + } + val creditSummary = remember(credits) { + buildCreditSummary(credits) + } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(start = 40.dp, end = 36.dp, top = 40.dp, bottom = 42.dp), + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Box( + modifier = Modifier.size(162.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .matchParentSize() + .clip(CircleShape) + .background(accentColor.copy(alpha = 0.14f)), + ) + Box( + modifier = Modifier + .then(avatarSharedElementModifier) + .size(148.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarRequest ?: avatarUrl, + contentDescription = person.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = person.name.initials(), + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(11.dp)) { + Text( + text = person.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + lineHeight = 34.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + person.knownFor?.takeIf { it.isNotBlank() }?.let { knownFor -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(accentColor.copy(alpha = 0.14f)) + .padding(horizontal = 12.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(accentColor), + ) + Text( + text = stringResource(Res.string.person_known_for, knownFor).replace(": ", " "), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = accentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + person.birthday?.let { birthday -> + PersonSidebarFact( + label = "Born", + value = personBirthLine(birthday = birthday, deathday = person.deathday), + ) + } + person.placeOfBirth?.takeIf { it.isNotBlank() }?.let { place -> + PersonSidebarFact(label = "Place of birth", value = place) + } + if (creditSummary.isNotBlank()) { + PersonSidebarFact(label = "Credits", value = creditSummary) + } + } + + person.biography?.takeIf { it.isNotBlank() }?.let { biography -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + SidebarLabel(text = "Biography") + Text( + text = biography, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 12, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun PersonSidebarFact( + label: String, + value: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + SidebarLabel(text = label) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SidebarLabel(text: String) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.70f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun HeroSection( @@ -725,6 +1016,50 @@ private fun PersonDetailError( // ─── Utility ─── +private fun String.initials(): String { + val parts = trim() + .split(" ") + .filter { it.isNotBlank() } + return parts + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + .ifBlank { firstOrNull()?.uppercase() ?: "?" } +} + +private fun buildCreditSummary(credits: List): String { + if (credits.isEmpty()) return "" + val firstYear = credits + .mapNotNull { it.rawReleaseDate?.take(4)?.toIntOrNull() } + .minOrNull() + return buildString { + append(credits.size) + append(if (credits.size == 1) " title" else " titles") + if (firstYear != null) { + append(" · since ") + append(firstYear) + } + } +} + +private fun personBirthLine(birthday: String, deathday: String?): String { + val birthdayDisplay = formatDateForDisplay(birthday) ?: birthday + val deathDisplay = deathday?.let { formatDateForDisplay(it) ?: it } + val age = calculateAge(birthday, deathday) + return buildString { + append(birthdayDisplay) + if (deathDisplay != null) { + append(" · died ") + append(deathDisplay) + } + if (age != null) { + append(" · ") + append(age) + append(" years") + } + } +} + private fun calculateAge(birthday: String, deathday: String?): Int? { val birthParts = birthday.split("-").mapNotNull { it.toIntOrNull() } if (birthParts.size < 3) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 04abb4ca7..6767f84e4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -2,16 +2,20 @@ package com.nuvio.app.features.details import androidx.compose.animation.Crossfade import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding @@ -64,6 +68,9 @@ private sealed interface EntityBrowseUiState { data class Success(val data: TmdbEntityBrowseData) : EntityBrowseUiState } +private val ENTITY_BROWSE_WIDE_LAYOUT_MIN_WIDTH = 900.dp +private val ENTITY_BROWSE_WIDE_SIDEBAR_WIDTH = 392.dp + @Composable fun TmdbEntityBrowseScreen( entityKind: TmdbEntityKind, @@ -175,9 +182,90 @@ private fun EntityBrowseContent( ), ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val useWideLayout = maxWidth >= ENTITY_BROWSE_WIDE_LAYOUT_MIN_WIDTH + if (useWideLayout) { + WideEntityBrowseContent( + data = data, + watchedKeys = watchedKeys, + onOpenMeta = onOpenMeta, + ) + } else if (data.rails.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.catalog_empty_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp), + ) { + EntityHeroSection( + header = data.header, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + + data.rails.forEach { rail -> + DetailPosterRailSection( + title = entityRailTitle(rail), + items = rail.items, + watchedKeys = watchedKeys, + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + } +} + +@Composable +private fun WideEntityBrowseContent( + data: TmdbEntityBrowseData, + watchedKeys: Set, + onOpenMeta: (MetaPreview) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 34.dp), + ) { + EntityIdentitySidebar( + header = data.header, + catalogueCount = data.rails.sumOf { it.items.size }, + modifier = Modifier + .width(ENTITY_BROWSE_WIDE_SIDEBAR_WIDTH) + .fillMaxHeight(), + ) + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) + if (data.rails.isEmpty()) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 40.dp), contentAlignment = Alignment.Center, ) { Text( @@ -189,46 +277,212 @@ private fun EntityBrowseContent( } else { Column( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxHeight() .verticalScroll(rememberScrollState()) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 56.dp), + .padding(start = 40.dp, bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(34.dp), ) { - EntityHeroSection( - header = data.header, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) - data.rails.forEach { rail -> - val mediaLabel = when (rail.mediaType) { - TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies) - TmdbEntityMediaType.TV -> stringResource(Res.string.media_series) - } - val railLabel = when (rail.railType) { - TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular) - TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated) - TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent) - } - val railTitle = stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel) - DetailPosterRailSection( - title = railTitle, + title = entityRailTitle(rail), items = rail.items, watchedKeys = watchedKeys, - headerHorizontalPadding = 20.dp, + headerHorizontalPadding = 0.dp, onPosterClick = onOpenMeta, ) - Spacer(modifier = Modifier.height(8.dp)) } - - Spacer(modifier = Modifier.height(32.dp)) } } } } +@Composable +private fun EntityIdentitySidebar( + header: com.nuvio.app.features.tmdb.TmdbEntityHeader, + catalogueCount: Int, + modifier: Modifier = Modifier, +) { + val accentColor = MaterialTheme.colorScheme.primary + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(start = 40.dp, end = 36.dp, top = 40.dp, bottom = 42.dp), + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Text( + text = when (header.kind) { + TmdbEntityKind.COMPANY -> stringResource(Res.string.details_browse_kind_company) + TmdbEntityKind.NETWORK -> stringResource(Res.string.details_browse_kind_network) + }.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.6.sp, + ), + color = accentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Box( + modifier = Modifier.size(144.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(28.dp)) + .background(accentColor.copy(alpha = 0.14f)), + ) + Box( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(24.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), + shape = RoundedCornerShape(24.dp), + ) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (!header.logo.isNullOrBlank()) { + Box( + modifier = Modifier + .width(92.dp) + .height(62.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .padding(10.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = header.logo, + contentDescription = header.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, + ) + } + } else { + Text( + text = header.name.initials(), + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Text( + text = header.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + lineHeight = 34.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + val metaLine = listOfNotNull( + header.secondaryLabel?.takeIf { it.isNotBlank() }, + header.originCountry?.takeIf { it.isNotBlank() }, + ).joinToString(" · ") + if (metaLine.isNotBlank()) { + Text( + text = metaLine, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + header.originCountry?.takeIf { it.isNotBlank() }?.let { country -> + EntitySidebarFact(label = "Country", value = country) + } + header.secondaryLabel?.takeIf { it.isNotBlank() }?.let { label -> + EntitySidebarFact(label = "Type", value = label) + } + if (catalogueCount > 0) { + EntitySidebarFact( + label = "Catalogue", + value = "$catalogueCount ${if (catalogueCount == 1) "title" else "titles"}", + ) + } + } + + header.description?.takeIf { it.isNotBlank() }?.let { description -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + EntitySidebarLabel(text = "About") + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 12, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun EntitySidebarFact( + label: String, + value: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + EntitySidebarLabel(text = label) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun EntitySidebarLabel(text: String) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.70f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun entityRailTitle(rail: com.nuvio.app.features.tmdb.TmdbEntityRail): String { + val mediaLabel = when (rail.mediaType) { + TmdbEntityMediaType.MOVIE -> stringResource(Res.string.media_movies) + TmdbEntityMediaType.TV -> stringResource(Res.string.media_series) + } + val railLabel = when (rail.railType) { + TmdbEntityRailType.POPULAR -> stringResource(Res.string.details_browse_rail_popular) + TmdbEntityRailType.TOP_RATED -> stringResource(Res.string.details_browse_rail_top_rated) + TmdbEntityRailType.RECENT -> stringResource(Res.string.details_browse_rail_recent) + } + return stringResource(Res.string.details_browse_rail_title, mediaLabel, railLabel) +} + @Composable private fun EntityHeroSection( header: com.nuvio.app.features.tmdb.TmdbEntityHeader, @@ -419,3 +673,14 @@ private fun EntityBrowseError( } } } + +private fun String.initials(): String { + val parts = trim() + .split(" ") + .filter { it.isNotBlank() } + return parts + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + .ifBlank { firstOrNull()?.uppercase() ?: "?" } +} From bdffcbb8bff76ff6cf17f23a20399b3f708f366a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:06:14 +0530 Subject: [PATCH 33/60] Refine wide cast loading layout --- .../features/details/PersonDetailScreen.kt | 418 ++++++++++++------ 1 file changed, 286 insertions(+), 132 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 08f323bd6..5da68dc8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp @@ -517,30 +518,6 @@ private fun PersonIdentitySidebar( maxLines = 3, overflow = TextOverflow.Ellipsis, ) - person.knownFor?.takeIf { it.isNotBlank() }?.let { knownFor -> - Row( - modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(accentColor.copy(alpha = 0.14f)) - .padding(horizontal = 12.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(6.dp) - .clip(CircleShape) - .background(accentColor), - ) - Text( - text = stringResource(Res.string.person_known_for, knownFor).replace(": ", " "), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = accentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } } Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { @@ -805,6 +782,18 @@ private fun PersonDetailSkeleton( ), ) } + val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = avatarTransitionKey, + ), + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } else { + Modifier + } Box( modifier = Modifier @@ -817,37 +806,194 @@ private fun PersonDetailSkeleton( .background(accentGradient), ) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { - with(sharedTransitionScope) { - Modifier.sharedElement( - sharedContentState = rememberSharedContentState( - key = avatarTransitionKey, - ), - animatedVisibilityScope = animatedVisibilityScope, + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + if (maxWidth >= PERSON_DETAIL_WIDE_LAYOUT_MIN_WIDTH) { + WidePersonDetailSkeleton( + personName = personName, + profilePhoto = profilePhoto, + avatarRequest = avatarRequest, + avatarSharedElementModifier = avatarSharedElementModifier, + skeletonPosterWidth = skeletonPosterWidth, + skeletonPosterHeight = skeletonPosterHeight, + skeletonPosterCornerRadius = posterCardStyle.cornerRadiusDp.dp, + showPosterLabels = !isLandscapeShelfMode, + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .then(avatarSharedElementModifier) + .size(140.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (!profilePhoto.isNullOrBlank()) { + AsyncImage( + model = avatarRequest ?: profilePhoto, + contentDescription = personName, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = personName.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = personName, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SkeletonLine( + widthFraction = 0.58f, + height = 14.dp, + ) + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 0.42f, + height = 14.dp, + ) + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 0.34f, + height = 14.dp, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + listOf(1.0f, 0.96f, 0.92f, 0.98f, 0.88f, 0.94f, 0.82f, 0.74f).forEachIndexed { index, widthFraction -> + SkeletonLine( + widthFraction = widthFraction, + height = 16.dp, + ) + if (index != 7) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(120.dp) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), ) } - } else { - Modifier + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + repeat(4) { + Column(modifier = Modifier.width(skeletonPosterWidth)) { + Box( + modifier = Modifier + .width(skeletonPosterWidth) + .height(skeletonPosterHeight) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + if (!isLandscapeShelfMode) { + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 1f, + height = 16.dp, + ) + Spacer(modifier = Modifier.height(4.dp)) + SkeletonLine( + widthFraction = 0.56f, + height = 12.dp, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) } + } + } + } +} + +@Composable +private fun WidePersonDetailSkeleton( + personName: String, + profilePhoto: String?, + avatarRequest: ImageRequest?, + avatarSharedElementModifier: Modifier, + skeletonPosterWidth: Dp, + skeletonPosterHeight: Dp, + skeletonPosterCornerRadius: Dp, + showPosterLabels: Boolean, +) { + Row( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 34.dp), + ) { + Column( + modifier = Modifier + .width(PERSON_DETAIL_WIDE_SIDEBAR_WIDTH) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 40.dp, end = 36.dp, top = 40.dp, bottom = 42.dp), + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Box( + modifier = Modifier.size(162.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .matchParentSize() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + ) Box( modifier = Modifier .then(avatarSharedElementModifier) - .size(140.dp) + .size(148.dp) .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { @@ -860,105 +1006,113 @@ private fun PersonDetailSkeleton( ) } else { Text( - text = personName.firstOrNull()?.uppercase() ?: "?", + text = personName.initials(), style = MaterialTheme.typography.displayMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - Spacer(modifier = Modifier.height(16.dp)) + } - Text( - text = personName, - style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = personName, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + lineHeight = 34.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) - Spacer(modifier = Modifier.height(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + repeat(3) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + SkeletonLine(widthFraction = 0.34f, height = 10.dp) + SkeletonLine(widthFraction = if (it == 1) 0.88f else 0.58f, height = 14.dp) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SkeletonLine( - widthFraction = 0.58f, - height = 14.dp, - ) - Spacer(modifier = Modifier.height(6.dp)) - SkeletonLine( - widthFraction = 0.42f, - height = 14.dp, - ) - Spacer(modifier = Modifier.height(6.dp)) - SkeletonLine( - widthFraction = 0.34f, - height = 14.dp, + widthFraction = 0.32f, + height = 10.dp, ) + listOf(0.96f, 1f, 0.92f, 0.98f, 0.84f, 0.90f).forEach { widthFraction -> + SkeletonLine(widthFraction = widthFraction, height = 16.dp) + } + } + } - Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.30f)), + ) - listOf(1.0f, 0.96f, 0.92f, 0.98f, 0.88f, 0.94f, 0.82f, 0.74f).forEachIndexed { index, widthFraction -> - SkeletonLine( - widthFraction = widthFraction, - height = 16.dp, + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 40.dp, bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(34.dp), + ) { + repeat(3) { + WideSkeletonPosterRail( + skeletonPosterWidth = skeletonPosterWidth, + skeletonPosterHeight = skeletonPosterHeight, + skeletonPosterCornerRadius = skeletonPosterCornerRadius, + showPosterLabels = showPosterLabels, + ) + } + } + } +} + +@Composable +private fun WideSkeletonPosterRail( + skeletonPosterWidth: Dp, + skeletonPosterHeight: Dp, + skeletonPosterCornerRadius: Dp, + showPosterLabels: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + Box( + modifier = Modifier + .width(120.dp) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(6) { + Column(modifier = Modifier.width(skeletonPosterWidth)) { + Box( + modifier = Modifier + .width(skeletonPosterWidth) + .height(skeletonPosterHeight) + .clip(RoundedCornerShape(skeletonPosterCornerRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), ) - if (index != 7) { - Spacer(modifier = Modifier.height(8.dp)) + if (showPosterLabels) { + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine(widthFraction = 1f, height = 16.dp) + Spacer(modifier = Modifier.height(4.dp)) + SkeletonLine(widthFraction = 0.56f, height = 12.dp) } } } - - Spacer(modifier = Modifier.height(24.dp)) - - // Filmography header skeleton - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .width(120.dp) - .height(18.dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Poster row skeleton - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - repeat(4) { - Column(modifier = Modifier.width(skeletonPosterWidth)) { - Box( - modifier = Modifier - .width(skeletonPosterWidth) - .height(skeletonPosterHeight) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) - if (!isLandscapeShelfMode) { - Spacer(modifier = Modifier.height(6.dp)) - SkeletonLine( - widthFraction = 1f, - height = 16.dp, - ) - Spacer(modifier = Modifier.height(4.dp)) - SkeletonLine( - widthFraction = 0.56f, - height = 12.dp, - ) - } - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) } } } @@ -966,7 +1120,7 @@ private fun PersonDetailSkeleton( @Composable private fun SkeletonLine( widthFraction: Float, - height: androidx.compose.ui.unit.Dp, + height: Dp, ) { Box( modifier = Modifier From 50a923d056f36d767ab23424527712c4001d7753 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:10:09 +0530 Subject: [PATCH 34/60] ref: remove entity outer box --- .../details/TmdbEntityBrowseScreen.kt | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 6767f84e4..38d10fe34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -324,46 +324,46 @@ private fun EntityIdentitySidebar( overflow = TextOverflow.Ellipsis, ) - Box( - modifier = Modifier.size(144.dp), - contentAlignment = Alignment.Center, - ) { + if (!header.logo.isNullOrBlank()) { Box( modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(28.dp)) - .background(accentColor.copy(alpha = 0.14f)), - ) - Box( - modifier = Modifier - .size(120.dp) - .clip(RoundedCornerShape(24.dp)) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), - shape = RoundedCornerShape(24.dp), - ) - .background(MaterialTheme.colorScheme.surfaceVariant), + .width(184.dp) + .height(104.dp) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + .padding(18.dp), contentAlignment = Alignment.Center, ) { - if (!header.logo.isNullOrBlank()) { - Box( - modifier = Modifier - .width(92.dp) - .height(62.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color.White) - .padding(10.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = header.logo, - contentDescription = header.name, - modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Fit, + AsyncImage( + model = header.logo, + contentDescription = header.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, + ) + } + } else { + Box( + modifier = Modifier.size(144.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(28.dp)) + .background(accentColor.copy(alpha = 0.14f)), + ) + Box( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(24.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.40f), + shape = RoundedCornerShape(24.dp), ) - } - } else { + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { Text( text = header.name.initials(), style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.ExtraBold), From f2ef1e1383ab7217711163a08f039b2594bfc35b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:37:59 +0530 Subject: [PATCH 35/60] refactor(plugin): unify dispatcher usage in PluginRuntime --- .../kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index c749c63b4..316bfd68b 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -35,6 +35,7 @@ private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" internal object PluginRuntime { private val log = Logger.withTag("PluginRuntime") + private val pluginDispatcher = Dispatchers.Default private val json = Json { ignoreUnknownKeys = true } @@ -49,7 +50,7 @@ internal object PluginRuntime { episode: Int?, scraperId: String, scraperSettings: Map = emptyMap(), - ): List = withContext(Dispatchers.IO) { + ): List = withContext(pluginDispatcher) { withTimeout(PLUGIN_TIMEOUT_MS) { executePluginInternal( code = code, @@ -78,7 +79,7 @@ internal object PluginRuntime { var resultJson = "[]" try { - quickJs(Dispatchers.IO) { + quickJs(pluginDispatcher) { define("console") { function("log") { args -> log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } @@ -329,7 +330,7 @@ internal object PluginRuntime { } val startedAt = kotlin.time.TimeSource.Monotonic.markNow() - val response = runBlocking(Dispatchers.IO) { + val response = runBlocking(pluginDispatcher) { httpRequestRaw( method = method, url = url, From 3e49c78de7cb41843279f88c395abb3d28dc4961 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:45:56 +0530 Subject: [PATCH 36/60] fix: multiple ios crashes --- .../commonMain/kotlin/com/nuvio/app/App.kt | 152 +++++++++--------- .../features/player/skip/SubmitIntroDialog.kt | 8 +- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 42fb5aafa..eecbc6584 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -47,12 +47,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -111,7 +113,6 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.CatalogTarget -import com.nuvio.app.features.catalog.CatalogTargetKind import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem @@ -303,65 +304,30 @@ data class StreamRoute( @Serializable data class CatalogRoute( + val launchId: Long, +) + +private data class CatalogLaunch( val title: String, val subtitle: String, - val targetKind: String, - val contentType: String, - val supportsPagination: Boolean = false, - val manifestUrl: String? = null, - val addonCatalogId: String? = null, - val genre: String? = null, - val librarySectionType: String? = null, - val collectionId: String? = null, - val folderId: String? = null, - val sourceKey: String? = null, -) { - constructor( - title: String, - subtitle: String, - target: CatalogTarget, - ) : this( - title = title, - subtitle = subtitle, - targetKind = when (target) { - is CatalogTarget.Addon -> CatalogTargetKind.ADDON - is CatalogTarget.Library -> CatalogTargetKind.LIBRARY - is CatalogTarget.CollectionSource -> CatalogTargetKind.COLLECTION_SOURCE - }.name, - contentType = target.contentType, - supportsPagination = target.supportsPagination, - manifestUrl = (target as? CatalogTarget.Addon)?.manifestUrl, - addonCatalogId = (target as? CatalogTarget.Addon)?.catalogId, - genre = (target as? CatalogTarget.Addon)?.genre, - librarySectionType = (target as? CatalogTarget.Library)?.sectionType, - collectionId = (target as? CatalogTarget.CollectionSource)?.collectionId, - folderId = (target as? CatalogTarget.CollectionSource)?.folderId, - sourceKey = (target as? CatalogTarget.CollectionSource)?.sourceKey, - ) + val target: CatalogTarget, +) - fun toCatalogTarget(): CatalogTarget = - when (CatalogTargetKind.valueOf(targetKind)) { - CatalogTargetKind.ADDON -> CatalogTarget.Addon( - manifestUrl = requireNotNull(manifestUrl), - contentType = contentType, - catalogId = requireNotNull(addonCatalogId), - genre = genre, - supportsPagination = supportsPagination, - ) +private object CatalogLaunchStore { + private var nextLaunchId = 1L + private val launches = mutableMapOf() - CatalogTargetKind.LIBRARY -> CatalogTarget.Library( - contentType = contentType, - sectionType = requireNotNull(librarySectionType), - ) + fun put(launch: CatalogLaunch): Long { + val launchId = nextLaunchId++ + launches[launchId] = launch + return launchId + } - CatalogTargetKind.COLLECTION_SOURCE -> CatalogTarget.CollectionSource( - collectionId = requireNotNull(collectionId), - folderId = requireNotNull(folderId), - sourceKey = requireNotNull(sourceKey), - contentType = contentType, - supportsPagination = supportsPagination, - ) - } + fun get(launchId: Long): CatalogLaunch? = launches[launchId] + + fun remove(launchId: Long) { + launches.remove(launchId) + } } private data class PosterActionTarget( @@ -726,6 +692,7 @@ private fun MainAppContent( ProfileSettingsSync.startObserving() } val hapticFeedback = LocalHapticFeedback.current + val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var searchFocusRequestCount by remember { mutableStateOf(0) } @@ -758,6 +725,14 @@ private fun MainAppContent( LibraryRepository.uiState }.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() + val openPosterActions: (PosterActionTarget) -> Unit = { target -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + focusManager.clearFocus(force = true) + coroutineScope.launch { + withFrameNanos { } + selectedPosterActionTarget = target + } + } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() @@ -1297,13 +1272,18 @@ private fun MainAppContent( } val onCatalogClick: (HomeCatalogSection) -> Unit = { section -> - navController.navigate( - CatalogRoute( + val launchId = CatalogLaunchStore.put( + CatalogLaunch( title = section.title, subtitle = section.subtitle, target = section.target, ), ) + navController.navigate( + CatalogRoute( + launchId = launchId, + ), + ) } val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { @@ -1313,8 +1293,8 @@ private fun MainAppContent( } val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> - navController.navigate( - CatalogRoute( + val launchId = CatalogLaunchStore.put( + CatalogLaunch( title = section.displayTitle, subtitle = librarySectionSubtitle, target = CatalogTarget.Library( @@ -1323,6 +1303,11 @@ private fun MainAppContent( ), ), ) + navController.navigate( + CatalogRoute( + launchId = launchId, + ), + ) } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> @@ -1505,18 +1490,18 @@ private fun MainAppContent( navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, onPosterLongClick = { meta -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterActionTarget = PosterActionTarget(preview = meta) + openPosterActions(PosterActionTarget(preview = meta)) }, onLibraryPosterClick = { item -> navController.navigate(DetailRoute(type = item.type, id = item.id)) }, onLibraryPosterLongClick = { item, section -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterActionTarget = PosterActionTarget( - preview = item.toMetaPreview(), - libraryItem = item, - libraryListKey = section.type, + openPosterActions( + PosterActionTarget( + preview = item.toMetaPreview(), + libraryItem = item, + libraryListKey = section.type, + ), ) }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, @@ -2487,29 +2472,38 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() - val target = route.toCatalogTarget() + val launch = remember(route.launchId) { CatalogLaunchStore.get(route.launchId) } + if (launch == null) { + LaunchedEffect(route.launchId) { + navController.popBackStack() + } + return@composable + } + val target = launch.target CatalogScreen( - title = route.title, - subtitle = route.subtitle, + title = launch.title, + subtitle = launch.subtitle, target = target, onBack = { CatalogRepository.clear() + CatalogLaunchStore.remove(route.launchId) navController.popBackStack() }, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, onPosterLongClick = { meta -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterActionTarget = if (target is CatalogTarget.Library) { - PosterActionTarget( - preview = meta, - libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L), - libraryListKey = target.sectionType, - ) - } else { - PosterActionTarget(preview = meta) - } + openPosterActions( + if (target is CatalogTarget.Library) { + PosterActionTarget( + preview = meta, + libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L), + libraryListKey = target.sectionType, + ) + } else { + PosterActionTarget(preview = meta) + }, + ) }, modifier = Modifier.fillMaxSize(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt index 6f3e80691..8be4314e4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt @@ -10,9 +10,11 @@ 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.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -86,7 +88,10 @@ fun SubmitIntroDialog( BasicAlertDialog(onDismissRequest = onDismiss) { Surface( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .widthIn(max = 420.dp) + .heightIn(max = 560.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 8.dp, @@ -94,6 +99,7 @@ fun SubmitIntroDialog( Column( modifier = Modifier .padding(24.dp) + .heightIn(max = 512.dp) .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp), ) { From e1cad830e91b8bd52300eaa0a30c5a436196e3cf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:51:20 +0530 Subject: [PATCH 37/60] Fix watch progress crash from concurrent updates --- .../watchprogress/WatchProgressRepository.kt | 129 ++++++++++++------ 1 file changed, 91 insertions(+), 38 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 3e178918d..36b9ac405 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -37,6 +37,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull @@ -84,6 +86,7 @@ object WatchProgressRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L + private val entriesLock = SynchronizedObject() private var entriesByVideoId: MutableMap = mutableMapOf() private var metadataResolutionJob: Job? = null private var isPullingNuvioSyncFromServer = false @@ -171,7 +174,7 @@ object WatchProgressRepository { currentProfileId = 1 profileGeneration += 1L lastAddonMetadataReadyFingerprint = null - entriesByVideoId.clear() + clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false @@ -186,7 +189,7 @@ object WatchProgressRepository { profileGeneration += 1L hasLoaded = true lastAddonMetadataReadyFingerprint = null - entriesByVideoId.clear() + clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { @@ -194,16 +197,14 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized - entriesByVideoId = storedPayload.entries - .associateBy { it.videoId } - .toMutableMap() + replaceLocalEntries(storedPayload.entries) } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false } log.d { - "Loaded watch progress for profile $profileId: entries=${entriesByVideoId.size} " + + "Loaded watch progress for profile $profileId: entries=${localEntryCount()} " + "deltaInitialized=$deltaInitialized cursor=$deltaCursorEventId lastPush=$lastSuccessfulPushEpochMs" } publish() @@ -310,7 +311,7 @@ object WatchProgressRepository { ) { if (!isActiveOperation(profileId, operationGeneration)) return log.d { - "Watch progress delta sync start: profile=$profileId entries=${entriesByVideoId.size} " + + "Watch progress delta sync start: profile=$profileId entries=${localEntryCount()} " + "deltaInitialized=$deltaInitialized cursor=$deltaCursorEventId lastPush=$lastSuccessfulPushEpochMs" } if (!deltaInitialized) { @@ -347,7 +348,7 @@ object WatchProgressRepository { persist() log.d { "Watch progress delta initialized for profile $profileId: cursor=$deltaCursorEventId " + - "entries=${entriesByVideoId.size}" + "entries=${localEntryCount()}" } return } @@ -424,7 +425,7 @@ object WatchProgressRepository { log.d { "Watch progress delta sync finished for profile $profileId: changed=$changed " + "appliedUpserts=$totalUpserts appliedDeletes=$totalDeletes preservedLocal=$preservedLocalItems " + - "cursor=$deltaCursorEventId entries=${entriesByVideoId.size}" + "cursor=$deltaCursorEventId entries=${localEntryCount()}" } } @@ -440,12 +441,14 @@ object WatchProgressRepository { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + "resetDeltaState=$resetDeltaState" } - entriesByVideoId = mergeWatchProgressEntriesPreservingUnsynced( + replaceLocalEntries( + mergeWatchProgressEntriesPreservingUnsynced( serverEntries = serverEntries, - localEntries = entriesByVideoId.values, + localEntries = localEntriesSnapshot(), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, pullStartedEpochMs = pullStartedEpochMs, - ).toMutableMap() + ), + ) if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false @@ -455,7 +458,7 @@ object WatchProgressRepository { persist() resolveRemoteMetadata() log.d { - "Watch progress snapshot applied for profile $profileId: entries=${entriesByVideoId.size} " + + "Watch progress snapshot applied for profile $profileId: entries=${localEntryCount()} " + "deltaInitialized=$deltaInitialized cursor=$deltaCursorEventId" } } @@ -472,16 +475,16 @@ object WatchProgressRepository { if (event.videoId.isBlank()) return@forEach when (event.operation.lowercase()) { WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { - val current = entriesByVideoId[event.videoId] + val current = localEntry(event.videoId) val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) if (current != updated) { - entriesByVideoId[event.videoId] = updated + upsertLocalEntry(updated) changed = true appliedUpserts += 1 } } WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { - val localEntry = entriesByVideoId[event.videoId] + val localEntry = localEntry(event.videoId) if ( localEntry != null && shouldPreserveLocalWatchProgressEntry( @@ -493,7 +496,7 @@ object WatchProgressRepository { preservedLocalItems = true return@forEach } - if (entriesByVideoId.remove(event.videoId) != null) { + if (removeLocalEntry(event.videoId) != null) { changed = true appliedDeletes += 1 } @@ -601,7 +604,7 @@ object WatchProgressRepository { private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration - val missingMetadataEntries = entriesByVideoId.values + val missingMetadataEntries = localEntriesSnapshot() .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, @@ -647,23 +650,25 @@ object WatchProgressRepository { var appliedEntries = 0 for (entry in result.entries) { - val current = entriesByVideoId[entry.videoId] ?: continue + val current = localEntry(entry.videoId) ?: continue val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { meta.videos.find { v -> v.season == current.seasonNumber && v.episode == current.episodeNumber } } else null - entriesByVideoId[current.videoId] = current.copy( - title = meta.name, - poster = meta.poster, - background = meta.background, - logo = meta.logo, - episodeTitle = episodeVideo?.title ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview - ?: meta.description - ?: current.pauseDescription, + upsertLocalEntry( + current.copy( + title = meta.name, + poster = meta.poster, + background = meta.background, + logo = meta.logo, + episodeTitle = episodeVideo?.title ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview + ?: meta.description + ?: current.pauseDescription, + ), ) appliedEntries += 1 } @@ -771,7 +776,7 @@ object WatchProgressRepository { } val removedEntries = videoIds.mapNotNull { videoId -> - entriesByVideoId.remove(videoId) + removeLocalEntry(videoId) } if (removedEntries.isNotEmpty()) { publish() @@ -832,7 +837,7 @@ object WatchProgressRepository { } entriesToRemove.forEach { entry -> - entriesByVideoId.remove(entry.videoId) + removeLocalEntry(entry.videoId) } publish() persist() @@ -844,7 +849,7 @@ object WatchProgressRepository { return if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.entries } else { - entriesByVideoId.values.toList() + localEntriesSnapshot() }.firstOrNull { it.videoId == videoId } } @@ -942,7 +947,7 @@ object WatchProgressRepository { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } - entriesByVideoId[session.videoId] = entry + upsertLocalEntry(entry) if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } @@ -1021,7 +1026,7 @@ object WatchProgressRepository { WatchProgressStorage.savePayload( currentProfileId, WatchProgressCodec.encodePayload( - entries = entriesByVideoId.values, + entries = localEntriesSnapshot(), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, @@ -1051,8 +1056,10 @@ object WatchProgressRepository { isTraktCompatibleId(parentMetaId) private fun removeStoredLocalEntries(entries: Collection): List = - entries.mapNotNull { entry -> - entriesByVideoId.remove(entry.videoId) + synchronized(entriesLock) { + entries.mapNotNull { entry -> + entriesByVideoId.remove(entry.videoId) + } } private fun currentEntries(): List { @@ -1061,7 +1068,7 @@ object WatchProgressRepository { // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). // Trakt will never return these IDs, so they must come from local storage. val traktItems = TraktProgressRepository.uiState.value.entries - val localNonTraktItems = entriesByVideoId.values.filter { + val localNonTraktItems = localEntriesSnapshot().filter { !isTraktCompatibleId(it.parentMetaId) } if (localNonTraktItems.isEmpty()) { @@ -1077,10 +1084,56 @@ object WatchProgressRepository { merged } } else { - entriesByVideoId.values.toList() + localEntriesSnapshot() } } + private fun localEntriesSnapshot(): List = + synchronized(entriesLock) { + entriesByVideoId.values.toList() + } + + private fun localEntry(videoId: String): WatchProgressEntry? = + synchronized(entriesLock) { + entriesByVideoId[videoId] + } + + private fun localEntryCount(): Int = + synchronized(entriesLock) { + entriesByVideoId.size + } + + private fun clearLocalEntries() { + synchronized(entriesLock) { + entriesByVideoId.clear() + } + } + + private fun replaceLocalEntries(entries: Collection) { + synchronized(entriesLock) { + entriesByVideoId = entries + .associateBy { it.videoId } + .toMutableMap() + } + } + + private fun replaceLocalEntries(entries: Map) { + synchronized(entriesLock) { + entriesByVideoId = entries.toMutableMap() + } + } + + private fun upsertLocalEntry(entry: WatchProgressEntry) { + synchronized(entriesLock) { + entriesByVideoId[entry.videoId] = entry + } + } + + private fun removeLocalEntry(videoId: String): WatchProgressEntry? = + synchronized(entriesLock) { + entriesByVideoId.remove(videoId) + } + fun isDroppedShow(contentId: String): Boolean { return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId) } From 029abadbe660c7fd73c605353c5d2a4db3fd5b36 Mon Sep 17 00:00:00 2001 From: Joe00011 <152079313+Joe00011@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:30:48 +0900 Subject: [PATCH 38/60] Add Japanese strings.xml translation --- .../composeResources/values-ja/strings.xml | 1870 +++++++++++++++++ 1 file changed, 1870 insertions(+) create mode 100644 composeApp/src/commonMain/composeResources/values-ja/strings.xml diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml new file mode 100644 index 000000000..6bb7a42fd --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -0,0 +1,1870 @@ + + データソース、謝辞、ライセンス情報 + サポーター・コントリビューターのクレジット + 戻る + キャンセル + 閉じる + 削除 + 完了 + 編集 + インポート + 次へ + OK + 再生 + 前へ + 削除 + 並べ替え + デフォルトに戻す + 再開 + 再試行 + 保存 + 保存中… + 検証 + インストール中 + アドオン + 有効 + %1$d カタログ + 設定可能 + 無効 + 更新中 + %1$d リソース + 利用不可 + アドオンを設定 + アドオンを削除 + マニフェストURLを追加して、カタログ・メタデータ・ストリーム・字幕をNuvioに読み込みましょう。 + アドオンがありません + アドオンのURLを入力してください。 + アドオンURL + アドオンをインストール + マニフェストの詳細を読み込み中... + マニフェストURLを検証し、インストール前にアドオンの詳細を読み込んでいます。 + アドオンを確認中 + インストール失敗 + %1$s を検証し、正常に追加しました。 + インストール完了 + アドオンを下へ移動 + アドオンを上へ移動 + 有効 + アドオン + カタログ + アドオンを更新 + アドオンを追加 + インストール済みアドオン + 概要 + %1$d ID ルール + バージョン %1$s + 選択済み + JSONをコピー + %1$d コレクション、%2$d フォルダー + 「%1$s」を削除しますか?この操作は取り消せません。 + コレクションを削除 + カタログを追加 + フォルダーを追加 + すべてのジャンル + インストール済みアドオンからカタログを追加して、このフォルダーの表示内容を設定してください。 + カタログソースがありません + 選択 + 絵文字 + 画像URL + なし + カバー + コレクションを作成 + 完了 + コレクションを編集 + フォルダーを編集 + コレクションエディターと同じ構造で、フォルダーの識別情報・表示・カタログソースを設定してください。 + 追加して始めましょう。 + フォルダーがありません + フォルダー + ジャンルフィルター + カバー画像のみ表示します + タイトルを非表示 + 新しいフォルダー + このコレクションをホームのすべてのカタログより上に表示します。複数のピン留めコレクションはコレクション作成順に並びます。 + カタログより上にピン留め + バックドロップ画像URL(任意) + フォルダー名 + アニメーションGIF URL(フォーカス中のみ再生) + コレクション名 + 変更を保存 + 保存 + 外観 + 基本情報 + カタログソース + このフォルダーに集約するアドオンカタログを選択してください。 + カタログを選択 + ジャンルを選択 + %1$d 件選択中 + %1$d カタログ + %1$d 件選択中 + ポスター + 正方形 + ワイド + すべてのカタログを1つのタブにまとめる + 「すべて」タブを表示 + 設定されている場合、静止カバーの代わりにGIFを再生します。 + 設定時にGIFを表示 + %1$d ソース · %2$s + タイルの形状 + + タブ + 表示モード + TMDBソース + 公開リスト + 制作会社 + ネットワーク + コレクション + 人物 + 監督 + カスタム + 既製のソースを選択してください。追加後に編集・削除できます。 + TMDBの公開リストURLまたはURLの番号のみを貼り付けてください。 + スタジオ名で検索するか、TMDBの会社ID/URLを貼り付けて直接追加してください。 + ネットワークIDを入力してください。主要なネットワークはプリセットとクイックフィルターで利用できます。 + 映画コレクション名を検索するか、TMDBのコレクションIDを貼り付けてください。 + TMDBの人物IDまたはURLを入力して、出演作品の行を作成します。 + TMDBの人物IDまたはURLを入力して、監督作品の行を作成します。 + 任意のフィルターを使ってTMDBのライブ行を作成します。不要なフィルターは空白のままにしてください。 + TMDBの公開リスト + ネットワークID + コレクションID + 人物ID + 制作会社名、ID、またはURL + TMDBのIDまたはURL + https://www.themoviedb.org/list/8504994 または 8504994 + Netflix: 213、HBO: 49、Disney+: 2739 + スター・ウォーズ コレクション: 10 + Marvel Studios、420、または会社URL + Tom Hanks: 31、または人物URL + 例:Marvel Studios、420、または https://www.themoviedb.org/company/420 + 例:スター・ウォーズ コレクション、ハリー・ポッター コレクション、またはコレクションURL + ID例:Netflix 213、HBO 49、Disney+ 2739 + 例:https://www.themoviedb.org/list/8504994 または 8504994 + 例:https://www.themoviedb.org/person/31-tom-hanks または 31 + 表示タイトル + 行/タブ名として表示されます。空白の場合はNuvioがソースから自動生成します。 + マーベル映画、Netflixオリジナル、Pixar + Tom Hanks 映画、お気に入り俳優 + Christopher Nolan 映画、お気に入り監督 + アクション映画、韓国ドラマ、2024年アニメ + 検索結果 + TMDBコレクション + TMDB 制作会社 %1$d + TMDBコレクション %1$d + タイプ + 映画 + シリーズ + 両方 + 並び順 + フィルター + 不要なフィルターは空白のままにしてください。 + クイックジャンル + クイック言語 + クイック国 + クイックキーワード + クイックスタジオ + クイックネットワーク + ジャンルID + TMDBのジャンル番号を使用してください。AND条件はカンマ、OR条件はパイプで区切ってください。 + 28,12 + 18,35 + 公開・放送開始日 + 公開・放送終了日 + YYYY-MM-DD形式で入力してください(例:2024-01-01)。 + 2020-01-01 + 2024-12-31 + 最低評価 + 最高評価 + TMDBの評価(0〜10)。例:7.0 + 7.0 + 10 + 最低投票数 + 投票数の少ないマイナー作品を除外できます。例:100 + 100 + 原語 + 2文字の言語コードを使用してください(例:en、ko、ja、hi)。 + en, ko, ja, hi + 制作国 + 2文字の国コードを使用してください(例:US、KR、JP、IN)。 + US, KR, JP, IN + キーワードID + TMDBのキーワード番号を使用してください。クイックチップで一般的な例を入力できます。 + スーパーヒーロー: 9715 + 会社ID + スタジオ/会社IDを使用してください。クイックチップで一般的な例を入力できます。 + Marvel Studios: 420 + ネットワークID + シリーズのみ。Netflix 213、HBO 49などのネットワークIDを使用してください。 + Netflix: 213 + + 4桁の年を入力してください(例:2024)。 + 2024 + プリセット + 検索 + ソースを追加 + Traktリストを追加 + Traktリストを編集 + Traktリスト + Traktリスト + タイトル、Trakt URL、またはリストIDで検索 + 公開TraktリストのURLまたは数値リストIDを使用するか、名前で検索してください。 + 週末に見る、受賞作品 + 検索結果 + トレンドリスト + 人気リスト + 並び順 + 昇順 + 降順 + リスト順 + 追加日時 + タイトル + 公開日 + 上映時間 + 人気 + 評価率 + 投票数 + Traktリスト名、URL、またはIDを入力 + TraktリストIDまたはURLを入力 + Traktリストを読み込めませんでした + Traktリストが見つかりません + 解決済みTraktリスト + Traktリスト %1$d + アクション + アドベンチャー + アニメ + コメディ + ホラー + SF + ドラマ + クライム + リアリティ + 英語 + 韓国語 + 日本語 + ヒンディー語 + スペイン語 + アメリカ + 韓国 + 日本 + インド + イギリス + スーパーヒーロー + 原作小説あり + タイムトラベル + 宇宙 + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + オリジナル + 人気 + 高評価 + 新着 + 投票数 + 視聴地域 + 視聴可能な国のISO 3166-1国コード。例:US、GB + クイック視聴地域 + 視聴プロバイダーID + TMDBの視聴プロバイダーIDを使用してください。AND条件はカンマ、OR条件はパイプで区切ってください。 + 8|337|350 + クイック視聴プロバイダー + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu + TMDBリスト + TMDB映画コレクション + 制作会社 + ネットワーク + 人物 + 監督 + TMDBディスカバー + コレクションを作成してカタログを整理しましょう。 + コレクションがありません + %1$d フォルダー + アイテムが見つかりません + フォルダーが見つかりません + コレクション + コレクションをインポート + JSON + コレクションのJSONを下に貼り付けてください。 + インポート + 新しいコレクション + ピン留め + すべて + マイコレクション + ❤️ Tapframe とフレンズが制作 + バージョン %1$s (%2$s) + オフ + オン + 一時停止 + 再読み込み + すでにアカウントをお持ちですか? + アカウントなしで続ける + アカウントを作成 + アカウントをお持ちでないですか? + メールアドレス + または + パスワード + サインインしてライブラリと視聴進捗にアクセス + サインイン + サインアップしてデバイス間でデータを同期 + サインアップ + データはこのデバイスにのみ保存されます + いつでも、どこでも、すべてをストリーム + おかえりなさい + ライブラリ + Traktライブラリ + ホーム + ライブラリ + プロフィール + 検索 + 音声トラック + 音声 + 自動同期 + 太字 + 内蔵 + 下端オフセット + キャプチャ + プレーヤーを閉じる + + 再生中 + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + エピソード + フォントサイズ + %1$dsp + 操作をロック + 字幕の行を読み込み中... + 字幕が見つかりません + 字幕を読み込めませんでした + 音声トラックがありません + エピソードがありません + ストリームが見つかりません + なし + 縁取り + 縁取りの色 + エピソード + ソース + ストリーム + 再生エラー + 再生中 + タップして字幕を取得 + 戻る + 再読み込み + リセット + デフォルトに戻す + 塗りつぶし + フィット + ズーム + 10秒戻る + -%1$ds + +%1$ds + -%1$ds + +%1$ds + 10秒進む + ソース + スタイル + アドオンの字幕を先に選択してください + 字幕 + 字幕ディレイ + 字幕 + テキストの不透明度 + 明るさ %1$s + 音量 %1$s + ミュート中 + ダウンロード済み + 放送 + 未定 + タップしてロック解除 + トラック %1$d + 操作のロックを解除 + 視聴中 + プロフィールを追加 + 検索をクリア + 発見 + インストール済みアドオンが有効な検索結果を返しませんでした。 + 検索に失敗しました + 検索する前に、アドオンを少なくとも1つインストールして有効にしてください。 + 有効なアドオンがありません + インストール済みの検索可能なカタログで、このクエリに一致するものが見つかりませんでした。 + 結果が見つかりません + インストール済みアドオンにカタログ検索機能がありません。 + 検索可能なカタログがありません + 映画・シリーズを検索... + 最近の検索 + 最近の検索から削除 + アプリについて + 一般 + アカウント + アドオン + 詳細設定 + レイアウト + コンテンツと探索 + 視聴を続ける + 連携サービス + ホームレイアウト + 連携 + ライセンスと帰属 + MDBList評価 + 詳細ページ + 通知 + 再生 + プラグイン + ポスターカードスタイル + 設定 + ストリーム + サポーターとコントリビューター + TMDB補完 + Trakt + アプリについて + アカウントと同期状況 + アカウント + 起動時とプロフィールの動作 + 詳細設定 + ホーム構成とポスタースタイル + 最新リリースをダウンロード + アップデートを確認 + アドオンと探索ソースを管理 + ダウンロードした映画とエピソードを管理 + ダウンロード + 一般 + 利用可能な連携を管理 + エピソード公開アラートの管理とテスト通知の送信 + ストリーム結果の表示とバッジURLルール + 別のプロフィールに切り替え + プロフィールを切り替え + Trakt連携画面を開く + 設定が見つかりません。 + 設定を検索... + 結果 + 起動時 + キャッシュ + 最後のプロフィールを記憶 + 起動時に最後に選択したプロフィールを記憶する + 「視聴を続ける」キャッシュをクリア + 「視聴を続ける」のキャッシュデータを削除して視聴進捗を更新する + キャッシュをクリアしました + アプリライセンス + データとサービス + 再生ライセンス + Nuvio Mobile + ソースコードとライセンス条件はプロジェクトリポジトリで確認できます。 + GNU General Public License v3.0のもとでライセンスされています。 + The Movie Database (TMDB) + NuvioはTMDB APIを使用して、映画・TV番組のメタデータ、アートワーク、予告編、キャスト、制作詳細、コレクション、おすすめ作品を取得しています。本製品はTMDB APIを使用していますが、TMDBの公式承認・認定を受けたものではありません。 + IMDb非商用データセット + NuvioはIMDbの非商用データセット(title.ratings.tsv.gzを含む)を使用してIMDb評価と投票数を取得しています。情報提供:IMDb(https://www.imdb.com)。許可を得て使用しています。IMDbデータは個人的・非商用目的でのみIMDの利用規約に基づいて使用できます。 + Trakt + Nuvioはアカウント認証、視聴履歴、進捗同期、ライブラリデータ、評価、リスト、コメントのためにTraktと連携しています。NuvioはTraktと提携・承認されたものではありません。 + Premiumize + Nuvioはアカウント認証、クラウドライブラリアクセス、キャッシュ確認、クラウド再生機能のためにPremiumizeと連携しています。NuvioはPremiumizeと提携・承認されたものではありません。 + TorBox + Nuvioはアカウント認証、クラウドライブラリアクセス、キャッシュ確認、クラウド再生機能のためにTorBoxと連携しています。NuvioはTorBoxと提携・承認されたものではありません。 + MDBList + NuvioはMDBListを使用して評価と外部スコアプロバイダーデータを取得しています。NuvioはMDBListと提携・承認されたものではありません。 + IntroDB + NuvioはIntroDB APIを使用して、スキップコントロールに使用するイントロ・振り返り・エンドクレジット・プレビューのタイムスタンプ(コミュニティ提供)を取得しています。NuvioはIntroDBと提携・承認されたものではありません。 + MPVKit + iOSビルドの再生に使用。 + MPVKit単体はLGPL v3.0でライセンスされています。libmpvおよびFFmpegライブラリを含むMPVKitバンドルも同様にLGPL v3.0でライセンスされています。 + AndroidX Media3 ExoPlayer 1.8.0 + Androidビルドの再生に使用。 + Apache License, Version 2.0のもとでライセンスされています。 + Traktリストを読み込み中… + Traktでこのタイトルを保存する場所を選択 + 寄付 + 詳細を見る + 削除 + 最初から再生 + 再生 + %1$d/10 + レビュー + ネタバレあり + Traktのレビューはまだありません。 + %1$d いいね + このコメントにはネタバレが含まれています。 + このコメントにはネタバレが含まれているため、非表示にしています。 + コメント + 予告編 + %1$s (%2$d) + 予告編 + 完了したエピソードがありません + ダウンロードがありません + %1$d 件のエピソードをダウンロード済み + ダウンロード中 + 映画 + シリーズ + ダウンロードを表示 + 完了 • %1$s + ダウンロード中 • %1$s + 失敗 + 一時停止中 • %1$s + 視聴済み + シーズン %1$d + スペシャル + 続きから再生 + ライブラリに追加 + 未視聴にする + 視聴済みにする + ライブラリから削除 + すべて見る + 手動で再生 + %1$s ロゴ + アカウント + アカウントを削除 + アカウントとすべての関連データが完全に削除されます。 + この操作は取り消せません。すべてのデータ、プロフィール、同期履歴が完全に削除されます。 + アカウントを削除しますか? + メールアドレス + サインインしていません + サインアウト + ログイン画面に戻ります。 + サインアウトしますか? + ステータス + 匿名 + サインイン済み + 同期バックエンド + AMOLED ブラック + OLEDスクリーン向けに純粋な黒背景を使用します。 + アプリの言語 + デバイスの言語 + 言語を選択 + 「視聴を続ける」セクションの設定 + Liquid Glass + iOS 26以降でネイティブのiPhoneタブバーを使用します。オン中はタブバーからのプロフィール即時切り替えが利用できません。 + カードの幅と角の丸みを調整します。 + ディスプレイ + ホーム + テーマ + コレクション • %1$s + 表示名 + ホーム画面の行を設定するには、ボード対応カタログを持つアドオンをインストールしてください。 + ホームカタログがありません + ヒーローソース + 非表示 + ホームにフォーカスを維持 + %1$s • 上限に達しました(最大 %2$d) + ヒーローソースが選択されていません + ヒーローに含まない + 移動するにはコレクションのトップへのピン留めを外してください + ピン留め + トップにピン留め + 並べ替え + カタログ + カタログとコレクション + コレクション + ホームレイアウト + ヒーローカタログ + %1$d / %2$d 件選択中 + ヒーローセクションを表示 + ホーム上部にヒーローカルーセルを表示します。 + 未公開コンテンツを非表示 + まだ公開されていない映画やシリーズを非表示にします。 + カタログの下線を非表示 + アプリ全体のカタログ・コレクションタイトル下のアクセントラインを非表示にします。 + %1$d / %2$d カタログ表示中 • %3$d ヒーローソース選択中 + カタログは名前の変更や並べ替えが必要なときのみ開いてください。 + 表示 + 値を隠す + プレーヤー、字幕、自動再生 + 角の丸み + ポスターカードスタイル + + カスタム + カードの幅と角の丸みを調整します。 + ラベルを非表示 + 横向きポスター + ライブプレビュー + %1$s (%2$s) + 角の丸み: %1$ddp + 高さ: %1$ddp + 幅: %1$ddp + クラシック + ピル + 丸み大 + シャープ + 丸み小 + バランス + コンフォート + コンパクト + 高密度 + + 標準 + 値を表示 + プレーヤーから離脱後にアプリを開くと、続きから再生するポップアップを表示します。 + 起動時に再開プロンプトを表示 + ネタバレを避けるため「視聴を続ける」の次のエピソードのサムネイルをぼかします。 + 「視聴を続ける」で未視聴をぼかす + 放送前の今後のエピソードも「視聴を続ける」に表示します。 + 未放送の次のエピソードを表示 + 並び順 + 並び順 + デフォルト + すべてのアイテムを新着順に並べる + ストリーミングスタイル + 公開済みを先頭に、今後のものを末尾に表示 + ポスターカードスタイル + 起動時 + 次のエピソードの動作 + 表示 + ホーム画面に「視聴を続ける」セクションを表示します。 + 「視聴を続ける」を表示 + カード + TV風の横長カード + ポスター + アートワーク優先のポスターカード + ワイド + 情報密度の高い横長カード + 最も遠くまで視聴したエピソードに基づいて次のエピソードを表示します。再視聴時に最近視聴したエピソードを使用するには無効にしてください。 + 最後まで視聴したエピソードから次へ + 利用可能な場合はエピソードのサムネイルを優先します。 + 「視聴を続ける」でエピソードサムネイルを優先 + ホーム + ソース + コンテンツソースのインストール・削除・更新・並べ替え + JavaScriptスクレイパーリポジトリのインストールと内部テスト + ホームレイアウト・コンテンツ表示・ポスターの動作を調整 + 詳細画面とエピソード画面の設定 + ホームに表示するフォルダー付きのカスタムカタロググループを作成 + 連携 + メタデータ補完の設定 + 外部評価プロバイダー + リンクとライブラリアクセス用のアカウントを連携 + 連携サービス + これらの連携は試験的なものであり、今後維持・変更・削除される可能性があります。 + クラウドライブラリ + 連携済みアカウントのファイルを参照して再生します。 + 再生可能なリンクを解決 + 結果に必要な場合、連携サービスに再生可能なリンクを要求します。このアイテムがそのサービスに追加される場合があります。 + 解決に使用するサービス + 再生可能なリンクを処理する連携アカウントを選択します。 + 先にアカウントを連携してください。 + アカウント + %1$s アカウントを連携します。 + ブラウザで %1$s アカウントをリンクします。 + %1$s APIキー + %1$s のAPIキーを入力してください。 + %1$s のAPIキーを入力 + 未設定 + 連携済み + %1$s を連携 + %1$s の連携を解除 + 連携を解除 + %1$s はこのデバイスで連携済みです。 + セキュアサインインを開始中... + リンクを開き、このコードを入力してNuvioを承認してください。 + コードをコピーしました。 + リンクを開く + 承認を待っています... + サインインを開始できませんでした。 + このサインイン方法はこのビルドでは設定されていません。 + このコードは期限切れです。再試行してください。 + リンクの準備 + リンクを事前準備 + 再生開始前に再生可能なリンクを解決します。 + 準備するリンク数 + できるだけ少ない数を使用してください。連携サービスは一定期間内に解決できるリンク数を制限する場合があります。映画やエピソードを開くと、「視聴する」を押さなくてもリンクが事前に準備されるため、その制限にカウントされる場合があります。 + 1リンク + %1$d リンク + フォーマット + 名前テンプレート + 結果名の表示形式を制御します。空白の場合は元の結果名を使用します。 + 説明テンプレート + 各結果に表示されるメタデータを制御します。空白の場合は元の結果詳細を使用します。 + フォーマットをリセット + 結果のフォーマットをデフォルトに戻します。 + Fusionスタイル + サイズバッジ + ストリーム結果とプレーヤーのソースパネルにファイルサイズバッジを表示します。 + アドオンロゴ + ストリームソースの横にアドオンのロゴと名前を表示します。 + 表示 + バッジの位置 + Fusionバッジとサイズバッジをストリームカードの上下どちらに表示するかを選択します。 + バッジの位置 + ストリームカード上のバッジの表示位置を選択してください。 + + + Fusionバッジ URL + Fusionスタイルのストリームバッジ JSON URLを最大 %1$d 件インポートできます。各URLは個別に更新・削除できます。 + インポートされたFusionスタイルのストリームバッジ JSON URLを管理します。 + バッジのインポートに失敗しました。 + バッジのJSON URLを入力してください。 + バッジURLはhttp://またはhttps://で始まる必要があります。 + バッジURLは最大 %1$d 件インポートできます。 + %1$d/%2$d URL、%3$d 件の有効なFusionバッジ + FusionバッジのURLがインポートされていません。 + Fusionバッジ JSON URL + %1$d/%2$d Fusion URLをインポート済み + 有効 + 無効 + %1$s、%2$d 件のバッジ有効、%3$d グループ + プレビュー + Fusionバッジプレビュー + このURLから %1$d 件のFusionスタイルバッジ + このURLにFusionスタイルのバッジ画像がありません。 + グループ %1$d + その他のFusionバッジ + APIキーを検証しました。 + このAPIキーを検証できませんでした。 + 評価を有効にする前に、下でMDBListのAPIキーを追加してください。 + MDBListから評価を取得するために必要です + APIキー + APIキー + MDBList評価を有効にする + メタデータ詳細画面で外部プロバイダーの評価を取得する + APIキー + 外部評価プロバイダー + MDBList評価 + アクション + 再生と保存のコントロール + キャスト + 主要キャスト一覧 + シネマティック背景 + ストリーム画面と同様に、コンテンツの後ろにぼかしたバックドロップを表示します。 + コレクション + 関連するコレクションやフランチャイズのレール + コメント + Traktのレビュー + 詳細 + 上映時間、ステータス、公開日、言語などの情報 + ヒーロー予告編再生 + 予告編が利用可能な場合、メタデータのヒーロー部分で予告編プレビューを再生します。 + エピソードカード + メタデータ画面でのエピソードの表示方法を選択します。 + 横型 + バックドロップスタイルの行カード + リスト + 詳細情報優先の縦積みカード + エピソード + シリーズのシーズンとエピソード一覧 + 未視聴エピソードをぼかす + ネタバレを避けるため、視聴済みになるまでエピソードのサムネイルをぼかします。 + グループ %1$d + この作品と似た作品 + 詳細ページのTMDBおすすめバックドロップ + なし + 概要 + あらすじ、評価、ジャンル、主要クレジット + 制作 + スタジオとネットワーク + 外観 + セクション + タブグループ %1$d + タブレイアウト + TVアプリのようにセクションをタブにグループ化します。タブグループごとに最大3つのセクションを割り当てられます。 + 予告編 + 予告編レールと再生ショートカット + Nuvioで通知が無効になっています。 + エピソード公開アラート + 保存済みシリーズの新しいエピソードが公開されたときにローカル通知をスケジュールします。 + Nuvioのシステム通知が無効になっています。アラートとテスト通知を受け取るには有効にしてください。 + このデバイスで現在 %1$d 件の公開アラートがスケジュールされています。 + アラート + テスト + テスト通知を送信 + テスト通知を送信中... + %1$s のローカルテスト通知を送信します。 + テスト通知を送るには、シリーズをライブラリに保存してください。 + テスト通知 + コミュニティ + Mobile・TV・Webを通じてNuvioを構築・支援している人々をご覧ください。 + 今月のサーバー・メンテナンス費用 + 達成済み。追加のサポートは開発費に充てられます。 + 100%%を超えた追加サポートは開発費に充てられます。 + 資金調達状況を読み込み中... + サポーターAPIが設定されていません。local.propertiesにDONATIONS_BASE_URLを追加してください。 + コントリビューター + サポーター + GitHubを開く + GitHubプロフィールは利用できません + メッセージはありません。 + コントリビューターを読み込み中... + サポーターを読み込み中... + コントリビューターを読み込めませんでした + サポーターを読み込めませんでした + コントリビューターが見つかりません。 + サポーターが見つかりません。 + コントリビューターを読み込めません。 + サポーターを読み込めません。 + 現在コントリビューターを読み込めません。 + 現在サポーターを読み込めません。 + 合計 %1$d コミット + 1月 + 2月 + 3月 + 4月 + 5月 + 6月 + 7月 + 8月 + 9月 + 10月 + 11月 + 12月 + %3$s年%1$s%2$s日 + インストール済みのすべてのアドオン + 有効なすべてのプラグイン + 許可するアドオン + 許可するプラグイン + Anime Skip + AnimeSkip クライアントID + AnimeSkip APIのクライアントIDを入力してください。anime-skip.comで取得できます。 + イントロ投稿を有効にする + イントロ・アウトロのタイムスタンプをコミュニティデータベースに投稿するボタンを表示します。 + IntroDB APIキー + タイムスタンプを投稿するためのIntroDB APIキーを入力してください。投稿に必要です。 + スキップタイムスタンプをAnimeSkipでも検索します(クライアントIDが必要)。 + 次のエピソードを自動再生 + プロンプトが表示されたら自動的に次のエピソードを開始します。 + デバイスデコーダーのみ + アプリデコーダーを優先(FFmpeg) + デバイスデコーダーを優先 + デコーダーの優先順位 + 外をタップして閉じる + 外をタップして保存して閉じる + %1$d 日 + %1$d 日 + %1$d 時間 + %1$d 時間 + ASS/SSA字幕にlibassを使用 + 試験的:高度なASS/SSAレンダリング(スタイル、位置、アニメーション) + 外部プレーヤー + 外部プレーヤーアプリ + 新しい再生をAndroidのデフォルト動画アプリまたはシステムの選択画面で開きます。 + 新しい再生を選択したインストール済みプレーヤーで開きます。 + 外部プレーヤーに字幕を転送 + 優先言語のアドオン字幕を取得して外部プレーヤーに渡します。 + 対応する外部プレーヤーがインストールされていません + プレーヤー + 内蔵 + 外部 + 新しい再生に使用するプレーヤーを選択します。 + 長押し速度 + 長押しで速度変更 + プレーヤー画面のどこかを長押しすると、一時的に再生速度を上げます。 + タッチジェスチャー + プレーヤー画面でのスワイプやダブルタップによるシーク・明るさ・音量調整を許可します。 + 無効な正規表現パターン + 最終リンクキャッシュ期間 + DV7 - HEVCフォールバック + DolbyVisionハードウェアをサポートしていないデバイス向けに、Dolby Vision Profile 7を標準HEVCにマップします + しきい値(分) + アウトロのタイムスタンプがない場合のフォールバック。 + %1$s 分 + 利用可能なアイテムがありません + 未設定 + デフォルト(メディアファイル) + デバイスの言語 + 原語 + TMDB補完が有効な必要があります + 強制 + なし + すべての字幕 + 動画のすべてのアドオン字幕を取得して表示します。 + 高速起動 + プレーヤーでリクエストするまでアドオン字幕の自動取得をスキップします。 + アドオン字幕の起動設定 + 優先言語のみ + アドオン字幕を取得しますが、優先言語の一致のみ表示します。 + 一気見グループを優先(次のエピソード) + 通常の自動再生ルールより先に同じソースプロファイル(同じアドオン/品質グループ)を試みます。 + 一気見グループを再利用 + 最後の一気見グループをセッション間で記憶して再利用します(「視聴を続ける」、詳細など)。 + 優先音声言語 + 優先言語 + プリセット + ストリーム名/タイトル/説明/アドオン/URLに対してマッチします。例:4K|2160p|Remux + 正規表現パターン + パターン未設定。例:4K|2160p|Remux + 1080p以上 + AVC / x264 + BluRay品質 + Dolby Atmos / DTS + 英語 + HDR / Dolby Vision + HEVC / x265 + CAM/TSを除外 + REMUX/HDRを除外 + 1080p 標準 + 4K / Remux + 720p 以下 + WEBソース + libassレンダリングモード + 標準キュー + エフェクトキャンバス + エフェクトOpenGL + オーバーレイキャンバス + オーバーレイOpenGL(推奨) + 最後のリンクを再利用 + キャッシュが有効な場合、同じ映画/エピソードの最後に機能したストリームを自動再生します + 第2音声言語 + 第2優先言語 + デコーダー + 次のエピソード + プレーヤー + スキップセグメント + ストリーム自動再生 + ストリーム選択 + 字幕と音声 + 字幕レンダリング + %1$d 件選択中 + 読み込みオーバーレイ + 最初の映像フレームが表示されるまで読み込み画面を表示します。 + 背景色 + 太字 + 字幕を太いフォントウェイトで表示します。 + 透明 + 縁取り + 縁取りの色 + 字幕テキストに縁取りを描画します。 + 優先言語の字幕のみ表示 + 優先する字幕言語に一致する字幕のみ表示します。 + 字幕サイズ + テキストの色 + 強制字幕を使用 + 字幕言語の設定に一致する場合、強制字幕を優先します。 + 垂直オフセット + イントロをスキップ + introdb.appを使用してイントロと振り返りを検出します。 + 自動再生のソース範囲 + インストール済みのすべてのアドオン + 自動再生はインストール済みアドオンのストリームのみを対象とします。 + すべてのソース + 自動再生はインストール済みアドオンと有効なプラグインの両方を使用できます。 + 有効なプラグインのみ + 自動再生は有効なプラグインのストリームのみを対象とします。 + インストール済みアドオンのみ + 自動再生はインストール済みアドオンのストリームのみを対象とします。 + ストリームの自動選択 + 最初のソースを自動再生 + 利用可能な最初のソースを自動的に再生します。 + 手動(ストリームを選択) + 常にソース一覧を表示して選択します。 + 正規表現に一致するソースを自動再生 + 正規表現パターンに一致するテキストを持つ最初のソースを再生します。 + ストリーム選択タイムアウト + 選択前にアドオンを待機する時間。 + しきい値(分) + 次のエピソードのしきい値モード + 終了前の分数 + パーセンテージ + しきい値(%) + アウトロのタイムスタンプがない場合のフォールバック。 + %1$s% + 即時 + %1$s 秒 + 無制限 + トンネル再生 + ハードウェアレベルの音声/映像同期。一部のAndroid TVデバイスで再生が改善される場合があります + 補完を有効にする前に、下で自分のTMDB APIキーを追加してください。 + APIキー + TMDB補完を有効にする + アドオンデータを強化するためのメタデータソースとしてTMDBを使用する + TMDBのv3 APIキーを入力してください。 + 言語コード + アートワーク + TMDBのロゴとバックドロップ画像 + 基本情報 + TMDBの説明、ジャンル、評価 + コレクション + 公開順のTMDB映画コレクション + クレジット + TMDBの写真付きキャスト、監督、脚本家 + 詳細 + TMDBの上映時間、ステータス、国、言語 + エピソード + TMDBのエピソードタイトル、概要、サムネイル、上映時間 + この作品と似た作品 + 詳細ページのTMDBおすすめバックドロップ + ネットワーク + TMDBのロゴ付きネットワーク + 制作会社 + TMDBの制作会社 + シーズンポスター + シリーズのメタデータ画面のシーズンセレクターにTMDBのシーズンポスターを使用します。 + 予告編 + 詳細の予告編セクション向けのTMDB動画から予告編候補を取得 + 個人APIキー + 言語 + タイトル、ロゴ、有効なフィールドのTMDBメタデータ言語 + 認証情報 + ローカライズ + モジュール + TMDB補完 + 承認後、自動的にリダイレクトされます。 + 認証 + コメント + メタデータページにTraktのレビューを表示する + Traktに連携 + %1$s として連携中 + Traktユーザー + 連携を解除 + ブラウザを開けませんでした + 機能 + ブラウザでTraktのサインインを完了してください + ウォッチリスト、視聴進捗、「視聴を続ける」、スクロブル、個人リストをTraktと同期します。 + local.propertiesにTraktの認証情報がありません(TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET)。 + Traktログインを開く + 保存アクションでTraktのウォッチリストと個人リストをターゲットにできます。 + Traktにサインインして、リストベースの保存とTraktライブラリモードを有効にしてください。 + ライブラリソース + 保存とコレクションの表示に使用するライブラリを選択 + ライブラリソース + ライブラリアイテムの保存・管理場所を選択してください + Trakt + Nuvioライブラリ + Traktライブラリを選択しました + Nuvioライブラリを選択しました + 視聴進捗 + 再開と「視聴を続ける」に使用する進捗ソースを選択 + 視聴進捗 + Traktスクロブルが有効な状態で、再開と「視聴を続ける」にTraktまたはNuvio同期のどちらを使用するかを選択してください。 + Trakt + Nuvio同期 + 視聴進捗ソースをTraktに設定しました + 視聴進捗ソースをNuvio同期に設定しました + 「似た作品」のソース + 詳細ページのおすすめ作品の取得元を選択 + 「似た作品」のソース + 詳細ページに表示するおすすめ作品のソースを選択してください。 + Trakt + TMDB + 「視聴を続ける」の期間 + 「視聴を続ける」に使用するTrakt履歴の範囲 + 「視聴を続ける」の期間 + 「視聴を続ける」に表示するTraktのアクティビティ範囲を選択してください。 + すべての履歴 + %1$d 日 + オーディエンススコア + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + 不明 + アンバー + クリムゾン + エメラルド + オーシャン + ローズ + バイオレット + ホワイト + 次のエピソード + ソースを検索中… + %1$s で %2$d 秒後に再生… + 次のエピソードのサムネイル + 未放送 + スキップ + イントロをスキップ + アウトロをスキップ + 振り返りをスキップ + 字幕が見つかりません + アフリカーンス語 + アルバニア語 + アムハラ語 + アラビア語 + アルメニア語 + アゼルバイジャン語 + バスク語 + ベラルーシ語 + ベンガル語 + ボスニア語 + ブルガリア語 + ビルマ語 + カタルーニャ語 + 中国語 + 中国語(簡体字) + 中国語(繁体字) + クロアチア語 + チェコ語 + デンマーク語 + オランダ語 + 英語 + エストニア語 + フィリピン語 + フィンランド語 + フランス語 + ガリシア語 + ジョージア語 + ドイツ語 + ギリシャ語 + グジャラート語 + ヘブライ語 + ヒンディー語 + ハンガリー語 + アイスランド語 + インドネシア語 + アイルランド語 + イタリア語 + 日本語 + カンナダ語 + カザフ語 + クメール語 + 韓国語 + ラオス語 + ラトビア語 + リトアニア語 + マケドニア語 + マレー語 + マラヤーラム語 + マルタ語 + マラーティー語 + モンゴル語 + ネパール語 + ノルウェー語 + ペルシャ語 + ポーランド語 + ポルトガル語(ポルトガル) + ポルトガル語(ブラジル) + パンジャーブ語 + ルーマニア語 + ロシア語 + セルビア語 + シンハラ語 + スロバキア語 + スロベニア語 + スペイン語 + スペイン語(ラテンアメリカ) + スワヒリ語 + スウェーデン語 + タミル語 + テルグ語 + タイ語 + トルコ語 + ウクライナ語 + ウルドゥー語 + ウズベク語 + ベトナム語 + ウェールズ語 + ズールー語 + クリア + 続ける + 無視 + インストール + 後で + いいえ + 更新 + はい + アプリを終了しますか? + アプリを終了 + このカタログはアイテムを返しませんでした。 + タイトルが見つかりません + その他のアクション + Wi-Fiまたはモバイルデータ接続を確認して再試行してください。 + 監督 + 読み込みに失敗しました + この作品と似た作品 + Powered by TMDB + Powered by Trakt + シーズン + このアドオンはシリーズの動画を返しましたが、シーズンやエピソード番号が含まれていませんでした。 + このアドオンはこのシリーズのエピソードメタデータを提供しませんでした。 + このアドオンはまだエピソードを公開していません。 + デバイスはオンラインですが、Nuvioは必要なサーバーに到達できませんでした。 + 折りたたむ + もっと見る ▾ + 脚本 + すべてのジャンル + カタログ + %1$s • %2$s + 選択したカタログが発見アイテムを返せませんでした。 + 発見を読み込めませんでした + インストール済みアドオンに発見用のボード対応カタログがありません。 + 発見カタログがありません + 選択したカタログとフィルターではアイテムが見つかりませんでした。 + タイトルが見つかりません + 発見カタログを参照する前に、少なくとも1つのアドオンをインストールして有効にしてください。 + カタログを選択 + ジャンルを選択 + タイプを選択 + タイプ + 前のエピソードを未視聴にする + 前のエピソードを視聴済みにする + %1$s を未視聴にする + %1$s を視聴済みにする + 前のシーズンを視聴済みにする + 未視聴にする + 視聴済みにする + 次のエピソード + %1$s 視聴済み + 残り %1$d 時間 %2$d 分 + 残り %1$d 分 + ホームにカタログ行を読み込む前に、少なくとも1つのアドオンをインストールして有効にしてください。 + インストール済みアドオンは現在、必須エクストラなしでボード対応カタログを提供していません。 + ホームの行がありません + 詳細を見る + 再生と保存のコントロール + アクション + 主要キャスト一覧 + 関連するコレクションやフランチャイズのレール + コレクション + Traktコメントセクション + 上映時間、ステータス、公開日、言語などの情報 + 詳細 + シリーズのシーズンとエピソード一覧 + おすすめ作品レール + この作品と似た作品 + あらすじ、評価、ジャンル、主要クレジット + 概要 + スタジオとネットワーク + 制作 + 予告編レールと再生ショートカット + オンラインに戻りました + サーバーに接続できません + 空のレスポンスボディ + リクエストが HTTP %1$d で失敗しました + インターネット接続がありません + (%1$d 歳) + %1$s%2$s 生まれ + %1$s 没 + 代表作:%1$s + 最新作 + %1$s の詳細を読み込めませんでした + 人気作 + 問題が発生しました + 公開予定 + 削除 + キャンセル + PINを入力 + %1$s のPINを入力 + PINを忘れた場合 + PINが正しくありません + ロックされています。%1$d 秒後に再試行してください + カタログが読み込まれるとアバターの選択肢がここに表示されます。 + アバター:%1$s + 有効なhttp://またはhttps://の画像URLを入力してください。 + アバターを選択 + 下からアバターを選択してください。 + プロフィールを作成 + カスタムアバターURLを選択しました。 + カスタムアバターURL + 画像リンクを貼り付けるか、内蔵のアバターカタログを使用するには空白のままにしてください。 + https://example.com/avatar.png + 「%1$s」のすべてのデータが完全に削除されます。 + プロフィールを削除 + プロフィールを追加 + プロフィールを編集 + 現在のPINを入力 + 新しいPINを入力 + プロフィール %1$d + アバターを読み込み中... + プロフィールを管理 + プロフィール名 + 新しいプロフィール + プライマリアドオンオフ + プライマリアドオンオン + %1$s のPINロックを解除 + PINロックを解除 + 保存中... + セキュリティ + このプロフィールをロックするには、PINを設定してください。 + このプロフィールはPINで保護されています。 + このプロフィールのアバターを選択してください。 + PINロックを設定 + 名前のないプロフィール + プライマリアドオンを使用 + 個別のリストを管理する代わりに、メインプロフィールのアドオン設定を共有します。 + 誰が視聴しますか? + ダウンロード済み + 再開 + アクティブなスクレイパー + さらにアドオンを確認中… + ストリームリンクをコピー + ファイルをダウンロード + 外部プレーヤーで開く + 内蔵プレーヤーで開く + インストール済みのストリームアドオンが有効なストリームレスポンスを返せませんでした。 + ストリームを読み込めませんでした + このタイトルのストリームを読み込むには、先にアドオンをインストールしてください。 + インストール済みアドオンはこのタイプのタイトルのストリームを提供していません。 + ストリームアドオンがありません + インストール済みアドオンがこのタイトルのストリームを返しませんでした。 + S%1$d E%2$d + エピソード + S%1$dE%2$d - %3$s + 取得中… + ソースを検索中… + アドオンから字幕を読み込み中… + ストリームを検索中… + ストリームリンクをコピーしました + 直接のストリームリンクがありません + メタデータがありません + ストリームを更新 + %1$d%% から再開 + %1$s から再開 + サイズ %1$s + このストリームタイプはサポートされていません + 設定でアカウントを連携してください。 + TorBoxにキャッシュされていません。 + このリンクは期限切れです。結果を更新しています。 + このリンクを開けませんでした。 + 外部プレーヤーを開けませんでした + 先に設定で外部プレーヤーを選択してください + 外部プレーヤーが利用できません + 予告編を閉じる + 予告編を再生できません + Traktリストの読み込みに失敗しました + Traktリストの更新に失敗しました + %1$s • %2$s + アップデートの確認に失敗しました + ダウンロードに失敗しました + ダウンロードボディが空です + ダウンロードした更新ファイルが見つかりません。 + ダウンロード中 %1$d%% + インストールを開始できません + 最新バージョンを使用しています。 + Nuvioのアプリインストールを有効にして、戻って続けてください。 + アップデートをダウンロード中... + アップデートが見つかりません。 + 新しいバージョンをインストールする準備ができました。 + このビルドではアプリ内アップデートが利用できません。 + ダウンロードを準備中 + リリースノート + インストールを許可して続ける + アップデートが利用可能 + アップデートの状況 + そのアドオンはすでにインストールされています。 + 有効なアドオンURLを入力してください + マニフェストを読み込めませんでした + Nuvio + アカウントの削除に失敗しました + サインインに失敗しました + サインアウトに失敗しました + サインアップに失敗しました + カタログアイテムを読み込めませんでした。 + 次のエピソード + 次のエピソード • S%1$dE%2$d + %1$s ロゴ + コメントの読み込みに失敗しました + どのアドオンからも詳細を読み込めませんでした。 + ネットワーク + このコンテンツのメタデータを提供するアドオンがありません。 + ダウンロードに失敗しました + ダウンロードの進捗とコントロールをリアルタイムで表示します。 + ダウンロード + ダウンロード完了 + %1$s をダウンロード中 • %2$s + %1$s をダウンロード中 • %2$s / %3$s + ダウンロード失敗 + %1$s を一時停止中 + 削除 + %1$s を %2$s から削除しますか? + %1$s をライブラリから削除しますか? + ライブラリから削除しますか? + 映画 + 保存済みシリーズの新しいエピソードが公開されたときに通知します。 + エピソード公開アラートのプレビュー。 + テスト通知の送信に失敗しました。 + %1$s のテスト通知を送信しました。 + このストリームを再生できません。 + MPVプレーヤーエンジンが利用できません。アプリを再ビルドしてください。 + このプロフィールのPINが変更されました。このデバイスのロックを更新するには一度接続してください。 + PINロックを解除できませんでした。再試行してください。 + PINロックを解除するにはインターネットに接続してください。 + このPINはこのデバイスでオフライン確認できません。オンラインで一度解除してください。 + PINを設定できませんでした。再試行してください。 + PINを設定するにはインターネットに接続してください。 + このプロフィールはプライマリアドオンを使用しています。 + %1$s の読み込みに失敗しました + ストリーム + 埋め込み + 認証が拒否されました + ブラウザでTraktのサインインを完了してください + Traktに連携しました + Traktの連携を解除しました + 無効なTraktコールバック + 無効なTraktコールバック状態 + 無効なTraktトークンレスポンス + Traktライブラリの読み込みに失敗しました + リスト %1$d + Traktが認証コードを返しませんでした + Traktの認証情報がありません + Traktの進捗の読み込みに失敗しました + Traktの公開リスト + 有効なTraktリストIDまたはURLを入力してください + %1$d 件 + %1$d いいね + local.propertiesにTraktの認証情報がありません(TRAKT_CLIENT_ID)。 + TraktリストIDがありません + Traktリストに数値IDが含まれていませんでした + Traktリストが見つからないか、公開されていません + Traktのレート制限に達しました + Traktリクエストに失敗しました + Traktのサインイン完了に失敗しました + Traktユーザー + ウォッチリスト + Traktの認証が期限切れです + Traktリストが見つかりません + Traktリストの上限に達しました + Traktのレート制限に達しました + Traktリクエストに失敗しました + Traktウォッチリストへの追加に失敗しました + Traktリストへの追加に失敗しました + 互換性のあるTrakt IDがありません + 空のレスポンスボディ + TMDBソースを使用するには設定でTMDB APIキーを追加してください。 + TMDBコレクション %1$d + TMDBコレクションが見つかりません + TMDB制作会社 %1$d + TMDB会社が見つかりません + TMDB監督 %1$d + TMDBディスカバーがデータを返しませんでした + TMDBディスカバー + 有効なTMDB IDまたはURLを入力してください。 + TMDBリスト %1$d + TMDBリストが見つかりません + TMDBソースを読み込めませんでした + TMDBコレクションIDがありません + TMDBリストIDがありません + TMDB人物IDがありません + TMDBネットワーク %1$d + TMDBネットワークが見つかりません + TMDBの人物クレジットが見つかりません + TMDB人物 %1$d + TMDB人物が見つかりません + 予告編 + 不明 + アドオン + 保存済み + %1$s を再生 + %1$s を再開 + JSONが空です。 + コレクション %1$d のIDが空白です。 + コレクション「%1$s」のタイトルが空白です。 + 「%2$s」のフォルダー %1$d のIDが空白です。 + 「%2$s」のフォルダー「%1$s」のタイトルが空白です。 + フォルダー「%2$s」のソース %1$d にフィールドが空白のものがあります。 + フォルダー「%2$s」のソース %1$d にTraktリストIDがありません。 + 無効なJSON:%1$s + アドオンが見つかりません:%1$s + Traktの映画リスト + Traktのシリーズリスト + 1月 + 2月 + 3月 + 4月 + 5月 + 6月 + 7月 + 8月 + 9月 + 10月 + 11月 + 12月 + 1月 + 2月 + 3月 + 4月 + 5月 + 6月 + 7月 + 8月 + 9月 + 10月 + 11月 + 12月 + 制作会社 + ネットワーク + %1$s を読み込めませんでした + 人気 + 新着 + %1$s • %2$s + 高評価 + レーティング + 映画の詳細 + 原語 + 制作国 + 公開情報 + 上映時間 + ポスター + テキスト + シリーズの詳細 + ステータス + 動画 + ファイル + 直接のストリームリンクがありません + 前のダウンロードを置き換えました + ダウンロードを開始しました + このストリーム形式はダウンロードに対応していません + 空のレスポンスボディ + ダウンロードファイルの確定に失敗しました + リクエストが HTTP %1$d で失敗しました + ダウンロードシステムが初期化されていません + 一時ダウンロードファイルを開けませんでした + 一時ダウンロードファイルが開いていません + ダウンロードリクエストに失敗しました + 一時ダウンロードファイルへの書き込みに失敗しました + %1$s - %2$s + 詳細画面で保存をタップしたタイトルがここに表示されます。 + ライブラリは空です + ライブラリを読み込めませんでした + その他 + クラウド + 保存済み + ライブラリ + Traktに連携してウォッチリストや個人リストにタイトルを保存してください。 + Traktライブラリは空です + Traktライブラリを読み込めませんでした + Traktライブラリ + アカウントを連携 + クラウドライブラリのファイルを参照するには、「連携サービス」設定でアカウントを連携してください。 + クラウドアカウントが連携されていません + 連携サービスを開く + 連携済みアカウントのファイルを参照するには、「連携サービス」設定でクラウドライブラリを有効にしてください。 + クラウドライブラリがオフです + 現在のフィルターに一致する再生可能なクラウドファイルがありません。 + まだありません + 再生するファイルを選択 + %1$s クラウドライブラリを読み込めませんでした + このアイテムには再生可能な動画ファイルがありません。 + 再生可能なファイルがありません + 再生可能なファイルがありません + クラウドライブラリがオフです。 + このクラウドファイルを再生できませんでした。 + ファイルを再生 + クラウドサービスが連携されていません。 + %1$s が連携されていません。 + %1$d 件の再生可能ファイル + すべて + クラウドライブラリを更新 + プロバイダーを選択 + タイプを選択 + 再生準備完了 + すべて + トレント + Usenet + Web + ファイル + アニメ + チャンネル + 映画 + シリーズ + TV + %1$s が配信されました + %1$s • %2$s が配信されました + 新しいエピソードが配信されました + %1$s が配信されました + エピソード公開 + アルコール/薬物 + 恐怖シーン + ヌード + 不適切な言葉 + 軽度 + 中程度 + 強度 + 暴力 + 制作 + 監督 + 脚本 + オーディエンススコア + 再生可能な予告編ストリームが見つかりません。 + シーズン %1$d - %2$s + B + KB + MB + GB + イントロを投稿 + 映像設定 + %1$s ではクラウドライブラリが利用できません。 + 映像 + 調整をリセット + 出力プリセット + HDRピーク検出 + メタデータが不正または欠落している場合にHDRのピーク輝度を推定します。 + トーンマッピング + デバンド + わずかなパフォーマンスコストで色の帯状ノイズを軽減します。 + フレーム補間 + mpvがディスプレイ同期をうまく使用できる場合にモーションをスムーズにします。 + 明るさ + コントラスト + 彩度 + ガンマ + ネイティブEDR + HDR対応のiPhoneとiPadに最適。 + SDRトーンマッピング + SDRスタイルの出力でより予測可能な白と黒。 + 互換性 + 旧iOS MPVの動作に最も近い。 + カスタム + 下の詳細設定値を使用します。 + オフ + iOS映像出力 + ハードウェアデコーダー + 拡張ダイナミックレンジ + 新しい再生セッションのデフォルトMetal出力モード。 + ディスプレイカラーヒント + mpvがデフォルトでアクティブなディスプレイの色空間をターゲットにするようにします。 + ターゲット原色 + ターゲット転送 + iOS音声出力 + 音声出力 + AVFoundationを試み、失敗した場合はAudioUnitにフォールバックします。 + 空間オーディオとマルチチャンネル出力の試験的サポート。 + レガシーのAudioUnit出力を使用します。 + 結果の管理 + 最大結果数 + 表示する結果数を制限します。 + 結果の並び順 + 結果の並び順を選択します。 + 解像度ごとの上限 + 並び替え後の2160p・1080p・720pの重複結果を制限します。 + 品質ごとの上限 + 並び替え後のBluRay・WEB-DL・REMUX の重複結果を制限します。 + サイズ範囲 + ファイルサイズで結果をフィルターします。 + 詳しく見る + デフォルト形式 + オリジナル形式 + 1行に1グループずつ入力してください。 + オリジナル順 + 最高品質優先 + ファイルサイズ大優先 + ファイルサイズ小優先 + 最高音質優先 + 言語優先 + すべて + %1$d 件選択中 + すべての結果 + %1$d 件の結果 + 最大 %1$dGB + %1$dGB 以上 + %1$d〜%2$dGB + 優先解像度 + 選択した解像度をデフォルト順で先頭に並べます。 + 必須解像度 + 選択した解像度のみ表示します。 + 除外する解像度 + 選択した解像度を非表示にします。 + 優先品質 + 選択した品質をデフォルト順で先頭に並べます。 + 必須品質 + 選択した品質のみ表示します。 + 除外する品質 + 選択した品質を非表示にします。 + 優先映像タグ + DV・HDR・10bit・IMAXなどのタグを並べ替えます。 + 必須映像タグ + DV・HDR・10bit・IMAX・SDRなどのタグを必須にします。 + 除外する映像タグ + DV・HDR・10bit・3Dなどのタグを非表示にします。 + 優先音声タグ + Atmos・TrueHD・DTS・AACなどのタグを並べ替えます。 + 必須音声タグ + Atmos・TrueHD・DTS・AACなどのタグを必須にします。 + 除外する音声タグ + 選択した音声タグを非表示にします。 + 優先チャンネル + 優先するチャンネルレイアウトを先頭に並べます。 + 必須チャンネル + 選択したチャンネルレイアウトのみ表示します。 + 除外するチャンネル + 選択したチャンネルレイアウトを非表示にします。 + 優先エンコード + AV1・HEVC・AVCなどのエンコードを並べ替えます。 + 必須エンコード + AV1・HEVC・AVCなどのエンコードを必須にします。 + 除外するエンコード + 選択したエンコードを非表示にします。 + 優先言語 + 優先する音声言語を先頭に並べます。 + 必須言語 + 選択した言語を含む結果のみ表示します。 + 除外する言語 + すべての言語が除外対象の結果を非表示にします。 + 必須リリースグループ + 選択したリリースグループのみ表示します。 + 除外するリリースグループ + 選択したリリースグループを非表示にします。 + タイムスタンプを投稿 + セグメントタイプ + イントロ + 振り返り + アウトロ + 開始時刻 (MM:SS) + 終了時刻 (MM:SS) + 投稿 + キャプチャ + ハードウェアデコーダー + 音声出力 + ターゲット原色 + ターゲット転送 + 解決済みTraktリスト + TMDBディスカバー + US, KR, JP, IN + Traktリスト名、URL、またはIDを入力してください + 有効なTMDB IDまたはURLを入力してください。 + TMDBソースを読み込めませんでした + TraktリストIDまたはURLを入力してください + Traktリストを読み込めませんでした + カナダ + オーストラリア + ドイツ + 映画 + シリーズ + クラウドライブラリが無効です。 + APIキーが無効か接続に失敗しました + マニフェストに「%1$s」がありません + TMDBコレクション %1$s + TMDB監督 %1$s + TMDBディスカバー + 有効なTMDB IDまたはURLを入力してください。 + TMDBリスト %1$s + TMDBネットワーク %1$s + TMDB人物 %1$s + TMDB制作会社 %1$s + TMDBソースを読み込めませんでした + ID %1$s + TMDBソースを使用するには設定でTMDB APIキーを追加してください。 + TMDBコレクションが見つかりません + TMDB会社が見つかりません + TMDBディスカバーがデータを返しませんでした + TMDBリストが見つかりません + TMDBコレクションIDがありません + TMDBリストIDがありません + TMDB人物IDがありません + TMDBネットワークが見つかりません + TMDBの人物クレジットが見つかりません + TMDB人物が見つかりません + Traktの認証情報がありません。 + %1$s (%2$d) + 有効なTraktリストIDまたはURLを入力してください + %1$d 件 + %1$d いいね + Traktリストが見つからないか、公開されていません + TraktリストIDがありません + Traktリストに数値IDが含まれていませんでした + Traktの公開リスト + Traktのレート制限に達しました + Traktリクエストに失敗しました + Traktコメントの読み込みに失敗しました (%1$d) + %1$d 時間 %2$d 分 + %1$d 時間 + %1$d 分 + ダウンロードファイルの確定に失敗しました + 一時ダウンロードファイルを開けませんでした + 一時ダウンロードファイルが開いていません + 一時ダウンロードファイルへの書き込みに失敗しました + Nuvioライブラリ + 接続の問題 + 空のレスポンスボディ + 接続を確認して再試行してください。 + リクエストが HTTP %1$d で失敗しました + %1$s (%2$s) + MPVプレーヤーエンジンが利用できません。アプリを再ビルドしてください。 + このストリームを再生できません。 + プラグイン無効 + プラグイン有効 + %1$d プロバイダー + 更新中 + %1$d リポジトリ + TMDB APIキー未設定 + TMDB APIキー設定済み + プラグインリポジトリをインストール + インストール中… + プロバイダーをテスト + テスト中… + プラグインリポジトリを削除 + プラグインリポジトリを更新 + 利用可能なプロバイダーがまだありません。 + リポジトリURLを追加してストリーム探索用のプロバイダープラグインをインストールしてください。 + プラグインリポジトリがまだインストールされていません。 + ストリーム探索中にプラグインプロバイダーを使用します。 + プラグインプロバイダーをグローバルで有効にする + そのプラグインリポジトリはすでにインストールされています。 + プラグインリポジトリのURLを入力してください。 + 有効なプラグインURLを入力してください。 + プラグインリポジトリをインストールできません + プロバイダーが見つかりません + リポジトリを更新できません + このビルドではプラグインが利用できません。 + ストリームでソースごとではなくリポジトリごとに1つのプロバイダーを表示します。 + リポジトリごとにプラグインプロバイダーをグループ化する + プラグインマニフェストURL + マニフェストに名前がありません。 + マニフェストにプロバイダーがありません。 + マニフェストにバージョンがありません。 + マニフェストに名前がありません。 + マニフェストにプロバイダーがありません。 + マニフェストにバージョンがありません。 + %1$s をインストールしました。 + リポジトリにより無効 + 説明なし + v%1$s + プラグインリポジトリ + バージョン %1$s + そのプラグインリポジトリはすでにインストールされています。 + プラグインリポジトリをインストールできません + リポジトリを更新できません + リポジトリを追加 + インストール済みリポジトリ + 概要 + プロバイダー + エラー + プロバイダーのテストに失敗しました + テスト結果 (%1$d 件) + プラグインプロバイダーにはTMDB APIキーが必要です。TMDB画面でAPIキーを設定しないと、プラグインプロバイダーが正常に動作しない場合があります。 + %1$s の検索結果が返されませんでした。 + APIキーが無効か接続に失敗しました + プラグインリポジトリ + イントロを投稿 + 投稿 + キャプチャ + 終了時刻 (MM:SS) + セグメントタイプ + 開始時刻 (MM:SS) + Traktに連携しました + Traktの連携を解除しました + TB + リリースにAPKアセットが見つかりません + ダウンロードが HTTP %1$d で失敗しました + ダウンロードした更新ファイルが見つかりません。 + ダウンロードボディが空です + GitHub releases APIエラー:%1$d + まだアップデートが公開されていません。 + リリースにタグまたは名前がありません + %1$s に放送 + 本日放送 + 明日放送 + + %1$d 日後に放送 + + %1$s + 今日 + 明日 + + %1$d 日後 + + 新しいエピソード + 新シーズン + Traktリスト %1$s + Androidシステムプレーヤー + P2Pストリーミング + このストリームはピアツーピア(P2P)技術を使用しています。P2Pを有効にすることで、以下の事項に同意したものとみなされます:\n\n• お使いのIPアドレスがネットワーク内の他のピアに表示されます\n• アクセスするコンテンツに関する責任はすべてあなたにあります\n• あなたの管轄地域においてこのコンテンツをストリーミングする合法的な権利を有することを確認します\n• Nuvioはいかなるコンテンツもホスト、配信、管理しません\n• P2Pストリーミングの使用から生じる法的結果についてNuvioは一切の責任を負いません\n\nこの機能の使用はすべてご自身の責任において行ってください。P2Pはいつでも設定で無効にできます。 + P2Pを有効にする + キャンセル + 不明なトレントエラー + P2Pストリーミング + ピアツーピア(トレント)ストリームを許可する + トレント統計を非表示 + 読み込み中および再生中のバッファ・シード数・ピア数・ダウンロード速度を非表示にする + %1$d シード · %2$d ピア + %1$s バッファ済み · %2$s · %3$s + %1$s · %2$s + %1$d ピア · %2$d シード · %3$d%% + ピアに接続中… + P2Pエンジンを起動中… + トレントの開始に失敗しました:%1$s + トレントエラー:%1$s + From 389b990358b462e06e15cbd6b782379366e36a12 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:46:55 +0530 Subject: [PATCH 39/60] Handle deleted remote accounts on mobile Fixes #81 --- .../com/nuvio/app/core/auth/AuthRepository.kt | 86 ++++++++++++++++++- .../features/profiles/ProfileRepository.kt | 15 ++-- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 11c318fc6..169f2c6f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -7,6 +7,7 @@ import com.nuvio.app.core.storage.LocalAccountDataCleaner import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email import io.github.jan.supabase.auth.status.SessionStatus +import io.github.jan.supabase.exceptions.RestException import io.github.jan.supabase.functions.functions import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -32,6 +33,7 @@ object AuthRepository { val error: StateFlow = _error.asStateFlow() private var initialized = false + private var validatedRemoteUserId: String? = null fun initialize() { if (initialized) return @@ -40,6 +42,7 @@ object AuthRepository { scope.launch { SyncBackendRepository.state.collectLatest { backendState -> if (!backendState.isLoaded) return@collectLatest + validatedRemoteUserId = null AuthStorage.loadAnonymousUserId()?.let { savedAnonId -> _state.value = AuthState.Authenticated( @@ -56,8 +59,10 @@ object AuthRepository { when (status) { is SessionStatus.Authenticated -> { val user = status.session.user + val userId = user?.id.orEmpty() + if (!validateRemoteSession(userId)) return@collect _state.value = AuthState.Authenticated( - userId = user?.id ?: "", + userId = userId, email = user?.email, isAnonymous = false, ) @@ -79,6 +84,25 @@ object AuthRepository { } } + private suspend fun validateRemoteSession(userId: String): Boolean { + if (userId.isBlank() || validatedRemoteUserId == userId) return true + + return runCatching { + SupabaseProvider.client.auth.retrieveUserForCurrentSession(false) + validatedRemoteUserId = userId + true + }.getOrElse { e -> + if (isInvalidRemoteSessionError(e)) { + log.w(e) { "Stored Supabase session no longer belongs to an active account; clearing local auth" } + clearLocalSessionAfterRemoteInvalidation() + false + } else { + log.w(e) { "Unable to validate stored Supabase session; keeping cached auth state" } + true + } + } + } + @OptIn(ExperimentalUuidApi::class) fun signInAnonymously() { _error.value = null @@ -118,6 +142,7 @@ object AuthRepository { _error.value = null val wasAnonymous = AuthStorage.loadAnonymousUserId() != null AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null if (!wasAnonymous) { SupabaseProvider.client.auth.signOut() } @@ -128,10 +153,32 @@ object AuthRepository { _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } + suspend fun signOutIfSessionInvalid(error: Throwable, source: String): Boolean { + if (!isInvalidRemoteSessionError(error)) return false + + log.w(error) { "$source failed because the current Supabase account/session is no longer valid; clearing local auth" } + clearLocalSessionAfterRemoteInvalidation() + return true + } + + private suspend fun clearLocalSessionAfterRemoteInvalidation() { + _error.value = null + AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null + runCatching { + SupabaseProvider.client.auth.clearSession() + }.onFailure { e -> + log.w(e) { "Failed to clear Supabase session after remote invalidation; continuing local reset" } + } + _state.value = AuthState.Unauthenticated + LocalAccountDataCleaner.wipe() + } + suspend fun resetForSyncBackendChange(): Result = runCatching { _error.value = null val wasAnonymous = AuthStorage.loadAnonymousUserId() != null AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null if (!wasAnonymous) { runCatching { @@ -152,6 +199,8 @@ object AuthRepository { _error.value = null SupabaseProvider.client.functions.invoke("delete-account") SupabaseProvider.client.auth.signOut() + validatedRemoteUserId = null + _state.value = AuthState.Unauthenticated LocalAccountDataCleaner.wipe() }.onFailure { e -> log.e(e) { "Account deletion failed" } @@ -161,4 +210,39 @@ object AuthRepository { fun clearError() { _error.value = null } + + private fun isInvalidRemoteSessionError(error: Throwable): Boolean { + val restError = error.findCause() + if (restError?.statusCode == 401 || restError?.statusCode == 403) return true + + val message = buildString { + append(error.message.orEmpty()) + if (restError != null) { + append(' ') + append(restError.error) + append(' ') + append(restError.description) + } + }.lowercase() + + return ( + "jwt" in message && + ("invalid" in message || "expired" in message || "malformed" in message) + ) || ( + "user" in message && + ("does not exist" in message || "not found" in message || "deleted" in message) + ) || ( + "foreign key" in message && + ("auth.users" in message || "user_id" in message) + ) + } + + private inline fun Throwable.findCause(): T? { + var current: Throwable? = this + while (current != null) { + if (current is T) return current + current = current.cause + } + return null + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 7644ec63d..cbc1c5545 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -120,7 +120,7 @@ object ProfileRepository { } return } - runCatching { + try { val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profiles") val profiles = result.decodeList() _state.value = _state.value.copy( @@ -133,7 +133,8 @@ object ProfileRepository { activeProfileIndex = _state.value.activeProfile!!.profileIndex } persist() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile pull")) return log.e(e) { "Failed to pull profiles" } if (!_state.value.isLoaded) { _state.value = _state.value.copy(isLoaded = true) @@ -182,13 +183,14 @@ object ProfileRepository { applyPayloadsLocally(profiles) return } - runCatching { + try { val params = buildJsonObject { put("p_profiles", json.encodeToJsonElement(profiles)) } SupabaseProvider.client.postgrest.rpc("sync_push_profiles", params) pullProfiles() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile push")) return log.e(e) { "Failed to push profiles" } } } @@ -273,11 +275,12 @@ object ProfileRepository { persist() return } - runCatching { + try { val params = buildJsonObject { put("p_profile_id", profileIndex) } SupabaseProvider.client.postgrest.rpc("sync_delete_profile_data", params) pullProfiles() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile delete")) return log.e(e) { "Failed to delete profile $profileIndex" } } } From cc89e07eed9d4d3610f98bca50d681b536c9c575 Mon Sep 17 00:00:00 2001 From: VenusIsJaded Date: Mon, 22 Jun 2026 05:38:31 -0500 Subject: [PATCH 40/60] fix: Let's Encrypt trust on Android 7 --- androidApp/src/main/AndroidManifest.xml | 1 + .../src/androidMain/res/raw/isrg_root_x1.pem | 31 +++++++++++++++++++ .../src/androidMain/res/raw/isrg_root_x2.pem | 14 +++++++++ .../res/xml/network_security_config.xml | 10 ++++++ 4 files changed, 56 insertions(+) create mode 100644 composeApp/src/androidMain/res/raw/isrg_root_x1.pem create mode 100644 composeApp/src/androidMain/res/raw/isrg_root_x2.pem create mode 100644 composeApp/src/androidMain/res/xml/network_security_config.xml diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 0d702e768..029223fa9 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:localeConfig="@xml/locale_config" android:supportsRtl="true" diff --git a/composeApp/src/androidMain/res/raw/isrg_root_x1.pem b/composeApp/src/androidMain/res/raw/isrg_root_x1.pem new file mode 100644 index 000000000..b85c8037f --- /dev/null +++ b/composeApp/src/androidMain/res/raw/isrg_root_x1.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/composeApp/src/androidMain/res/raw/isrg_root_x2.pem b/composeApp/src/androidMain/res/raw/isrg_root_x2.pem new file mode 100644 index 000000000..7d903edc9 --- /dev/null +++ b/composeApp/src/androidMain/res/raw/isrg_root_x2.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw +CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg +R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 +MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT +ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW ++1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 +ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW +tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 +/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE----- diff --git a/composeApp/src/androidMain/res/xml/network_security_config.xml b/composeApp/src/androidMain/res/xml/network_security_config.xml new file mode 100644 index 000000000..0c94d7308 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + From fca06204e0bbc4b1165fdf5de3ef0dab32bc2052 Mon Sep 17 00:00:00 2001 From: VenusIsJaded Date: Mon, 22 Jun 2026 11:13:40 +0000 Subject: [PATCH 41/60] fix(details): keep addon IMDb rating instead of TMDB score When a metadata addon (e.g. aiometadata) supplies a genuine IMDb rating, the TMDB enrichment on the details page was overwriting it with TMDB's vote_average because the copy() preferred enrichment.rating over the existing value. This made the IMDb placeholder display the TMDB score on mobile, even though the addon provided IMDb ratings (the TV version was already correct). Invert the precedence so the addon-provided imdbRating is kept and TMDB's vote_average is only used as a fallback when no rating was supplied. Fixes #1377 --- .../kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index dd690f741..c2a6eaec4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -647,7 +647,8 @@ object TmdbMetadataService { updated = updated.copy( name = enrichment.localizedTitle ?: updated.name, description = enrichment.description ?: updated.description, - imdbRating = enrichment.rating?.formatRating() ?: updated.imdbRating, + imdbRating = updated.imdbRating?.takeIf { it.isNotBlank() } + ?: enrichment.rating?.formatRating(), genres = enrichment.genres.ifEmpty { updated.genres }, ) } From 0bfd0869171dc33fe5f1556ffbe1c401989b031e Mon Sep 17 00:00:00 2001 From: VenusIsJaded Date: Mon, 22 Jun 2026 07:16:03 -0500 Subject: [PATCH 42/60] fix: ExoPlayer unpausing when leaving the app and coming back The LifecycleEventObserver was only capturing playWhenReady at the time the effect ran. When ON_START fired later, it would use the old value and resume playback even if the user had paused before leaving the app. Use rememberUpdatedState so we always read the live value. Fixes #1080 --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 3ec9539cb..97911e9c3 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -90,6 +90,7 @@ actual fun PlatformPlayerSurface( val lifecycleOwner = LocalLifecycleOwner.current val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + val latestPlayWhenReady = rememberUpdatedState(playWhenReady) val coroutineScope = rememberCoroutineScope() val playerSettings = remember { @@ -385,7 +386,7 @@ actual fun PlatformPlayerSurface( val activity = context.findActivity() val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_START -> exoPlayer.playWhenReady = playWhenReady + Lifecycle.Event.ON_START -> exoPlayer.playWhenReady = latestPlayWhenReady.value Lifecycle.Event.ON_STOP -> { val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true @@ -405,7 +406,7 @@ actual fun PlatformPlayerSurface( } LaunchedEffect(exoPlayer, playWhenReady) { - exoPlayer.playWhenReady = playWhenReady + exoPlayer.playWhenReady = latestPlayWhenReady.value syncPlayerViewKeepScreenOn() latestOnSnapshot.value(exoPlayer.snapshot()) } From 8498a5301d1c2502d8edca6e7128720d115b0995 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:36:56 +0530 Subject: [PATCH 43/60] Fix mobile compile after merge --- composeApp/build.gradle.kts | 1 + .../{values-pt-BR/strings.xml‎ => values-pt-rBR/strings.xml} | 0 .../kotlin/com/nuvio/app/features/settings/AppLanguage.kt | 2 +- gradle/libs.versions.toml | 2 ++ 4 files changed, 4 insertions(+), 1 deletion(-) rename composeApp/src/commonMain/composeResources/{values-pt-BR/strings.xml‎ => values-pt-rBR/strings.xml} (100%) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d0ef2456f..36dbe497f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -383,6 +383,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.atomicfu) implementation(libs.androidx.navigation.compose) implementation(libs.kermit) implementation(libs.supabase.postgrest) diff --git a/composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ b/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml similarity index 100% rename from composeApp/src/commonMain/composeResources/values-pt-BR/strings.xml‎ rename to composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index 5823a7aa6..d50235738 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -31,7 +31,7 @@ enum class AppLanguage( INDONESIAN("id", Res.string.lang_indonesian), ITALIAN("it", Res.string.lang_italian), POLISH("pl", Res.string.lang_polish), - PORTUGUESE BRAZIL("pt-BR", Res.string.lang_portuguese_brazil), + PORTUGUESE_BRAZIL("pt-BR", Res.string.lang_portuguese_brazil), PORTUGUESE("pt", Res.string.lang_portuguese_portugal), SPANISH("es", Res.string.lang_spanish), TURKISH("tr", Res.string.lang_turkish), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9b1b5475..f24e0bd39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" +atomicfu = "0.32.1" ktor = "3.4.1" material3 = "1.12.0-alpha02" androidx-media3 = "1.8.0" @@ -54,6 +55,7 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } From 6bd055d174050338b33a18c3d1e6c55d0a180f4c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:51:35 +0530 Subject: [PATCH 44/60] revert: rollback dependencies causing instablilty durign navigation and plugin fetches --- .../com/nuvio/app/features/plugins/PluginRuntime.kt | 13 +++---------- gradle/libs.versions.toml | 8 ++++---- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index 316bfd68b..d27bca8f9 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -28,14 +28,12 @@ import org.jetbrains.compose.resources.getString import kotlin.random.Random private const val PLUGIN_TIMEOUT_MS = 60_000L -private const val SLOW_PLUGIN_FETCH_MS = 2_000L private const val MAX_FETCH_BODY_CHARS = 256 * 1024 private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" internal object PluginRuntime { private val log = Logger.withTag("PluginRuntime") - private val pluginDispatcher = Dispatchers.Default private val json = Json { ignoreUnknownKeys = true } @@ -50,7 +48,7 @@ internal object PluginRuntime { episode: Int?, scraperId: String, scraperSettings: Map = emptyMap(), - ): List = withContext(pluginDispatcher) { + ): List = withContext(Dispatchers.Default) { withTimeout(PLUGIN_TIMEOUT_MS) { executePluginInternal( code = code, @@ -79,7 +77,7 @@ internal object PluginRuntime { var resultJson = "[]" try { - quickJs(pluginDispatcher) { + quickJs(Dispatchers.Default) { define("console") { function("log") { args -> log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } @@ -329,8 +327,7 @@ internal object PluginRuntime { headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } - val startedAt = kotlin.time.TimeSource.Monotonic.markNow() - val response = runBlocking(pluginDispatcher) { + val response = runBlocking { httpRequestRaw( method = method, url = url, @@ -339,10 +336,6 @@ internal object PluginRuntime { followRedirects = followRedirects, ) } - val elapsed = startedAt.elapsedNow() - if (elapsed.inWholeMilliseconds >= SLOW_PLUGIN_FETCH_MS) { - log.w { "Slow plugin fetch $method $url status=${response.status} elapsed=$elapsed" } - } val responseHeaders = response.headers.mapValues { (_, value) -> truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f24e0bd39..6d9dd170c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,15 +5,15 @@ android-compileSdkMinor = "0" android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.12.2" -androidx-navigation = "2.10.0-alpha02" +androidx-navigation = "2.9.2" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-core-splashscreen = "1.0.1" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.11.0-beta02" +androidx-lifecycle = "2.11.0-beta01" androidx-work = "2.10.3" androidx-testExt = "1.3.0" -composeMultiplatform = "1.12.0-alpha02" +composeMultiplatform = "1.11.1" coil = "3.5.0-beta01" kermit = "2.0.5" junit = "4.13.2" @@ -21,7 +21,7 @@ kotlin = "2.3.0" kotlinx-serialization = "1.8.1" atomicfu = "0.32.1" ktor = "3.4.1" -material3 = "1.12.0-alpha02" +material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" supabase = "3.4.1" quickjsKt = "1.0.5" From 85e1522712b9415ba1ded601fc809e8e5fa224c6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:31:21 +0530 Subject: [PATCH 45/60] Temporarily disable AVFoundation audio output --- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-ja/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../commonMain/composeResources/values/strings.xml | 2 +- .../com/nuvio/app/features/player/PlayerModels.kt | 14 ++++++++++++-- .../features/player/PlayerSettingsRepository.kt | 8 +++----- .../app/features/settings/PlaybackSettingsPage.kt | 2 +- iosApp/iosApp/Player/MPVPlayerBridge.swift | 10 ++++++++-- 9 files changed, 29 insertions(+), 15 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 0fc342989..287403fae 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1596,7 +1596,7 @@ Transfert cible Sortie audio iOS Sortie audio - Essaie d’abord AVFoundation, puis bascule sur AudioUnit. + Utilise AudioUnit tant que la sortie AVFoundation est temporairement désactivée. Prise en charge expérimentale de l’audio spatial et de la sortie multicanal. Utilise l’ancienne sortie AudioUnit. diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 6bb7a42fd..ca0a88553 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -1610,7 +1610,7 @@ ターゲット転送 iOS音声出力 音声出力 - AVFoundationを試み、失敗した場合はAudioUnitにフォールバックします。 + AVFoundation出力が一時的に無効な間はAudioUnitを使用します。 空間オーディオとマルチチャンネル出力の試験的サポート。 レガシーのAudioUnit出力を使用します。 結果の管理 diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 89b367d02..4c7e450d3 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1762,7 +1762,7 @@ Nieprawidłowy klucz API lub błąd połączenia Wyjście audio Użyj starszego wyjścia AudioUnit. - Najpierw AVFoundation, potem AudioUnit jako zapasowe. + Użyj AudioUnit, gdy wyjście AVFoundation jest tymczasowo wyłączone. Eksperymentalna obsługa Spatial Audio i wielokanałowego wyjścia. Wyjście audio Wyjście audio iOS diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 2b4589e4e..51a4a7041 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -1586,7 +1586,7 @@ Transferência de destino SAÍDA DE ÁUDIO DO iOS Saída de áudio - Tenta primeiro AVFoundation e, se necessário, recorre ao AudioUnit. + Use AudioUnit enquanto a saída AVFoundation estiver temporariamente desativada. Suporte experimental para Áudio Espacial e saída multicanal. Utiliza a saída AudioUnit antiga. Gestão dos resultados diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e86bea0bc..9f003d8ba 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1617,7 +1617,7 @@ iOS audio output Audio output - Try AVFoundation first, then fall back to AudioUnit. + Use AudioUnit while AVFoundation output is temporarily disabled. Experimental support for Spatial Audio and multichannel output. Use the legacy AudioUnit output. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 9eacfcc69..87c29c5b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -153,9 +153,19 @@ enum class IosAudioOutputMode( val mpvValue: String, val label: String, ) { - Auto("avfoundation,audiounit,", "Auto"), + Auto("audiounit", "Auto"), AvFoundation("avfoundation", "AVFoundation"), - AudioUnit("audiounit", "AudioUnit"), + AudioUnit("audiounit", "AudioUnit"); + + companion object { + val selectableEntries: List = listOf(Auto, AudioUnit) + + fun fromStoredName(name: String?): IosAudioOutputMode = + name + ?.let { runCatching { valueOf(it) }.getOrNull() } + ?.takeUnless { it == AvFoundation } + ?: Auto + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index d81608626..768ba5640 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -325,9 +325,7 @@ object PlayerSettingsRepository { iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode() ?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() } ?: IosHardwareDecoderMode.VideoToolbox - iosAudioOutputMode = PlayerSettingsStorage.loadIosAudioOutputMode() - ?.let { runCatching { IosAudioOutputMode.valueOf(it) }.getOrNull() } - ?: IosAudioOutputMode.Auto + iosAudioOutputMode = IosAudioOutputMode.fromStoredName(PlayerSettingsStorage.loadIosAudioOutputMode()) iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true @@ -736,9 +734,9 @@ object PlayerSettingsRepository { fun setIosAudioOutputMode(mode: IosAudioOutputMode) { ensureLoaded() - iosAudioOutputMode = mode + iosAudioOutputMode = mode.takeUnless { it == IosAudioOutputMode.AvFoundation } ?: IosAudioOutputMode.Auto publish() - PlayerSettingsStorage.saveIosAudioOutputMode(mode.name) + PlayerSettingsStorage.saveIosAudioOutputMode(iosAudioOutputMode.name) } fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 6dc6f3093..bc7d893b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -1299,7 +1299,7 @@ private fun PlaybackSettingsSection( if (showIosAudioOutputDialog) { IosEnumSelectionDialog( title = stringResource(Res.string.settings_playback_ios_audio_output_dialog), - options = IosAudioOutputMode.entries, + options = IosAudioOutputMode.selectableEntries, selected = autoPlayPlayerSettings.iosAudioOutputMode, label = { it.label }, description = { diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 2b24ff8e2..2dd32ce07 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -224,7 +224,7 @@ private struct PendingLoadRequest { final class MPVPlayerViewController: UIViewController { - private static let defaultAudioOutput = "avfoundation,audiounit," + private static let defaultAudioOutput = "audiounit" private let errorStateLock = NSLock() private var metalLayer = MetalLayer() @@ -556,7 +556,13 @@ final class MPVPlayerViewController: UIViewController { func configureAudioOutput(audioOutput: String) { guard mpv != nil else { return } - setStringProperty("ao", audioOutput) + let resolvedAudioOutput: String + if audioOutput.contains("avfoundation") { + resolvedAudioOutput = Self.defaultAudioOutput + } else { + resolvedAudioOutput = audioOutput + } + setStringProperty("ao", resolvedAudioOutput) } func setSpeed(_ speed: Float) { From f0f5e583a39ee18cdd3d44f3c0e0b13e5c323712 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:43:22 +0530 Subject: [PATCH 46/60] Fix home collections refresh Fixes #1368 --- .../kotlin/com/nuvio/app/core/sync/SyncManager.kt | 12 +++++++++++- .../kotlin/com/nuvio/app/features/home/HomeScreen.kt | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index afaecb15d..5b8e22e20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -102,7 +102,7 @@ object SyncManager { private fun pullForegroundForProfile(profileId: Int) { scope.launch { - log.i { "pullForegroundForProfile($profileId) — syncing watch progress + library" } + log.i { "pullForegroundForProfile($profileId) — syncing watch progress, library, collections, and home settings" } launch { runCatching { LibraryRepository.pullFromServer(profileId) } @@ -113,6 +113,16 @@ object SyncManager { runCatching { WatchProgressRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "Foreground watch progress pull failed" } } } + + launch { + runCatching { CollectionSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground collections pull failed" } } + } + + launch { + runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 4e20144d0..0f9763463 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -453,8 +453,12 @@ fun HomeScreen( HomeRepository.refresh(enabledAddons) } - LaunchedEffect(collections) { + LaunchedEffect(collections, enabledAddons) { HomeCatalogSettingsRepository.syncCollections(collections) + HomeRepository.applyCurrentSettings() + if (collections.any { it.folders.isNotEmpty() }) { + HomeRepository.refresh(enabledAddons, force = true) + } } LaunchedEffect( From 6e815b91ed928e0c9acecf29c2f9a8e33aff637c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:43:12 +0530 Subject: [PATCH 47/60] feat: libmpv engine for android --- composeApp/build.gradle.kts | 1 + .../features/player/PlayerEngine.android.kt | 609 ++++++++++++++++++ .../player/PlayerSettingsStorage.android.kt | 73 +++ .../composeResources/values/strings.xml | 10 + .../nuvio/app/features/player/PlayerModels.kt | 25 + .../player/PlayerSettingsRepository.kt | 56 ++ .../features/player/PlayerSettingsStorage.kt | 8 + .../features/settings/PlaybackSettingsPage.kt | 183 +++++- .../app/features/settings/SettingsScreen.kt | 26 + .../player/PlayerSettingsStorage.ios.kt | 61 ++ gradle/libs.versions.toml | 2 + 11 files changed, 1050 insertions(+), 4 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 36dbe497f..5d159c4c2 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -355,6 +355,7 @@ kotlin { implementation(libs.androidx.media3.common) implementation(libs.androidx.media3.container) implementation(libs.androidx.media3.extractor) + implementation(libs.mpv.android.lib) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) if (androidDistribution == "full") { implementation(files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 74fce3310..9df72333b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -10,6 +10,7 @@ import android.util.TypedValue import android.graphics.Typeface import android.os.Build import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.util.AttributeSet import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.Composable @@ -62,6 +63,10 @@ import androidx.media3.ui.SubtitleView import androidx.media3.ui.CaptionStyleCompat import com.nuvio.app.R import com.nuvio.app.features.streams.normalizeStreamType +import `is`.xyz.mpv.BaseMPVView +import `is`.xyz.mpv.MPV +import `is`.xyz.mpv.MPVNode +import `is`.xyz.mpv.Utils import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers @@ -91,6 +96,95 @@ actual fun PlatformPlayerSurface( onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, +) { + val playerSettings = remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState.value + } + val playerSourceKey = listOf( + sourceUrl, + sourceAudioUrl.orEmpty(), + sanitizePlaybackHeaders(sourceHeaders), + sanitizePlaybackResponseHeaders(sourceResponseHeaders), + normalizeStreamType(streamType).orEmpty(), + useYoutubeChunkedPlayback, + ) + var activeEngine by remember(playerSourceKey, playerSettings.androidPlaybackEngine) { + mutableStateOf(playerSettings.androidPlaybackEngine.initialAndroidEngine()) + } + + when (activeEngine) { + ResolvedAndroidPlaybackEngine.ExoPlayer -> ExoPlayerSurface( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + externalSubtitles = externalSubtitles, + streamType = streamType, + useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, + modifier = modifier, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + useNativeController = useNativeController, + onControllerReady = onControllerReady, + onSnapshot = onSnapshot, + onError = { message -> + if (message != null && playerSettings.androidPlaybackEngine == AndroidPlaybackEngine.Auto) { + Log.w(TAG, "ExoPlayer failed; falling back to libmpv: $message") + activeEngine = ResolvedAndroidPlaybackEngine.Libmpv + onError(null) + } else { + onError(message) + } + }, + ) + ResolvedAndroidPlaybackEngine.Libmpv -> LibmpvPlayerSurface( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + externalSubtitles = externalSubtitles, + modifier = modifier, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + videoOutput = playerSettings.androidLibmpvVideoOutput, + hardwareDecodingEnabled = playerSettings.androidLibmpvHardwareDecodingEnabled, + yuv420pEnabled = playerSettings.androidLibmpvYuv420pEnabled, + onControllerReady = onControllerReady, + onSnapshot = onSnapshot, + onError = onError, + ) + } +} + +private enum class ResolvedAndroidPlaybackEngine { + ExoPlayer, + Libmpv, +} + +private fun AndroidPlaybackEngine.initialAndroidEngine(): ResolvedAndroidPlaybackEngine = + when (this) { + AndroidPlaybackEngine.Auto, + AndroidPlaybackEngine.ExoPlayer -> ResolvedAndroidPlaybackEngine.ExoPlayer + AndroidPlaybackEngine.Libmpv -> ResolvedAndroidPlaybackEngine.Libmpv + } + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +private fun ExoPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + sourceResponseHeaders: Map, + externalSubtitles: List, + streamType: String?, + useYoutubeChunkedPlayback: Boolean, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + useNativeController: Boolean, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -647,6 +741,186 @@ actual fun PlatformPlayerSurface( ) } +@Composable +private fun LibmpvPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + externalSubtitles: List, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + videoOutput: AndroidLibmpvVideoOutput, + hardwareDecodingEnabled: Boolean, + yuv420pEnabled: Boolean, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val latestOnSnapshot = rememberUpdatedState(onSnapshot) + val latestOnError = rememberUpdatedState(onError) + val latestPlayWhenReady = rememberUpdatedState(playWhenReady) + val coroutineScope = rememberCoroutineScope() + val sanitizedSourceHeaders = remember(sourceHeaders) { + sanitizePlaybackHeaders(sourceHeaders) + } + var playerViewRef by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + val activity = context.findActivity() + val observer = LifecycleEventObserver { _, event -> + val view = playerViewRef ?: return@LifecycleEventObserver + when (event) { + Lifecycle.Event.ON_START -> view.setPaused(!latestPlayWhenReady.value) + Lifecycle.Event.ON_STOP -> { + val isInPictureInPicture = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true + val isFinishing = activity?.isFinishing == true + if (!isInPictureInPicture || isFinishing) { + view.setPaused(true) + } + } + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + DisposableEffect(playerViewRef) { + val view = playerViewRef ?: return@DisposableEffect onDispose {} + fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) { + coroutineScope.launch(Dispatchers.Main.immediate) { + latestOnSnapshot.value(view.snapshot()) + if (updateKeepScreenOn) { + view.keepScreenOn = view.shouldKeepScreenOn() + } + } + } + val observer = object : MPV.EventObserver { + override fun eventProperty(property: String) = Unit + override fun eventProperty(property: String, value: Long) { + if (property == "cache-buffering-state") { + dispatchSnapshot(updateKeepScreenOn = true) + } + } + override fun eventProperty(property: String, value: Boolean) { + if (property == "eof-reached" || property == "pause" || property == "paused-for-cache" || property == "seeking") { + dispatchSnapshot(updateKeepScreenOn = true) + } + } + override fun eventProperty(property: String, value: String) = Unit + override fun eventProperty(property: String, value: Double) { + if (property == "duration" || property == "time-pos" || property == "speed") { + dispatchSnapshot() + } + } + override fun eventProperty(property: String, value: MPVNode) { + if (property == "track-list") dispatchSnapshot() + } + override fun event(eventId: Int, data: MPVNode) { + when (eventId) { + MPV.mpvEvent.MPV_EVENT_FILE_LOADED, + MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { + coroutineScope.launch(Dispatchers.Main.immediate) { + latestOnError.value(null) + latestOnSnapshot.value(view.snapshot()) + } + } + MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot() + } + } + } + view.mpv.addObserver(observer) + onDispose { + view.mpv.removeObserver(observer) + } + } + + DisposableEffect(playerViewRef) { + val view = playerViewRef ?: return@DisposableEffect onDispose {} + PlayerPictureInPictureManager.registerPausePlaybackCallback { + view.setPaused(true) + } + onDispose { + PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + view.keepScreenOn = false + } + } + + LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { + val view = playerViewRef ?: return@LaunchedEffect + view.loadSource( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + requestHeaders = sanitizedSourceHeaders, + externalSubtitles = externalSubtitles, + playWhenReady = latestPlayWhenReady.value, + ) + latestOnSnapshot.value(view.snapshot()) + } + + LaunchedEffect(playerViewRef, playWhenReady) { + val view = playerViewRef ?: return@LaunchedEffect + view.setPaused(!latestPlayWhenReady.value) + view.keepScreenOn = view.shouldKeepScreenOn() + latestOnSnapshot.value(view.snapshot()) + } + + LaunchedEffect(playerViewRef, resizeMode) { + playerViewRef?.applyResizeMode(resizeMode) + } + + LaunchedEffect(playerViewRef) { + val view = playerViewRef ?: return@LaunchedEffect + onControllerReady(view.controller(context)) + } + + LaunchedEffect(playerViewRef) { + val view = playerViewRef ?: return@LaunchedEffect + while (isActive) { + latestOnSnapshot.value(view.snapshot()) + view.keepScreenOn = view.shouldKeepScreenOn() + delay(250L) + } + } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + NuvioLibmpvView( + context = viewContext, + videoOutput = videoOutput, + hardwareDecodingEnabled = hardwareDecodingEnabled, + yuv420pEnabled = yuv420pEnabled, + ).apply { + layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + keepScreenOn = false + runCatching { + Utils.copyAssets(viewContext) + initialize(viewContext.filesDir.path, viewContext.cacheDir.path) + }.onFailure { error -> + Log.e(TAG, "Failed to initialize libmpv", error) + latestOnError.value(error.localizedMessage ?: "libmpv unavailable") + } + playerViewRef = this + } + }, + update = { view -> + playerViewRef = view + view.applyResizeMode(resizeMode) + }, + onRelease = { view -> + if (playerViewRef === view) playerViewRef = null + runCatching { view.destroy() } + }, + ) +} + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this @@ -654,6 +928,341 @@ private tailrec fun Context.findActivity(): Activity? = else -> null } +private class NuvioLibmpvView( + context: Context, + private val videoOutput: AndroidLibmpvVideoOutput, + private val hardwareDecodingEnabled: Boolean, + private val yuv420pEnabled: Boolean, + attrs: AttributeSet? = null, +) : BaseMPVView(context, attrs) { + private var currentSourceUrl: String? = null + private var currentSourceAudioUrl: String? = null + private var currentRequestHeaders: Map = emptyMap() + private var currentExternalSubtitles: List = emptyList() + + override fun initOptions() { + setVo(videoOutput.mpvValue) + mpv.setOptionString("profile", "fast") + mpv.setOptionString("hwdec", if (hardwareDecodingEnabled) "auto" else "no") + if (yuv420pEnabled) { + mpv.setOptionString("vf", "format=yuv420p") + } + mpv.setOptionString("msg-level", "all=warn") + mpv.setOptionString("tls-verify", "yes") + mpv.setOptionString("tls-ca-file", "${context.filesDir.path}/cacert.pem") + mpv.setOptionString("demuxer-max-bytes", "${libmpvCacheBytes()}").logIfMpvError("demuxer-max-bytes") + mpv.setOptionString("demuxer-max-back-bytes", "${libmpvCacheBytes()}").logIfMpvError("demuxer-max-back-bytes") + mpv.setOptionString("vd-lavc-film-grain", "cpu") + mpv.setPropertyBoolean("keep-open", true) + mpv.setPropertyBoolean("input-default-bindings", true) + mpv.setPropertyBoolean("audio-fallback-to-null", true) + } + + override fun postInitOptions() = Unit + + override fun observeProperties() { + val props = mapOf( + "pause" to MPV.mpvFormat.MPV_FORMAT_FLAG, + "paused-for-cache" to MPV.mpvFormat.MPV_FORMAT_FLAG, + "core-idle" to MPV.mpvFormat.MPV_FORMAT_FLAG, + "eof-reached" to MPV.mpvFormat.MPV_FORMAT_FLAG, + "seeking" to MPV.mpvFormat.MPV_FORMAT_FLAG, + "cache-buffering-state" to MPV.mpvFormat.MPV_FORMAT_INT64, + "duration" to MPV.mpvFormat.MPV_FORMAT_DOUBLE, + "time-pos" to MPV.mpvFormat.MPV_FORMAT_DOUBLE, + "demuxer-cache-time" to MPV.mpvFormat.MPV_FORMAT_DOUBLE, + "speed" to MPV.mpvFormat.MPV_FORMAT_DOUBLE, + "track-list" to MPV.mpvFormat.MPV_FORMAT_NODE, + ) + props.forEach { (name, format) -> mpv.observeProperty(name, format) } + } + + fun loadSource( + sourceUrl: String, + sourceAudioUrl: String?, + requestHeaders: Map, + externalSubtitles: List, + playWhenReady: Boolean, + ) { + val sameSource = + currentSourceUrl == sourceUrl && + currentSourceAudioUrl == sourceAudioUrl && + currentRequestHeaders == requestHeaders && + currentExternalSubtitles == externalSubtitles + currentSourceUrl = sourceUrl + currentSourceAudioUrl = sourceAudioUrl + currentRequestHeaders = requestHeaders + currentExternalSubtitles = externalSubtitles + applyRequestHeaders(requestHeaders) + setPaused(!playWhenReady) + if (!sameSource) { + playFile(sourceUrl) + if (!sourceAudioUrl.isNullOrBlank()) { + mpv.command("audio-add", sourceAudioUrl, "auto") + } + externalSubtitles.forEachIndexed { index, subtitle -> + val flag = if (index == 0) "auto" else "cached" + mpv.command("sub-add", subtitle.url, flag) + } + } + } + + fun setPaused(paused: Boolean) { + runCatching { mpv.setPropertyBoolean("pause", paused) } + } + + fun snapshot(): PlayerPlaybackSnapshot { + val paused = mpv.getPropertyBoolean("pause") ?: true + val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false + val idle = mpv.getPropertyBoolean("core-idle") ?: false + val ended = mpv.getPropertyBoolean("eof-reached") ?: false + val seeking = mpv.getPropertyBoolean("seeking") ?: false + val cacheBufferingState = mpv.getPropertyInt("cache-buffering-state") + val durationMs = mpv.getPropertyDouble("duration").toMillis() + val positionMs = mpv.getPropertyDouble("time-pos").toMillis() + val cachePositionMs = mpv.getPropertyDouble("demuxer-cache-time").toMillis() + val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100 + val isLoading = pausedForCache || + (!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L))) + return PlayerPlaybackSnapshot( + isLoading = isLoading, + isPlaying = !paused && !isLoading && !idle && !ended, + isEnded = ended, + durationMs = durationMs, + positionMs = positionMs, + bufferedPositionMs = maxOf(positionMs, cachePositionMs), + playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(), + ) + } + + fun shouldKeepScreenOn(): Boolean { + val snapshot = snapshot() + return snapshot.isPlaying || snapshot.isLoading + } + + fun applyResizeMode(resizeMode: PlayerResizeMode) { + when (resizeMode) { + PlayerResizeMode.Fit -> { + mpv.setPropertyDouble("panscan", 0.0) + mpv.setPropertyString("video-aspect-override", "no") + } + PlayerResizeMode.Fill -> { + mpv.setPropertyDouble("panscan", 1.0) + mpv.setPropertyString("video-aspect-override", "no") + } + PlayerResizeMode.Zoom -> { + mpv.setPropertyDouble("panscan", 0.5) + mpv.setPropertyString("video-aspect-override", "no") + } + } + } + + fun controller(context: Context): PlayerEngineController = + object : PlayerEngineController { + override fun play() = setPaused(false) + + override fun pause() = setPaused(true) + + override fun seekTo(positionMs: Long) { + mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") + } + + override fun seekBy(offsetMs: Long) { + mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") + } + + override fun retry() { + currentSourceUrl?.let { playFile(it) } + setPaused(false) + } + + override fun setPlaybackSpeed(speed: Float) { + mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble()) + } + + override fun setMuted(muted: Boolean) { + mpv.setPropertyBoolean("mute", muted) + } + + override fun getAudioTracks(): List = + extractLibmpvTracks(context, type = "audio").mapIndexed { index, track -> + AudioTrack( + index = index, + id = track.id.toString(), + label = track.label, + language = track.language, + isSelected = track.isSelected, + ) + } + + override fun getSubtitleTracks(): List = + extractLibmpvTracks(context, type = "sub").mapIndexed { index, track -> + SubtitleTrack( + index = index, + id = track.id.toString(), + label = track.label, + language = track.language, + isSelected = track.isSelected, + isForced = track.isForced, + ) + } + + override fun selectAudioTrack(index: Int) { + if (index < 0) { + mpv.setPropertyString("aid", "no") + } else { + extractLibmpvTracks(context, type = "audio").getOrNull(index)?.let { track -> + mpv.setPropertyInt("aid", track.id) + } + } + } + + override fun selectSubtitleTrack(index: Int) { + if (index < 0) { + mpv.setPropertyString("sid", "no") + } else { + extractLibmpvTracks(context, type = "sub").getOrNull(index)?.let { track -> + mpv.setPropertyInt("sid", track.id) + } + } + } + + override fun setSubtitleUri(url: String) { + mpv.command("sub-add", url, "select") + } + + override fun clearExternalSubtitle() { + mpv.setPropertyString("sid", "no") + } + + override fun clearExternalSubtitleAndSelect(trackIndex: Int) { + selectSubtitleTrack(trackIndex) + } + + override fun applySubtitleStyle(style: SubtitleStyleState) { + mpv.setPropertyString("sub-ass-override", "force") + mpv.setPropertyString("sub-color", style.textColor.toMpvColor()) + mpv.setPropertyString("sub-back-color", style.backgroundColor.toMpvColor()) + mpv.setPropertyString("sub-outline-color", style.outlineColor.toMpvColor()) + mpv.setPropertyString("sub-border-color", style.outlineColor.toMpvColor()) + mpv.setPropertyString("sub-border-style", style.toMpvSubtitleBorderStyle()) + mpv.setPropertyString("sub-bold", if (style.bold) "yes" else "no") + mpv.setPropertyInt("sub-font-size", style.toMpvSubtitleFontSize()) + mpv.setPropertyInt("sub-outline-size", style.toMpvSubtitleOutlineSize()) + mpv.setPropertyInt("sub-border-size", style.toMpvSubtitleOutlineSize()) + mpv.setPropertyInt("sub-pos", (100 - style.bottomOffset / 10).coerceIn(0, 100)) + } + + override fun setSubtitleDelayMs(delayMs: Int) { + mpv.setPropertyDouble( + "sub-delay", + delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) / 1000.0, + ) + } + } + + private fun applyRequestHeaders(headers: Map) { + val userAgent = headers.entries.firstOrNull { it.key.equals("User-Agent", ignoreCase = true) }?.value + if (!userAgent.isNullOrBlank()) { + mpv.setPropertyString("user-agent", userAgent) + } + val serialized = headers + .filterKeys { !it.equals("User-Agent", ignoreCase = true) } + .map { (key, value) -> "${key}: ${value.replace(",", "\\,")}" } + .joinToString(",") + mpv.setPropertyString("http-header-fields", serialized) + } + + private fun extractLibmpvTracks(context: Context, type: String): List { + val nodes = mpv.getPropertyNode("track-list")?.asArray()?.toList().orEmpty() + return nodes + .filter { node -> node.nodeString("type") == type } + .mapIndexedNotNull { index, node -> + val id = node.nodeInt("id") ?: return@mapIndexedNotNull null + val rawLabel = node.nodeString("title") + ?: node.nodeString("external-filename")?.substringAfterLast('/') + ?: node.nodeString("codec") + val language = node.nodeString("lang") ?: normalizeLanguageCode(rawLabel) + val label = rawLabel?.takeIf { it.isNotBlank() } + ?: runBlocking { getString(Res.string.compose_player_track_number, index + 1) } + LibmpvTrack( + id = id, + label = label, + language = language, + isSelected = node.nodeBoolean("selected") ?: false, + isForced = inferForcedSubtitleTrack( + label = label, + language = language, + trackId = id.toString(), + hasForcedSelectionFlag = node.nodeBoolean("forced") ?: false, + ), + ) + } + } +} + +private data class LibmpvTrack( + val id: Int, + val label: String, + val language: String?, + val isSelected: Boolean, + val isForced: Boolean, +) + +private fun libmpvCacheBytes(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 * 1024 * 1024 else 32 * 1024 * 1024 + +private fun Int.logIfMpvError(option: String) { + if (this < 0) Log.w(TAG, "libmpv option failed: $option status=$this") +} + +private fun Double?.toMillis(): Long = + this?.takeIf { it.isFinite() && it > 0.0 }?.let { (it * 1000.0).toLong() } ?: 0L + +private fun MPVNode.nodeString(key: String): String? = + runCatching { this[key]?.asString() }.getOrNull()?.takeIf { it.isNotBlank() } + +private fun MPVNode.nodeInt(key: String): Int? = + runCatching { this[key]?.asInt()?.toInt() }.getOrNull() + +private fun MPVNode.nodeBoolean(key: String): Boolean? = + runCatching { this[key]?.asBoolean() }.getOrNull() + +private fun androidx.compose.ui.graphics.Color.toMpvColor(): String { + val argb = toArgb() + val alpha = (argb ushr 24) and 0xff + val red = (argb shr 16) and 0xff + val green = (argb shr 8) and 0xff + val blue = argb and 0xff + return "#%02X%02X%02X%02X".format(alpha, red, green, blue) +} + +private fun androidx.compose.ui.graphics.Color.alphaByte(): Int = + (toArgb() ushr 24) and 0xff + +private fun SubtitleStyleState.toMpvSubtitleFontSize(): Int = + (fontSizeSp * MPV_SUBTITLE_FONT_SIZE_SCALE).toInt().coerceIn( + MPV_SUBTITLE_FONT_SIZE_MIN, + MPV_SUBTITLE_FONT_SIZE_MAX, + ) + +private fun SubtitleStyleState.toMpvSubtitleOutlineSize(): Int = + if (!outlineEnabled) 0 else (outlineWidth * MPV_SUBTITLE_OUTLINE_SIZE_SCALE).toInt().coerceAtLeast(1) + +private fun SubtitleStyleState.toMpvSubtitleBorderStyle(): String = + if (outlineEnabled) { + "outline-and-shadow" + } else if (backgroundColor.alphaByte() > 0) { + "opaque-box" + } else { + "outline-and-shadow" + } + +private const val MPV_SUBTITLE_FONT_SIZE_SCALE = 55.0 / 18.0 +private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36 +private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122 +private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5 + private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = PlayerPlaybackSnapshot( isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 32b4c4b1a..98c550808 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -44,6 +44,10 @@ actual object PlayerSettingsStorage { private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode" private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" + private const val androidPlaybackEngineKey = "android_playback_engine" + private const val androidLibmpvVideoOutputKey = "android_libmpv_video_output" + private const val androidLibmpvHardwareDecodingEnabledKey = "android_libmpv_hardware_decoding_enabled" + private const val androidLibmpvYuv420pEnabledKey = "android_libmpv_yuv420p_enabled" private const val decoderPriorityKey = "decoder_priority" private const val mapDV7ToHevcKey = "map_dv7_to_hevc" private const val tunnelingEnabledKey = "tunneling_enabled" @@ -107,6 +111,10 @@ actual object PlayerSettingsStorage { addonSubtitleStartupModeKey, streamReuseLastLinkEnabledKey, streamReuseLastLinkCacheHoursKey, + androidPlaybackEngineKey, + androidLibmpvVideoOutputKey, + androidLibmpvHardwareDecodingEnabledKey, + androidLibmpvYuv420pEnabledKey, decoderPriorityKey, mapDV7ToHevcKey, tunnelingEnabledKey, @@ -526,6 +534,60 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadAndroidPlaybackEngine(): String? = + preferences?.getString(ProfileScopedKey.of(androidPlaybackEngineKey), null) + + actual fun saveAndroidPlaybackEngine(engine: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(androidPlaybackEngineKey), engine) + ?.apply() + } + + actual fun loadAndroidLibmpvVideoOutput(): String? = + preferences?.getString(ProfileScopedKey.of(androidLibmpvVideoOutputKey), null) + + actual fun saveAndroidLibmpvVideoOutput(output: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(androidLibmpvVideoOutputKey), output) + ?.apply() + } + + actual fun loadAndroidLibmpvHardwareDecodingEnabled(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(androidLibmpvHardwareDecodingEnabledKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveAndroidLibmpvHardwareDecodingEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(androidLibmpvHardwareDecodingEnabledKey), enabled) + ?.apply() + } + + actual fun loadAndroidLibmpvYuv420pEnabled(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(androidLibmpvYuv420pEnabledKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, false) + } else { + null + } + } + + actual fun saveAndroidLibmpvYuv420pEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(androidLibmpvYuv420pEnabledKey), enabled) + ?.apply() + } + actual fun loadDecoderPriority(): Int? = preferences?.let { sharedPreferences -> val key = ProfileScopedKey.of(decoderPriorityKey) @@ -998,6 +1060,12 @@ actual object PlayerSettingsStorage { loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) } loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } + loadAndroidPlaybackEngine()?.let { put(androidPlaybackEngineKey, encodeSyncString(it)) } + loadAndroidLibmpvVideoOutput()?.let { put(androidLibmpvVideoOutputKey, encodeSyncString(it)) } + loadAndroidLibmpvHardwareDecodingEnabled()?.let { + put(androidLibmpvHardwareDecodingEnabledKey, encodeSyncBoolean(it)) + } + loadAndroidLibmpvYuv420pEnabled()?.let { put(androidLibmpvYuv420pEnabledKey, encodeSyncBoolean(it)) } loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } @@ -1065,6 +1133,11 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode) payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) + payload.decodeSyncString(androidPlaybackEngineKey)?.let(::saveAndroidPlaybackEngine) + payload.decodeSyncString(androidLibmpvVideoOutputKey)?.let(::saveAndroidLibmpvVideoOutput) + payload.decodeSyncBoolean(androidLibmpvHardwareDecodingEnabledKey) + ?.let(::saveAndroidLibmpvHardwareDecodingEnabled) + payload.decodeSyncBoolean(androidLibmpvYuv420pEnabledKey)?.let(::saveAndroidLibmpvYuv420pEnabled) payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9f003d8ba..e84da9962 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -910,6 +910,16 @@ Secondary Audio Language Secondary Preferred Language DECODER + Playback Engine + Use ExoPlayer first and fall back to libmpv if playback fails. + Use ExoPlayer and Android Media3 decoders. + Use libmpv for Android playback. + libmpv Renderer + libmpv Renderer + libmpv Hardware Decoding + Use mpv hardware decoding when available. + libmpv YUV420P Compatibility + Force YUV420P output for devices with renderer or color issues. NEXT EPISODE PLAYER SKIP SEGMENTS diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 87c29c5b6..7f5efb6f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -82,6 +82,31 @@ enum class PlayerResizeMode { Zoom, } +enum class AndroidPlaybackEngine( + val label: String, +) { + Auto("Auto"), + ExoPlayer("ExoPlayer"), + Libmpv("libmpv"), +} + +enum class AndroidLibmpvVideoOutput( + val mpvValue: String, + val label: String, + val description: String, +) { + GpuNext( + mpvValue = "gpu-next", + label = "GPU next", + description = "Modern libmpv renderer with higher quality processing.", + ), + Gpu( + mpvValue = "gpu", + label = "GPU", + description = "Compatibility renderer for devices that have issues with GPU next.", + ), +} + enum class IosVideoOutputPreset( val label: String, val description: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 768ba5640..900553a3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -48,6 +48,10 @@ data class PlayerSettingsUiState( val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES, val streamReuseLastLinkEnabled: Boolean = false, val streamReuseLastLinkCacheHours: Int = 24, + val androidPlaybackEngine: AndroidPlaybackEngine = AndroidPlaybackEngine.Auto, + val androidLibmpvVideoOutput: AndroidLibmpvVideoOutput = AndroidLibmpvVideoOutput.GpuNext, + val androidLibmpvHardwareDecodingEnabled: Boolean = true, + val androidLibmpvYuv420pEnabled: Boolean = false, val decoderPriority: Int = 1, val mapDV7ToHevc: Boolean = false, val tunnelingEnabled: Boolean = false, @@ -108,6 +112,10 @@ object PlayerSettingsRepository { private var addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES private var streamReuseLastLinkEnabled = false private var streamReuseLastLinkCacheHours = 24 + private var androidPlaybackEngine = AndroidPlaybackEngine.Auto + private var androidLibmpvVideoOutput = AndroidLibmpvVideoOutput.GpuNext + private var androidLibmpvHardwareDecodingEnabled = true + private var androidLibmpvYuv420pEnabled = false private var decoderPriority = 1 private var mapDV7ToHevc = false private var tunnelingEnabled = false @@ -173,6 +181,10 @@ object PlayerSettingsRepository { addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES streamReuseLastLinkEnabled = false streamReuseLastLinkCacheHours = 24 + androidPlaybackEngine = AndroidPlaybackEngine.Auto + androidLibmpvVideoOutput = AndroidLibmpvVideoOutput.GpuNext + androidLibmpvHardwareDecodingEnabled = true + androidLibmpvYuv420pEnabled = false decoderPriority = 1 mapDV7ToHevc = false tunnelingEnabled = false @@ -263,6 +275,14 @@ object PlayerSettingsRepository { ?: AddonSubtitleStartupMode.ALL_SUBTITLES streamReuseLastLinkEnabled = PlayerSettingsStorage.loadStreamReuseLastLinkEnabled() ?: false streamReuseLastLinkCacheHours = PlayerSettingsStorage.loadStreamReuseLastLinkCacheHours() ?: 24 + androidPlaybackEngine = PlayerSettingsStorage.loadAndroidPlaybackEngine() + ?.let { runCatching { AndroidPlaybackEngine.valueOf(it) }.getOrNull() } + ?: AndroidPlaybackEngine.Auto + androidLibmpvVideoOutput = PlayerSettingsStorage.loadAndroidLibmpvVideoOutput() + ?.let { runCatching { AndroidLibmpvVideoOutput.valueOf(it) }.getOrNull() } + ?: AndroidLibmpvVideoOutput.GpuNext + androidLibmpvHardwareDecodingEnabled = PlayerSettingsStorage.loadAndroidLibmpvHardwareDecodingEnabled() ?: true + androidLibmpvYuv420pEnabled = PlayerSettingsStorage.loadAndroidLibmpvYuv420pEnabled() ?: false decoderPriority = PlayerSettingsStorage.loadDecoderPriority() ?: 1 mapDV7ToHevc = PlayerSettingsStorage.loadMapDV7ToHevc() ?: false tunnelingEnabled = PlayerSettingsStorage.loadTunnelingEnabled() ?: false @@ -489,6 +509,38 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveStreamReuseLastLinkCacheHours(hours) } + fun setAndroidPlaybackEngine(engine: AndroidPlaybackEngine) { + ensureLoaded() + if (androidPlaybackEngine == engine) return + androidPlaybackEngine = engine + publish() + PlayerSettingsStorage.saveAndroidPlaybackEngine(engine.name) + } + + fun setAndroidLibmpvVideoOutput(output: AndroidLibmpvVideoOutput) { + ensureLoaded() + if (androidLibmpvVideoOutput == output) return + androidLibmpvVideoOutput = output + publish() + PlayerSettingsStorage.saveAndroidLibmpvVideoOutput(output.name) + } + + fun setAndroidLibmpvHardwareDecodingEnabled(enabled: Boolean) { + ensureLoaded() + if (androidLibmpvHardwareDecodingEnabled == enabled) return + androidLibmpvHardwareDecodingEnabled = enabled + publish() + PlayerSettingsStorage.saveAndroidLibmpvHardwareDecodingEnabled(enabled) + } + + fun setAndroidLibmpvYuv420pEnabled(enabled: Boolean) { + ensureLoaded() + if (androidLibmpvYuv420pEnabled == enabled) return + androidLibmpvYuv420pEnabled = enabled + publish() + PlayerSettingsStorage.saveAndroidLibmpvYuv420pEnabled(enabled) + } + fun setDecoderPriority(priority: Int) { ensureLoaded() if (decoderPriority == priority) return @@ -850,6 +902,10 @@ object PlayerSettingsRepository { addonSubtitleStartupMode = addonSubtitleStartupMode, streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + androidPlaybackEngine = androidPlaybackEngine, + androidLibmpvVideoOutput = androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled, decoderPriority = decoderPriority, mapDV7ToHevc = mapDV7ToHevc, tunnelingEnabled = tunnelingEnabled, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index b2b0f2982..8ca6050ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -53,6 +53,14 @@ internal expect object PlayerSettingsStorage { fun saveStreamReuseLastLinkEnabled(enabled: Boolean) fun loadStreamReuseLastLinkCacheHours(): Int? fun saveStreamReuseLastLinkCacheHours(hours: Int) + fun loadAndroidPlaybackEngine(): String? + fun saveAndroidPlaybackEngine(engine: String) + fun loadAndroidLibmpvVideoOutput(): String? + fun saveAndroidLibmpvVideoOutput(output: String) + fun loadAndroidLibmpvHardwareDecodingEnabled(): Boolean? + fun saveAndroidLibmpvHardwareDecodingEnabled(enabled: Boolean) + fun loadAndroidLibmpvYuv420pEnabled(): Boolean? + fun saveAndroidLibmpvYuv420pEnabled(enabled: Boolean) fun loadDecoderPriority(): Int? fun saveDecoderPriority(priority: Int) fun loadMapDV7ToHevc(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index bc7d893b1..555060434 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -55,6 +55,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.player.AddonSubtitleStartupMode +import com.nuvio.app.features.player.AndroidLibmpvVideoOutput +import com.nuvio.app.features.player.AndroidPlaybackEngine import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp @@ -98,6 +100,10 @@ internal fun LazyListScope.playbackSettingsContent( secondaryPreferredSubtitleLanguage: String?, streamReuseLastLinkEnabled: Boolean, streamReuseLastLinkCacheHours: Int, + androidPlaybackEngine: AndroidPlaybackEngine, + androidLibmpvVideoOutput: AndroidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled: Boolean, + androidLibmpvYuv420pEnabled: Boolean, decoderPriority: Int, mapDV7ToHevc: Boolean, tunnelingEnabled: Boolean, @@ -117,6 +123,10 @@ internal fun LazyListScope.playbackSettingsContent( secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + androidPlaybackEngine = androidPlaybackEngine, + androidLibmpvVideoOutput = androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled, decoderPriority = decoderPriority, mapDV7ToHevc = mapDV7ToHevc, tunnelingEnabled = tunnelingEnabled, @@ -252,6 +262,10 @@ private fun PlaybackSettingsSection( secondaryPreferredSubtitleLanguage: String?, streamReuseLastLinkEnabled: Boolean, streamReuseLastLinkCacheHours: Int, + androidPlaybackEngine: AndroidPlaybackEngine, + androidLibmpvVideoOutput: AndroidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled: Boolean, + androidLibmpvYuv420pEnabled: Boolean, decoderPriority: Int, mapDV7ToHevc: Boolean, tunnelingEnabled: Boolean, @@ -269,6 +283,8 @@ private fun PlaybackSettingsSection( var showExternalPlayerDialog by remember { mutableStateOf(false) } var showExternalPlayerAppDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) } + var showPlaybackEngineDialog by remember { mutableStateOf(false) } + var showLibmpvVideoOutputDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } var showIosAudioOutputDialog by remember { mutableStateOf(false) } @@ -557,7 +573,8 @@ private fun PlaybackSettingsSection( onClick = { showSubtitleOutlineColorDialog = true }, ) } - if (!isIos) { + val showLibassSettings = !isIos && androidPlaybackEngine != AndroidPlaybackEngine.Libmpv + if (showLibassSettings) { SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_playback_enable_libass), @@ -762,15 +779,54 @@ private fun PlaybackSettingsSection( if (!isIos) { val decoderEnabled = !autoPlayPlayerSettings.externalPlayerEnabled + val exoOptionsEnabled = decoderEnabled && androidPlaybackEngine != AndroidPlaybackEngine.Libmpv + val libmpvOptionsVisible = androidPlaybackEngine != AndroidPlaybackEngine.ExoPlayer + val libmpvOptionsEnabled = decoderEnabled && libmpvOptionsVisible SettingsSection( title = stringResource(Res.string.settings_playback_section_decoder), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_engine), + description = androidPlaybackEngine.label, + enabled = decoderEnabled, + isTablet = isTablet, + onClick = { showPlaybackEngineDialog = true }, + ) + if (libmpvOptionsVisible) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_libmpv_video_output), + description = androidLibmpvVideoOutput.label, + enabled = libmpvOptionsEnabled, + isTablet = isTablet, + onClick = { showLibmpvVideoOutputDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_libmpv_hardware_decoding), + description = stringResource(Res.string.settings_playback_libmpv_hardware_decoding_description), + checked = androidLibmpvHardwareDecodingEnabled, + enabled = libmpvOptionsEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setAndroidLibmpvHardwareDecodingEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_libmpv_yuv420p), + description = stringResource(Res.string.settings_playback_libmpv_yuv420p_description), + checked = androidLibmpvYuv420pEnabled, + enabled = libmpvOptionsEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setAndroidLibmpvYuv420pEnabled, + ) + } + SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_playback_decoder_priority), description = decoderPriorityLabel(decoderPriority), - enabled = decoderEnabled, + enabled = exoOptionsEnabled, isTablet = isTablet, onClick = { showDecoderPriorityDialog = true }, ) @@ -779,7 +835,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_map_dv7_to_hevc), description = stringResource(Res.string.settings_playback_map_dv7_to_hevc_description), checked = mapDV7ToHevc, - enabled = decoderEnabled, + enabled = exoOptionsEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setMapDV7ToHevc, ) @@ -788,7 +844,7 @@ private fun PlaybackSettingsSection( title = stringResource(Res.string.settings_playback_tunneled_playback), description = stringResource(Res.string.settings_playback_tunneled_playback_description), checked = tunnelingEnabled, - enabled = decoderEnabled, + enabled = exoOptionsEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setTunnelingEnabled, ) @@ -1271,6 +1327,32 @@ private fun PlaybackSettingsSection( ) } + if (showPlaybackEngineDialog) { + PlaybackEngineDialog( + selectedEngine = androidPlaybackEngine, + onEngineSelected = { engine -> + PlayerSettingsRepository.setAndroidPlaybackEngine(engine) + showPlaybackEngineDialog = false + }, + onDismiss = { showPlaybackEngineDialog = false }, + ) + } + + if (showLibmpvVideoOutputDialog) { + IosEnumSelectionDialog( + title = stringResource(Res.string.settings_playback_libmpv_video_output_dialog), + options = AndroidLibmpvVideoOutput.entries, + selected = androidLibmpvVideoOutput, + label = { it.label }, + description = { it.description }, + onSelect = { + PlayerSettingsRepository.setAndroidLibmpvVideoOutput(it) + showLibmpvVideoOutputDialog = false + }, + onDismiss = { showLibmpvVideoOutputDialog = false }, + ) + } + if (showHoldToSpeedValueDialog) { HoldToSpeedValueDialog( selectedSpeed = holdToSpeedValue, @@ -1946,6 +2028,99 @@ private fun DecoderPriorityDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun PlaybackEngineDialog( + selectedEngine: AndroidPlaybackEngine, + onEngineSelected: (AndroidPlaybackEngine) -> Unit, + onDismiss: () -> Unit, +) { + val descriptions = mapOf( + AndroidPlaybackEngine.Auto to Res.string.settings_playback_engine_auto_description, + AndroidPlaybackEngine.ExoPlayer to Res.string.settings_playback_engine_exoplayer_description, + AndroidPlaybackEngine.Libmpv to Res.string.settings_playback_engine_libmpv_description, + ) + + 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 = stringResource(Res.string.settings_playback_engine), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AndroidPlaybackEngine.entries.forEach { engine -> + val isSelected = engine == selectedEngine + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onEngineSelected(engine) }, + shape = RoundedCornerShape(14.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = engine.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(descriptions.getValue(engine)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun IosEnumSelectionDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index dd1551b07..ea95d9ffd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -69,6 +69,8 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.AndroidLibmpvVideoOutput +import com.nuvio.app.features.player.AndroidPlaybackEngine import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository @@ -258,6 +260,10 @@ fun SettingsScreen( secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, streamReuseLastLinkEnabled = playerSettingsUiState.streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = playerSettingsUiState.streamReuseLastLinkCacheHours, + androidPlaybackEngine = playerSettingsUiState.androidPlaybackEngine, + androidLibmpvVideoOutput = playerSettingsUiState.androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = playerSettingsUiState.androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = playerSettingsUiState.androidLibmpvYuv420pEnabled, decoderPriority = playerSettingsUiState.decoderPriority, mapDV7ToHevc = playerSettingsUiState.mapDV7ToHevc, tunnelingEnabled = playerSettingsUiState.tunnelingEnabled, @@ -309,6 +315,10 @@ fun SettingsScreen( secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, streamReuseLastLinkEnabled = playerSettingsUiState.streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = playerSettingsUiState.streamReuseLastLinkCacheHours, + androidPlaybackEngine = playerSettingsUiState.androidPlaybackEngine, + androidLibmpvVideoOutput = playerSettingsUiState.androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = playerSettingsUiState.androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = playerSettingsUiState.androidLibmpvYuv420pEnabled, decoderPriority = playerSettingsUiState.decoderPriority, mapDV7ToHevc = playerSettingsUiState.mapDV7ToHevc, tunnelingEnabled = playerSettingsUiState.tunnelingEnabled, @@ -370,6 +380,10 @@ private fun MobileSettingsScreen( secondaryPreferredSubtitleLanguage: String?, streamReuseLastLinkEnabled: Boolean, streamReuseLastLinkCacheHours: Int, + androidPlaybackEngine: AndroidPlaybackEngine, + androidLibmpvVideoOutput: AndroidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled: Boolean, + androidLibmpvYuv420pEnabled: Boolean, decoderPriority: Int, mapDV7ToHevc: Boolean, tunnelingEnabled: Boolean, @@ -541,6 +555,10 @@ private fun MobileSettingsScreen( secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + androidPlaybackEngine = androidPlaybackEngine, + androidLibmpvVideoOutput = androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled, decoderPriority = decoderPriority, mapDV7ToHevc = mapDV7ToHevc, tunnelingEnabled = tunnelingEnabled, @@ -696,6 +714,10 @@ private fun TabletSettingsScreen( secondaryPreferredSubtitleLanguage: String?, streamReuseLastLinkEnabled: Boolean, streamReuseLastLinkCacheHours: Int, + androidPlaybackEngine: AndroidPlaybackEngine, + androidLibmpvVideoOutput: AndroidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled: Boolean, + androidLibmpvYuv420pEnabled: Boolean, decoderPriority: Int, mapDV7ToHevc: Boolean, tunnelingEnabled: Boolean, @@ -926,6 +948,10 @@ private fun TabletSettingsScreen( secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + androidPlaybackEngine = androidPlaybackEngine, + androidLibmpvVideoOutput = androidLibmpvVideoOutput, + androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled, + androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled, decoderPriority = decoderPriority, mapDV7ToHevc = mapDV7ToHevc, tunnelingEnabled = tunnelingEnabled, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 8541b4079..4e60c4d2e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -42,6 +42,10 @@ actual object PlayerSettingsStorage { private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode" private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" + private const val androidPlaybackEngineKey = "android_playback_engine" + private const val androidLibmpvVideoOutputKey = "android_libmpv_video_output" + private const val androidLibmpvHardwareDecodingEnabledKey = "android_libmpv_hardware_decoding_enabled" + private const val androidLibmpvYuv420pEnabledKey = "android_libmpv_yuv420p_enabled" private const val decoderPriorityKey = "decoder_priority" private const val mapDV7ToHevcKey = "map_dv7_to_hevc" private const val tunnelingEnabledKey = "tunneling_enabled" @@ -105,6 +109,10 @@ actual object PlayerSettingsStorage { addonSubtitleStartupModeKey, streamReuseLastLinkEnabledKey, streamReuseLastLinkCacheHoursKey, + androidPlaybackEngineKey, + androidLibmpvVideoOutputKey, + androidLibmpvHardwareDecodingEnabledKey, + androidLibmpvYuv420pEnabledKey, decoderPriorityKey, mapDV7ToHevcKey, tunnelingEnabledKey, @@ -450,6 +458,48 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setInteger(hours.toLong(), forKey = ProfileScopedKey.of(streamReuseLastLinkCacheHoursKey)) } + actual fun loadAndroidPlaybackEngine(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(androidPlaybackEngineKey)) + + actual fun saveAndroidPlaybackEngine(engine: String) { + NSUserDefaults.standardUserDefaults.setObject(engine, forKey = ProfileScopedKey.of(androidPlaybackEngineKey)) + } + + actual fun loadAndroidLibmpvVideoOutput(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(androidLibmpvVideoOutputKey)) + + actual fun saveAndroidLibmpvVideoOutput(output: String) { + NSUserDefaults.standardUserDefaults.setObject(output, forKey = ProfileScopedKey.of(androidLibmpvVideoOutputKey)) + } + + actual fun loadAndroidLibmpvHardwareDecodingEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(androidLibmpvHardwareDecodingEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveAndroidLibmpvHardwareDecodingEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(androidLibmpvHardwareDecodingEnabledKey)) + } + + actual fun loadAndroidLibmpvYuv420pEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(androidLibmpvYuv420pEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveAndroidLibmpvYuv420pEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(androidLibmpvYuv420pEnabledKey)) + } + actual fun loadDecoderPriority(): Int? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(decoderPriorityKey) @@ -837,6 +887,12 @@ actual object PlayerSettingsStorage { loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) } loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } + loadAndroidPlaybackEngine()?.let { put(androidPlaybackEngineKey, encodeSyncString(it)) } + loadAndroidLibmpvVideoOutput()?.let { put(androidLibmpvVideoOutputKey, encodeSyncString(it)) } + loadAndroidLibmpvHardwareDecodingEnabled()?.let { + put(androidLibmpvHardwareDecodingEnabledKey, encodeSyncBoolean(it)) + } + loadAndroidLibmpvYuv420pEnabled()?.let { put(androidLibmpvYuv420pEnabledKey, encodeSyncBoolean(it)) } loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } @@ -904,6 +960,11 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode) payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) + payload.decodeSyncString(androidPlaybackEngineKey)?.let(::saveAndroidPlaybackEngine) + payload.decodeSyncString(androidLibmpvVideoOutputKey)?.let(::saveAndroidLibmpvVideoOutput) + payload.decodeSyncBoolean(androidLibmpvHardwareDecodingEnabledKey) + ?.let(::saveAndroidLibmpvHardwareDecodingEnabled) + payload.decodeSyncBoolean(androidLibmpvYuv420pEnabledKey)?.let(::saveAndroidLibmpvYuv420pEnabled) payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d9dd170c..ea27f7d4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ atomicfu = "0.32.1" ktor = "3.4.1" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" +mpv-android-lib = "0.1.12" supabase = "3.4.1" quickjsKt = "1.0.5" ksoup = "0.2.6" @@ -72,6 +73,7 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" } androidx-media3-container = { module = "androidx.media3:media3-container", version.ref = "androidx-media3" } androidx-media3-extractor = { module = "androidx.media3:media3-extractor", version.ref = "androidx-media3" } +mpv-android-lib = { module = "io.github.abdallahmehiz:mpv-android-lib", version.ref = "mpv-android-lib" } supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" } supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" } supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", version.ref = "supabase" } From 3c1bdd3e187f67faf0aaad7024205865e7c84519 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:12:43 +0530 Subject: [PATCH 48/60] update proguard rules and bump version --- composeApp/proguard-rules.pro | 3 + .../features/player/PlayerEngine.android.kt | 1 - .../com/nuvio/app/features/home/HomeScreen.kt | 7 +- .../components/HomeContinueWatchingSection.kt | 92 +++++++------------ iosApp/Configuration/Version.xcconfig | 4 +- mpvKt | 1 + 6 files changed, 44 insertions(+), 64 deletions(-) create mode 160000 mpvKt diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index 7bd3949ea..49a3ed451 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -51,6 +51,9 @@ -keep class com.google.android.exoplayer2.** { *; } -keep interface com.google.android.exoplayer2.** { *; } +-keep class is.xyz.mpv.** { *; } +-keep interface is.xyz.mpv.** { *; } + # Common optional security providers used by okhttp on some devices. -dontwarn okhttp3.internal.platform.** -dontwarn org.conscrypt.** diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 9df72333b..e8621d641 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -51,7 +51,6 @@ import androidx.media3.exoplayer.ForwardingRenderer import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MergingMediaSource -import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 0f9763463..917700ab2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -746,7 +746,7 @@ fun HomeScreen( when { !hasActiveAddons && !hasRenderableCollectionRows -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, @@ -771,7 +771,7 @@ fun HomeScreen( homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, @@ -819,7 +819,7 @@ fun HomeScreen( else -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, @@ -877,6 +877,7 @@ fun HomeScreen( } private const val HOME_CATALOG_PREVIEW_LIMIT = 18 +private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 99f36172d..fc5bb09f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -241,67 +240,44 @@ private fun HomeContinueWatchingSectionContent( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() - val itemOrderKey = remember(items) { - items.joinToString(separator = "|") { item -> item.continueWatchingRowOrderKey() } - } - - key(itemOrderKey) { - NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), - entries = items, - modifier = modifier, - headerHorizontalPadding = sectionPadding, - rowContentPadding = PaddingValues(horizontal = sectionPadding), - itemSpacing = layout.itemGap, - showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, - key = { item -> item.videoId }, - ) { item -> - when (style) { - ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( - item = item, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - } + NuvioShelfSection( + title = stringResource(Res.string.compose_settings_page_continue_watching), + entries = items, + modifier = modifier, + headerHorizontalPadding = sectionPadding, + rowContentPadding = PaddingValues(horizontal = sectionPadding), + itemSpacing = layout.itemGap, + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, + key = { item -> item.videoId }, + ) { item -> + when (style) { + ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( + item = item, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) } } } -private fun ContinueWatchingItem.continueWatchingRowOrderKey(): String = - buildString { - append(if (isNextUp) "next" else "progress") - append(':') - append(parentMetaId) - append(':') - append(videoId) - append(':') - append(seasonNumber) - append('x') - append(episodeNumber) - append(":seed=") - append(nextUpSeedSeasonNumber) - append('x') - append(nextUpSeedEpisodeNumber) - } - @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 6f9ed2c37..fd39ee076 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=83 -MARKETING_VERSION=0.2.11 +CURRENT_PROJECT_VERSION=84 +MARKETING_VERSION=0.2.12 diff --git a/mpvKt b/mpvKt new file mode 160000 index 000000000..01a93062d --- /dev/null +++ b/mpvKt @@ -0,0 +1 @@ +Subproject commit 01a93062dd73f642cbb0511f6a05f1fd344b36a9 From 611b934000b7b79d692d232864aef082391957fc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:25:08 +0530 Subject: [PATCH 49/60] update workflows --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- .github/workflows/close-stale-issues.yml | 86 ++++++++++++++++++++++++ .github/workflows/stale-needs-info.yml | 46 ++++++------- 3 files changed, 108 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/close-stale-issues.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1229aba48..ef13405bc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,8 @@ body: If we can reproduce it, we can usually fix it. Please describe the bug in enough detail that someone else can understand what failed without watching a video or guessing from the title. Please replace the default title with a short summary of the actual problem. - Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed if the missing details are not added. + Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed after 1 day if the missing details are not added. + Bug reports left open for more than 30 days may be closed as stale. If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml new file mode 100644 index 000000000..c0723d01e --- /dev/null +++ b/.github/workflows/close-stale-issues.yml @@ -0,0 +1,86 @@ +name: Close stale bug issues + +on: + schedule: + - cron: "29 6 * * *" # daily + workflow_dispatch: + inputs: + dry_run: + description: Log matching issues without commenting or closing them + required: false + type: boolean + default: false + +permissions: + issues: write + +jobs: + close_stale: + runs-on: ubuntu-latest + steps: + - name: Close bug issues open longer than 30 days + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const inputs = context.payload.inputs || {}; + + const dryRun = String(inputs.dry_run || "false").toLowerCase() === "true"; + const CLOSE_AFTER_DAYS = 30; + const closeMarker = ""; + + const cutoff = new Date(Date.now() - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const items = await github.paginate(github.rest.search.issuesAndPullRequests, { + q: `repo:${owner}/${repo} is:issue is:open label:bug created:<${cutoff}`, + per_page: 100, + }); + + core.info(`Found ${items.length} open bug issues older than ${CLOSE_AFTER_DAYS} days.`); + + for (const item of items) { + const issue_number = item.number; + + if (dryRun) { + core.info(`#${issue_number}: would comment and close.`); + continue; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const alreadyCommented = comments.some(comment => + (comment.body || "").includes(closeMarker) + ); + + if (!alreadyCommented) { + const body = + `${closeMarker}\n` + + `Closing this bug report because it has been open for more than 30 days.\n\n` + + `If this is still relevant, please open a fresh issue with the latest details.`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + state_reason: "not_planned", + }); + + core.info(`#${issue_number}: closed.`); + } diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml index 803b43341..97fce1207 100644 --- a/.github/workflows/stale-needs-info.yml +++ b/.github/workflows/stale-needs-info.yml @@ -1,4 +1,4 @@ -name: Close stale needs-info issues +name: Close needs-info bug issues on: schedule: @@ -12,7 +12,7 @@ jobs: close_stale: runs-on: ubuntu-latest steps: - - name: Warn then close inactive needs-info + - name: Close inactive needs-info bug issues uses: actions/github-script@v7 with: script: | @@ -20,20 +20,16 @@ jobs: const repo = context.repo.repo; const NEEDS_INFO = "needs-info"; - const WARN_AFTER_DAYS = 14; - const CLOSE_AFTER_DAYS = 21; + const CLOSE_AFTER_DAYS = 1; - const warnMarker = ""; const closeMarker = ""; const now = Date.now(); - const warnCutoff = now - WARN_AFTER_DAYS * 24 * 60 * 60 * 1000; const closeCutoff = now - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000; async function listOpenNeedsInfoIssues() { - const q = `repo:${owner}/${repo} is:issue is:open label:"${NEEDS_INFO}"`; - const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 50 }); - return res.data.items || []; + const q = `repo:${owner}/${repo} is:issue is:open label:bug label:"${NEEDS_INFO}"`; + return github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 }); } const items = await listOpenNeedsInfoIssues(); @@ -47,24 +43,22 @@ jobs: issue_number, per_page: 100, }); - const hasWarned = comments.some(c => (c.body || "").includes(warnMarker)); const hasClosedComment = comments.some(c => (c.body || "").includes(closeMarker)); - if (updatedAtMs <= closeCutoff && hasWarned && !hasClosedComment) { - const body = - `${closeMarker}\n` + - `Closing this for now since we didn't get the requested details.\n\n` + - `If you can share the missing info, reply here and we can reopen.`; - await github.rest.issues.createComment({ owner, repo, issue_number, body }); - await github.rest.issues.update({ owner, repo, issue_number, state: "closed" }); - continue; - } - - if (updatedAtMs <= warnCutoff && !hasWarned) { - const body = - `${warnMarker}\n` + - `Just a quick ping: this issue is labeled \`${NEEDS_INFO}\` and hasn't had any updates in a bit.\n\n` + - `If you can add the missing details, we can keep going. Otherwise it may be closed after a grace period.`; - await github.rest.issues.createComment({ owner, repo, issue_number, body }); + if (updatedAtMs <= closeCutoff) { + if (!hasClosedComment) { + const body = + `${closeMarker}\n` + + `Closing this bug report since we didn't get the requested details within 1 day.\n\n` + + `If you can share the missing info, reply here and we can reopen.`; + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + state_reason: "not_planned", + }); } } From 69f0dab65e42fe8b2844a62b3eb5bc3945c1f228 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:43:27 +0530 Subject: [PATCH 50/60] feat: local db switch for development --- composeApp/build.gradle.kts | 42 ++++ .../core/build/AppFeaturePolicy.android.kt | 1 + .../core/build/AppFeaturePolicy.android.kt | 1 + .../composeResources/values/strings.xml | 3 + .../nuvio/app/core/build/AppFeaturePolicy.kt | 1 + .../app/core/network/SyncBackendConfig.kt | 4 +- .../app/core/network/SyncBackendRepository.kt | 55 +++++- .../com/nuvio/app/features/auth/AuthScreen.kt | 11 ++ .../features/dev/DebugSyncBackendSwitch.kt | 182 ++++++++++++++++++ .../features/settings/AccountSettingsPage.kt | 20 +- .../core/build/AppFeaturePolicy.desktop.kt | 1 + .../app/core/build/AppFeaturePolicy.ios.kt | 1 + .../app/core/build/AppFeaturePolicy.ios.kt | 1 + 13 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5d159c4c2..1d971e6a3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -40,6 +40,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val syncBackendManifestUrl: Property + @get:Input + abstract val debugBuild: Property + @TaskAction fun generate() { val props = Properties() @@ -140,6 +143,15 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |} """.trimMargin() ) + resolve("AppBuildConfig.kt").writeText( + """ + |package com.nuvio.app.core.build + | + |object AppBuildConfig { + | const val IS_DEBUG_BUILD = ${debugBuild.get()} + |} + """.trimMargin() + ) } outDir.resolve("com/nuvio/app/features/settings").apply { @@ -256,6 +268,35 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String = ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } ?: fallback +fun booleanConfigValue(key: String): Boolean? { + val rawValue = runtimeLocalProperties.getProperty(key) + ?: providers.environmentVariable(key).orNull + ?: providers.gradleProperty(key).orNull + return rawValue + ?.trim() + ?.lowercase() + ?.let { value -> + when (value) { + "1", "true", "yes", "y", "debug" -> true + "0", "false", "no", "n", "release" -> false + else -> null + } + } +} + +val xcodeConfiguration = providers.environmentVariable("CONFIGURATION").orNull + ?.trim() + ?.lowercase() +val kotlinFrameworkBuildType = providers.environmentVariable("KOTLIN_FRAMEWORK_BUILD_TYPE").orNull + ?.trim() + ?.lowercase() +val inferredDebugBuild = requestedGradleTasks.any { "debug" in it } || + xcodeConfiguration == "debug" || + kotlinFrameworkBuildType == "debug" +val isDebugBuild = booleanConfigValue("NUVIO_DEBUG_BUILD") + ?: booleanConfigValue("nuvio.debugBuild") + ?: inferredDebugBuild + val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) @@ -266,6 +307,7 @@ val generateRuntimeConfigs = tasks.register("generat nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL")) + debugBuild.set(isDebugBuild) } tasks.withType>().configureEach { diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 996401f6b..62e4aa331 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -7,4 +7,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = true actual val inAppUpdaterEnabled: Boolean = true actual val imdbRatingLogoEnabled: Boolean = true + actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 2b308a232..1a639c5cc 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -7,4 +7,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false + actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e84da9962..008521a52 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -19,6 +19,7 @@ Retry Save Saving… + Switch Validate Installing Addons @@ -533,6 +534,8 @@ Anonymous Signed in Sync backend + Switch backend? + Switch to %1$s and sign out? You can sign in again on the selected backend. AMOLED Black Use pure black backgrounds for OLED screens. App Language diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index 8e62eb8ed..55f7c1e16 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -12,4 +12,5 @@ expect object AppFeaturePolicy { val heroTrailerPlaybackSupported: Boolean val inAppUpdaterEnabled: Boolean val imdbRatingLogoEnabled: Boolean + val debugBackendSwitcherEnabled: Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt index ca45da22e..9510e6c17 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -45,6 +45,7 @@ data class SyncBackendState( val appliedRevision: String = "", val isLoaded: Boolean = false, val lastManifestError: String? = null, + val isManualDebugOverride: Boolean = false, ) sealed interface SyncBackendRefreshResult { @@ -68,6 +69,7 @@ internal data class StoredSyncBackendSelection( val backend: SyncBackendConfig? = null, val backendId: String = "", val appliedRevision: String = "", + val manualDebugOverride: Boolean = false, ) object SyncBackendDefaults { @@ -113,7 +115,7 @@ internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendCon ?.takeIf { it.isUsableClientConfig() } } -private fun SyncBackendConfig.isUsableClientConfig(): Boolean = +internal fun SyncBackendConfig.isUsableClientConfig(): Boolean = id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) && normalizedSupabaseUrl.startsWith("https://") && anonKey.isNotBlank() && diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt index db3065293..8491efc82 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.network import co.touchlab.kermit.Logger +import com.nuvio.app.core.build.AppFeaturePolicy import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,23 +36,37 @@ object SyncBackendRepository { .getOrNull() } - val backend = storedSelection - ?.let { selection -> - selection.backendId.ifBlank { selection.backend?.id.orEmpty() } - } - ?.let(SyncBackendDefaults::byId) - ?: SyncBackendDefaults.hosted() + val storedManualDebugOverride = storedSelection?.manualDebugOverride == true + val backend = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) { + SyncBackendDefaults.hosted() + } else { + storedSelection + ?.let { selection -> + selection.backendId.ifBlank { selection.backend?.id.orEmpty() } + } + ?.let(SyncBackendDefaults::byId) + ?: SyncBackendDefaults.hosted() + } _state.value = SyncBackendState( selectedBackend = backend, - appliedRevision = storedSelection?.appliedRevision.orEmpty(), + appliedRevision = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) { + "" + } else { + storedSelection?.appliedRevision.orEmpty() + }, isLoaded = true, + isManualDebugOverride = storedManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled, ) } suspend fun refreshFromManifest(): SyncBackendRefreshResult { ensureLoaded() + if (_state.value.isManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled) { + return SyncBackendRefreshResult.Unchanged + } + val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim() if (manifestUrl.isBlank()) { return SyncBackendRefreshResult.NotConfigured @@ -97,19 +112,40 @@ object SyncBackendRepository { revision: String, ): SyncBackendConfig { val normalizedBackend = backend.normalized() - saveSelection(normalizedBackend, revision) + saveSelection(normalizedBackend, revision, manualDebugOverride = false) + return normalizedBackend + } + + fun debugSelectableBackends(): List = + listOf(SyncBackendDefaults.hosted(), SyncBackendDefaults.nuvio()) + .filter { backend -> backend.isUsableClientConfig() } + + fun applyDebugBackendAfterLogout(backend: SyncBackendConfig): SyncBackendConfig? { + if (!AppFeaturePolicy.debugBackendSwitcherEnabled) return null + + val normalizedBackend = backend.normalized() + .takeIf { it.isUsableClientConfig() } + ?: return null + + saveSelection( + backend = normalizedBackend, + revision = DEBUG_MANUAL_REVISION, + manualDebugOverride = true, + ) return normalizedBackend } private fun saveSelection( backend: SyncBackendConfig, revision: String, + manualDebugOverride: Boolean = false, ) { val normalizedBackend = backend.normalized() val payload = json.encodeToString( StoredSyncBackendSelection( backendId = normalizedBackend.id, appliedRevision = revision, + manualDebugOverride = manualDebugOverride, ), ) SyncBackendStorage.saveSelectionPayload(payload) @@ -117,6 +153,9 @@ object SyncBackendRepository { selectedBackend = normalizedBackend, appliedRevision = revision, isLoaded = true, + isManualDebugOverride = manualDebugOverride, ) } + + private const val DEBUG_MANUAL_REVISION = "debug-manual" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt index 11e3a9e65..0f84bacca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt @@ -69,6 +69,8 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.ui.nuvioOverlayGradientBrush import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioSurfaceCard +import com.nuvio.app.features.dev.DebugSyncBackendSwitch +import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -157,6 +159,15 @@ fun AuthScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (shouldShowDebugSyncBackendSwitch()) { + Spacer(modifier = Modifier.height(24.dp)) + DebugSyncBackendSwitch( + modifier = Modifier.fillMaxWidth(), + requireConfirmation = false, + container = true, + ) + } + Spacer(modifier = Modifier.height(48.dp)) NuvioSurfaceCard { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt new file mode 100644 index 000000000..7abe984b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/dev/DebugSyncBackendSwitch.kt @@ -0,0 +1,182 @@ +package com.nuvio.app.features.dev + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.rememberCoroutineScope +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.network.SYNC_BACKEND_HOSTED_ID +import com.nuvio.app.core.network.SYNC_BACKEND_NUVIO_ID +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.network.SyncBackendConfig +import com.nuvio.app.core.network.SyncBackendRepository +import com.nuvio.app.core.network.hasSameConnectionIdentity +import com.nuvio.app.core.ui.NuvioStatusModal +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_switch +import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_message +import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_title +import nuvio.composeapp.generated.resources.settings_account_sync_backend +import org.jetbrains.compose.resources.stringResource + +internal fun shouldShowDebugSyncBackendSwitch(): Boolean = + AppFeaturePolicy.debugBackendSwitcherEnabled && + SyncBackendRepository.debugSelectableBackends().size >= 2 + +@Composable +internal fun DebugSyncBackendSwitch( + modifier: Modifier = Modifier, + requireConfirmation: Boolean, + container: Boolean = false, +) { + if (!shouldShowDebugSyncBackendSwitch()) return + + val backendState by SyncBackendRepository.state.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + val selectableBackends = remember { SyncBackendRepository.debugSelectableBackends() } + val hostedBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_HOSTED_ID } + ?: return + val nuvioBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_NUVIO_ID } + ?: return + val selectedBackend = backendState.selectedBackend + val nuvioSelected = selectedBackend.id == SYNC_BACKEND_NUVIO_ID + val targetBackend = if (nuvioSelected) hostedBackend else nuvioBackend + val tokens = MaterialTheme.nuvio + var pendingBackend by remember { mutableStateOf(null) } + var isSwitching by remember { mutableStateOf(false) } + + fun switchToBackend(backend: SyncBackendConfig) { + if (isSwitching || selectedBackend.hasSameConnectionIdentity(backend)) return + + isSwitching = true + coroutineScope.launch { + AuthRepository.resetForSyncBackendChange() + .onSuccess { + val appliedBackend = SyncBackendRepository.applyDebugBackendAfterLogout(backend) + if (appliedBackend != null) { + SupabaseProvider.rebuildClient() + NetworkStatusRepository.requestRefresh(force = true) + } + } + pendingBackend = null + isSwitching = false + } + } + + fun requestBackendSwitch(backend: SyncBackendConfig) { + if (selectedBackend.hasSameConnectionIdentity(backend)) return + + if (requireConfirmation) { + pendingBackend = backend + } else { + switchToBackend(backend) + } + } + + val content: @Composable () -> Unit = { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isSwitching) { requestBackendSwitch(targetBackend) } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = stringResource(Res.string.settings_account_sync_backend), + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + Text( + text = selectedBackend.displayName, + style = MaterialTheme.typography.bodyLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (isSwitching) { + CircularProgressIndicator( + color = tokens.colors.accent, + strokeWidth = NuvioTokens.Border.medium, + ) + } else { + Switch( + checked = nuvioSelected, + onCheckedChange = { checked -> + requestBackendSwitch(if (checked) nuvioBackend else hostedBackend) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = tokens.colors.onAccent, + checkedTrackColor = tokens.colors.accent, + uncheckedThumbColor = tokens.colors.textMuted, + uncheckedTrackColor = tokens.colors.borderDefault, + ), + ) + } + } + } + + if (container) { + Surface( + modifier = modifier.fillMaxWidth(), + color = tokens.colors.surface, + shape = tokens.shapes.compactCard, + border = BorderStroke(tokens.borders.hairline, tokens.colors.borderSubtle), + ) { + content() + } + } else { + Row(modifier = modifier.fillMaxWidth()) { + content() + } + } + + pendingBackend?.let { backend -> + NuvioStatusModal( + title = stringResource(Res.string.debug_backend_switch_confirm_title), + message = stringResource( + Res.string.debug_backend_switch_confirm_message, + backend.displayName, + ), + isVisible = true, + isBusy = isSwitching, + confirmText = stringResource(Res.string.action_switch), + dismissText = stringResource(Res.string.action_cancel), + onConfirm = { switchToBackend(backend) }, + onDismiss = { pendingBackend = null }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index 205140e42..9c875a37c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -29,6 +29,8 @@ import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import com.nuvio.app.features.dev.DebugSyncBackendSwitch +import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel @@ -100,11 +102,19 @@ private fun AccountSettingsBody( } } - Spacer(modifier = Modifier.height(8.dp)) - AccountInfoRow( - label = stringResource(Res.string.settings_account_sync_backend), - value = syncBackendLabel, - ) + if (shouldShowDebugSyncBackendSwitch()) { + Spacer(modifier = Modifier.height(8.dp)) + DebugSyncBackendSwitch( + modifier = Modifier.fillMaxWidth(), + requireConfirmation = true, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + AccountInfoRow( + label = stringResource(Res.string.settings_account_sync_backend), + value = syncBackendLabel, + ) + } } NuvioPrimaryButton( diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index 2dda9682e..ebffde7e9 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -7,4 +7,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true + actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 5f7b3114f..37660c9c2 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -7,4 +7,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = false + actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 670f8092a..2a17ddfd5 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -7,4 +7,5 @@ actual object AppFeaturePolicy { actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false actual val imdbRatingLogoEnabled: Boolean = true + actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD } From df8b05f1c20dfcfad9601c9642c6a54491d1dcf5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:22:53 +0530 Subject: [PATCH 51/60] Adjust App Store settings policy and copy --- .../core/build/AppFeaturePolicy.android.kt | 3 + .../core/build/AppFeaturePolicy.android.kt | 3 + .../composeResources/values/strings.xml | 6 + .../commonMain/kotlin/com/nuvio/app/App.kt | 16 ++- .../nuvio/app/core/build/AppFeaturePolicy.kt | 3 + .../nuvio/app/features/addons/AddonsScreen.kt | 50 ++++++++- .../features/settings/AccountSettingsPage.kt | 104 ++++++++++++++++++ .../settings/ContentDiscoverySettingsPage.kt | 10 +- .../app/features/settings/SettingsRootPage.kt | 19 ++-- .../app/features/settings/SettingsScreen.kt | 64 +++++++++-- .../app/features/settings/SettingsSearch.kt | 43 ++++++-- .../core/build/AppFeaturePolicy.desktop.kt | 3 + .../app/core/build/AppFeaturePolicy.ios.kt | 3 + .../app/core/build/AppFeaturePolicy.ios.kt | 3 + 14 files changed, 289 insertions(+), 41 deletions(-) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 62e4aa331..c09e2c295 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -2,6 +2,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true + actual val supportersContributorsPageEnabled: Boolean = true + actual val accountDeletionEnabled: Boolean = false + actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = true diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 1a639c5cc..3be7ac2f0 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -2,6 +2,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false + actual val supportersContributorsPageEnabled: Boolean = true + actual val accountDeletionEnabled: Boolean = false + actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 008521a52..ff7e6c75b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,11 @@ Unavailable Configure addon Delete addon + Connect your own media server to browse and play from your personal library. + Add a server URL above when you want Nuvio to show your private library. + No personal libraries connected. + Server URL + Add Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio. No addons installed yet. Enter an addon URL. @@ -634,6 +639,7 @@ HOME SOURCES Install, remove, refresh, and sort your content sources. + Connect personal media sources and manage access to your own library. Install JavaScript scraper repositories and test providers internally. Adjust home layout, content visibility, and poster behavior Settings for the detail and episode screens. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 8ac3a7b50..4f040a0be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1582,7 +1582,9 @@ private fun MainAppContent( }, onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, onSupportersContributorsSettingsClick = { - navController.navigate(SupportersContributorsSettingsRoute) + if (AppFeaturePolicy.supportersContributorsPageEnabled) { + navController.navigate(SupportersContributorsSettingsRoute) + } }, onLicensesAttributionsSettingsClick = { navController.navigate(LicensesAttributionsSettingsRoute) @@ -2681,9 +2683,15 @@ private fun MainAppContent( navController = navController, backStackEntry = backStackEntry, ) - SupportersContributorsSettingsScreen( - onBack = onBack, - ) + if (AppFeaturePolicy.supportersContributorsPageEnabled) { + SupportersContributorsSettingsScreen( + onBack = onBack, + ) + } else { + LaunchedEffect(Unit) { + onBack() + } + } } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index 55f7c1e16..3c3f74700 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -7,6 +7,9 @@ enum class TrailerPlaybackMode { expect object AppFeaturePolicy { val pluginsEnabled: Boolean + val supportersContributorsPageEnabled: Boolean + val accountDeletionEnabled: Boolean + val personalMediaAddonCopyEnabled: Boolean val p2pEnabled: Boolean val trailerPlaybackMode: TrailerPlaybackMode val heroTrailerPlaybackSupported: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 7e12666dc..2d7db4a85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.build.AppFeaturePolicy import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioIconActionButton import com.nuvio.app.core.ui.NuvioInfoBadge @@ -93,6 +94,7 @@ internal fun AddonsSettingsPageContent( var formMessage by rememberSaveable { mutableStateOf(null) } var installModalState by remember { mutableStateOf(null) } val enterAddonUrlMessage = stringResource(Res.string.addons_error_enter_url) + val usePersonalMediaCopy = AppFeaturePolicy.personalMediaAddonCopyEnabled val overview = remember(uiState.addons) { uiState.addons.toOverview() } @@ -107,6 +109,7 @@ internal fun AddonsSettingsPageContent( AddAddonCard( addonUrl = addonUrl, formMessage = formMessage, + usePersonalMediaCopy = usePersonalMediaCopy, onAddonUrlChange = { addonUrl = it formMessage = null @@ -138,7 +141,7 @@ internal fun AddonsSettingsPageContent( SectionHeader(stringResource(Res.string.addons_section_installed)) if (uiState.addons.isEmpty()) { - EmptyStateCard() + EmptyStateCard(usePersonalMediaCopy = usePersonalMediaCopy) } else { val lastIndex = uiState.addons.lastIndex uiState.addons.forEachIndexed { index, addon -> @@ -283,6 +286,7 @@ private fun VerticalSeparator() { private fun AddAddonCard( addonUrl: String, formMessage: String?, + usePersonalMediaCopy: Boolean, onAddonUrlChange: (String) -> Unit, onAddClick: () -> Unit, ) { @@ -290,14 +294,34 @@ private fun AddAddonCard( NuvioInputField( value = addonUrl, onValueChange = onAddonUrlChange, - placeholder = stringResource(Res.string.addons_input_placeholder), + placeholder = stringResource( + if (usePersonalMediaCopy) { + Res.string.addons_appstore_input_placeholder + } else { + Res.string.addons_input_placeholder + }, + ), ) Spacer(modifier = Modifier.height(18.dp)) NuvioPrimaryButton( - text = stringResource(Res.string.addons_install_button), + text = stringResource( + if (usePersonalMediaCopy) { + Res.string.addons_appstore_install_button + } else { + Res.string.addons_install_button + }, + ), enabled = addonUrl.isNotBlank(), onClick = onAddClick, ) + if (usePersonalMediaCopy) { + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = stringResource(Res.string.addons_appstore_add_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } formMessage?.let { message -> Spacer(modifier = Modifier.height(14.dp)) Text( @@ -330,16 +354,30 @@ private sealed interface AddonInstallModalState { } @Composable -private fun EmptyStateCard() { +private fun EmptyStateCard( + usePersonalMediaCopy: Boolean, +) { NuvioSurfaceCard { Text( - text = stringResource(Res.string.addons_empty_title), + text = stringResource( + if (usePersonalMediaCopy) { + Res.string.addons_appstore_empty_title + } else { + Res.string.addons_empty_title + }, + ), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(Res.string.addons_empty_subtitle), + text = stringResource( + if (usePersonalMediaCopy) { + Res.string.addons_appstore_empty_subtitle + } else { + Res.string.addons_empty_subtitle + }, + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index 9c875a37c..7cc47203c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,16 +27,24 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.dev.DebugSyncBackendSwitch import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.auth_account_deletion_failed +import nuvio.composeapp.generated.resources.settings_account_delete_account +import nuvio.composeapp.generated.resources.settings_account_delete_account_description +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title import nuvio.composeapp.generated.resources.settings_account_email import nuvio.composeapp.generated.resources.settings_account_not_signed_in import nuvio.composeapp.generated.resources.settings_account_sign_out @@ -62,7 +72,12 @@ private fun AccountSettingsBody( val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSignOutConfirm by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var isDeletingAccount by remember { mutableStateOf(false) } + var deleteErrorMessage by remember { mutableStateOf(null) } val syncBackendLabel = syncBackendState.selectedBackend.displayName + val deleteAccountFallbackMessage = stringResource(Res.string.auth_account_deletion_failed) + val canDeleteAccount = AppFeaturePolicy.accountDeletionEnabled && authState is AuthState.Authenticated Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { NuvioSurfaceCard { @@ -121,6 +136,16 @@ private fun AccountSettingsBody( text = stringResource(Res.string.settings_account_sign_out), onClick = { showSignOutConfirm = true }, ) + + if (canDeleteAccount) { + DeleteAccountCard( + errorMessage = deleteErrorMessage, + onDeleteClick = { + deleteErrorMessage = null + showDeleteConfirm = true + }, + ) + } } NuvioStatusModal( @@ -135,6 +160,85 @@ private fun AccountSettingsBody( }, onDismiss = { showSignOutConfirm = false }, ) + + NuvioStatusModal( + title = stringResource(Res.string.settings_account_delete_confirm_title), + message = stringResource(Res.string.settings_account_delete_confirm_message), + isVisible = showDeleteConfirm, + isBusy = isDeletingAccount, + confirmText = stringResource(Res.string.settings_account_delete_account), + dismissText = stringResource(Res.string.action_cancel), + onConfirm = { + if (isDeletingAccount) return@NuvioStatusModal + isDeletingAccount = true + scope.launch { + val result = AuthRepository.deleteAccount() + isDeletingAccount = false + showDeleteConfirm = false + deleteErrorMessage = if (result.isSuccess) { + null + } else { + AuthRepository.error.value + ?: result.exceptionOrNull()?.message + ?: deleteAccountFallbackMessage + } + } + }, + onDismiss = { + if (!isDeletingAccount) { + showDeleteConfirm = false + } + }, + ) +} + +@Composable +private fun DeleteAccountCard( + errorMessage: String?, + onDeleteClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + NuvioSurfaceCard { + Text( + text = stringResource(Res.string.settings_account_delete_account), + style = MaterialTheme.typography.titleMedium, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.settings_account_delete_account_description), + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + errorMessage?.let { message -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = tokens.colors.danger, + ) + } + Spacer(modifier = Modifier.height(14.dp)) + Button( + onClick = onDeleteClick, + modifier = Modifier + .fillMaxWidth() + .height(NuvioTokens.Space.s48 + NuvioTokens.Space.s4), + shape = tokens.shapes.button, + colors = ButtonDefaults.buttonColors( + containerColor = tokens.colors.danger, + contentColor = tokens.colors.textInverse, + ), + ) { + Text( + text = stringResource(Res.string.settings_account_delete_account), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + } + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 438fd3c30..bd2652ca0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Hub import androidx.compose.material.icons.rounded.Tune +import com.nuvio.app.core.build.AppFeaturePolicy import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_addons import nuvio.composeapp.generated.resources.compose_settings_page_homescreen @@ -14,6 +15,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen import nuvio.composeapp.generated.resources.compose_settings_page_plugins import nuvio.composeapp.generated.resources.collections_header import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description +import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description_appstore import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description @@ -39,7 +41,13 @@ internal fun LazyListScope.contentDiscoveryContent( SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_addons), - description = stringResource(Res.string.settings_content_discovery_addons_description), + description = stringResource( + if (AppFeaturePolicy.personalMediaAddonCopyEnabled) { + Res.string.settings_content_discovery_addons_description_appstore + } else { + Res.string.settings_content_discovery_addons_description + }, + ), icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onAddonsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index f557f762b..36f04b12b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -79,6 +79,7 @@ internal fun LazyListScope.settingsRootContent( showGeneralSection: Boolean = true, showAboutSection: Boolean = true, showAdvancedSection: Boolean = true, + showSupportersContributorsPage: Boolean = true, ) { if (showAccountSection) { item { @@ -189,14 +190,16 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_supporters_contributors), - description = stringResource(Res.string.about_supporters_contributors_subtitle), - icon = Icons.Rounded.Favorite, - isTablet = isTablet, - onClick = onSupportersContributorsClick, - ) - SettingsGroupDivider(isTablet = isTablet) + if (showSupportersContributorsPage) { + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_supporters_contributors), + description = stringResource(Res.string.about_supporters_contributors_subtitle), + icon = Icons.Rounded.Favorite, + isTablet = isTablet, + onClick = onSupportersContributorsClick, + ) + SettingsGroupDivider(isTablet = isTablet) + } SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_licenses_attributions), description = stringResource(Res.string.about_licenses_attributions_subtitle), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index ea95d9ffd..fab7e7ed3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -94,6 +94,12 @@ private val SettingsSearchRevealThreshold = 28.dp private const val SettingsSearchRevealAnimationMillis = 240L private const val SettingsSearchRevealHapticDelayMillis = 90L +private fun SettingsPage.isEnabledByPolicy(): Boolean = + when (this) { + SettingsPage.SupportersContributors -> AppFeaturePolicy.supportersContributorsPageEnabled + else -> true + } + @Composable fun SettingsScreen( modifier: Modifier = Modifier, @@ -216,9 +222,20 @@ fun SettingsScreen( var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } val scrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } - val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } + val page = remember(currentPage) { + runCatching { SettingsPage.valueOf(currentPage) } + .getOrDefault(SettingsPage.Root) + .takeIf { it.isEnabledByPolicy() } + ?: SettingsPage.Root + } val previousPage = page.previousPage() + LaunchedEffect(page, currentPage) { + if (page.name != currentPage) { + currentPage = page.name + } + } + LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { rootActionRequests.collect { if (!rootActionsEnabled) return@collect @@ -232,9 +249,12 @@ fun SettingsScreen( } LaunchedEffect(requestedPageName, rootActionsEnabled) { - val targetPage = requestedPageName - ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } - ?: return@LaunchedEffect + val requestedPage = requestedPageName ?: return@LaunchedEffect + val targetPage = runCatching { SettingsPage.valueOf(requestedPage) }.getOrNull() + if (targetPage == null || !targetPage.isEnabledByPolicy()) { + onRequestedPageConsumed() + return@LaunchedEffect + } if (!rootActionsEnabled) return@LaunchedEffect currentPage = targetPage.name onRequestedPageConsumed() @@ -449,6 +469,9 @@ private fun MobileSettingsScreen( } val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + supportersContributorsPageEnabled = AppFeaturePolicy.supportersContributorsPageEnabled, + accountDeletionEnabled = AppFeaturePolicy.accountDeletionEnabled, + personalMediaAddonCopyEnabled = AppFeaturePolicy.personalMediaAddonCopyEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -458,7 +481,11 @@ private fun MobileSettingsScreen( when (target) { is SettingsSearchTarget.Page -> when (target.page) { SettingsPage.Account -> onAccountClick() - SettingsPage.SupportersContributors -> onSupportersContributorsClick() + SettingsPage.SupportersContributors -> { + if (AppFeaturePolicy.supportersContributorsPageEnabled) { + onSupportersContributorsClick() + } + } SettingsPage.LicensesAttributions -> onLicensesAttributionsClick() SettingsPage.ContinueWatching -> onContinueWatchingClick() SettingsPage.Addons -> onAddonsClick() @@ -531,15 +558,18 @@ private fun MobileSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, + showSupportersContributorsPage = AppFeaturePolicy.supportersContributorsPageEnabled, ) } } SettingsPage.Account -> accountSettingsContent( isTablet = false, ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = false, - ) + SettingsPage.SupportersContributors -> { + if (AppFeaturePolicy.supportersContributorsPageEnabled) { + supportersContributorsContent(isTablet = false) + } + } SettingsPage.LicensesAttributions -> licensesAttributionsContent( isTablet = false, ) @@ -822,6 +852,9 @@ private fun TabletSettingsScreen( val hapticScope = rememberCoroutineScope() val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + supportersContributorsPageEnabled = AppFeaturePolicy.supportersContributorsPageEnabled, + accountDeletionEnabled = AppFeaturePolicy.accountDeletionEnabled, + personalMediaAddonCopyEnabled = AppFeaturePolicy.personalMediaAddonCopyEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -829,7 +862,11 @@ private fun TabletSettingsScreen( fun openSearchTarget(target: SettingsSearchTarget) { when (target) { - is SettingsSearchTarget.Page -> openInlinePage(target.page) + is SettingsSearchTarget.Page -> { + if (target.page.isEnabledByPolicy()) { + openInlinePage(target.page) + } + } SettingsSearchTarget.Downloads -> onDownloadsClick() SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() @@ -924,15 +961,18 @@ private fun TabletSettingsScreen( showGeneralSection = activeCategory == SettingsCategory.General, showAboutSection = activeCategory == SettingsCategory.About, showAdvancedSection = activeCategory == SettingsCategory.Advanced, + showSupportersContributorsPage = AppFeaturePolicy.supportersContributorsPageEnabled, ) } } SettingsPage.Account -> accountSettingsContent( isTablet = true, ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = true, - ) + SettingsPage.SupportersContributors -> { + if (AppFeaturePolicy.supportersContributorsPageEnabled) { + supportersContributorsContent(isTablet = true) + } + } SettingsPage.LicensesAttributions -> licensesAttributionsContent( isTablet = true, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 248d63977..69ab6a9af 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -79,6 +79,9 @@ internal data class SettingsSearchEntry( @Composable internal fun settingsSearchEntries( pluginsEnabled: Boolean, + supportersContributorsPageEnabled: Boolean, + accountDeletionEnabled: Boolean, + personalMediaAddonCopyEnabled: Boolean, liquidGlassNativeTabBarSupported: Boolean, switchProfileAvailable: Boolean, checkForUpdatesAvailable: Boolean, @@ -261,14 +264,16 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_notifications_description), icon = Icons.Rounded.Notifications, ) - addPage( - page = SettingsPage.SupportersContributors, - key = "supporters", - title = supportersPage, - description = stringResource(Res.string.about_supporters_contributors_subtitle), - category = aboutCategory, - icon = Icons.Rounded.Favorite, - ) + if (supportersContributorsPageEnabled) { + addPage( + page = SettingsPage.SupportersContributors, + key = "supporters", + title = supportersPage, + description = stringResource(Res.string.about_supporters_contributors_subtitle), + category = aboutCategory, + icon = Icons.Rounded.Favorite, + ) + } addPage( page = SettingsPage.LicensesAttributions, key = "licenses-attributions", @@ -316,7 +321,7 @@ internal fun settingsSearchEntries( key = "check-updates", title = stringResource(Res.string.compose_settings_root_check_updates_title), description = stringResource(Res.string.compose_settings_root_check_updates_description), - page = supportersPage, + page = if (supportersContributorsPageEnabled) supportersPage else licensesPage, section = stringResource(Res.string.compose_settings_root_about_section), category = aboutCategory, icon = Icons.Rounded.CloudDownload, @@ -342,6 +347,18 @@ internal fun settingsSearchEntries( category = accountCategory, icon = Icons.Rounded.AccountCircle, ) + if (accountDeletionEnabled) { + addRow( + page = SettingsPage.Account, + key = "account-delete", + title = stringResource(Res.string.settings_account_delete_account), + description = stringResource(Res.string.settings_account_delete_account_description), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + } addRow( page = SettingsPage.Appearance, @@ -418,7 +435,13 @@ internal fun settingsSearchEntries( page = SettingsPage.Addons, key = "addons", title = addonsPage, - description = stringResource(Res.string.settings_content_discovery_addons_description), + description = stringResource( + if (personalMediaAddonCopyEnabled) { + Res.string.settings_content_discovery_addons_description_appstore + } else { + Res.string.settings_content_discovery_addons_description + }, + ), icon = Icons.Rounded.Extension, ) if (pluginsEnabled) { diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index ebffde7e9..9d23979f3 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -2,6 +2,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false + actual val supportersContributorsPageEnabled: Boolean = true + actual val accountDeletionEnabled: Boolean = false + actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 37660c9c2..20a1f5522 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -2,6 +2,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false + actual val supportersContributorsPageEnabled: Boolean = false + actual val accountDeletionEnabled: Boolean = true + actual val personalMediaAddonCopyEnabled: Boolean = true actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 2a17ddfd5..dc3e1839f 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -2,6 +2,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true + actual val supportersContributorsPageEnabled: Boolean = true + actual val accountDeletionEnabled: Boolean = false + actual val personalMediaAddonCopyEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = false From 3b94341ac479252f4792510847f1c43e2eedc9a9 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:39:41 +0530 Subject: [PATCH 52/60] cleanup --- mpvEx | 1 - mpvKt | 1 - 2 files changed, 2 deletions(-) delete mode 160000 mpvEx delete mode 160000 mpvKt diff --git a/mpvEx b/mpvEx deleted file mode 160000 index 4151a45f8..000000000 --- a/mpvEx +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48 diff --git a/mpvKt b/mpvKt deleted file mode 160000 index 01a93062d..000000000 --- a/mpvKt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 01a93062dd73f642cbb0511f6a05f1fd344b36a9 From 9d32d238f4f63981c40c959669310032f10f8db2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:55:30 +0530 Subject: [PATCH 53/60] fix: libmpv ass override. fixes #1403 --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index e8621d641..47e274125 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -1139,7 +1139,7 @@ private class NuvioLibmpvView( } override fun applySubtitleStyle(style: SubtitleStyleState) { - mpv.setPropertyString("sub-ass-override", "force") + mpv.setPropertyString("sub-ass-override", "no") mpv.setPropertyString("sub-color", style.textColor.toMpvColor()) mpv.setPropertyString("sub-back-color", style.backgroundColor.toMpvColor()) mpv.setPropertyString("sub-outline-color", style.outlineColor.toMpvColor()) From 87670e7cb09282be952370f93a248dd35d8b71b4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:55 +0530 Subject: [PATCH 54/60] ref: remove startup observer causing stale catalog setting push --- .../commonMain/kotlin/com/nuvio/app/App.kt | 4 - .../home/HomeCatalogSettingsRepository.kt | 15 +++- .../home/HomeCatalogSettingsSyncService.kt | 74 +------------------ 3 files changed, 16 insertions(+), 77 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4f040a0be..3abbb4eb2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -182,7 +182,6 @@ import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository import com.nuvio.app.features.collection.CollectionSyncService -import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy @@ -687,9 +686,6 @@ private fun MainAppContent( remember { CollectionSyncService.startObserving() } - remember { - HomeCatalogSettingsSyncService.startObserving() - } remember { ProfileSettingsSync.startObserving() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index c5bd40ad7..067502d36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -180,6 +180,7 @@ object HomeCatalogSettingsRepository { publish() persist() HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() } fun setHideCatalogUnderline(enabled: Boolean) { @@ -188,10 +189,11 @@ object HomeCatalogSettingsRepository { hideCatalogUnderline = enabled publish() persist() + HomeCatalogSettingsSyncService.triggerPush() } fun setHeroSourceEnabled(key: String, enabled: Boolean) { - updatePreference(key) { preference -> + updatePreference(key, pushRemote = false) { preference -> if (!enabled) { preference.copy(heroSourceEnabled = false) } else if (selectedHeroSourceCount(excludingKey = key) >= HERO_SOURCE_SELECTION_LIMIT) { @@ -224,6 +226,7 @@ object HomeCatalogSettingsRepository { publish() persist() HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() } fun moveUp(key: String) { @@ -249,6 +252,7 @@ object HomeCatalogSettingsRepository { publish() persist() HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() } private fun ensureLoaded() { @@ -385,14 +389,20 @@ object HomeCatalogSettingsRepository { private fun updatePreference( key: String, + pushRemote: Boolean = true, transform: (StoredHomeCatalogPreference) -> StoredHomeCatalogPreference, ) { ensureLoaded() val current = preferences[key] ?: return - preferences[key] = transform(current) + val updated = transform(current) + if (updated == current) return + preferences[key] = updated publish() persist() HomeRepository.applyCurrentSettings() + if (pushRemote) { + HomeCatalogSettingsSyncService.triggerPush() + } } private fun selectedHeroSourceCount(excludingKey: String? = null): Int { @@ -427,6 +437,7 @@ object HomeCatalogSettingsRepository { publish() persist() HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() } fun exportToSyncPayload(): SyncHomeCatalogPayload { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index 5a4851042..15f5ffc7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -3,23 +3,18 @@ package com.nuvio.app.features.home import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM -import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlin.concurrent.Volatile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -68,17 +63,6 @@ private data class PullToken( val profileId: Int, ) -private data class ObservedHomeCatalogChange( - val signature: String, - val token: PullToken?, - val initialPullCompleteAtEmission: Boolean, -) - -private data class HomeCatalogChangeSignature( - val signature: String, - val token: PullToken, -) - object HomeCatalogSettingsSyncService { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("HomeCatalogSettingsSyncService") @@ -87,7 +71,6 @@ object HomeCatalogSettingsSyncService { encodeDefaults = true } - private const val PUSH_DEBOUNCE_MS = 1500L private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @@ -95,19 +78,10 @@ object HomeCatalogSettingsSyncService { var isSyncingFromRemote: Boolean = false private var pushJob: Job? = null - private var observeJob: Job? = null @Volatile private var completedInitialPull: PullToken? = null - @Volatile - private var remoteAppliedSignature: HomeCatalogChangeSignature? = null - - fun startObserving() { - if (observeJob?.isActive == true) return - observeLocalChangesAndPush() - } - suspend fun pullFromServer(profileId: Int) { runCatching { val pullToken = currentPullToken(profileId) ?: return @@ -124,12 +98,12 @@ object HomeCatalogSettingsSyncService { if (remotePayload.items.isEmpty()) { log.i { "pullFromServer — remote has empty items, preserving local catalog order" } - applyRemotePayload(remotePayload, pullToken) + applyRemotePayload(remotePayload) markInitialPullComplete(pullToken) return } - applyRemotePayload(remotePayload, pullToken) + applyRemotePayload(remotePayload) log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } markInitialPullComplete(pullToken) }.onFailure { e -> @@ -170,43 +144,6 @@ object HomeCatalogSettingsSyncService { } } - @OptIn(FlowPreview::class) - private fun observeLocalChangesAndPush() { - observeJob = scope.launch { - HomeCatalogSettingsRepository.uiState - .map { state -> - val token = currentPullToken() - ObservedHomeCatalogChange( - signature = state.signature, - token = token, - initialPullCompleteAtEmission = token?.let(::hasCompletedInitialPull) == true, - ) - } - .drop(1) - .distinctUntilChanged() - .debounce(PUSH_DEBOUNCE_MS) - .collect { change -> - val token = change.token ?: return@collect - val changeSignature = HomeCatalogChangeSignature(change.signature, token) - if (!change.initialPullCompleteAtEmission) { - if (changeSignature == remoteAppliedSignature) { - remoteAppliedSignature = null - } - log.d { "observeLocalChangesAndPush — skipped before initial home catalog pull completed" } - return@collect - } - if (changeSignature == remoteAppliedSignature) { - remoteAppliedSignature = null - log.d { "observeLocalChangesAndPush — skipped remote-applied catalog change" } - return@collect - } - if (isSyncingFromRemote) return@collect - if (currentPullToken() != token) return@collect - pushToRemote(token.profileId) - } - } - } - private fun currentPullToken(profileId: Int = ProfileRepository.activeProfileId): PullToken? { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) return null @@ -225,15 +162,10 @@ object HomeCatalogSettingsSyncService { private fun applyRemotePayload( payload: SyncHomeCatalogPayload, - token: PullToken, ) { isSyncingFromRemote = true try { HomeCatalogSettingsRepository.applyFromRemote(payload) - remoteAppliedSignature = HomeCatalogChangeSignature( - signature = HomeCatalogSettingsRepository.uiState.value.signature, - token = token, - ) } finally { isSyncingFromRemote = false } From 4c7af0891ea9878d46f25098032d3a63e8d339a8 Mon Sep 17 00:00:00 2001 From: siriusvoid <271671657+siriusvoid@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:51:18 +0000 Subject: [PATCH 55/60] fix: styled ASS/SSA subtitles rendering flattened on iOS --- iosApp/iosApp/Player/MPVPlayerBridge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 37111147e..cd99196b9 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -621,7 +621,7 @@ final class MPVPlayerViewController: UIViewController { ) { guard mpv != nil else { return } - checkError(mpv_set_property_string(mpv, "sub-ass-override", "force")) + checkError(mpv_set_property_string(mpv, "sub-ass-override", "no")) checkError(mpv_set_property_string(mpv, "sub-color", textColor)) checkError(mpv_set_property_string(mpv, "sub-back-color", backgroundColor)) checkError(mpv_set_property_string(mpv, "sub-outline-color", outlineColor)) From d767f60d3580f39ae0347b4df4ce9969613ac4df Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:04:17 +0530 Subject: [PATCH 56/60] Apply profile mesh background to loading screens --- .../commonMain/kotlin/com/nuvio/app/App.kt | 17 +++- .../app/core/ui/ProfileMeshBackground.kt | 91 +++++++++++++++++++ .../profiles/ProfileSelectionScreen.kt | 23 ++--- 3 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProfileMeshBackground.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3abbb4eb2..8a02385b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -99,6 +99,7 @@ import com.nuvio.app.core.ui.configurePlatformImageLoader import com.nuvio.app.core.ui.NuvioToastHost import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt +import com.nuvio.app.core.ui.ProfileMeshBackground import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens @@ -166,6 +167,7 @@ import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab +import com.nuvio.app.features.profiles.parseHexColor import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen @@ -733,6 +735,10 @@ private fun MainAppContent( } } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val launchOverlayProfileColor = remember(profileState.activeProfile, profileState.profiles) { + val sourceProfile = profileState.activeProfile ?: profileState.profiles.firstOrNull() + sourceProfile?.avatarColorHex?.let(::parseHexColor) ?: Color(0xFF1E88E5) + } val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState @@ -2913,7 +2919,10 @@ private fun MainAppContent( enter = fadeIn(), exit = fadeOut(androidx.compose.animation.core.tween(400)), ) { - AppLaunchOverlay(modifier = Modifier.fillMaxSize()) + AppLaunchOverlay( + profileColor = launchOverlayProfileColor, + modifier = Modifier.fillMaxSize(), + ) } // Auto-dismiss profile switch overlay @@ -3237,15 +3246,19 @@ private fun TabletTopPillItem( @Composable private fun AppLaunchOverlay( + profileColor: Color, modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio Box( modifier = modifier - .background(tokens.colors.background) .zIndex(NuvioTokens.Z.dialog), contentAlignment = Alignment.Center, ) { + ProfileMeshBackground( + profileColor = profileColor, + modifier = Modifier.fillMaxSize(), + ) Column( horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProfileMeshBackground.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProfileMeshBackground.kt new file mode 100644 index 000000000..f276ff169 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ProfileMeshBackground.kt @@ -0,0 +1,91 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +@Composable +fun ProfileMeshBackground( + profileColor: Color, + modifier: Modifier = Modifier, +) { + val animatedProfileColor by animateColorAsState( + targetValue = profileColor, + animationSpec = tween(durationMillis = 520), + label = "profileMeshBackgroundColor", + ) + val baseColor = Color.Black + val primaryMeshColor = lerp(baseColor, animatedProfileColor, 0.58f) + val secondaryMeshColor = lerp(animatedProfileColor, MaterialTheme.colorScheme.secondary, 0.32f) + val tertiaryMeshColor = lerp(animatedProfileColor, MaterialTheme.colorScheme.tertiary, 0.28f) + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + val maxDimension = maxOf(size.width, size.height) + + drawRect(color = baseColor, size = size) + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to primaryMeshColor.copy(alpha = 0.46f), + 0.38f to primaryMeshColor.copy(alpha = 0.2f), + 0.72f to primaryMeshColor.copy(alpha = 0.05f), + 1f to Color.Transparent, + ), + center = Offset(size.width * -0.08f, size.height * -0.02f), + radius = maxDimension * 0.7f, + ), + size = size, + ) + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to animatedProfileColor.copy(alpha = 0.24f), + 0.44f to animatedProfileColor.copy(alpha = 0.09f), + 0.78f to animatedProfileColor.copy(alpha = 0.02f), + 1f to Color.Transparent, + ), + center = Offset(size.width * 0.16f, size.height * 0.18f), + radius = maxDimension * 0.46f, + ), + size = size, + ) + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to secondaryMeshColor.copy(alpha = 0.16f), + 0.5f to secondaryMeshColor.copy(alpha = 0.05f), + 1f to Color.Transparent, + ), + center = Offset(size.width * 0.9f, size.height * 0.12f), + radius = maxDimension * 0.36f, + ), + size = size, + ) + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to tertiaryMeshColor.copy(alpha = 0.08f), + 0.52f to tertiaryMeshColor.copy(alpha = 0.03f), + 1f to Color.Transparent, + ), + center = Offset(size.width * 0.28f, size.height * 0.4f), + radius = maxDimension * 0.28f, + ), + size = size, + ) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 195ba6748..25761a450 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -46,7 +46,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -59,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.ui.ProfileMeshBackground import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -100,26 +100,23 @@ fun ProfileSelectionScreen( } val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val backgroundProfileColor = remember(profileState.activeProfile, profileState.profiles) { + val sourceProfile = profileState.activeProfile ?: profileState.profiles.firstOrNull() + sourceProfile?.avatarColorHex?.let(::parseHexColor) ?: Color(0xFF1E88E5) + } BoxWithConstraints( modifier = modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f), - ), - ), - ) - .padding(top = statusBarTop), + .fillMaxSize(), ) { val isTabletLayout = maxWidth >= 768.dp + ProfileMeshBackground(profileColor = backgroundProfileColor) + Column( modifier = Modifier .fillMaxSize() + .padding(top = statusBarTop) .then( if (isTabletLayout) { Modifier @@ -137,7 +134,7 @@ fun ProfileSelectionScreen( text = stringResource(Res.string.profile_who_is_watching), style = MaterialTheme.typography.headlineLarge.copy( fontSize = 30.sp, - letterSpacing = (-0.5).sp, + letterSpacing = 0.sp, ), color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Bold, From a2d3fda68661bde33b70997d6e5c5b99e3880dd3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:45:50 +0530 Subject: [PATCH 57/60] ref: reorganize settings page --- .../composeResources/values/strings.xml | 2 +- .../settings/AppearanceSettingsPage.kt | 62 +++++++++++++++++-- .../settings/ContentDiscoverySettingsPage.kt | 48 -------------- .../settings/IntegrationsSettingsPage.kt | 3 - .../app/features/settings/SettingsModels.kt | 14 ++--- .../app/features/settings/SettingsRootPage.kt | 12 ---- .../app/features/settings/SettingsScreen.kt | 16 ++--- .../app/features/settings/SettingsSearch.kt | 4 +- .../features/settings/StreamsSettingsPage.kt | 3 - 9 files changed, 74 insertions(+), 90 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ff7e6c75b..78f0e206c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -436,7 +436,7 @@ ACCOUNT Startup and profile behavior. ADVANCED - Home structure and poster styles + Home, streams, collections, and detail pages Download latest release Check for updates Manage addons and discovery sources. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 157bed8ea..1ac3eb1b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -20,9 +20,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Style -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -50,8 +47,12 @@ import com.nuvio.app.core.ui.ThemeColors import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.cd_selected +import nuvio.composeapp.generated.resources.collections_header import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.settings_appearance_app_language import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title import nuvio.composeapp.generated.resources.settings_appearance_amoled_black @@ -63,6 +64,10 @@ import nuvio.composeapp.generated.resources.settings_appearance_poster_customiza import nuvio.composeapp.generated.resources.settings_appearance_section_display import nuvio.composeapp.generated.resources.settings_appearance_section_home import nuvio.composeapp.generated.resources.settings_appearance_section_theme +import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description +import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description +import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description +import nuvio.composeapp.generated.resources.compose_settings_root_streams_description import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import androidx.compose.material3.ExperimentalMaterial3Api @@ -79,6 +84,10 @@ internal fun LazyListScope.appearanceSettingsContent( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + onHomescreenClick: () -> Unit, + onMetaScreenClick: () -> Unit, + onStreamsClick: () -> Unit, + onCollectionsClick: () -> Unit, onContinueWatchingClick: () -> Unit, onPosterCustomizationClick: () -> Unit, ) { @@ -161,7 +170,6 @@ internal fun LazyListScope.appearanceSettingsContent( SettingsNavigationRow( title = stringResource(Res.string.settings_appearance_app_language), description = stringResource(selectedAppLanguage.labelRes), - icon = Icons.Rounded.Language, isTablet = isTablet, onClick = { showLanguageSheet = true }, ) @@ -186,10 +194,23 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_homescreen), + description = stringResource(Res.string.settings_content_discovery_homescreen_description), + isTablet = isTablet, + onClick = onHomescreenClick, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.collections_header), + description = stringResource(Res.string.settings_content_discovery_collections_description), + isTablet = isTablet, + onClick = onCollectionsClick, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_continue_watching), description = stringResource(Res.string.settings_appearance_continue_watching_description), - icon = Icons.Rounded.Style, isTablet = isTablet, onClick = onContinueWatchingClick, ) @@ -197,13 +218,42 @@ internal fun LazyListScope.appearanceSettingsContent( SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_poster_customization), description = stringResource(Res.string.settings_appearance_poster_customization_description), - icon = Icons.Rounded.Tune, isTablet = isTablet, onClick = onPosterCustomizationClick, ) } } } + item { + SettingsSection( + title = stringResource(Res.string.compose_settings_page_streams), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_streams), + description = stringResource(Res.string.compose_settings_root_streams_description), + isTablet = isTablet, + onClick = onStreamsClick, + ) + } + } + } + item { + SettingsSection( + title = stringResource(Res.string.compose_settings_page_meta_screen), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_meta_screen), + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), + isTablet = isTablet, + onClick = onMetaScreenClick, + ) + } + } + } } private data class AppLanguageSheetOption( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index bd2652ca0..e4d923875 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -1,26 +1,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CollectionsBookmark -import androidx.compose.material.icons.rounded.Extension -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.Hub -import androidx.compose.material.icons.rounded.Tune import com.nuvio.app.core.build.AppFeaturePolicy import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_addons -import nuvio.composeapp.generated.resources.compose_settings_page_homescreen -import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen import nuvio.composeapp.generated.resources.compose_settings_page_plugins -import nuvio.composeapp.generated.resources.collections_header import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description_appstore -import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description -import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description -import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description import nuvio.composeapp.generated.resources.settings_content_discovery_plugins_description -import nuvio.composeapp.generated.resources.settings_content_discovery_section_home import nuvio.composeapp.generated.resources.settings_content_discovery_section_sources import org.jetbrains.compose.resources.stringResource @@ -29,9 +16,6 @@ internal fun LazyListScope.contentDiscoveryContent( showPluginsEntry: Boolean, onAddonsClick: () -> Unit, onPluginsClick: () -> Unit, - onHomescreenClick: () -> Unit, - onMetaScreenClick: () -> Unit, - onCollectionsClick: () -> Unit = {}, ) { item { SettingsSection( @@ -48,7 +32,6 @@ internal fun LazyListScope.contentDiscoveryContent( Res.string.settings_content_discovery_addons_description }, ), - icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onAddonsClick, ) @@ -56,7 +39,6 @@ internal fun LazyListScope.contentDiscoveryContent( SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_plugins), description = stringResource(Res.string.settings_content_discovery_plugins_description), - icon = Icons.Rounded.Hub, isTablet = isTablet, onClick = onPluginsClick, ) @@ -64,34 +46,4 @@ internal fun LazyListScope.contentDiscoveryContent( } } } - item { - SettingsSection( - title = stringResource(Res.string.settings_content_discovery_section_home), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_homescreen), - description = stringResource(Res.string.settings_content_discovery_homescreen_description), - icon = Icons.Rounded.Home, - isTablet = isTablet, - onClick = onHomescreenClick, - ) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_meta_screen), - description = stringResource(Res.string.settings_content_discovery_meta_screen_description), - icon = Icons.Rounded.Tune, - isTablet = isTablet, - onClick = onMetaScreenClick, - ) - SettingsNavigationRow( - title = stringResource(Res.string.collections_header), - description = stringResource(Res.string.settings_content_discovery_collections_description), - icon = Icons.Rounded.CollectionsBookmark, - isTablet = isTablet, - onClick = onCollectionsClick, - ) - } - } - } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index a4999c899..2925674f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,7 +1,5 @@ package com.nuvio.app.features.settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CloudQueue import androidx.compose.foundation.lazy.LazyListScope import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.Res @@ -44,7 +42,6 @@ internal fun LazyListScope.integrationsContent( SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_debrid), description = stringResource(Res.string.settings_integrations_debrid_description), - icon = Icons.Rounded.CloudQueue, isTablet = isTablet, onClick = onDebridClick, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 1fc6ca309..14bc6114f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -74,16 +74,16 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Root, ), - Streams( - titleRes = Res.string.compose_settings_page_streams, - category = SettingsCategory.General, - parentPage = Root, - ), Appearance( titleRes = Res.string.compose_settings_page_appearance, category = SettingsCategory.General, parentPage = Root, ), + Streams( + titleRes = Res.string.compose_settings_page_streams, + category = SettingsCategory.General, + parentPage = Appearance, + ), Advanced( titleRes = Res.string.compose_settings_page_advanced, category = SettingsCategory.Advanced, @@ -122,12 +122,12 @@ internal enum class SettingsPage( Homescreen( titleRes = Res.string.compose_settings_page_homescreen, category = SettingsCategory.General, - parentPage = ContentDiscovery, + parentPage = Appearance, ), MetaScreen( titleRes = Res.string.compose_settings_page_meta_screen, category = SettingsCategory.General, - parentPage = ContentDiscovery, + parentPage = Appearance, ), Integrations( titleRes = Res.string.compose_settings_page_integrations, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 36f04b12b..1ed8b5d86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.People import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -32,7 +31,6 @@ import nuvio.composeapp.generated.resources.compose_settings_page_integrations import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions import nuvio.composeapp.generated.resources.compose_settings_page_notifications import nuvio.composeapp.generated.resources.compose_settings_page_playback -import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_root_account_description import nuvio.composeapp.generated.resources.compose_settings_root_appearance_description @@ -44,7 +42,6 @@ import nuvio.composeapp.generated.resources.compose_settings_root_downloads_titl import nuvio.composeapp.generated.resources.compose_settings_root_general_section import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description -import nuvio.composeapp.generated.resources.compose_settings_root_streams_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description @@ -62,7 +59,6 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.settingsRootContent( isTablet: Boolean, onPlaybackClick: () -> Unit, - onStreamsClick: () -> Unit, onAppearanceClick: () -> Unit, onAdvancedClick: () -> Unit, onNotificationsClick: () -> Unit, @@ -156,14 +152,6 @@ internal fun LazyListScope.settingsRootContent( onClick = onPlaybackClick, ) SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_streams), - description = stringResource(Res.string.compose_settings_root_streams_description), - icon = Icons.Rounded.Style, - isTablet = isTablet, - onClick = onStreamsClick, - ) - SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_integrations), description = stringResource(Res.string.compose_settings_root_integrations_description), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index fab7e7ed3..d822438b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -545,7 +545,6 @@ private fun MobileSettingsScreen( settingsRootContent( isTablet = false, onPlaybackClick = { onPageChange(SettingsPage.Playback) }, - onStreamsClick = { onPageChange(SettingsPage.Streams) }, onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onAdvancedClick = { onPageChange(SettingsPage.Advanced) }, onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, @@ -609,6 +608,10 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + onHomescreenClick = onHomescreenClick, + onMetaScreenClick = onMetaScreenClick, + onStreamsClick = { onPageChange(SettingsPage.Streams) }, + onCollectionsClick = onCollectionsClick, onContinueWatchingClick = onContinueWatchingClick, onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, ) @@ -640,9 +643,6 @@ private fun MobileSettingsScreen( showPluginsEntry = AppFeaturePolicy.pluginsEnabled, onAddonsClick = onAddonsClick, onPluginsClick = onPluginsClick, - onHomescreenClick = onHomescreenClick, - onMetaScreenClick = onMetaScreenClick, - onCollectionsClick = onCollectionsClick, ) SettingsPage.Addons -> addonsSettingsContent() SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() @@ -944,7 +944,6 @@ private fun TabletSettingsScreen( settingsRootContent( isTablet = true, onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, - onStreamsClick = { openInlinePage(SettingsPage.Streams) }, onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, onAdvancedClick = { openInlinePage(SettingsPage.Advanced) }, onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, @@ -1012,6 +1011,10 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, + onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, + onStreamsClick = { openInlinePage(SettingsPage.Streams) }, + onCollectionsClick = onCollectionsClick, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, ) @@ -1043,9 +1046,6 @@ private fun TabletSettingsScreen( showPluginsEntry = AppFeaturePolicy.pluginsEnabled, onAddonsClick = { openInlinePage(SettingsPage.Addons) }, onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, - onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, - onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, - onCollectionsClick = onCollectionsClick, ) SettingsPage.Addons -> addonsSettingsContent() SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 69ab6a9af..6924857b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -471,8 +471,8 @@ internal fun settingsSearchEntries( key = "collections", title = collectionsPage, description = stringResource(Res.string.settings_content_discovery_collections_description), - page = contentDiscoveryPage, - section = stringResource(Res.string.settings_content_discovery_section_home), + page = layoutPage, + section = stringResource(Res.string.settings_appearance_section_home), category = generalCategory, icon = Icons.Rounded.CollectionsBookmark, target = SettingsSearchTarget.Collections, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index 7943b55a4..38117df1d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -118,14 +117,12 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { SettingsNavigationRow( title = stringResource(Res.string.settings_stream_badge_position_title), description = badgePlacementLabel, - icon = Icons.Rounded.Style, isTablet = isTablet, onClick = { showBadgePositionDialog = true }, ) SettingsNavigationRow( title = stringResource(Res.string.settings_stream_badge_urls_title), description = badgeRulesPreview(currentRules), - icon = Icons.Rounded.Style, isTablet = isTablet, onClick = { showBadgeImportDialog = true }, ) From b9c3e62eb0f8259d72f762543b06cf5e61b4b5df Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:46:53 +0530 Subject: [PATCH 58/60] ref: adjust authscreen layout and gradients --- .../composeResources/values/strings.xml | 4 +- .../com/nuvio/app/features/auth/AuthScreen.kt | 1240 +++++++++++++---- 2 files changed, 944 insertions(+), 300 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 78f0e206c..71d719ef5 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -324,7 +324,9 @@ Sign up to sync your data across devices Sign Up Your data will only be stored locally - Stream everything, everywhere + Watch your library, anywhere + By signing up, I agree to the + Terms Welcome Back Library Trakt Library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt index 0f84bacca..cd510a5a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt @@ -4,40 +4,47 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -48,29 +55,41 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository -import com.nuvio.app.core.ui.nuvioOverlayGradientBrush -import com.nuvio.app.core.ui.NuvioPrimaryButton -import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.dev.DebugSyncBackendSwitch import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.PI +import kotlin.math.sin import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -87,10 +106,84 @@ import nuvio.composeapp.generated.resources.compose_auth_sign_up import nuvio.composeapp.generated.resources.compose_auth_sign_up_subtitle import nuvio.composeapp.generated.resources.compose_auth_store_locally import nuvio.composeapp.generated.resources.compose_auth_tagline +import nuvio.composeapp.generated.resources.compose_auth_terms_link +import nuvio.composeapp.generated.resources.compose_auth_terms_prefix import nuvio.composeapp.generated.resources.compose_auth_welcome_back import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +private val AuthTextPrimary = Color(0xFFF5F7F8) +private val AuthTextSecondary = Color(0xFF969CA3) +private val AuthTextMuted = Color(0xFF6E7178) +private val AuthPrimaryButtonBackground = Color(0xFFF5F5F5) +private val AuthPrimaryButtonText = Color(0xFF111111) +private val AuthFieldBackground = Color.White.copy(alpha = 0.04f) +private val AuthFieldBackgroundMobile = Color.White.copy(alpha = 0.035f) +private val AuthFieldBorder = Color.White.copy(alpha = 0.08f) +private val AuthPaneBackground = Color.White.copy(alpha = 0.022f) +private val AuthPaneBorder = Color.White.copy(alpha = 0.07f) +private val AuthDividerColor = Color.White.copy(alpha = 0.10f) +private val AuthSecondaryButtonBackground = Color.White.copy(alpha = 0.05f) +private val AuthSecondaryButtonBorder = Color.White.copy(alpha = 0.09f) + +private data class AuthFormMetrics( + val fieldHeight: Dp, + val fieldHorizontalPadding: Dp, + val iconSize: Dp, + val passwordIconSize: Dp, + val primaryTop: Dp, + val primaryHeight: Dp, + val toggleTop: Dp, + val dividerTop: Dp, + val secondaryTop: Dp, + val secondaryHeight: Dp, + val fieldBackground: Color, +) + +private val MobileAuthFormMetrics = AuthFormMetrics( + fieldHeight = 56.dp, + fieldHorizontalPadding = 16.dp, + iconSize = 19.dp, + passwordIconSize = 20.dp, + primaryTop = 22.dp, + primaryHeight = 54.dp, + toggleTop = 18.dp, + dividerTop = 28.dp, + secondaryTop = 24.dp, + secondaryHeight = 54.dp, + fieldBackground = AuthFieldBackgroundMobile, +) + +private val LargeAuthFormMetrics = AuthFormMetrics( + fieldHeight = 58.dp, + fieldHorizontalPadding = 18.dp, + iconSize = 20.dp, + passwordIconSize = 21.dp, + primaryTop = 24.dp, + primaryHeight = 56.dp, + toggleTop = 18.dp, + dividerTop = 30.dp, + secondaryTop = 26.dp, + secondaryHeight = 56.dp, + fieldBackground = AuthFieldBackground, +) + +private fun largeAuthScale(screenWidth: Dp): Float = + (screenWidth.value / 1194f).coerceIn(1f, 1.32f) + +private fun largeAuthFormMetrics(scale: Float): AuthFormMetrics = LargeAuthFormMetrics.copy( + fieldHeight = 58.dp * scale, + fieldHorizontalPadding = 18.dp * scale, + iconSize = 20.dp * scale, + passwordIconSize = 21.dp * scale, + primaryTop = 24.dp * scale, + primaryHeight = 56.dp * scale, + toggleTop = 18.dp * scale, + dividerTop = 30.dp * scale, + secondaryTop = 26.dp * scale, + secondaryHeight = 56.dp * scale, +) + @Composable fun AuthScreen( modifier: Modifier = Modifier, @@ -106,6 +199,23 @@ fun AuthScreen( var emailFieldBounds by remember { mutableStateOf(null) } var passwordFieldBounds by remember { mutableStateOf(null) } + fun submitAuth() { + if (email.isBlank() || password.length < 6 || isLoading) return + isLoading = true + focusManager.clearFocus(force = true) + scope.launch { + if (isSignUp) AuthRepository.signUpWithEmail(email, password) + else AuthRepository.signInWithEmail(email, password) + isLoading = false + } + } + + fun toggleAuthMode() { + isSignUp = !isSignUp + AuthRepository.clearError() + } + + val showDebugSwitch = shouldShowDebugSyncBackendSwitch() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box( @@ -126,40 +236,131 @@ fun AuthScreen( } }, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(brush = nuvioOverlayGradientBrush()), - ) + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val screenWidth = maxWidth + val largeScreen = screenWidth >= 900.dp + val compactLargeScreen = screenWidth < 1100.dp + + Box( + modifier = Modifier + .fillMaxSize() + .authGradientBackground(largeScreen = largeScreen), + ) { + if (largeScreen) { + val largeScale = largeAuthScale(screenWidth) + val formPaneWidth = (screenWidth * 0.30f).coerceIn(460.dp * largeScale, 720.dp * largeScale) + val brandHorizontalPadding = (screenWidth * 0.055f).coerceIn(56.dp * largeScale, 128.dp * largeScale) + val formHorizontalPadding = (formPaneWidth * 0.14f).coerceIn(48.dp, 96.dp) + val formMetrics = largeAuthFormMetrics(largeScale) + + AuthLargeLayout( + modifier = Modifier.fillMaxSize(), + isSignUp = isSignUp, + email = email, + password = password, + passwordVisible = passwordVisible, + isLoading = isLoading, + authError = authError, + showDebugSwitch = showDebugSwitch, + formPaneWidth = if (compactLargeScreen) 460.dp else formPaneWidth, + brandHorizontalPadding = brandHorizontalPadding, + formHorizontalPadding = formHorizontalPadding, + formMetrics = formMetrics, + scale = largeScale, + onEmailChange = { + email = it + AuthRepository.clearError() + }, + onPasswordChange = { + password = it + AuthRepository.clearError() + }, + onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, + onSubmit = ::submitAuth, + onToggleAuthMode = ::toggleAuthMode, + onContinueWithoutAccount = { + focusManager.clearFocus(force = true) + AuthRepository.signInAnonymously() + }, + onEmailBoundsChange = { emailFieldBounds = it }, + onPasswordBoundsChange = { passwordFieldBounds = it }, + ) + } else { + AuthMobileLayout( + isSignUp = isSignUp, + email = email, + password = password, + passwordVisible = passwordVisible, + isLoading = isLoading, + authError = authError, + showDebugSwitch = showDebugSwitch, + statusBarTop = statusBarTop, + onEmailChange = { + email = it + AuthRepository.clearError() + }, + onPasswordChange = { + password = it + AuthRepository.clearError() + }, + onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, + onSubmit = ::submitAuth, + onToggleAuthMode = ::toggleAuthMode, + onContinueWithoutAccount = { + focusManager.clearFocus(force = true) + AuthRepository.signInAnonymously() + }, + onEmailBoundsChange = { emailFieldBounds = it }, + onPasswordBoundsChange = { passwordFieldBounds = it }, + ) + } + } + } + } +} + +@Composable +private fun AuthMobileLayout( + isSignUp: Boolean, + email: String, + password: String, + passwordVisible: Boolean, + isLoading: Boolean, + authError: String?, + showDebugSwitch: Boolean, + statusBarTop: Dp, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onPasswordVisibilityToggle: () -> Unit, + onSubmit: () -> Unit, + onToggleAuthMode: () -> Unit, + onContinueWithoutAccount: () -> Unit, + onEmailBoundsChange: (Rect) -> Unit, + onPasswordBoundsChange: (Rect) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + start = 30.dp, + top = statusBarTop + 48.dp, + end = 30.dp, + bottom = 40.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Column( modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(start = 24.dp, end = 24.dp, top = statusBarTop + 60.dp, bottom = 40.dp), + .widthIn(max = 342.dp) + .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier - .widthIn(max = 460.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(Res.drawable.app_logo_wordmark), - contentDescription = null, - modifier = Modifier - .fillMaxWidth(0.6f) - .height(48.dp), - contentScale = ContentScale.Fit, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.compose_auth_tagline), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AuthBrandLockup(logoHeight = 38.dp) - if (shouldShowDebugSyncBackendSwitch()) { + if (showDebugSwitch) { Spacer(modifier = Modifier.height(24.dp)) DebugSyncBackendSwitch( modifier = Modifier.fillMaxWidth(), @@ -168,274 +369,715 @@ fun AuthScreen( ) } - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(64.dp)) - NuvioSurfaceCard { - AnimatedContent( - targetState = isSignUp, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "heading", - ) { signUp -> - Text( - text = if (signUp) stringResource(Res.string.compose_auth_create_account) - else stringResource(Res.string.compose_auth_welcome_back), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(modifier = Modifier.height(6.dp)) - AnimatedContent( - targetState = isSignUp, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "subtitle", - ) { signUp -> - Text( - text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle) - else stringResource(Res.string.compose_auth_sign_in_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - OutlinedTextField( - value = email, - onValueChange = { - email = it - AuthRepository.clearError() - }, - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - emailFieldBounds = coordinates.boundsInRoot() - }, - singleLine = true, - placeholder = { - Text( - text = stringResource(Res.string.compose_auth_email), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ), - shape = RoundedCornerShape(14.dp), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(14.dp)) - - OutlinedTextField( - value = password, - onValueChange = { - password = it - AuthRepository.clearError() - }, - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - passwordFieldBounds = coordinates.boundsInRoot() - }, - singleLine = true, - placeholder = { - Text( - text = stringResource(Res.string.compose_auth_password), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - visualTransformation = if (passwordVisible) VisualTransformation.None - else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - if (email.isNotBlank() && password.isNotBlank() && !isLoading) { - isLoading = true - scope.launch { - if (isSignUp) AuthRepository.signUpWithEmail(email, password) - else AuthRepository.signInWithEmail(email, password) - isLoading = false - } - } - }, - ), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff - else Icons.Rounded.Visibility, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - shape = RoundedCornerShape(14.dp), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - authError?.let { errorText -> - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = errorText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - NuvioPrimaryButton( - text = if (isLoading) { - "" - } else if (isSignUp) { - stringResource(Res.string.compose_auth_create_account) - } else { - stringResource(Res.string.compose_auth_sign_in) - }, - enabled = email.isNotBlank() && password.length >= 6 && !isLoading, - onClick = { - isLoading = true - scope.launch { - if (isSignUp) AuthRepository.signUpWithEmail(email, password) - else AuthRepository.signInWithEmail(email, password) - isLoading = false - } - }, - ) - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - .padding(top = 4.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.5.dp, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - AnimatedContent( - targetState = isSignUp, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "togglePrompt", - ) { signUp -> - Text( - text = if (signUp) stringResource(Res.string.compose_auth_already_have_account) - else stringResource(Res.string.compose_auth_dont_have_account), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - AnimatedContent( - targetState = isSignUp, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "toggleAction", - ) { signUp -> - Text( - text = if (signUp) stringResource(Res.string.compose_auth_sign_in) - else stringResource(Res.string.compose_auth_sign_up), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable { - isSignUp = !isSignUp - AuthRepository.clearError() - }, - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .weight(1f) - .height(1.dp) - .background(MaterialTheme.colorScheme.outline), - ) - Text( - text = stringResource(Res.string.compose_auth_or_separator), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Box( - modifier = Modifier - .weight(1f) - .height(1.dp) - .background(MaterialTheme.colorScheme.outline), - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Button( - onClick = { - AuthRepository.signInAnonymously() - }, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - enabled = !isLoading, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, + AuthHeading( + isSignUp = isSignUp, + headingStyle = MaterialTheme.typography.headlineLarge.copy( + color = AuthTextPrimary, + fontSize = 28.sp, + lineHeight = 31.sp, + fontWeight = FontWeight.SemiBold, + ), + subtitleStyle = MaterialTheme.typography.bodyLarge.copy( + color = AuthTextSecondary, + fontSize = 15.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.Normal, ), - ) { - Text( - text = stringResource(Res.string.compose_auth_continue_without_account), - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(Res.string.compose_auth_store_locally), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, ) + + Spacer(modifier = Modifier.height(28.dp)) + + AuthForm( + isSignUp = isSignUp, + email = email, + password = password, + passwordVisible = passwordVisible, + isLoading = isLoading, + authError = authError, + metrics = MobileAuthFormMetrics, + onEmailChange = onEmailChange, + onPasswordChange = onPasswordChange, + onPasswordVisibilityToggle = onPasswordVisibilityToggle, + onSubmit = onSubmit, + onToggleAuthMode = onToggleAuthMode, + onContinueWithoutAccount = onContinueWithoutAccount, + onEmailBoundsChange = onEmailBoundsChange, + onPasswordBoundsChange = onPasswordBoundsChange, + ) + } + } +} + +@Composable +private fun AuthLargeLayout( + modifier: Modifier = Modifier, + isSignUp: Boolean, + email: String, + password: String, + passwordVisible: Boolean, + isLoading: Boolean, + authError: String?, + showDebugSwitch: Boolean, + formPaneWidth: Dp, + brandHorizontalPadding: Dp, + formHorizontalPadding: Dp, + formMetrics: AuthFormMetrics, + scale: Float, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onPasswordVisibilityToggle: () -> Unit, + onSubmit: () -> Unit, + onToggleAuthMode: () -> Unit, + onContinueWithoutAccount: () -> Unit, + onEmailBoundsChange: (Rect) -> Unit, + onPasswordBoundsChange: (Rect) -> Unit, +) { + Row( + modifier = modifier, + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = brandHorizontalPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Image( + painter = painterResource(Res.drawable.app_logo_wordmark), + contentDescription = null, + modifier = Modifier.height(60.dp * scale), + contentScale = ContentScale.Fit, + ) + Spacer(modifier = Modifier.height(32.dp * scale)) + Text( + text = stringResource(Res.string.compose_auth_tagline), + modifier = Modifier.widthIn(max = 440.dp * scale), + style = MaterialTheme.typography.displayLarge.copy( + color = AuthTextPrimary, + fontSize = (40f * scale).sp, + lineHeight = (45f * scale).sp, + fontWeight = FontWeight.SemiBold, + ), + ) + Spacer(modifier = Modifier.height(18.dp * scale)) + Text( + text = stringResource(Res.string.compose_auth_sign_up_subtitle), + modifier = Modifier.widthIn(max = 400.dp * scale), + style = MaterialTheme.typography.bodyLarge.copy( + color = AuthTextSecondary, + fontSize = (17f * scale).sp, + lineHeight = (26f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + + if (showDebugSwitch) { + Spacer(modifier = Modifier.height(24.dp * scale)) + DebugSyncBackendSwitch( + modifier = Modifier.widthIn(max = 520.dp * scale), + requireConfirmation = false, + container = true, + ) } } + + Box( + modifier = Modifier + .width(formPaneWidth) + .fillMaxHeight() + .background(AuthPaneBackground) + .drawBehind { + drawLine( + color = AuthPaneBorder, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 1.dp.toPx(), + ) + }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = formHorizontalPadding), + ) { + AuthHeading( + isSignUp = isSignUp, + headingStyle = MaterialTheme.typography.headlineLarge.copy( + color = AuthTextPrimary, + fontSize = (30f * scale).sp, + lineHeight = (33f * scale).sp, + fontWeight = FontWeight.SemiBold, + ), + subtitleStyle = MaterialTheme.typography.bodyLarge.copy( + color = AuthTextSecondary, + fontSize = (15f * scale).sp, + lineHeight = (21f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + + Spacer(modifier = Modifier.height(32.dp * scale)) + + AuthForm( + isSignUp = isSignUp, + email = email, + password = password, + passwordVisible = passwordVisible, + isLoading = isLoading, + authError = authError, + metrics = formMetrics, + scale = scale, + onEmailChange = onEmailChange, + onPasswordChange = onPasswordChange, + onPasswordVisibilityToggle = onPasswordVisibilityToggle, + onSubmit = onSubmit, + onToggleAuthMode = onToggleAuthMode, + onContinueWithoutAccount = onContinueWithoutAccount, + onEmailBoundsChange = onEmailBoundsChange, + onPasswordBoundsChange = onPasswordBoundsChange, + ) + } + } + } +} + +@Composable +private fun AuthBrandLockup( + logoHeight: Dp, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(Res.drawable.app_logo_wordmark), + contentDescription = null, + modifier = Modifier.height(logoHeight), + contentScale = ContentScale.Fit, + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = stringResource(Res.string.compose_auth_tagline), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextSecondary, + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + ), + ) + } +} + +@Composable +private fun AuthHeading( + isSignUp: Boolean, + headingStyle: TextStyle, + subtitleStyle: TextStyle, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedContent( + targetState = isSignUp, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "authHeading", + ) { signUp -> + Text( + text = if (signUp) stringResource(Res.string.compose_auth_create_account) + else stringResource(Res.string.compose_auth_welcome_back), + style = headingStyle, + color = AuthTextPrimary, + ) + } + AnimatedContent( + targetState = isSignUp, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "authSubtitle", + ) { signUp -> + Text( + text = if (signUp) stringResource(Res.string.compose_auth_sign_up_subtitle) + else stringResource(Res.string.compose_auth_sign_in_subtitle), + style = subtitleStyle, + color = AuthTextSecondary, + ) + } + } +} + +@Composable +private fun AuthForm( + isSignUp: Boolean, + email: String, + password: String, + passwordVisible: Boolean, + isLoading: Boolean, + authError: String?, + metrics: AuthFormMetrics, + scale: Float = 1f, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onPasswordVisibilityToggle: () -> Unit, + onSubmit: () -> Unit, + onToggleAuthMode: () -> Unit, + onContinueWithoutAccount: () -> Unit, + onEmailBoundsChange: (Rect) -> Unit, + onPasswordBoundsChange: (Rect) -> Unit, +) { + val uriHandler = LocalUriHandler.current + Column( + modifier = Modifier.fillMaxWidth(), + ) { + AuthTextField( + value = email, + onValueChange = onEmailChange, + placeholder = stringResource(Res.string.compose_auth_email), + icon = Icons.Rounded.Email, + metrics = metrics, + scale = scale, + modifier = Modifier.onGloballyPositioned { coordinates -> + onEmailBoundsChange(coordinates.boundsInRoot()) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + + Spacer(modifier = Modifier.height(14.dp)) + + AuthTextField( + value = password, + onValueChange = onPasswordChange, + placeholder = stringResource(Res.string.compose_auth_password), + icon = Icons.Rounded.Lock, + metrics = metrics, + scale = scale, + isPassword = true, + passwordVisible = passwordVisible, + onPasswordVisibilityToggle = onPasswordVisibilityToggle, + modifier = Modifier.onGloballyPositioned { coordinates -> + onPasswordBoundsChange(coordinates.boundsInRoot()) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() }, + ), + ) + + authError?.let { errorText -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = errorText, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.error, + fontSize = (13f * scale).sp, + lineHeight = (18f * scale).sp, + ), + ) + } + + if (isSignUp) { + Spacer(modifier = Modifier.height(14.dp * scale)) + AuthTermsAcknowledgement( + scale = scale, + onTermsClick = { uriHandler.openUri("https://nuvio.tv/terms") }, + ) + } + + Spacer(modifier = Modifier.height(metrics.primaryTop)) + + AuthPrimaryButton( + text = if (isSignUp) stringResource(Res.string.compose_auth_create_account) + else stringResource(Res.string.compose_auth_sign_in), + isLoading = isLoading, + enabled = !isLoading, + height = metrics.primaryHeight, + scale = scale, + onClick = onSubmit, + ) + + Spacer(modifier = Modifier.height(metrics.toggleTop)) + + AuthModeToggle( + isSignUp = isSignUp, + scale = scale, + onToggleAuthMode = onToggleAuthMode, + ) + + Spacer(modifier = Modifier.height(metrics.dividerTop)) + + AuthDivider(scale = scale) + + Spacer(modifier = Modifier.height(metrics.secondaryTop)) + + AuthSecondaryButton( + text = stringResource(Res.string.compose_auth_continue_without_account), + enabled = !isLoading, + height = metrics.secondaryHeight, + scale = scale, + onClick = onContinueWithoutAccount, + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = stringResource(Res.string.compose_auth_store_locally), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextMuted, + fontSize = (13f * scale).sp, + lineHeight = (18f * scale).sp, + fontWeight = FontWeight.Normal, + ), + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun AuthTermsAcknowledgement( + scale: Float, + onTermsClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.compose_auth_terms_prefix), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextSecondary, + fontSize = (13f * scale).sp, + lineHeight = (18f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + Spacer(modifier = Modifier.width(4.dp * scale)) + Text( + text = stringResource(Res.string.compose_auth_terms_link), + modifier = Modifier.clickable(onClick = onTermsClick), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextPrimary, + fontSize = (13f * scale).sp, + lineHeight = (18f * scale).sp, + fontWeight = FontWeight.SemiBold, + ), + ) + } +} + +@Composable +private fun AuthTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + icon: ImageVector, + metrics: AuthFormMetrics, + scale: Float, + modifier: Modifier = Modifier, + isPassword: Boolean = false, + passwordVisible: Boolean = false, + onPasswordVisibilityToggle: () -> Unit = {}, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + val shape = RoundedCornerShape(14.dp) + Row( + modifier = modifier + .fillMaxWidth() + .height(metrics.fieldHeight) + .background(metrics.fieldBackground, shape) + .border(1.dp, AuthFieldBorder, shape) + .padding(horizontal = metrics.fieldHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(metrics.iconSize), + tint = AuthTextMuted, + ) + Spacer(modifier = Modifier.width(12.dp)) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = AuthTextPrimary, + fontSize = (16f * scale).sp, + lineHeight = (22f * scale).sp, + fontWeight = FontWeight.Normal, + ), + cursorBrush = SolidColor(AuthTextPrimary), + visualTransformation = if (isPassword && !passwordVisible) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart, + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyLarge.copy( + color = AuthTextMuted, + fontSize = (16f * scale).sp, + lineHeight = (22f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + } + innerTextField() + } + }, + ) + if (isPassword) { + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(15.dp)) + .clickable(onClick = onPasswordVisibilityToggle), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff + else Icons.Rounded.Visibility, + contentDescription = null, + modifier = Modifier.size(metrics.passwordIconSize), + tint = AuthTextSecondary, + ) + } + } + } +} + +@Composable +private fun AuthPrimaryButton( + text: String, + isLoading: Boolean, + enabled: Boolean, + height: Dp, + scale: Float, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(height), + enabled = enabled, + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthPrimaryButtonBackground, + contentColor = AuthPrimaryButtonText, + disabledContainerColor = if (isLoading) { + AuthPrimaryButtonBackground + } else { + AuthPrimaryButtonBackground.copy(alpha = 0.45f) + }, + disabledContentColor = AuthPrimaryButtonText.copy(alpha = 0.55f), + ), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp * scale), + color = AuthPrimaryButtonText, + strokeWidth = 2.dp * scale, + ) + } else { + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = (16f * scale).sp, + lineHeight = (22f * scale).sp, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } +} + +@Composable +private fun AuthModeToggle( + isSignUp: Boolean, + scale: Float, + onToggleAuthMode: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = isSignUp, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "authTogglePrompt", + ) { signUp -> + Text( + text = if (signUp) { + stringResource(Res.string.compose_auth_already_have_account).trimEnd() + } else { + stringResource(Res.string.compose_auth_dont_have_account).trimEnd() + }, + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextSecondary, + fontSize = (14f * scale).sp, + lineHeight = (20f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + } + Spacer(modifier = Modifier.width(6.dp)) + AnimatedContent( + targetState = isSignUp, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "authToggleAction", + ) { signUp -> + Text( + text = if (signUp) stringResource(Res.string.compose_auth_sign_in) + else stringResource(Res.string.compose_auth_sign_up), + modifier = Modifier.clickable(onClick = onToggleAuthMode), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextPrimary, + fontSize = (14f * scale).sp, + lineHeight = (20f * scale).sp, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } +} + +@Composable +private fun AuthDivider(scale: Float) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(AuthDividerColor), + ) + Text( + text = stringResource(Res.string.compose_auth_or_separator).trim(), + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.bodyMedium.copy( + color = AuthTextMuted, + fontSize = (13f * scale).sp, + lineHeight = (18f * scale).sp, + fontWeight = FontWeight.Normal, + ), + ) + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(AuthDividerColor), + ) + } +} + +@Composable +private fun AuthSecondaryButton( + text: String, + enabled: Boolean, + height: Dp, + scale: Float, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(height), + enabled = enabled, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, AuthSecondaryButtonBorder), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthSecondaryButtonBackground, + contentColor = AuthTextPrimary, + disabledContainerColor = AuthSecondaryButtonBackground.copy(alpha = 0.45f), + disabledContentColor = AuthTextPrimary.copy(alpha = 0.55f), + ), + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = (15f * scale).sp, + lineHeight = (21f * scale).sp, + fontWeight = FontWeight.Medium, + ), + textAlign = TextAlign.Center, + ) + } +} + +private fun Modifier.authGradientBackground(largeScreen: Boolean): Modifier = drawWithCache { + val angleDegrees = if (largeScreen) 122.0 else 148.0 + val angleRadians = angleDegrees * PI / 180.0 + val directionX = sin(angleRadians).toFloat() + val directionY = (-cos(angleRadians)).toFloat() + val halfLength = (abs(size.width * directionX) + abs(size.height * directionY)) / 2f + val center = Offset(size.width / 2f, size.height / 2f) + val start = Offset( + x = center.x - directionX * halfLength, + y = center.y - directionY * halfLength, + ) + val end = Offset( + x = center.x + directionX * halfLength, + y = center.y + directionY * halfLength, + ) + val colorStops = if (largeScreen) { + arrayOf( + 0f to Color(0xFF21113B), + 0.14f to Color(0xFF21113B), + 0.26f to Color(0xFF1A0E2F), + 0.36f to Color(0xFF130A23), + 0.48f to Color(0xFF0A060F), + 0.60f to Color(0xFF050408), + 0.70f to Color.Black, + 1f to Color.Black, + ) + } else { + arrayOf( + 0f to Color(0xFF21113B), + 0.12f to Color(0xFF21113B), + 0.24f to Color(0xFF1A0E2F), + 0.34f to Color(0xFF130A23), + 0.44f to Color(0xFF0A060F), + 0.58f to Color(0xFF050408), + 0.64f to Color.Black, + 1f to Color.Black, + ) + } + val brush = Brush.linearGradient( + colorStops = colorStops, + start = start, + end = end, + ) + onDrawBehind { + drawRect(brush = brush) } } From 19f1e1a6566bdfb4f803f45e157b8fe9e57d4cd9 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:38:35 +0530 Subject: [PATCH 59/60] Remove settings sidebar border --- .../kotlin/com/nuvio/app/features/settings/SettingsScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index d822438b2..351bcc081 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -2,7 +2,6 @@ package com.nuvio.app.features.settings import com.nuvio.app.core.build.AppFeaturePolicy -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -808,7 +807,6 @@ private fun TabletSettingsScreen( .width(280.dp) .fillMaxSize(), color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Column( modifier = Modifier From dafa53fd508c4bb316bc7e316cc59dbecbc31353 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:56:45 +0530 Subject: [PATCH 60/60] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index fd39ee076..f9897f920 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=84 -MARKETING_VERSION=0.2.12 +CURRENT_PROJECT_VERSION=85 +MARKETING_VERSION=0.2.13