Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost

This commit is contained in:
Luqman Fadlli 2026-06-25 17:14:22 +07:00
commit 2465d4e6b2
108 changed files with 11241 additions and 2062 deletions

View file

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

View file

@ -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 = "<!-- nuvio-bot:close-stale-issue -->";
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.`);
}

View file

@ -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 = "<!-- nuvio-bot:stale-warning -->";
const closeMarker = "<!-- nuvio-bot:stale-close -->";
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",
});
}
}

View file

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

View file

@ -40,6 +40,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input
abstract val syncBackendManifestUrl: Property<String>
@get:Input
abstract val debugBuild: Property<Boolean>
@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<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
@ -266,6 +307,7 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("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<KotlinCompilationTask<*>>().configureEach {
@ -355,6 +397,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"))
@ -383,6 +426,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)

View file

@ -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.**

View file

@ -2,9 +2,13 @@ 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
actual val inAppUpdaterEnabled: Boolean = true
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -11,13 +11,18 @@ internal object PlatformPlaybackDataSourceFactory {
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
): DataSource.Factory {
val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
} else {
PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
}
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, networkFactory)
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
upstreamFactory = networkFactory,
externalSubtitles = externalSubtitles
)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {

View file

@ -1,42 +1,249 @@
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.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
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 {
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(normalizeDigestAlgorithm(algorithm)).digest(data)
}
internal fun pluginPbkdf2(
password: ByteArray,
salt: ByteArray,
iterations: Int,
keySizeBits: Int,
algorithm: String,
): ByteArray {
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))
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(
mode: String,
key: ByteArray,
iv: ByteArray,
data: ByteArray,
): ByteArray {
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")
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 = 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")
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')
}
}
internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray {
val normalized = normalizeHmacAlgorithm(algorithm)
val mac = Mac.getInstance(normalized)
mac.init(SecretKeySpec(key, normalized))
return mac.doFinal(data)
}
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")
}
val mac = Mac.getInstance(normalized)
mac.init(SecretKeySpec(key.encodeToByteArray(), normalized))
val digest = mac.doFinal(data.encodeToByteArray())
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())
@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()
}
@ -46,11 +253,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)
@ -58,5 +265,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()
}

View file

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

View file

@ -10,6 +10,13 @@ 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?,

View file

@ -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
@ -41,6 +42,10 @@ import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.DefaultLoadControl
@ -61,6 +66,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
@ -82,6 +91,96 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
resizeMode: PlayerResizeMode,
useNativeController: Boolean,
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<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
@ -96,6 +195,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 {
@ -133,12 +233,28 @@ actual fun PlatformPlayerSurface(
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val volumeBoostAudioProcessor = remember(playerSourceKey) { VolumeBoostAudioProcessor() }
val initialMediaItem = remember(playerSourceKey) {
val initialMediaItem = remember(playerSourceKey, externalSubtitles) {
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()
}
playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
)
).buildUpon()
.setMediaId(sourceUrl)
.apply {
if (subtitleConfigs.isNotEmpty()) {
setSubtitleConfigurations(subtitleConfigs)
}
}
.build()
}
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
@ -154,12 +270,14 @@ actual fun PlatformPlayerSurface(
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
externalSubtitles,
) {
PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
externalSubtitles = externalSubtitles,
)
}
@ -316,6 +434,21 @@ actual fun PlatformPlayerSurface(
resolvedMediaItem = MediaItem.Builder()
.setUri(sourceUrl)
.setMimeType(probedMime)
.setMediaId(sourceUrl)
.apply {
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()) {
setSubtitleConfigurations(subtitleConfigs)
}
}
.build()
latestOnError.value(null)
return@launch
@ -393,7 +526,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
@ -413,7 +546,7 @@ actual fun PlatformPlayerSurface(
}
LaunchedEffect(exoPlayer, playWhenReady) {
exoPlayer.playWhenReady = playWhenReady
exoPlayer.playWhenReady = latestPlayWhenReady.value
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
}
@ -636,6 +769,186 @@ actual fun PlatformPlayerSurface(
)
}
@Composable
private fun LibmpvPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
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<NuvioLibmpvView?>(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
@ -643,6 +956,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<String, String> = emptyMap()
private var currentExternalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = 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<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
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<AudioTrack> =
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<SubtitleTrack> =
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", "no")
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<String, String>) {
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<LibmpvTrack> {
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,
@ -1097,15 +1745,15 @@ private class SubtitleOffsetRenderer(
}
}
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
private fun resolveSubtitleMimeType(url: String, headers: Map<String, String>? = 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<String?, String?>? {
private fun probeSubtitleHeaders(url: String, headers: Map<String, String>? = null): Pair<String?, String?>? {
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
@ -1115,6 +1763,9 @@ private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
readTimeout = 5_000
instanceFollowRedirects = true
setRequestProperty("Accept", "*/*")
headers?.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
connection.responseCode
@ -1169,3 +1820,50 @@ private fun guessSubtitleMime(url: String): String {
else -> MimeTypes.TEXT_VTT
}
}
internal class SubtitleRequestHeaderDataSourceFactory(
private val upstreamFactory: DataSource.Factory,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
SubtitleRequestHeaderDataSource(
upstream = upstreamFactory.createDataSource(),
externalSubtitles = externalSubtitles,
)
}
internal class SubtitleRequestHeaderDataSource(
private val upstream: DataSource,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : 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<String, List<String>> = upstream.responseHeaders
override fun close() {
upstream.close()
}
}

View file

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

View file

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

View file

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

View file

@ -8,8 +8,10 @@
<locale android:name="id"/>
<locale android:name="it"/>
<locale android:name="pl"/>
<locale android:name="pt-BR"/>
<locale android:name="pt"/>
<locale android:name="es"/>
<locale android:name="tr"/>
<locale android:name="nb"/>
<locale android:name="ja"/>
</locale-config>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/isrg_root_x1" />
<certificates src="@raw/isrg_root_x2" />
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -2,9 +2,13 @@ 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
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = false
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -10,9 +10,14 @@ internal object PlatformPlaybackDataSourceFactory {
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
): DataSource.Factory {
val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpFactory)
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
upstreamFactory = httpFactory,
externalSubtitles = externalSubtitles
)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {

View file

@ -453,7 +453,7 @@
<string name="settings_appearance_app_language_sheet_title">Vyberte jazyk</string>
<string name="settings_appearance_continue_watching_description">Nastavení pro sekci Pokračovat ve sledování.</string>
<string name="settings_appearance_liquid_glass">Tekuté sklo (Liquid Glass)</string>
<string name="settings_appearance_liquid_glass_description">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é.</string>
<string name="settings_appearance_liquid_glass_description">Použít nativní lištu panelů na iPhonu v iOS 26 a novějším.</string>
<string name="settings_appearance_poster_customization_description">Vyladit šířku karty a poloměr rohů.</string>
<string name="settings_appearance_section_display">ZOBRAZENÍ</string>
<string name="settings_appearance_section_home">DOMŮ</string>

View file

@ -530,7 +530,7 @@
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Utilise la barre donglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre donglets nest pas disponible quand cette option est activée.</string>
<string name="settings_appearance_liquid_glass_description">Utilise la barre donglets native iPhone sur iOS 26 et versions ultérieures.</string>
<string name="settings_appearance_poster_customization_description">Ajuste la largeur des cartes et le rayon des coins.</string>
<string name="settings_appearance_section_display">AFFICHAGE</string>
<string name="settings_appearance_section_home">ACCUEIL</string>
@ -1596,7 +1596,7 @@
<string name="settings_playback_ios_target_transfer">Transfert cible</string>
<string name="settings_playback_ios_audio_output_section">Sortie audio iOS</string>
<string name="settings_playback_ios_audio_output">Sortie audio</string>
<string name="settings_playback_ios_audio_output_auto_desc">Essaie dabord AVFoundation, puis bascule sur AudioUnit.</string>
<string name="settings_playback_ios_audio_output_auto_desc">Utilise AudioUnit tant que la sortie AVFoundation est temporairement désactivée.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Prise en charge expérimentale de laudio spatial et de la sortie multicanal.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Utilise lancienne sortie AudioUnit.</string>
<!-- Debrid Result Management section -->

View file

@ -508,7 +508,7 @@
<string name="settings_appearance_app_language_sheet_title">Pilih Bahasa</string>
<string name="settings_appearance_continue_watching_description">Pengaturan untuk bagian Lanjutkan Menonton.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif.</string>
<string name="settings_appearance_liquid_glass_description">Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru.</string>
<string name="settings_appearance_poster_customization_description">Sesuaikan lebar kartu dan radius sudut.</string>
<string name="settings_appearance_section_display">TAMPILAN</string>
<string name="settings_appearance_section_home">BERANDA</string>

View file

@ -1240,7 +1240,7 @@
<string name="settings_licenses_attributions_exoplayer_body">Utilizzato per la riproduzione sulle build Android.</string>
<string name="settings_licenses_attributions_exoplayer_license">Rilasciato sotto la licenza Apache License, Versione 2.0.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive.</string>
<string name="layout_hide_unreleased">Nascondi contenuti non rilasciati</string>
<string name="layout_hide_unreleased_sub">Nascondi i film e le serie TV che non sono ancora stati rilasciati.</string>
<string name="settings_homescreen_hide_catalog_underline">Nascondi sottolineatura catalogo</string>

File diff suppressed because it is too large Load diff

View file

@ -492,7 +492,7 @@
<string name="settings_appearance_app_language_sheet_title">Velg språk</string>
<string name="settings_appearance_continue_watching_description">Innstillinger for Fortsett å se-seksjonen.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på.</string>
<string name="settings_appearance_liquid_glass_description">Bruk den innebygde iPhone-fanen i iOS 26 og nyere.</string>
<string name="settings_appearance_poster_customization_description">Juster kortbredde og hjørneradius.</string>
<string name="settings_appearance_section_display">VISNING</string>
<string name="settings_appearance_section_home">HJEM</string>

View file

@ -508,7 +508,7 @@
<string name="settings_appearance_app_language_sheet_title">Wybierz język</string>
<string name="settings_appearance_continue_watching_description">Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">Użyj natywnego paska kart iPhone na iOS 26 i nowszych.</string>
<string name="settings_appearance_poster_customization_description">Dostosuj szerokość i zaokrąglenie rogów kart plakatów.</string>
<string name="settings_appearance_section_display">WYŚWIETLANIE</string>
<string name="settings_appearance_section_home">EKRAN GŁÓWNY</string>
@ -1762,7 +1762,7 @@
<string name="settings_playback_introdb_invalid_key">Nieprawidłowy klucz API lub błąd połączenia</string>
<string name="settings_playback_ios_audio_output">Wyjście audio</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Użyj starszego wyjścia AudioUnit.</string>
<string name="settings_playback_ios_audio_output_auto_desc">Najpierw AVFoundation, potem AudioUnit jako zapasowe.</string>
<string name="settings_playback_ios_audio_output_auto_desc">Użyj AudioUnit, gdy wyjście AVFoundation jest tymczasowo wyłączone.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Eksperymentalna obsługa Spatial Audio i wielokanałowego wyjścia.</string>
<string name="settings_playback_ios_audio_output_dialog">Wyjście audio</string>
<string name="settings_playback_ios_audio_output_section">Wyjście audio iOS</string>

File diff suppressed because it is too large Load diff

View file

@ -1586,7 +1586,7 @@
<string name="settings_playback_ios_target_transfer">Transferência de destino</string>
<string name="settings_playback_ios_audio_output_section">SAÍDA DE ÁUDIO DO iOS</string>
<string name="settings_playback_ios_audio_output">Saída de áudio</string>
<string name="settings_playback_ios_audio_output_auto_desc">Tenta primeiro AVFoundation e, se necessário, recorre ao AudioUnit.</string>
<string name="settings_playback_ios_audio_output_auto_desc">Use AudioUnit enquanto a saída AVFoundation estiver temporariamente desativada.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Suporte experimental para Áudio Espacial e saída multicanal.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Utiliza a saída AudioUnit antiga.</string>
<string name="settings_debrid_section_result_management">Gestão dos resultados</string>

View file

@ -498,7 +498,7 @@
<string name="settings_appearance_app_language_sheet_title">Dil seç</string>
<string name="settings_appearance_continue_watching_description">İzlemeye Devam Et rafını göster, gizle ve stilini ayarla.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_liquid_glass_description">iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan.</string>
<string name="settings_appearance_poster_customization_description">Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla.</string>
<string name="settings_appearance_section_display">EKRAN</string>
<string name="settings_appearance_section_home">ANA SAYFA</string>

View file

@ -19,6 +19,7 @@
<string name="action_retry">Retry</string>
<string name="action_save">Save</string>
<string name="action_saving">Saving…</string>
<string name="action_switch">Switch</string>
<string name="action_validate">Validate</string>
<string name="addon_installing">Installing</string>
<string name="addon_title">Addons</string>
@ -31,6 +32,11 @@
<string name="addons_badge_unavailable">Unavailable</string>
<string name="addons_configure">Configure addon</string>
<string name="addons_delete">Delete addon</string>
<string name="addons_appstore_add_description">Connect your own media server to browse and play from your personal library.</string>
<string name="addons_appstore_empty_subtitle">Add a server URL above when you want Nuvio to show your private library.</string>
<string name="addons_appstore_empty_title">No personal libraries connected.</string>
<string name="addons_appstore_input_placeholder">Server URL</string>
<string name="addons_appstore_install_button">Add</string>
<string name="addons_empty_subtitle">Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.</string>
<string name="addons_empty_title">No addons installed yet.</string>
<string name="addons_error_enter_url">Enter an addon URL.</string>
@ -318,7 +324,9 @@
<string name="compose_auth_sign_up_subtitle">Sign up to sync your data across devices</string>
<string name="compose_auth_sign_up">Sign Up</string>
<string name="compose_auth_store_locally">Your data will only be stored locally</string>
<string name="compose_auth_tagline">Stream everything, everywhere</string>
<string name="compose_auth_tagline">Watch your library, anywhere</string>
<string name="compose_auth_terms_prefix">By signing up, I agree to the</string>
<string name="compose_auth_terms_link">Terms</string>
<string name="compose_auth_welcome_back">Welcome Back</string>
<string name="compose_catalog_subtitle_library">Library</string>
<string name="compose_catalog_subtitle_trakt_library">Trakt Library</string>
@ -430,7 +438,7 @@
<string name="compose_settings_root_account_section">ACCOUNT</string>
<string name="compose_settings_root_advanced_description">Startup and profile behavior.</string>
<string name="compose_settings_root_advanced_section">ADVANCED</string>
<string name="compose_settings_root_appearance_description">Home structure and poster styles</string>
<string name="compose_settings_root_appearance_description">Home, streams, collections, and detail pages</string>
<string name="compose_settings_root_check_updates_description">Download latest release</string>
<string name="compose_settings_root_check_updates_title">Check for updates</string>
<string name="compose_settings_root_content_discovery_description">Manage addons and discovery sources.</string>
@ -533,6 +541,8 @@
<string name="settings_account_status_anonymous">Anonymous</string>
<string name="settings_account_status_signed_in">Signed in</string>
<string name="settings_account_sync_backend">Sync backend</string>
<string name="debug_backend_switch_confirm_title">Switch backend?</string>
<string name="debug_backend_switch_confirm_message">Switch to %1$s and sign out? You can sign in again on the selected backend.</string>
<string name="settings_appearance_amoled_black">AMOLED Black</string>
<string name="settings_appearance_amoled_description">Use pure black backgrounds for OLED screens.</string>
<string name="settings_appearance_app_language">App Language</string>
@ -540,7 +550,7 @@
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later.</string>
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
<string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</string>
@ -631,6 +641,7 @@
<string name="settings_content_discovery_section_home">HOME</string>
<string name="settings_content_discovery_section_sources">SOURCES</string>
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
<string name="settings_content_discovery_addons_description_appstore">Connect personal media sources and manage access to your own library.</string>
<string name="settings_content_discovery_plugins_description">Install JavaScript scraper repositories and test providers internally.</string>
<string name="settings_content_discovery_homescreen_description">Adjust home layout, content visibility, and poster behavior</string>
<string name="settings_content_discovery_meta_screen_description">Settings for the detail and episode screens.</string>
@ -910,6 +921,16 @@
<string name="settings_playback_secondary_audio_language">Secondary Audio Language</string>
<string name="settings_playback_secondary_subtitle_language">Secondary Preferred Language</string>
<string name="settings_playback_section_decoder">DECODER</string>
<string name="settings_playback_engine">Playback Engine</string>
<string name="settings_playback_engine_auto_description">Use ExoPlayer first and fall back to libmpv if playback fails.</string>
<string name="settings_playback_engine_exoplayer_description">Use ExoPlayer and Android Media3 decoders.</string>
<string name="settings_playback_engine_libmpv_description">Use libmpv for Android playback.</string>
<string name="settings_playback_libmpv_video_output">libmpv Renderer</string>
<string name="settings_playback_libmpv_video_output_dialog">libmpv Renderer</string>
<string name="settings_playback_libmpv_hardware_decoding">libmpv Hardware Decoding</string>
<string name="settings_playback_libmpv_hardware_decoding_description">Use mpv hardware decoding when available.</string>
<string name="settings_playback_libmpv_yuv420p">libmpv YUV420P Compatibility</string>
<string name="settings_playback_libmpv_yuv420p_description">Force YUV420P output for devices with renderer or color issues.</string>
<string name="settings_playback_section_next_episode">NEXT EPISODE</string>
<string name="settings_playback_section_player">PLAYER</string>
<string name="settings_playback_section_skip_segments">SKIP SEGMENTS</string>
@ -1617,7 +1638,7 @@
<!-- Playback settings iOS audio output -->
<string name="settings_playback_ios_audio_output_section">iOS audio output</string>
<string name="settings_playback_ios_audio_output">Audio output</string>
<string name="settings_playback_ios_audio_output_auto_desc">Try AVFoundation first, then fall back to AudioUnit.</string>
<string name="settings_playback_ios_audio_output_auto_desc">Use AudioUnit while AVFoundation output is temporarily disabled.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Experimental support for Spatial Audio and multichannel output.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Use the legacy AudioUnit output.</string>
<!-- Debrid Result Management section -->

View file

@ -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
@ -97,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
@ -106,12 +109,12 @@ 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
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
@ -159,10 +162,12 @@ 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
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
@ -179,7 +184,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
@ -303,65 +307,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<Long, CatalogLaunch>()
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(
@ -719,13 +688,11 @@ private fun MainAppContent(
remember {
CollectionSyncService.startObserving()
}
remember {
HomeCatalogSettingsSyncService.startObserving()
}
remember {
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) }
@ -733,6 +700,7 @@ private fun MainAppContent(
val searchScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
var nativeProfileSwitcherVisible by remember { mutableStateOf(false) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
@ -758,7 +726,19 @@ 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 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
@ -785,6 +765,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) }
@ -816,6 +800,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) {
@ -823,16 +829,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<TabsRoute>() == true
NativeTabBridge.publishTabBarVisible(visible)
}
@ -942,7 +952,6 @@ private fun MainAppContent(
SyncManager.requestForegroundPull(activeProfileId, force = true)
}
}
var profileSwitchLoading by remember { mutableStateOf(false) }
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(null) }
val activePlaybackProfileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId
@ -1194,6 +1203,7 @@ private fun MainAppContent(
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
logo = logo,
poster = poster,
background = background,
@ -1297,13 +1307,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 +1328,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 +1338,11 @@ private fun MainAppContent(
),
),
)
navController.navigate(
CatalogRoute(
launchId = launchId,
),
)
}
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
@ -1434,9 +1454,16 @@ 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<TabsRoute>() == 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)
@ -1505,18 +1532,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,
@ -1557,7 +1584,9 @@ private fun MainAppContent(
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
if (AppFeaturePolicy.supportersContributorsPageEnabled) {
navController.navigate(SupportersContributorsSettingsRoute)
}
},
onLicensesAttributionsSettingsClick = {
navController.navigate(LicensesAttributionsSettingsRoute)
@ -1589,7 +1618,26 @@ 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()
.padding(bottom = nativeProfileTabAnchorBottomPadding),
)
}
}
@ -1984,6 +2032,7 @@ private fun MainAppContent(
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
externalSubtitles = emptyList(),
streamType = cached.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2120,6 +2169,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2247,6 +2297,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2407,6 +2458,7 @@ private fun MainAppContent(
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
externalSubtitles = launch.externalSubtitles,
streamType = launch.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2487,29 +2539,38 @@ private fun MainAppContent(
}
composable<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
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(),
)
@ -2561,6 +2622,8 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
streamType = null,
logo = item.logo,
poster = item.poster,
background = item.background,
@ -2622,9 +2685,15 @@ private fun MainAppContent(
navController = navController,
backStackEntry = backStackEntry,
)
SupportersContributorsSettingsScreen(
onBack = onBack,
)
if (AppFeaturePolicy.supportersContributorsPageEnabled) {
SupportersContributorsSettingsScreen(
onBack = onBack,
)
} else {
LaunchedEffect(Unit) {
onBack()
}
}
}
composable<LicensesAttributionsSettingsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
@ -2850,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
@ -3174,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,
) {

View file

@ -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<String?> = _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<Unit> = 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<RestException>()
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 <reified T : Throwable> Throwable.findCause(): T? {
var current: Throwable? = this
while (current != null) {
if (current is T) return current
current = current.cause
}
return null
}
}

View file

@ -7,9 +7,13 @@ 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
val inAppUpdaterEnabled: Boolean
val imdbRatingLogoEnabled: Boolean
val debugBackendSwitcherEnabled: Boolean
}

View file

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

View file

@ -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<SyncBackendConfig> =
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"
}

View file

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

View file

@ -20,11 +20,17 @@ internal enum class NativeNavigationTab {
internal object NativeTabBridge {
private val _requestedTabs = MutableSharedFlow<NativeNavigationTab>(extraBufferCapacity = 1)
val requestedTabs: SharedFlow<NativeNavigationTab> = _requestedTabs.asSharedFlow()
private val _profileTabLongPresses = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val profileTabLongPresses: SharedFlow<Unit> = _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,6 +89,13 @@ 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?,

View file

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

View file

@ -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<String?>(null) }
var installModalState by remember { mutableStateOf<AddonInstallModalState?>(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,
)

View file

@ -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
@ -50,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
@ -257,68 +261,332 @@ 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<MetaPreview>,
latestCredits: List<MetaPreview>,
upcomingCredits: List<MetaPreview>,
watchedKeys: Set<String>,
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,
)
}
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(
@ -514,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
@ -526,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,
) {
@ -569,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))
}
}
}
@ -675,7 +1120,7 @@ private fun PersonDetailSkeleton(
@Composable
private fun SkeletonLine(
widthFraction: Float,
height: androidx.compose.ui.unit.Dp,
height: Dp,
) {
Box(
modifier = Modifier
@ -725,6 +1170,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<MetaPreview>): 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

View file

@ -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<String>,
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,
)
if (!header.logo.isNullOrBlank()) {
Box(
modifier = Modifier
.width(184.dp)
.height(104.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White)
.padding(18.dp),
contentAlignment = Alignment.Center,
) {
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),
)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
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() ?: "?" }
}

View file

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

View file

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

View file

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

View file

@ -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(
@ -742,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,
@ -767,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,
@ -815,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,
@ -873,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

View file

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

View file

@ -66,6 +66,7 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
streamType: String? = null,
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,

View file

@ -26,6 +26,7 @@ data class PlayerLaunch(
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val streamType: String? = null,
val logo: String? = null,
val poster: String? = null,
@ -81,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,
@ -152,9 +178,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<IosAudioOutputMode> = listOf(Auto, AudioUnit)
fun fromStoredName(name: String?): IosAudioOutputMode =
name
?.let { runCatching { valueOf(it) }.getOrNull() }
?.takeUnless { it == AvFoundation }
?: Auto
}
}
@Composable

View file

@ -11,6 +11,7 @@ fun PlayerScreen(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
streamType: String? = null,
providerName: String,
streamTitle: String,
@ -48,6 +49,7 @@ fun PlayerScreen(
sourceAudioUrl = sourceAudioUrl,
sourceHeaders = sourceHeaders,
sourceResponseHeaders = sourceResponseHeaders,
externalSubtitles = externalSubtitles,
streamType = streamType,
providerName = providerName,
streamTitle = streamTitle,

View file

@ -30,6 +30,7 @@ internal data class PlayerScreenArgs(
val parentMetaId: String,
val parentMetaType: String,
val providerAddonId: String?,
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val torrentInfoHash: String?,
val torrentFileIdx: Int?,
val torrentFilename: String?,

View file

@ -57,6 +57,7 @@ internal class PlayerScreenRuntime(
val torrentTrackers: List<String> get() = args.torrentTrackers
val initialPositionMs: Long get() = args.initialPositionMs
val initialProgressFraction: Float? get() = args.initialProgressFraction
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> get() = args.externalSubtitles
val isSeries: Boolean get() = parentMetaType == "series"
lateinit var scope: CoroutineScope

View file

@ -122,6 +122,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
externalSubtitles = externalSubtitles,
streamType = activeStreamType,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,

View file

@ -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
@ -325,9 +345,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
@ -491,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
@ -736,9 +786,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) {
@ -852,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,

View file

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

View file

@ -480,3 +480,32 @@ object PlayerStreamsRepository {
setJob(job)
}
}
private data class PlayerInstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun StreamsUiState.streamDiagnostics(): String {
val streamCount = groups.sumOf { it.streams.size }
val loadingCount = groups.count { it.isLoading }
val errorCount = groups.count { !it.error.isNullOrBlank() }
val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group ->
buildString {
append(group.addonName)
append(':')
append(group.streams.size)
if (group.isLoading) append(":loading")
if (!group.error.isNullOrBlank()) append(":error")
}
}
val suffix = if (groups.size > 4) "+${groups.size - 4}" else ""
return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " +
"loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " +
"sample=$sampleGroups$suffix"
}
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"

View file

@ -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("""<tt[\s>]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) ->
SubtitleFormatHint.Ttml
else -> SubtitleFormatHint.Srt
}
}
@ -53,6 +82,69 @@ object PlayerSubtitleCueParser {
}
.sortedBy { it.startTimeMs }
private fun parseAss(text: String): List<SubtitleSyncCue> {
var inEventsSection = false
var formatFields: List<String>? = 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<String>?): 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<SubtitleSyncCue> =
Regex("""<p\b([^>]*)>(.*?)</p>""", 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("""<br\s*/?>""", RegexOption.IGNORE_CASE), " ")
.cleanSubtitleCueText()
if (body.isBlank()) null else SubtitleSyncCue(start, body)
}
.sortedBy { it.startTimeMs }
.toList()
private fun parseCueStart(timingLine: String): Long? {
val startPart = timingLine.substringBefore("-->").trim()
return parseTimestamp(startPart)
@ -76,12 +168,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<String>.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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace(Regex("\\s+"), " ")
.trim()
}

View file

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

View file

@ -21,6 +21,7 @@ data class PluginManifestScraper(
val filename: String,
@SerialName("supportedTypes") val supportedTypes: List<String> = listOf("movie", "tv"),
val enabled: Boolean = true,
val hasSettings: Boolean = false,
val logo: String? = null,
@SerialName("contentLanguage") val contentLanguage: List<String>? = null,
@SerialName("supportedPlatforms") val supportedPlatforms: List<String>? = null,
@ -52,6 +53,7 @@ data class PluginScraper(
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val hasSettings: Boolean = false,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
@ -76,6 +78,15 @@ data class PluginRuntimeResult(
val peers: Int? = null,
val infoHash: String? = null,
val headers: Map<String, String>? = null,
val subtitles: List<PluginSubtitleResult>? = null,
)
@Serializable
data class PluginSubtitleResult(
val url: String,
val language: String,
val name: String? = null,
val headers: Map<String, String>? = null
)
data class PluginsUiState(
@ -119,6 +130,7 @@ internal data class StoredPluginScraper(
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val hasSettings: Boolean = false,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,

View file

@ -120,7 +120,7 @@ object ProfileRepository {
}
return
}
runCatching {
try {
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profiles")
val profiles = result.decodeList<NuvioProfile>()
_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" }
}
}

View file

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

View file

@ -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
@ -140,8 +142,8 @@ fun ProfileSwitcherTab(
if (profile.pinEnabled) {
pinProfile = profile
} else {
onProfileSelected(profile)
showPopup = false
onProfileSelected(profile)
}
}
@ -336,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 tokens = MaterialTheme.nuvio
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<NuvioProfile?>(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) {
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) { -NuvioTokens.Space.s64.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(tokens.elevation.overlay, tokens.shapes.sheet)
.background(
tokens.colors.surfaceSheet,
tokens.shapes.sheet,
)
.padding(tokens.spacing.sheetPadding),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding),
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,

View file

@ -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,14 +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
@ -60,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<String?>(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 {
@ -100,17 +117,35 @@ 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(
text = stringResource(Res.string.settings_account_sign_out),
onClick = { showSignOutConfirm = true },
)
if (canDeleteAccount) {
DeleteAccountCard(
errorMessage = deleteErrorMessage,
onDeleteClick = {
deleteErrorMessage = null
showDeleteConfirm = true
},
)
}
}
NuvioStatusModal(
@ -125,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

View file

@ -9,10 +9,12 @@ 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
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
@ -29,10 +31,12 @@ 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),
NORWEGIAN("nb", Res.string.lang_norwegian),
JAPANESE("ja", Res.string.lang_japanese),
;
companion object {

View file

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

View file

@ -1,24 +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_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_addons_description_appstore
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
@ -27,9 +16,6 @@ internal fun LazyListScope.contentDiscoveryContent(
showPluginsEntry: Boolean,
onAddonsClick: () -> Unit,
onPluginsClick: () -> Unit,
onHomescreenClick: () -> Unit,
onMetaScreenClick: () -> Unit,
onCollectionsClick: () -> Unit = {},
) {
item {
SettingsSection(
@ -39,8 +25,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),
icon = Icons.Rounded.Extension,
description = stringResource(
if (AppFeaturePolicy.personalMediaAddonCopyEnabled) {
Res.string.settings_content_discovery_addons_description_appstore
} else {
Res.string.settings_content_discovery_addons_description
},
),
isTablet = isTablet,
onClick = onAddonsClick,
)
@ -48,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,
)
@ -56,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,
)
}
}
}
}

View file

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

View file

@ -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,
@ -1299,7 +1381,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 = {
@ -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 <T> IosEnumSelectionDialog(

View file

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

View file

@ -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,
@ -79,6 +75,7 @@ internal fun LazyListScope.settingsRootContent(
showGeneralSection: Boolean = true,
showAboutSection: Boolean = true,
showAdvancedSection: Boolean = true,
showSupportersContributorsPage: Boolean = true,
) {
if (showAccountSection) {
item {
@ -155,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),
@ -189,14 +178,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),

View file

@ -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
@ -69,6 +68,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
@ -92,6 +93,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,
@ -214,9 +221,20 @@ fun SettingsScreen(
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val scrollToTopRequests = remember { MutableSharedFlow<Unit>(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
@ -230,9 +248,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()
@ -258,6 +279,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 +334,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 +399,10 @@ private fun MobileSettingsScreen(
secondaryPreferredSubtitleLanguage: String?,
streamReuseLastLinkEnabled: Boolean,
streamReuseLastLinkCacheHours: Int,
androidPlaybackEngine: AndroidPlaybackEngine,
androidLibmpvVideoOutput: AndroidLibmpvVideoOutput,
androidLibmpvHardwareDecodingEnabled: Boolean,
androidLibmpvYuv420pEnabled: Boolean,
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
@ -435,6 +468,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,
@ -444,7 +480,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()
@ -504,7 +544,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) },
@ -517,15 +556,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,
)
@ -541,6 +583,10 @@ private fun MobileSettingsScreen(
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
androidPlaybackEngine = androidPlaybackEngine,
androidLibmpvVideoOutput = androidLibmpvVideoOutput,
androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled,
androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled,
decoderPriority = decoderPriority,
mapDV7ToHevc = mapDV7ToHevc,
tunnelingEnabled = tunnelingEnabled,
@ -561,6 +607,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) },
)
@ -592,9 +642,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()
@ -696,6 +743,10 @@ private fun TabletSettingsScreen(
secondaryPreferredSubtitleLanguage: String?,
streamReuseLastLinkEnabled: Boolean,
streamReuseLastLinkCacheHours: Int,
androidPlaybackEngine: AndroidPlaybackEngine,
androidLibmpvVideoOutput: AndroidLibmpvVideoOutput,
androidLibmpvHardwareDecodingEnabled: Boolean,
androidLibmpvYuv420pEnabled: Boolean,
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
@ -756,7 +807,6 @@ private fun TabletSettingsScreen(
.width(280.dp)
.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
modifier = Modifier
@ -800,6 +850,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,
@ -807,7 +860,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()
@ -885,7 +942,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) },
@ -902,15 +958,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,
)
@ -926,6 +985,10 @@ private fun TabletSettingsScreen(
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
androidPlaybackEngine = androidPlaybackEngine,
androidLibmpvVideoOutput = androidLibmpvVideoOutput,
androidLibmpvHardwareDecodingEnabled = androidLibmpvHardwareDecodingEnabled,
androidLibmpvYuv420pEnabled = androidLibmpvYuv420pEnabled,
decoderPriority = decoderPriority,
mapDV7ToHevc = mapDV7ToHevc,
tunnelingEnabled = tunnelingEnabled,
@ -946,6 +1009,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) },
)
@ -977,9 +1044,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()

View file

@ -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) {
@ -448,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,

View file

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

View file

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

View file

@ -2,9 +2,18 @@ package com.nuvio.app.features.streams
import com.nuvio.app.core.build.AppFeaturePolicy
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
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<String, String>? = null
)
data class StreamItem(
val name: String? = null,
val title: String? = null,
@ -22,6 +31,7 @@ data class StreamItem(
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
val debridCacheStatus: StreamDebridCacheStatus? = null,
val externalSubtitles: List<StreamSubtitle> = emptyList(),
val badges: List<StreamBadge> = emptyList(),
) {
val streamLabel: String

View file

@ -799,3 +799,4 @@ object StreamsRepository {
_uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) }
}
}

View file

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

View file

@ -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<String, WatchProgressEntry> = 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<WatchProgressEntry>): List<WatchProgressEntry> =
entries.mapNotNull { entry ->
entriesByVideoId.remove(entry.videoId)
synchronized(entriesLock) {
entries.mapNotNull { entry ->
entriesByVideoId.remove(entry.videoId)
}
}
private fun currentEntries(): List<WatchProgressEntry> {
@ -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<WatchProgressEntry> =
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<WatchProgressEntry>) {
synchronized(entriesLock) {
entriesByVideoId = entries
.associateBy { it.videoId }
.toMutableMap()
}
}
private fun replaceLocalEntries(entries: Map<String, WatchProgressEntry>) {
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)
}

View file

@ -12,6 +12,7 @@ class PlayerLaunchStoreTest {
profileId = 1,
title = "Title",
sourceUrl = "https://example.com/video.m3u8?token=a/b:c",
externalSubtitles = emptyList(),
streamTitle = "Source",
providerName = "Provider",
parentMetaId = "tt1234567",

View file

@ -2,9 +2,13 @@ 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
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -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
@ -337,7 +338,6 @@ actual object PluginRepository {
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = emptyMap(),
)
}
}
@ -391,6 +391,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,
@ -484,12 +485,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))
}
@ -551,6 +552,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,

View file

@ -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<String, JsonElement>().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)
)
}
}
}
}
}

View file

@ -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
@ -39,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
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.plugins_badge_disabled
@ -101,6 +104,9 @@ fun PluginsSettingsPageContent(
var testingScraperId by remember { mutableStateOf<String?>(null) }
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
var configuringScraper by remember { mutableStateOf<PluginScraper?>(null) }
var configuringLayout by remember { mutableStateOf<String?>(null) }
val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() }
}
@ -408,11 +414,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))
@ -501,6 +526,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(fallback: String): String {

View file

@ -0,0 +1,272 @@
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
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
import kotlinx.coroutines.CompletableDeferred
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 nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_unknown
import org.jetbrains.compose.resources.getString
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,
): List<PluginRuntimeResult> = withContext(Dispatchers.Default) {
val scraperSettingsJson = PluginStorage.loadScraperSettings(scraperId) ?: "{}"
val scraperSettingsMap = runCatching {
json.decodeFromString<Map<String, JsonElement>>(scraperSettingsJson)
}.getOrElse { emptyMap() }
withTimeout(PLUGIN_TIMEOUT_MS) {
executePluginInternal(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraperId,
scraperSettings = scraperSettingsMap,
)
}
}
suspend fun getPluginSettingsLayout(
code: String,
scraperId: String,
): String? = withContext(Dispatchers.Default) {
withTimeout(PLUGIN_TIMEOUT_MS) {
val jsRuntime = JsRuntime()
val deferred = CompletableDeferred<String?>()
try {
jsRuntime.use {
val polyfillCode = JsBindings.buildPolyfillCode(
scraperIdJson = JsonPrimitive(scraperId).toString(),
settingsJson = "{}"
)
evaluate<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(wrappedCode)
val callCode = """
(async function() {
try {
var onSettings = (typeof module !== 'undefined' && module.exports && module.exports.onSettings) || globalThis.onSettings;
if (typeof onSettings === 'function') {
var layout = await onSettings();
__capture_settings_result(JSON.stringify(layout || []));
} else {
__capture_settings_result("[]");
}
} catch (e) {
console.error("onSettings error:", e);
__capture_settings_result("[]");
}
})();
""".trimIndent()
function("__capture_settings_result") { args: Array<Any?> ->
deferred.complete(args.getOrNull(0)?.toString())
null
}
evaluate<Any?>(callCode)
deferred.await()
}
} catch (e: Exception) {
null
}
}
}
private suspend fun executePluginInternal(
code: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
scraperId: String,
scraperSettings: Map<String, JsonElement>,
): List<PluginRuntimeResult> {
val jsRuntime = JsRuntime()
val deferred = CompletableDeferred<String>()
val domBridge = DomBridge()
val hostRegistry = HostApiRegistry().apply {
addModule(HostFunctions(scraperId) { deferred.complete(it) })
addModule(FetchBridge())
addModule(UrlBridge())
addModule(CryptoBridge())
addModule(WasmBridge())
addModule(domBridge)
}
try {
jsRuntime.use {
hostRegistry.registerAll(this)
val settingsJson = JsonObject(scraperSettings).toString()
val polyfillCode = JsBindings.buildPolyfillCode(
scraperIdJson = JsonPrimitive(scraperId).toString(),
settingsJson = settingsJson,
)
evaluate<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(wrappedCode)
val tmdbIdArg = JsonPrimitive(tmdbId).toString()
val mediaTypeArg = JsonPrimitive(mediaType).toString()
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($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 : "");
__capture_result(JSON.stringify([]));
}
})();
""".trimIndent()
evaluate<Any?>(callCode)
deferred.await()
}
// Result is captured inside use block, but returned outside to satisfy compiler
return parseJsonResults(deferred.await())
} finally {
domBridge.clear()
}
}
private fun parseJsonResults(rawJson: String): List<PluginRuntimeResult> {
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() }
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") ?: runBlocking { getString(Res.string.generic_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,
subtitles = subtitles,
)
}.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())
}
}

View file

@ -0,0 +1,135 @@
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.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
import com.nuvio.app.features.plugins.pluginSign
import com.nuvio.app.features.plugins.pluginVerify
internal class CryptoBridge : HostModule {
override fun register(runtime: QuickJs) {
// 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
pluginGetRandomValues(length).toHexString()
}
runtime.function("__crypto_digest_hex_raw") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val data = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
pluginDigest(algorithm, data).toHexString()
}
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"
pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm).toHexString()
}
runtime.function("__crypto_aes_encrypt_hex") { args ->
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
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_hex") { args ->
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
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_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: ""
val privateKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
pluginSign(algorithm, privateKey, data).toHexString()
}
runtime.function("__crypto_verify_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: ""
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) ---
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("")
}
}
}
private fun ByteArray.toHexString(): String =
joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}

View file

@ -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<String, Document>()
private val elementCache = mutableMapOf<String, Element>()
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()
}
}

View file

@ -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<HostModule>()
fun addModule(module: HostModule) {
modules.add(module)
}
fun registerAll(runtime: QuickJs) {
modules.forEach { it.register(runtime) }
}
}

View file

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

View file

@ -0,0 +1,973 @@
package com.nuvio.app.features.plugins.runtime.js
internal object JsBindings {
fun buildPolyfillCode(scraperIdJson: String, settingsJson: String): String {
return """
globalThis.SCRAPER_ID = $scraperIdJson;
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()}
${textEncoderPolyfill()}
${cheerioPolyfill()}
${requirePolyfill()}
${arrayPolyfill()}
${objectPolyfill()}
${stringPolyfill()}
""".trimIndent()
}
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 = __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);
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() = """
var WordArray = {
init: function(words, sigBytes) {
this.words = words || [];
this.sigBytes = sigBytes != undefined ? sigBytes : this.words.length * 4;
},
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();
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;
},
clamp: function() {
var words = this.words;
var sigBytes = this.sigBytes;
if (sigBytes % 4) {
words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8);
}
words.length = Math.ceil(sigBytes / 4);
return this;
},
clone: function() {
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;
}
return bytes;
}
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);
}
return __wordArrayCreate(words, bytes.length);
}
function __normalizeWordArrayInput(value) {
if (__isWordArray(value)) return __wordArrayToBytes(value);
if (typeof value === 'string') return new TextEncoder().encode(value);
return __toUint8Array(value);
}
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) {
return __bytesToHex(__wordArrayToBytes(wordArray));
},
parse: function(hexStr) {
return __bytesToWordArray(__hexToBytes(hexStr));
}
},
Utf8: {
stringify: function(wordArray) {
return new TextDecoder('utf-8').decode(__wordArrayToBytes(wordArray));
},
parse: function(utf8Str) {
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]);
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);
}
},
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: { 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))); },
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 || {};
var pBytes = __normalizeWordArrayInput(pass);
var sBytes = __normalizeWordArrayInput(salt);
var iter = options.iterations || 1000;
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;
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 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) {
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);
},
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 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 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));
return __bytesToArrayBuffer(__nativeAesBytes(true, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
},
decrypt: async function(params, key, data) {
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));
return __bytesToArrayBuffer(__nativeAesBytes(false, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
},
sign: async function(algo, key, data) {
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) {
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) 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);
}
};
// WebAssembly placeholder
globalThis.WebAssembly = {
instantiate: async function(bufferSource, importObject) {
console.warn("WebAssembly.instantiate called (placeholder)");
return { instance: { exports: {} }, module: {} };
}
};
""".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) {
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()
}

View file

@ -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 <T> use(block: suspend QuickJs.() -> T): T {
return quickJs(dispatcher) {
block()
}
}
}

View file

@ -0,0 +1,101 @@
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_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.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(response.body),
"headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }),
),
)
return result.toString()
}
private fun parseHeaders(headersJson: String): Map<String, String> {
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
}
}

View file

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

View file

@ -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.
* 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) {
// Placeholder for WASM instantiation bridge
// runtime.function("__native_wasm_instantiate") { ... }
}
}

View file

@ -2,9 +2,13 @@ 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
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = false
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -2,9 +2,13 @@ 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
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -2,21 +2,396 @@ 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
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.*
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)
val status = SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0))
require(status == 0) { "Failed to generate secure random bytes: status $status" }
return bytes
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
val normalized = normalizeDigestAlgorithm(algorithm)
val output = ByteArray(
when (normalized) {
"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")
},
)
data.usePinned { pinnedData ->
output.usePinned { pinnedOutput ->
val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null
val outputPtr = pinnedOutput.addressOf(0).reinterpret<UByteVar>()
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)
"SHA384" -> CC_SHA384(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 {
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)
password.usePinned { pinnedPassword ->
salt.usePinned { pinnedSalt ->
derivedKey.usePinned { pinnedDerivedKey ->
val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret<ByteVar>() else null
val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret<UByteVar>() else null
val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret<UByteVar>()
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 {
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
memScoped {
val cryptorRefVar = alloc<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
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<platform.posix.size_tVar>()
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)
var finalData: ByteArray? = null
memScoped {
val dataOutMoved = alloc<platform.posix.size_tVar>()
var options = 0U
if (isEcb) {
options = options or kCCOptionECBMode
}
if (!isNoPadding) {
options = options or 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 {
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" }
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<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
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<platform.posix.size_tVar>()
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)
var finalData: ByteArray? = null
memScoped {
val dataOutMoved = alloc<platform.posix.size_tVar>()
var options = 0U
if (isEcb) {
options = options or kCCOptionECBMode
}
if (!isNoPadding) {
options = options or 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')
@ -24,62 +399,130 @@ 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) {
"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")
},
)
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)
"SHA384" -> CC_SHA384(dataPtr, input.size.toUInt(), outputPtr)
"SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr)
}
}
}
return output.toHex()
}
@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<UByteVar>(),
)
}
}
}
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"
"SHA384" -> "SHA384"
"SHA512" -> "SHA512"
else -> error("Unsupported digest algorithm: $algorithm")
}
}
@OptIn(ExperimentalForeignApi::class)
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")
}
@OptIn(ExperimentalForeignApi::class)
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()
"SHA384" -> kCCHmacAlgSHA384 to CC_SHA384_DIGEST_LENGTH.toInt()
"SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported HMAC algorithm: $algorithm")
}
val output = UByteArray(outputSize)
CCHmac(
alg,
keyBytes.refTo(0),
keyBytes.size.toULong(),
input.refTo(0),
input.size.toULong(),
output.refTo(0),
)
return output.toHex()
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 ByteArray.toHex(): String =
joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
private fun String.normalizedAlgorithmToken(): String =
uppercase()
.replace("-", "")
.replace("_", "")
.replace("/", "")
.replace(" ", "")
@OptIn(ExperimentalEncodingApi::class)
internal fun pluginBase64Encode(data: String): String =
Base64.encode(data.encodeToByteArray())
@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()
}
@ -89,11 +532,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)
@ -101,5 +544,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()
}

View file

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

View file

@ -9,6 +9,10 @@ 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"
@ -38,6 +42,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?,

View file

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

Some files were not shown because too many files have changed in this diff Show more