mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 00:17:59 +00:00
Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost
This commit is contained in:
commit
2465d4e6b2
108 changed files with 11241 additions and 2062 deletions
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
86
.github/workflows/close-stale-issues.yml
vendored
Normal file
86
.github/workflows/close-stale-issues.yml
vendored
Normal 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.`);
|
||||
}
|
||||
46
.github/workflows/stale-needs-info.yml
vendored
46
.github/workflows/stale-needs-info.yml
vendored
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
3
composeApp/proguard-rules.pro
vendored
3
composeApp/proguard-rules.pro
vendored
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
31
composeApp/src/androidMain/res/raw/isrg_root_x1.pem
Normal file
31
composeApp/src/androidMain/res/raw/isrg_root_x1.pem
Normal 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-----
|
||||
14
composeApp/src/androidMain/res/raw/isrg_root_x2.pem
Normal file
14
composeApp/src/androidMain/res/raw/isrg_root_x2.pem
Normal 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-----
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 d’onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d’onglets n’est pas disponible quand cette option est activée.</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Utilise la barre d’onglets 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 d’abord 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 l’audio spatial et de la sortie multicanal.</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">Utilise l’ancienne sortie AudioUnit.</string>
|
||||
<!-- Debrid Result Management section -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1870
composeApp/src/commonMain/composeResources/values-ja/strings.xml
Normal file
1870
composeApp/src/commonMain/composeResources/values-ja/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1344
composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml
Normal file
1344
composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() &&
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?: "?" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
sourceAudioUrl = activeSourceAudioUrl,
|
||||
sourceHeaders = activeSourceHeaders,
|
||||
sourceResponseHeaders = activeSourceResponseHeaders,
|
||||
externalSubtitles = externalSubtitles,
|
||||
streamType = activeStreamType,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
playWhenReady = shouldPlay,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -799,3 +799,4 @@ object StreamsRepository {
|
|||
_uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") { ... }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue