mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-26 17:12:16 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost
This commit is contained in:
commit
83bc15441b
230 changed files with 19814 additions and 1966 deletions
26
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
26
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -9,10 +9,11 @@ body:
|
|||
value: |
|
||||
Thanks for reporting a bug.
|
||||
|
||||
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
|
||||
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.
|
||||
|
||||
If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
|
||||
Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed if the missing details are not added.
|
||||
If the app crashes or force closes, logs are required. Crash reports without logs will be closed.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
@ -121,11 +122,26 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug description
|
||||
description: |
|
||||
Explain the problem in detail. Do not only write "not working" or "same issue".
|
||||
Include what screen/flow you were using, what you tapped or tried to do, what changed on screen, and any visible error text.
|
||||
placeholder: |
|
||||
I was trying to ...
|
||||
The app ...
|
||||
I saw ...
|
||||
This happens on ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
|
||||
description: Exact steps, one action per line. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
|
||||
placeholder: |
|
||||
1. Open ...
|
||||
2. Navigate to ...
|
||||
|
|
@ -146,7 +162,7 @@ body:
|
|||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
placeholder: "What actually happened (include any on-screen error text/codes)."
|
||||
placeholder: "What actually happened? Include any on-screen error text/codes and whether the app froze, crashed, ignored input, showed the wrong content, etc."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -197,7 +213,7 @@ body:
|
|||
attributes:
|
||||
label: Logs (required for crash reports)
|
||||
description: |
|
||||
Required if the app crashes or force closes.
|
||||
Required if the app crashes or force closes. Crash reports without logs will be closed.
|
||||
For other bug reports, logs are optional but still helpful.
|
||||
|
||||
**Android:** `adb logcat -d | tail -n 300`
|
||||
|
|
|
|||
96
.github/workflows/triage-needs-info.yml
vendored
96
.github/workflows/triage-needs-info.yml
vendored
|
|
@ -55,10 +55,37 @@ jobs:
|
|||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizedMeaningfulText(value) {
|
||||
return normalizeText(value)
|
||||
.replace(/^```[a-zA-Z0-9_-]*/i, "")
|
||||
.replace(/```$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripIssuePrefix(value) {
|
||||
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
|
||||
}
|
||||
|
||||
function isEmptyish(value) {
|
||||
const text = normalizedMeaningfulText(value);
|
||||
return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text);
|
||||
}
|
||||
|
||||
function isVague(value) {
|
||||
const text = normalizedMeaningfulText(value);
|
||||
return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text);
|
||||
}
|
||||
|
||||
function hasUsefulText(value, minimumLength) {
|
||||
const text = normalizedMeaningfulText(value);
|
||||
return !isEmptyish(text) && !isVague(text) && text.length >= minimumLength;
|
||||
}
|
||||
|
||||
const description = extractFirstSection([
|
||||
"Bug description",
|
||||
"Describe the bug",
|
||||
"Detailed description",
|
||||
]);
|
||||
const steps = extractSection("Steps to reproduce");
|
||||
const expected = extractSection("Expected behavior");
|
||||
const actual = extractSection("Actual behavior");
|
||||
|
|
@ -69,7 +96,7 @@ jobs:
|
|||
const extra = extractSection("Anything else? (optional)");
|
||||
const summaryTitle = stripIssuePrefix(title);
|
||||
|
||||
const looksLikeBugForm = !!(steps || expected || actual);
|
||||
const looksLikeBugForm = !!(description || steps || expected || actual);
|
||||
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
|
||||
const isFeatureIssue =
|
||||
hasLabel("enhancement") ||
|
||||
|
|
@ -84,20 +111,20 @@ jobs:
|
|||
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
|
||||
const numericOnlyTitle = /^#?\d+$/;
|
||||
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
|
||||
const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
|
||||
const crashContext = [summaryTitle, description, steps, actual, extra].map(normalizeText).join("\n");
|
||||
const isCrashIssue = crashPattern.test(crashContext);
|
||||
const normalizedLogs = normalizeText(logs);
|
||||
const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
|
||||
const normalizedLogs = normalizedMeaningfulText(logs);
|
||||
const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs);
|
||||
|
||||
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
|
||||
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
|
||||
}
|
||||
if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
|
||||
if (!expected || expected.length < 10) problems.push("Expected behavior");
|
||||
if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
|
||||
if (isCrashIssue && !hasLogs) {
|
||||
problems.push("Logs (required for crash reports; include a log snippet or stack trace)");
|
||||
if (!hasUsefulText(description, 50)) {
|
||||
problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")");
|
||||
}
|
||||
if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)");
|
||||
if (!hasUsefulText(expected, 10)) problems.push("Expected behavior");
|
||||
if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)");
|
||||
|
||||
async function ensureLabel(name, color, description) {
|
||||
try {
|
||||
|
|
@ -111,6 +138,50 @@ jobs:
|
|||
|
||||
const hasNeedsInfo = hasLabel(NEEDS_INFO);
|
||||
|
||||
if (isCrashIssue && !hasLogs) {
|
||||
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
|
||||
if (!hasNeedsInfo) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [NEEDS_INFO],
|
||||
});
|
||||
}
|
||||
|
||||
const marker = "<!-- nuvio-bot:crash-logs-required-close -->";
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
|
||||
if (!alreadyCommented) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body:
|
||||
`${marker}\n` +
|
||||
`Closing this crash report because crash reports must include logs.\n\n` +
|
||||
`Please open a new report, or ask us to reopen this one, with a log snippet or stack trace from around the crash.\n\n` +
|
||||
`Useful examples:\n` +
|
||||
`- Android: \`adb logcat -d | tail -n 300\`\n` +
|
||||
`- iOS: crash log from Xcode Organizer or Console.app\n` +
|
||||
`- Desktop: terminal/console output from around the crash`,
|
||||
});
|
||||
}
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
|
||||
if (!hasNeedsInfo) {
|
||||
|
|
@ -125,12 +196,13 @@ jobs:
|
|||
const marker = "<!-- nuvio-bot:needs-info -->";
|
||||
const commentBody =
|
||||
`${marker}\n` +
|
||||
`Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
|
||||
`Missing / too short:\n` +
|
||||
`Thanks for the report. Could you add more detail so we can reproduce it?\n\n` +
|
||||
`Missing / too vague:\n` +
|
||||
problems.map(p => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` +
|
||||
`${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`;
|
||||
`Reports that only say "not working", "broken", or "same issue" are not enough to debug.\n` +
|
||||
`Logs are optional for most issues, but they help a lot. Crash reports without logs are closed automatically.\n`;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
|
|
|
|||
2
composeApp/Configuration/DesktopVersion.properties
Normal file
2
composeApp/Configuration/DesktopVersion.properties
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VERSION_NAME=0.1.1
|
||||
VERSION_CODE=2
|
||||
|
|
@ -5,9 +5,13 @@ import org.gradle.api.file.RegularFileProperty
|
|||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.Sync
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.jvm.tasks.Jar
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
|
||||
import java.util.Properties
|
||||
|
|
@ -26,6 +30,12 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
@get:Input
|
||||
abstract val appVersionCode: Property<Int>
|
||||
|
||||
@get:Input
|
||||
abstract val desktopAppVersionName: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val desktopAppVersionCode: Property<Int>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val props = Properties()
|
||||
|
|
@ -112,6 +122,8 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
|object AppVersionConfig {
|
||||
| const val VERSION_NAME = "${appVersionName.get()}"
|
||||
| const val VERSION_CODE = ${appVersionCode.get()}
|
||||
| const val DESKTOP_VERSION_NAME = "${desktopAppVersionName.get()}"
|
||||
| const val DESKTOP_VERSION_CODE = ${desktopAppVersionCode.get()}
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
|
|
@ -148,6 +160,23 @@ fun readXcconfigValue(file: File, key: String): String? {
|
|||
?.second
|
||||
}
|
||||
|
||||
fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'"
|
||||
|
||||
fun cmdQuote(value: String): String = "\"${value.replace("\"", "\"\"")}\""
|
||||
|
||||
fun psSingleQuote(value: String): String = "'${value.replace("'", "''")}'"
|
||||
|
||||
fun semanticVersionSortKey(value: String): String =
|
||||
value.split('.', '-', '_')
|
||||
.joinToString(".") { part ->
|
||||
part.toIntOrNull()?.toString()?.padStart(8, '0') ?: part
|
||||
}
|
||||
|
||||
fun newestDirectory(root: File): File? =
|
||||
root.takeIf(File::exists)
|
||||
?.listFiles(File::isDirectory)
|
||||
?.maxByOrNull { semanticVersionSortKey(it.name) }
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidApplication)
|
||||
|
|
@ -171,6 +200,31 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V
|
|||
val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION")
|
||||
?.toIntOrNull()
|
||||
?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}")
|
||||
val desktopVersionConfigFile = rootProject.file("composeApp/Configuration/DesktopVersion.properties")
|
||||
val desktopVersionProps = Properties().apply {
|
||||
if (desktopVersionConfigFile.exists()) {
|
||||
desktopVersionConfigFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
val desktopReleaseVersionName = (
|
||||
providers.gradleProperty("nuvio.desktop.versionName").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_VERSION_NAME")
|
||||
?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_NAME")
|
||||
?: desktopVersionProps.getProperty("VERSION_NAME")
|
||||
?: "0.1.0"
|
||||
).trim()
|
||||
require(desktopReleaseVersionName.isNotBlank()) {
|
||||
"Desktop version name must not be blank."
|
||||
}
|
||||
val desktopReleaseVersionCode = (
|
||||
providers.gradleProperty("nuvio.desktop.versionCode").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_VERSION_CODE")
|
||||
?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_CODE")
|
||||
?: desktopVersionProps.getProperty("VERSION_CODE")
|
||||
)?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.toIntOrNull()
|
||||
?: 1
|
||||
val iosDistribution = (
|
||||
providers.gradleProperty("nuvio.ios.distribution").orNull
|
||||
?: System.getenv("NUVIO_IOS_DISTRIBUTION")
|
||||
|
|
@ -187,6 +241,7 @@ val iosDistributionSourceDir = if (iosDistribution == "full") {
|
|||
}
|
||||
val iosFrameworkBundleId = "com.nuvio.media"
|
||||
val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin")
|
||||
val fullPluginSourceDir = fullCommonSourceDir.resolve("com/nuvio/app/features/plugins")
|
||||
val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin")
|
||||
val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName ->
|
||||
taskName.substringAfterLast(':').lowercase()
|
||||
|
|
@ -205,6 +260,356 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
|
|||
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
|
||||
appVersionName.set(releaseAppVersionName)
|
||||
appVersionCode.set(releaseAppVersionCode)
|
||||
desktopAppVersionName.set(desktopReleaseVersionName)
|
||||
desktopAppVersionCode.set(desktopReleaseVersionCode)
|
||||
}
|
||||
|
||||
val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true)
|
||||
val isWindowsHost = System.getProperty("os.name").contains("win", ignoreCase = true)
|
||||
val mpvKitDir = providers.gradleProperty("nuvio.mpvkit.dir")
|
||||
.orElse(rootProject.layout.projectDirectory.dir("MPVKit").asFile.absolutePath)
|
||||
val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm")
|
||||
val macosPlayerBridgeOutput = layout.buildDirectory.file("native/macos/libplayer_bridge.dylib")
|
||||
val macosPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
else -> "x86_64"
|
||||
}
|
||||
val mpvKitRoot = File(mpvKitDir.get())
|
||||
val mpvKitDistRoot = File(mpvKitRoot, "dist")
|
||||
val mpvKitLibmpvRoot = File(mpvKitDistRoot, "libmpv/macos/thin/$macosPlayerBridgeArch")
|
||||
val mpvKitLibmpvPkgConfigFile = File(mpvKitLibmpvRoot, "lib/pkgconfig/mpv.pc")
|
||||
val mpvKitGeneratedPkgConfigDirs = if (mpvKitDistRoot.exists()) {
|
||||
mpvKitDistRoot.walkTopDown()
|
||||
.filter { it.isDirectory && it.invariantSeparatorsPath.endsWith("/macos/thin/$macosPlayerBridgeArch/lib/pkgconfig") }
|
||||
.toList()
|
||||
.sortedBy { it.absolutePath }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val mpvKitGeneratedLibSearchArgs = mpvKitGeneratedPkgConfigDirs
|
||||
.mapNotNull { it.parentFile }
|
||||
.distinctBy { it.absolutePath }
|
||||
.joinToString(" ") { "-L${shellQuote(it.absolutePath)}" }
|
||||
val missingMpvKitMacosFrameworks = if (mpvKitLibmpvPkgConfigFile.exists()) emptyList() else listOf("mpv.pc")
|
||||
val missingMpvKitMacosMessage = """
|
||||
MPVKit macOS libmpv artifacts are missing for $macosPlayerBridgeArch: ${missingMpvKitMacosFrameworks.joinToString()}.
|
||||
Build MPVKit's macOS runtime first:
|
||||
cd ${mpvKitRoot.absolutePath}
|
||||
make build platform=macos
|
||||
Or pass -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit.
|
||||
""".trimIndent()
|
||||
val missingMpvKitMacosShellMessage = missingMpvKitMacosMessage.replace("'", "'\"'\"'")
|
||||
val macosPlayerBridgeSourceFile = macosPlayerBridgeSource.asFile
|
||||
val macosPlayerBridgeOutputFile = macosPlayerBridgeOutput.get().asFile
|
||||
val macosPlayerBridgeJavaHome = providers.systemProperty("java.home").get()
|
||||
val mpvKitLibmpvStaticLib = File(mpvKitLibmpvRoot, "lib/libmpv.a")
|
||||
if (isMacHost) {
|
||||
macosPlayerBridgeOutputFile.parentFile.mkdirs()
|
||||
}
|
||||
val macosPlayerBridgeCommand = if (missingMpvKitMacosFrameworks.isNotEmpty()) {
|
||||
listOf(
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"printf '%s\\n' '$missingMpvKitMacosShellMessage' >&2; exit 1",
|
||||
)
|
||||
} else {
|
||||
mutableListOf(
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""
|
||||
set -eu
|
||||
SDKROOT="${'$'}(xcrun --sdk macosx --show-sdk-path)"
|
||||
SWIFTC="${'$'}(xcrun --toolchain XcodeDefault --find swiftc)"
|
||||
SWIFT_TOOLCHAIN="${'$'}{SWIFTC%/usr/bin/swiftc}"
|
||||
SWIFT_LIB="${'$'}{SWIFT_TOOLCHAIN}/usr/lib/swift/macosx"
|
||||
DEFAULT_PC="${'$'}(pkg-config --variable pc_path pkg-config)"
|
||||
export PKG_CONFIG_LIBDIR=${shellQuote(mpvKitGeneratedPkgConfigDirs.joinToString(":"))}:"${'$'}{DEFAULT_PC}"
|
||||
exec xcrun clang++ \
|
||||
-std=c++17 \
|
||||
-dynamiclib \
|
||||
-fobjc-arc \
|
||||
-ObjC++ \
|
||||
-arch ${shellQuote(macosPlayerBridgeArch)} \
|
||||
-isysroot "${'$'}{SDKROOT}" \
|
||||
-mmacosx-version-min=11.0 \
|
||||
${shellQuote(macosPlayerBridgeSourceFile.absolutePath)} \
|
||||
-o ${shellQuote(macosPlayerBridgeOutputFile.absolutePath)} \
|
||||
-I${shellQuote("$macosPlayerBridgeJavaHome/include")} \
|
||||
-I${shellQuote("$macosPlayerBridgeJavaHome/include/darwin")} \
|
||||
-I${shellQuote(File(mpvKitLibmpvRoot, "include").absolutePath)} \
|
||||
$mpvKitGeneratedLibSearchArgs \
|
||||
-L"${'$'}{SWIFT_LIB}" \
|
||||
-L/usr/lib/swift \
|
||||
-framework AppKit \
|
||||
-framework WebKit \
|
||||
-framework Metal \
|
||||
-framework Security \
|
||||
-lswiftCompatibility56 \
|
||||
-lswiftCompatibilityConcurrency \
|
||||
-lswiftCompatibilityPacks \
|
||||
-lc++ \
|
||||
${'$'}(pkg-config --libs --static mpv)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val buildMacosPlayerBridge = tasks.register<Exec>("buildMacosPlayerBridge") {
|
||||
notCompatibleWithConfigurationCache("Builds a host-local player bridge against MPVKit's macOS libmpv artifacts.")
|
||||
enabled = isMacHost
|
||||
inputs.file(macosPlayerBridgeSource)
|
||||
if (mpvKitLibmpvStaticLib.exists()) {
|
||||
inputs.file(mpvKitLibmpvStaticLib)
|
||||
}
|
||||
if (mpvKitLibmpvPkgConfigFile.exists()) {
|
||||
inputs.file(mpvKitLibmpvPkgConfigFile)
|
||||
}
|
||||
inputs.files(mpvKitGeneratedPkgConfigDirs.mapNotNull { it.parentFile?.resolve("lib")?.takeIf(File::exists) })
|
||||
outputs.file(macosPlayerBridgeOutput)
|
||||
commandLine(macosPlayerBridgeCommand)
|
||||
}
|
||||
|
||||
val windowsPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86" -> "x86"
|
||||
else -> "x64"
|
||||
}
|
||||
val windowsPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/windows/player_bridge.cpp")
|
||||
val windowsPlayerBridgeOutput = layout.buildDirectory.file("native/windows/player_bridge.dll")
|
||||
val windowsPlayerBridgeImportLib = layout.buildDirectory.file("native/windows/player_bridge.lib")
|
||||
val windowsPlayerBridgePdb = layout.buildDirectory.file("native/windows/player_bridge.pdb")
|
||||
val windowsPlayerBridgeObj = layout.buildDirectory.file("native/windows/player_bridge.obj")
|
||||
val windowsPlayerBridgeScript = layout.buildDirectory.file("native/windows/build-player-bridge.bat")
|
||||
val windowsPlayerRuntimeOutput = layout.buildDirectory.dir("native/windows-runtime")
|
||||
if (isWindowsHost) {
|
||||
windowsPlayerBridgeOutput.get().asFile.parentFile.mkdirs()
|
||||
}
|
||||
val windowsWebView2Root = providers.gradleProperty("nuvio.webview2.dir").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: newestDirectory(File(System.getProperty("user.home"), ".nuget/packages/microsoft.web.webview2"))
|
||||
?: File("__missing_webview2__")
|
||||
val windowsWebView2IncludeDir = File(windowsWebView2Root, "build/native/include")
|
||||
val windowsWebView2NativeDir = File(windowsWebView2Root, "build/native/$windowsPlayerBridgeArch")
|
||||
val windowsWebView2LoaderLib = File(windowsWebView2NativeDir, "WebView2Loader.dll.lib")
|
||||
val windowsWebView2LoaderDll = File(windowsWebView2NativeDir, "WebView2Loader.dll")
|
||||
val windowsLibmpvRuntimeDir = providers.gradleProperty("nuvio.windows.libmpv.runtimeDir").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: listOf(
|
||||
File("C:/Program Files (x86)/Nuvio/app/native"),
|
||||
File("C:/Program Files/Nuvio/app/native"),
|
||||
).firstOrNull { File(it, "libmpv-2.dll").exists() }
|
||||
val windowsLibmpvDll = providers.gradleProperty("nuvio.windows.libmpv.dll").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: windowsLibmpvRuntimeDir?.resolve("libmpv-2.dll")
|
||||
?: listOf(
|
||||
File("C:/msys64/ucrt64/bin/libmpv-2.dll"),
|
||||
File("C:/msys64/mingw64/bin/libmpv-2.dll"),
|
||||
).firstOrNull(File::exists)
|
||||
val windowsVsWhere = File("C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe")
|
||||
val windowsVcvarsRelativePath = when (windowsPlayerBridgeArch) {
|
||||
"x86" -> "VC\\Auxiliary\\Build\\vcvars32.bat"
|
||||
"arm64" -> "VC\\Auxiliary\\Build\\vcvarsarm64.bat"
|
||||
else -> "VC\\Auxiliary\\Build\\vcvars64.bat"
|
||||
}
|
||||
val windowsVcvarsPath = providers.gradleProperty("nuvio.windows.vcvars.path").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val windowsPlayerBridgeJavaHome = providers.systemProperty("java.home").get()
|
||||
val missingWindowsPlayerBridgeInputs = listOfNotNull(
|
||||
"WebView2.h".takeUnless { windowsWebView2IncludeDir.resolve("WebView2.h").exists() },
|
||||
"WebView2Loader.dll.lib".takeUnless { windowsWebView2LoaderLib.exists() },
|
||||
)
|
||||
val missingWindowsPlayerBridgeMessage = """
|
||||
Windows desktop player bridge inputs are missing: ${missingWindowsPlayerBridgeInputs.joinToString()}.
|
||||
Install the Microsoft.Web.WebView2 NuGet package or pass -Pnuvio.webview2.dir=C:/path/to/microsoft.web.webview2/version.
|
||||
libmpv is loaded at runtime; pass -Pnuvio.windows.libmpv.runtimeDir=C:/path/to/mpv-dlls to bundle it.
|
||||
""".trimIndent()
|
||||
val windowsPlayerBridgeCommand = if (missingWindowsPlayerBridgeInputs.isNotEmpty()) {
|
||||
listOf(
|
||||
"cmd",
|
||||
"/c",
|
||||
"echo ${missingWindowsPlayerBridgeMessage.replace("\n", " ")} 1>&2 && exit /b 1",
|
||||
)
|
||||
} else {
|
||||
val sourceFile = windowsPlayerBridgeSource.asFile
|
||||
val outputFile = windowsPlayerBridgeOutput.get().asFile
|
||||
val importLibFile = windowsPlayerBridgeImportLib.get().asFile
|
||||
val pdbFile = windowsPlayerBridgePdb.get().asFile
|
||||
val objFile = windowsPlayerBridgeObj.get().asFile
|
||||
val javaIncludeDir = File(windowsPlayerBridgeJavaHome, "include")
|
||||
val javaWin32IncludeDir = File(javaIncludeDir, "win32")
|
||||
val compileCommand = listOf(
|
||||
"cl",
|
||||
"/nologo",
|
||||
"/EHsc",
|
||||
"/std:c++17",
|
||||
"/LD",
|
||||
"/DUNICODE",
|
||||
"/D_UNICODE",
|
||||
"/DNOMINMAX",
|
||||
"/DWIN32_LEAN_AND_MEAN",
|
||||
"/permissive-",
|
||||
cmdQuote(sourceFile.absolutePath),
|
||||
"/I${cmdQuote(javaIncludeDir.absolutePath)}",
|
||||
"/I${cmdQuote(javaWin32IncludeDir.absolutePath)}",
|
||||
"/I${cmdQuote(windowsWebView2IncludeDir.absolutePath)}",
|
||||
"/Fo${cmdQuote(objFile.absolutePath)}",
|
||||
"/Fd${cmdQuote(pdbFile.absolutePath)}",
|
||||
"/Fe${cmdQuote(outputFile.absolutePath)}",
|
||||
"/link",
|
||||
"/NOLOGO",
|
||||
"/INCREMENTAL:NO",
|
||||
"/IMPLIB:${cmdQuote(importLibFile.absolutePath)}",
|
||||
cmdQuote(windowsWebView2LoaderLib.absolutePath),
|
||||
"Ole32.lib",
|
||||
"User32.lib",
|
||||
"Gdi32.lib",
|
||||
"Dwmapi.lib",
|
||||
).joinToString(" ")
|
||||
val powershellCompileCommand = compileCommand.replace("\"", "__DQ__")
|
||||
val powershellCommand = """
|
||||
${'$'}ErrorActionPreference = 'Stop'
|
||||
${'$'}dq = [char]34
|
||||
${'$'}vcvars = ${psSingleQuote(windowsVcvarsPath.orEmpty())}
|
||||
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars)) {
|
||||
${'$'}vswhere = ${psSingleQuote(windowsVsWhere.absolutePath)}
|
||||
if (Test-Path -LiteralPath ${'$'}vswhere) {
|
||||
${'$'}vcvars = & ${'$'}vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find ${psSingleQuote(windowsVcvarsRelativePath)} | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars) -or -not (Test-Path -LiteralPath ${'$'}vcvars)) {
|
||||
Write-Error 'Visual Studio C++ toolchain was not found. Install MSVC or pass -Pnuvio.windows.vcvars.path=C:\path\to\vcvars64.bat.'
|
||||
exit 1
|
||||
}
|
||||
${'$'}vcvars = ([string]${'$'}vcvars).Trim()
|
||||
${'$'}bat = ${psSingleQuote(windowsPlayerBridgeScript.get().asFile.absolutePath)}
|
||||
${'$'}compile = ${psSingleQuote(powershellCompileCommand)}.Replace('__DQ__', ${'$'}dq)
|
||||
${'$'}lines = @(
|
||||
'@echo off',
|
||||
('set {0}VCVARS={1}{0}' -f ${'$'}dq, ${'$'}vcvars),
|
||||
('call {0}%VCVARS%{0} >nul' -f ${'$'}dq),
|
||||
'if errorlevel 1 exit /b %errorlevel%',
|
||||
${'$'}compile,
|
||||
'exit /b %ERRORLEVEL%'
|
||||
)
|
||||
Set-Content -LiteralPath ${'$'}bat -Value ${'$'}lines -Encoding ASCII
|
||||
& cmd.exe /d /c ${'$'}bat
|
||||
${'$'}code = ${'$'}LASTEXITCODE
|
||||
if (${'$'}code -ne 0) { exit ${'$'}code }
|
||||
""".trimIndent()
|
||||
listOf(
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
powershellCommand,
|
||||
)
|
||||
}
|
||||
val buildWindowsPlayerBridge = tasks.register<Exec>("buildWindowsPlayerBridge") {
|
||||
notCompatibleWithConfigurationCache("Builds a host-local player bridge against WebView2 and libmpv for Windows.")
|
||||
enabled = isWindowsHost
|
||||
inputs.file(windowsPlayerBridgeSource)
|
||||
if (windowsWebView2IncludeDir.exists()) {
|
||||
inputs.dir(windowsWebView2IncludeDir)
|
||||
}
|
||||
if (windowsWebView2LoaderLib.exists()) {
|
||||
inputs.file(windowsWebView2LoaderLib)
|
||||
}
|
||||
outputs.file(windowsPlayerBridgeOutput)
|
||||
outputs.file(windowsPlayerBridgeImportLib)
|
||||
outputs.file(windowsPlayerBridgePdb)
|
||||
commandLine(windowsPlayerBridgeCommand)
|
||||
}
|
||||
|
||||
val prepareWindowsPlayerRuntime = tasks.register<Sync>("prepareWindowsPlayerRuntime") {
|
||||
enabled = isWindowsHost
|
||||
into(windowsPlayerRuntimeOutput)
|
||||
if (windowsWebView2LoaderDll.exists()) {
|
||||
from(windowsWebView2LoaderDll)
|
||||
}
|
||||
when {
|
||||
windowsLibmpvRuntimeDir?.exists() == true -> {
|
||||
from(windowsLibmpvRuntimeDir) {
|
||||
include("*.dll")
|
||||
}
|
||||
}
|
||||
windowsLibmpvDll?.exists() == true -> {
|
||||
from(windowsLibmpvDll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val generateWindowsPlayerRuntimeIndex = tasks.register<GenerateNativeRuntimeIndexTask>("generateWindowsPlayerRuntimeIndex") {
|
||||
enabled = isWindowsHost
|
||||
dependsOn(prepareWindowsPlayerRuntime)
|
||||
runtimeDir.set(windowsPlayerRuntimeOutput)
|
||||
indexFile.set(windowsPlayerRuntimeOutput.map { it.file("runtime-files.txt") })
|
||||
}
|
||||
|
||||
abstract class GenerateNativeRuntimeIndexTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
abstract val runtimeDir: DirectoryProperty
|
||||
|
||||
@get:OutputFile
|
||||
abstract val indexFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val dir = runtimeDir.get().asFile
|
||||
val files = dir
|
||||
.listFiles { file -> file.isFile && file.name != indexFile.get().asFile.name }
|
||||
.orEmpty()
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
indexFile.get().asFile.writeText(files.joinToString(separator = "\n", postfix = "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Jar>().configureEach {
|
||||
if (isMacHost && name == "desktopJar") {
|
||||
dependsOn(buildMacosPlayerBridge)
|
||||
from(macosPlayerBridgeOutput) {
|
||||
into("native/macos")
|
||||
}
|
||||
}
|
||||
if (isWindowsHost && name == "desktopJar") {
|
||||
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
|
||||
from(windowsPlayerBridgeOutput) {
|
||||
into("native/windows")
|
||||
}
|
||||
from(windowsPlayerRuntimeOutput) {
|
||||
into("native/windows")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindowsHost) {
|
||||
val desktopNativePlayerTasks = setOf(
|
||||
"run",
|
||||
"runRelease",
|
||||
"desktopRun",
|
||||
"runDistributable",
|
||||
"runReleaseDistributable",
|
||||
"desktopRunHot",
|
||||
"hotRunDesktop",
|
||||
"hotRunDesktopAsync",
|
||||
"hotDevDesktop",
|
||||
"hotDevDesktopAsync",
|
||||
"createDistributable",
|
||||
"createReleaseDistributable",
|
||||
"createRuntimeImage",
|
||||
"package",
|
||||
"packageDistributionForCurrentOS",
|
||||
"packageMsi",
|
||||
"packageUberJarForCurrentOS",
|
||||
"packageReleaseDistributionForCurrentOS",
|
||||
"packageReleaseMsi",
|
||||
"packageReleaseUberJarForCurrentOS",
|
||||
)
|
||||
tasks.matching { it.name in desktopNativePlayerTasks }.configureEach {
|
||||
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||
|
|
@ -217,6 +622,12 @@ kotlin {
|
|||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
jvm("desktop") {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
val iosTargets = listOf(
|
||||
iosArm64(),
|
||||
|
|
@ -281,6 +692,16 @@ kotlin {
|
|||
implementation(libs.androidx.media3.extractor)
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar"))))
|
||||
}
|
||||
val desktopMain by getting {
|
||||
kotlin.srcDir(fullPluginSourceDir)
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.quickjs.kt)
|
||||
implementation(libs.ksoup)
|
||||
}
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
|
@ -309,6 +730,46 @@ kotlin {
|
|||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.nuvio.app.MainKt"
|
||||
val smokePlayerUrl = providers.gradleProperty("nuvio.desktop.smokePlayerUrl").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL")
|
||||
jvmArgs += listOfNotNull(
|
||||
"-Dapple.awt.application.appearance=NSAppearanceNameDarkAqua",
|
||||
"--add-opens=java.desktop/java.awt=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.awt.windows=ALL-UNNAMED",
|
||||
smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" },
|
||||
)
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "Nuvio"
|
||||
packageVersion = desktopReleaseVersionName
|
||||
vendor = "Nuvio Media"
|
||||
modules("java.net.http")
|
||||
macOS {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.icns"))
|
||||
}
|
||||
windows {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.ico"))
|
||||
shortcut = true
|
||||
menu = true
|
||||
menuGroup = "Nuvio"
|
||||
}
|
||||
linux {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.png"))
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes.release.proguard {
|
||||
isEnabled.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
dependencies {
|
||||
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
|
||||
|
|
@ -316,6 +777,10 @@ afterEvaluate {
|
|||
}
|
||||
}
|
||||
|
||||
configurations.matching { it.name == "iosMainImplementation" }.configureEach {
|
||||
project.dependencies.add(name, libs.ktor.client.darwin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
|
|
@ -361,14 +826,6 @@ android {
|
|||
manifest.srcFile("src/androidFull/AndroidManifest.xml")
|
||||
java.srcDir(fullCommonSourceDir)
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
isEnable = !isAndroidAppBundleBuild
|
||||
reset()
|
||||
include("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
isUniversalApk = false
|
||||
}
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.core.build
|
|||
|
||||
actual object AppFeaturePolicy {
|
||||
actual val pluginsEnabled: Boolean = true
|
||||
actual val downloadsEnabled: Boolean = true
|
||||
actual val notificationsEnabled: Boolean = true
|
||||
actual val p2pEnabled: Boolean = true
|
||||
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
|
||||
actual val heroTrailerPlaybackSupported: Boolean = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
actual object AppVersionPolicy {
|
||||
actual val displayVersionName: String = AppVersionConfig.VERSION_NAME
|
||||
actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE
|
||||
actual val basedOnVersionName: String? = null
|
||||
}
|
||||
|
|
@ -26,4 +26,6 @@ internal object PluginStorage {
|
|||
|
||||
internal fun currentPluginPlatform(): String = "android"
|
||||
|
||||
internal fun currentPluginPlatformTags(): Set<String> = setOf(currentPluginPlatform())
|
||||
|
||||
internal fun currentEpochMillis(): Long = System.currentTimeMillis()
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
|
|||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
|
||||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.SubtitleFileCache
|
||||
import com.nuvio.app.features.player.PlayerPictureInPictureManager
|
||||
import com.nuvio.app.features.p2p.P2pSettingsStorage
|
||||
import com.nuvio.app.features.p2p.P2pStreamingEngine
|
||||
|
|
@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() {
|
|||
P2pSettingsStorage.initialize(applicationContext)
|
||||
P2pStreamingEngine.initialize(applicationContext)
|
||||
ExternalPlayerPlatform.initialize(applicationContext)
|
||||
SubtitleFileCache.initialize(applicationContext)
|
||||
ProfileStorage.initialize(applicationContext)
|
||||
AvatarStorage.initialize(applicationContext)
|
||||
ProfilePinCacheStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ class AndroidPlatform : Platform {
|
|||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||
|
||||
internal actual val isIos: Boolean = false
|
||||
internal actual val isIos: Boolean = false
|
||||
internal actual val isDesktop: Boolean = false
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_profile_pin_cache",
|
||||
"nuvio_theme_settings",
|
||||
"nuvio_poster_card_style",
|
||||
"nuvio_debrid_settings",
|
||||
"nuvio_mdblist_settings",
|
||||
"nuvio_downloads",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
||||
@Composable
|
||||
internal actual fun NuvioAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
placeholder: Painter?,
|
||||
error: Painter?,
|
||||
fallback: Painter?,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)?,
|
||||
alignment: Alignment,
|
||||
contentScale: ContentScale,
|
||||
alpha: Float,
|
||||
colorFilter: ColorFilter?,
|
||||
filterQuality: FilterQuality?,
|
||||
clipToBounds: Boolean,
|
||||
desktopImageScaling: NuvioDesktopImageScaling,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality ?: DefaultFilterQuality,
|
||||
clipToBounds = clipToBounds,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this
|
||||
|
|
@ -30,6 +30,7 @@ actual object DebridSettingsStorage {
|
|||
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||
private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_"
|
||||
private fun syncKeys(): List<String> =
|
||||
listOf(
|
||||
enabledKey,
|
||||
|
|
@ -150,6 +151,20 @@ actual object DebridSettingsStorage {
|
|||
saveString(streamDescriptionTemplateKey, template)
|
||||
}
|
||||
|
||||
actual fun loadPendingDeviceAuthorization(providerId: String): String? =
|
||||
loadString(pendingDeviceAuthorizationKey(providerId))
|
||||
|
||||
actual fun savePendingDeviceAuthorization(providerId: String, payload: String) {
|
||||
saveString(pendingDeviceAuthorizationKey(providerId), payload)
|
||||
}
|
||||
|
||||
actual fun clearPendingDeviceAuthorization(providerId: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)))
|
||||
?.apply()
|
||||
}
|
||||
|
||||
private fun loadBoolean(key: String): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val scopedKey = ProfileScopedKey.of(key)
|
||||
|
|
@ -249,4 +264,10 @@ actual object DebridSettingsStorage {
|
|||
else -> "debrid_${normalized}_api_key"
|
||||
}
|
||||
}
|
||||
|
||||
private fun pendingDeviceAuthorizationKey(providerId: String): String {
|
||||
val normalized = DebridProviders.byId(providerId)?.id
|
||||
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
|
||||
return "$pendingDeviceAuthorizationPrefix$normalized"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform {
|
|||
}
|
||||
|
||||
// Title extras
|
||||
val displayTitle = request.streamTitle ?: request.title
|
||||
val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true)
|
||||
putExtra(Intent.EXTRA_TITLE, displayTitle)
|
||||
putExtra("title", displayTitle)
|
||||
putExtra("forcename", displayTitle) // Vimu Player
|
||||
|
|
@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform {
|
|||
if (request.resumePositionMs > 0L) {
|
||||
putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv
|
||||
putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player
|
||||
putExtra("forceresume", true) // Vimu: enable resume for network streams
|
||||
putExtra("from_start", false) // VLC: don't force start from beginning
|
||||
}
|
||||
|
||||
|
|
@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform {
|
|||
val subtitleNames = subtitles.map { it.name }.toTypedArray()
|
||||
val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray()
|
||||
|
||||
// Grant read permission for content:// URIs via ClipData.
|
||||
// FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras.
|
||||
// Adding all subtitle URIs to ClipData ensures the receiving player
|
||||
// gets read access to all of them.
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val clipData = android.content.ClipData(
|
||||
"subtitles",
|
||||
arrayOf("application/x-subrip", "text/vtt"),
|
||||
android.content.ClipData.Item(subtitleUris.first())
|
||||
)
|
||||
subtitleUris.drop(1).forEach { subtitleUri ->
|
||||
clipData.addItem(android.content.ClipData.Item(subtitleUri))
|
||||
}
|
||||
setClipData(clipData)
|
||||
|
||||
// MX Player / mpv-android / Nova
|
||||
putExtra("subs", subtitleUris)
|
||||
putExtra("subs.name", subtitleNames)
|
||||
putExtra("subs.filename", subtitleFilenames)
|
||||
putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url)))
|
||||
putExtra("subs.enable", arrayOf(subtitleUris.first()))
|
||||
|
||||
// Just Player
|
||||
putExtra("subtitle_uri", subtitleUris)
|
||||
putExtra("subtitle_name", subtitleNames)
|
||||
|
||||
// VLC (single subtitle — use first one)
|
||||
putExtra("subtitles_location", Uri.parse(subtitles.first().url))
|
||||
putExtra("subtitles_location", subtitleUris.first())
|
||||
|
||||
// Vimu Player
|
||||
putExtra("forcedsrt", subtitles.first().url)
|
||||
|
|
|
|||
|
|
@ -85,7 +85,13 @@ actual fun PlatformPlayerSurface(
|
|||
modifier: Modifier,
|
||||
playWhenReady: Boolean,
|
||||
resizeMode: PlayerResizeMode,
|
||||
initialPositionMs: Long,
|
||||
useNativeController: Boolean,
|
||||
playerControlsState: PlayerControlsState,
|
||||
onPlayerControlsAction: (PlayerControlsAction) -> Boolean,
|
||||
onPlayerControlsEvent: (String, Double) -> Boolean,
|
||||
onPlayerControlsScrubChange: (Long) -> Boolean,
|
||||
onPlayerControlsScrubFinished: (Long) -> Boolean,
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
/**
|
||||
* Android implementation: downloads subtitles to local cache and returns content:// URIs
|
||||
* via FileProvider so external players can read them.
|
||||
*/
|
||||
actual object SubtitleCacheProvider {
|
||||
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
|
||||
return SubtitleFileCache.cacheSubtitles(subtitles)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Downloads subtitle files from remote URLs to local cache and provides
|
||||
* content:// URIs via FileProvider so external players can read them.
|
||||
*/
|
||||
object SubtitleFileCache {
|
||||
private const val TAG = "SubtitleFileCache"
|
||||
private const val SUBTITLE_CACHE_DIR = "subtitles"
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private val cacheDir: File?
|
||||
get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } }
|
||||
|
||||
/**
|
||||
* Downloads subtitle files and returns updated [SubtitleInput] list with
|
||||
* content:// URIs instead of HTTP URLs.
|
||||
*
|
||||
* Subtitles that fail to download are silently skipped (not included in result).
|
||||
* Returns null if no subtitles could be downloaded.
|
||||
*/
|
||||
suspend fun cacheSubtitles(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
|
||||
if (subtitles.isEmpty()) return null
|
||||
val context = appContext ?: return null
|
||||
|
||||
// Clean old cached subtitles before downloading new ones
|
||||
clearCache()
|
||||
|
||||
val cached = subtitles.mapNotNull { input ->
|
||||
try {
|
||||
val localUri = downloadToCache(context, input)
|
||||
if (localUri != null) {
|
||||
input.copy(url = localUri.toString())
|
||||
} else {
|
||||
Log.w(TAG, "Failed to download subtitle: ${input.name}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error caching subtitle: ${input.name}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return cached.ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a single subtitle file and returns a content:// URI.
|
||||
*/
|
||||
private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val dir = cacheDir ?: return@withContext null
|
||||
val extension = guessExtension(input.url)
|
||||
val filename = sanitizeFilename("${input.lang}_${input.name}.$extension")
|
||||
val file = File(dir, filename)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(input.url)
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body ?: return@withContext null
|
||||
file.outputStream().use { output ->
|
||||
body.byteStream().copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
// Return content:// URI via FileProvider
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to download subtitle file: ${input.url}", e)
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all cached subtitle files.
|
||||
*/
|
||||
fun clearCache() {
|
||||
try {
|
||||
cacheDir?.listFiles()?.forEach { it.delete() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to clear subtitle cache", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun guessExtension(url: String): String {
|
||||
val path = url.substringBefore('?').substringBefore('#').trimEnd('/')
|
||||
return when {
|
||||
path.endsWith(".vtt", ignoreCase = true) -> "vtt"
|
||||
path.endsWith(".ass", ignoreCase = true) -> "ass"
|
||||
path.endsWith(".ssa", ignoreCase = true) -> "ssa"
|
||||
path.endsWith(".ttml", ignoreCase = true) -> "ttml"
|
||||
path.endsWith(".dfxp", ignoreCase = true) -> "dfxp"
|
||||
else -> "srt"
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_")
|
||||
.take(100) // Limit filename length
|
||||
}
|
||||
}
|
||||
|
|
@ -18,11 +18,13 @@ actual object ThemeSettingsStorage {
|
|||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val desktopNavigationLayoutKey = "desktop_navigation_layout"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
desktopNavigationLayoutKey,
|
||||
)
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
|
@ -68,6 +70,16 @@ actual object ThemeSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadDesktopNavigationLayout(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(desktopNavigationLayoutKey), null)
|
||||
|
||||
actual fun saveDesktopNavigationLayout(layoutName: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSelectedAppLanguage(): String? {
|
||||
val value = preferences?.getString(selectedAppLanguageKey, null)
|
||||
if (value != null) return value
|
||||
|
|
@ -93,6 +105,7 @@ actual object ThemeSettingsStorage {
|
|||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||
|
|
@ -103,6 +116,7 @@ actual object ThemeSettingsStorage {
|
|||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ actual object StreamBadgeSettingsStorage {
|
|||
private const val legacyDebridPreferencesName = "nuvio_debrid_settings"
|
||||
private const val streamBadgeRulesKey = "stream_badge_rules"
|
||||
private const val showFileSizeBadgesKey = "show_file_size_badges"
|
||||
private const val showAddonLogoKey = "show_addon_logo"
|
||||
private const val streamBadgePlacementKey = "stream_badge_placement"
|
||||
private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules"
|
||||
|
||||
|
|
@ -41,6 +42,12 @@ actual object StreamBadgeSettingsStorage {
|
|||
saveBoolean(showFileSizeBadgesKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey)
|
||||
|
||||
actual fun saveShowAddonLogo(enabled: Boolean) {
|
||||
saveBoolean(showAddonLogoKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey)
|
||||
|
||||
actual fun saveStreamBadgePlacement(placement: String) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
<cache-path
|
||||
name="nuvio_updates"
|
||||
path="updates/" />
|
||||
<cache-path
|
||||
name="nuvio_subtitles"
|
||||
path="subtitles/" />
|
||||
<files-path
|
||||
name="nuvio_downloads"
|
||||
path="downloads/" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.core.build
|
|||
|
||||
actual object AppFeaturePolicy {
|
||||
actual val pluginsEnabled: Boolean = false
|
||||
actual val downloadsEnabled: Boolean = true
|
||||
actual val notificationsEnabled: Boolean = true
|
||||
actual val p2pEnabled: Boolean = true
|
||||
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
|
||||
actual val heroTrailerPlaybackSupported: Boolean = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
actual object AppVersionPolicy {
|
||||
actual val displayVersionName: String = AppVersionConfig.VERSION_NAME
|
||||
actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE
|
||||
actual val basedOnVersionName: String? = null
|
||||
}
|
||||
|
|
@ -1456,4 +1456,7 @@
|
|||
<string name="cloud_library_type_torrents">Torrenty</string>
|
||||
<string name="cloud_library_type_usenet">Usenet</string>
|
||||
<string name="cloud_library_type_web">Web</string>
|
||||
<string name="settings_stream_addon_logo_title">Logo dodatku</string>
|
||||
<string name="settings_stream_addon_logo_description">Pokazuj logo i nazwę dodatku obok źródeł.</string>
|
||||
<string name="settings_stream_display_section">Wyświetlanie</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@
|
|||
<string name="collections_pinned">Pinned</string>
|
||||
<string name="collections_tab_all">All</string>
|
||||
<string name="collections_your_collections">Your Collections</string>
|
||||
<string name="compose_about_based_on_version_format">Based on Nuvio %1$s</string>
|
||||
<string name="compose_about_made_with">Made with ❤️ by Tapframe and friends</string>
|
||||
<string name="compose_about_version_format">Version %1$s (%2$s)</string>
|
||||
<string name="compose_action_off">Off</string>
|
||||
|
|
@ -535,6 +536,10 @@
|
|||
<string name="settings_appearance_app_language">App Language</string>
|
||||
<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_desktop_navigation">Desktop Navigation</string>
|
||||
<string name="settings_appearance_desktop_navigation_sheet_title">Choose Desktop Navigation</string>
|
||||
<string name="settings_appearance_desktop_navigation_sidebar">Sidebar</string>
|
||||
<string name="settings_appearance_desktop_navigation_top_bar">Top Bar</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_poster_customization_description">Tune card width and corner radius.</string>
|
||||
|
|
@ -681,6 +686,9 @@
|
|||
<string name="settings_stream_badges_section">Fusion Style</string>
|
||||
<string name="settings_stream_size_badges_title">Size badges</string>
|
||||
<string name="settings_stream_size_badges_description">Show file size badges in stream results and player source panels.</string>
|
||||
<string name="settings_stream_addon_logo_title">Addon logo</string>
|
||||
<string name="settings_stream_addon_logo_description">Show addon logo and name next to stream sources.</string>
|
||||
<string name="settings_stream_display_section">Display</string>
|
||||
<string name="settings_stream_badge_position_title">Badge position</string>
|
||||
<string name="settings_stream_badge_position_description">Choose whether Fusion and size badges appear above or below stream cards.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">Badge position</string>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app
|
|||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -11,6 +12,9 @@ import androidx.compose.animation.togetherWith
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
|
|
@ -19,15 +23,18 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -55,7 +62,10 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
|
|
@ -153,12 +163,15 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch
|
|||
import com.nuvio.app.features.player.SubtitleLanguageOption
|
||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||
import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar
|
||||
import com.nuvio.app.features.profiles.AvatarCatalogItem
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
import com.nuvio.app.features.profiles.NuvioProfile
|
||||
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.SidebarProfileSwitcherStack
|
||||
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||
import com.nuvio.app.features.search.SearchScreen
|
||||
import com.nuvio.app.features.settings.SettingsScreen
|
||||
|
|
@ -168,13 +181,16 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
|
|||
import com.nuvio.app.features.settings.AddonsSettingsScreen
|
||||
import com.nuvio.app.features.settings.PluginsSettingsScreen
|
||||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||
import com.nuvio.app.features.settings.DesktopNavigationLayout
|
||||
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
|
||||
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
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.CollectionRepository
|
||||
import com.nuvio.app.features.collection.CollectionSyncService
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
||||
import com.nuvio.app.features.collection.FolderDetailScreen
|
||||
import com.nuvio.app.features.collection.FolderDetailRepository
|
||||
|
|
@ -192,6 +208,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
|
|||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
|
|
@ -204,10 +221,13 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
|||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
||||
|
|
@ -321,6 +341,11 @@ enum class AppScreenTab {
|
|||
Settings,
|
||||
}
|
||||
|
||||
private val DesktopSidebarCollapsedWidth = 76.dp
|
||||
private val DesktopSidebarExpandedWidth = 184.dp
|
||||
private val DesktopSidebarExpandedContentWidth = 144.dp
|
||||
private val DesktopSidebarIconSlotSize = 36.dp
|
||||
|
||||
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
|
||||
AppScreenTab.Home -> NativeNavigationTab.Home
|
||||
AppScreenTab.Search -> NativeNavigationTab.Search
|
||||
|
|
@ -342,16 +367,46 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba
|
|||
streamTitle = streamTitle,
|
||||
sourceHeaders = sourceHeaders,
|
||||
resumePositionMs = initialPositionMs,
|
||||
season = seasonNumber,
|
||||
episode = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
)
|
||||
|
||||
private enum class AppGateScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
ProfileSelection,
|
||||
ProfileSwitching,
|
||||
ProfileEdit,
|
||||
Main,
|
||||
}
|
||||
|
||||
private data class PendingProfileSwitch(
|
||||
val profile: NuvioProfile,
|
||||
val syncOnEnter: Boolean,
|
||||
)
|
||||
|
||||
private suspend fun warmProfileBoundRepositories() {
|
||||
withContext(Dispatchers.Default) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
DownloadsRepository.ensureLoaded()
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
LibraryRepository.ensureLoaded()
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
CollectionSyncService.startObserving()
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
|
|
@ -414,6 +469,7 @@ fun App() {
|
|||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
var isNewProfile by remember { mutableStateOf(false) }
|
||||
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingProfileSwitch by remember { mutableStateOf<PendingProfileSwitch?>(null) }
|
||||
|
||||
fun rememberedStartupProfile(profiles: List<NuvioProfile>): NuvioProfile? {
|
||||
val currentProfileState = ProfileRepository.state.value
|
||||
|
|
@ -429,6 +485,12 @@ fun App() {
|
|||
?.takeUnless { it.pinEnabled }
|
||||
}
|
||||
|
||||
fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) {
|
||||
autoSkipProfileSelection = false
|
||||
pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter)
|
||||
gateScreen = AppGateScreen.ProfileSwitching.name
|
||||
}
|
||||
|
||||
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
|
||||
if (profiles.isEmpty()) {
|
||||
autoSkipProfileSelection = true
|
||||
|
|
@ -437,12 +499,7 @@ fun App() {
|
|||
}
|
||||
|
||||
rememberedStartupProfile(profiles)?.let { profile ->
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
if (syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
requestProfileSwitch(profile, syncOnEnter)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -453,34 +510,61 @@ fun App() {
|
|||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
return
|
||||
}
|
||||
ProfileRepository.selectProfile(onlyProfile.profileIndex)
|
||||
if (syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
requestProfileSwitch(onlyProfile, syncOnEnter)
|
||||
} else {
|
||||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(gateScreen, pendingProfileSwitch) {
|
||||
if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) {
|
||||
gateScreen = AppGateScreen.Loading.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingProfileSwitch) {
|
||||
val request = pendingProfileSwitch ?: return@LaunchedEffect
|
||||
runCatching {
|
||||
ProfileRepository.switchToProfile(request.profile.profileIndex)
|
||||
warmProfileBoundRepositories()
|
||||
if (request.syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(request.profile.profileIndex)
|
||||
}
|
||||
}.onSuccess {
|
||||
pendingProfileSwitch = null
|
||||
autoSkipProfileSelection = false
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
}.onFailure {
|
||||
pendingProfileSwitch = null
|
||||
autoSkipProfileSelection = false
|
||||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
|
||||
if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect
|
||||
|
||||
val cachedProfiles = profileState.profiles
|
||||
val allowOfflineProfileAccess =
|
||||
val hasCachedProfileAccess =
|
||||
cachedProfiles.isNotEmpty() &&
|
||||
authState !is AuthState.Authenticated &&
|
||||
networkStatusUiState.condition != NetworkCondition.Online
|
||||
authState !is AuthState.Authenticated
|
||||
val allowCachedProfileAccess =
|
||||
hasCachedProfileAccess &&
|
||||
(
|
||||
networkStatusUiState.condition != NetworkCondition.Online ||
|
||||
gateScreen != AppGateScreen.Auth.name
|
||||
)
|
||||
|
||||
when (authState) {
|
||||
is AuthState.Loading -> {
|
||||
if (allowOfflineProfileAccess) {
|
||||
if (hasCachedProfileAccess) {
|
||||
enterProfileGate(cachedProfiles, syncOnEnter = false)
|
||||
} else {
|
||||
gateScreen = AppGateScreen.Loading.name
|
||||
}
|
||||
}
|
||||
is AuthState.Unauthenticated -> {
|
||||
if (allowOfflineProfileAccess) {
|
||||
if (allowCachedProfileAccess) {
|
||||
enterProfileGate(cachedProfiles, syncOnEnter = false)
|
||||
} else {
|
||||
ProfileRepository.clearInMemory()
|
||||
|
|
@ -517,10 +601,10 @@ fun App() {
|
|||
gateScreen == AppGateScreen.ProfileSelection.name
|
||||
) {
|
||||
rememberedStartupProfile(profileState.profiles)?.let { profile ->
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
requestProfileSwitch(
|
||||
profile = profile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -529,10 +613,10 @@ fun App() {
|
|||
val onlyProfile = profileState.profiles.first()
|
||||
if (onlyProfile.pinEnabled) return@LaunchedEffect
|
||||
|
||||
ProfileRepository.selectProfile(onlyProfile.profileIndex)
|
||||
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
requestProfileSwitch(
|
||||
profile = onlyProfile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,15 +629,9 @@ fun App() {
|
|||
},
|
||||
) { currentGate ->
|
||||
when (currentGate) {
|
||||
AppGateScreen.Loading.name -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.nuvio.colors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent)
|
||||
}
|
||||
AppGateScreen.Loading.name,
|
||||
AppGateScreen.ProfileSwitching.name -> {
|
||||
AppLaunchOverlay(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
AppGateScreen.Auth.name -> {
|
||||
AuthScreen(modifier = Modifier.fillMaxSize())
|
||||
|
|
@ -566,11 +644,10 @@ fun App() {
|
|||
}
|
||||
ProfileSelectionScreen(
|
||||
onProfileSelected = { profile ->
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
if (authState is AuthState.Authenticated) {
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
requestProfileSwitch(
|
||||
profile = profile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
},
|
||||
onEditProfile = { profile ->
|
||||
editingProfile = profile
|
||||
|
|
@ -616,18 +693,6 @@ private fun MainAppContent(
|
|||
) {
|
||||
val navController = rememberNavController()
|
||||
val appUpdaterController = rememberAppUpdaterController()
|
||||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
remember {
|
||||
CollectionSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
|
|
@ -636,10 +701,17 @@ private fun MainAppContent(
|
|||
val searchScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
warmProfileBoundRepositories()
|
||||
}
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val liquidGlassNativeTabBarEnabled by remember {
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val desktopNavigationLayout by remember {
|
||||
ThemeSettingsRepository.desktopNavigationLayout
|
||||
}.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
|
||||
|
|
@ -652,32 +724,14 @@ private fun MainAppContent(
|
|||
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember { mutableStateOf(false) }
|
||||
var pickerError by remember { mutableStateOf<String?>(null) }
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val libraryUiState by remember {
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val p2pSettingsUiState by remember {
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
P2pSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchedUiState by remember {
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val downloadsUiState by remember {
|
||||
DownloadsRepository.ensureLoaded()
|
||||
DownloadsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by remember {
|
||||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
|
@ -823,6 +877,7 @@ private fun MainAppContent(
|
|||
NetworkCondition.ServersUnreachable,
|
||||
-> {
|
||||
offlineLaunchRouteHandled = true
|
||||
if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect
|
||||
val hasPlayableDownload = downloadsUiState.completedItems.any {
|
||||
DownloadsRepository.playableLocalFileUri(it) != null
|
||||
}
|
||||
|
|
@ -910,10 +965,7 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val continueWatchingPreferencesUiState by remember {
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
ContinueWatchingPreferencesRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(
|
||||
initialHomeReady,
|
||||
|
|
@ -928,6 +980,14 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentBackStackEntry?.destination) {
|
||||
val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute<StreamRoute>() == true ||
|
||||
currentBackStackEntry?.destination?.hasRoute<PlayerRoute>() == true
|
||||
if (inPlaybackFlow) {
|
||||
resumePromptItem = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
|
||||
when (deepLink) {
|
||||
|
|
@ -940,9 +1000,11 @@ private fun MainAppContent(
|
|||
}
|
||||
|
||||
AppDeepLink.Downloads -> {
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
launchSingleTop = true
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
AppDeepLinkRepository.markConsumed(deepLink)
|
||||
}
|
||||
|
|
@ -1069,7 +1131,7 @@ private fun MainAppContent(
|
|||
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
|
||||
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
|
||||
|
||||
if (!manualSelection) {
|
||||
if (!manualSelection && AppFeaturePolicy.downloadsEnabled) {
|
||||
val downloadedItem = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = seasonNumber,
|
||||
|
|
@ -1193,6 +1255,7 @@ private fun MainAppContent(
|
|||
type = section.type,
|
||||
catalogId = section.catalogId,
|
||||
supportsPagination = section.supportsPagination,
|
||||
genre = section.genre,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1217,6 +1280,7 @@ private fun MainAppContent(
|
|||
}
|
||||
|
||||
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
|
||||
resumePromptItem = null
|
||||
if (item.isCloudLibraryContinueWatchingItem()) {
|
||||
coroutineScope.launch {
|
||||
when (
|
||||
|
|
@ -1324,12 +1388,31 @@ private fun MainAppContent(
|
|||
val isTabletLayout = maxWidth >= 768.dp
|
||||
val useNativeBottomTabs =
|
||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||
val useDesktopSidebar = isDesktop &&
|
||||
isTabletLayout &&
|
||||
!useNativeBottomTabs &&
|
||||
desktopNavigationLayout == DesktopNavigationLayout.Sidebar
|
||||
val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar
|
||||
val topChromePadding = if (useFloatingTopBar) {
|
||||
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
max(statusBarPadding + 24.dp, 48.dp) + 64.dp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
|
||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||
profileSwitchLoading = true
|
||||
selectedTab = AppScreenTab.Home
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
ProfileRepository.switchToProfile(profile.profileIndex)
|
||||
warmProfileBoundRepositories()
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
delay(300)
|
||||
} finally {
|
||||
profileSwitchLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -1381,8 +1464,10 @@ private fun MainAppContent(
|
|||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
.padding(innerPadding)
|
||||
.padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp),
|
||||
selectedTab = selectedTab,
|
||||
topChromePadding = topChromePadding,
|
||||
searchFocusRequestCount = searchFocusRequestCount,
|
||||
rootActionsEnabled = tabsRouteActive,
|
||||
homeScrollToTopRequests = homeScrollToTopRequests,
|
||||
|
|
@ -1438,7 +1523,11 @@ private fun MainAppContent(
|
|||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onDownloadsSettingsClick = {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
navController.navigate(DownloadsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
|
|
@ -1474,7 +1563,14 @@ private fun MainAppContent(
|
|||
)
|
||||
}
|
||||
|
||||
if (isTabletLayout && !useNativeBottomTabs) {
|
||||
if (useDesktopSidebar) {
|
||||
DesktopHoverSidebar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = ::handleRootTabClick,
|
||||
onProfileSelected = onProfileSelected,
|
||||
onAddProfileRequested = onSwitchProfile,
|
||||
)
|
||||
} else if (useFloatingTopBar) {
|
||||
TabletFloatingTopBar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = ::handleRootTabClick,
|
||||
|
|
@ -1707,10 +1803,7 @@ private fun MainAppContent(
|
|||
hasResolvedVideoId = true
|
||||
}
|
||||
|
||||
val playerSettings by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
|
||||
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
|
||||
|
|
@ -1722,7 +1815,7 @@ private fun MainAppContent(
|
|||
replaceStreamRoute: Boolean,
|
||||
) {
|
||||
val infoHash = stream.p2pInfoHash ?: return
|
||||
val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
|
||||
val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
|
||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
type = launch.type,
|
||||
|
|
@ -1739,12 +1832,11 @@ private fun MainAppContent(
|
|||
addonId = stream.addonId,
|
||||
requestHeaders = emptyMap(),
|
||||
responseHeaders = emptyMap(),
|
||||
filename = stream.p2pFilename,
|
||||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
infoHash = infoHash,
|
||||
fileIdx = stream.p2pFileIdx,
|
||||
magnetUri = stream.torrentMagnetUri,
|
||||
sources = stream.p2pSourceHints,
|
||||
fileIdx = stream.fileIdx,
|
||||
sources = stream.sources,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
}
|
||||
|
|
@ -1771,9 +1863,8 @@ private fun MainAppContent(
|
|||
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
|
||||
parentMetaType = launch.parentMetaType ?: launch.type,
|
||||
torrentInfoHash = infoHash,
|
||||
torrentFileIdx = stream.p2pFileIdx,
|
||||
torrentFilename = stream.p2pFilename,
|
||||
torrentMagnetUri = stream.torrentMagnetUri,
|
||||
torrentFileIdx = stream.fileIdx,
|
||||
torrentFilename = stream.behaviorHints.filename,
|
||||
torrentTrackers = stream.p2pTrackers,
|
||||
initialPositionMs = resolvedResumePositionMs ?: 0L,
|
||||
initialProgressFraction = resolvedResumeProgressFraction,
|
||||
|
|
@ -1842,10 +1933,10 @@ private fun MainAppContent(
|
|||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||
if (cached != null) {
|
||||
if (cached.url.isBlank() && (!cached.infoHash.isNullOrBlank() || !cached.magnetUri.isNullOrBlank())) {
|
||||
if (cached.url.isBlank() && !cached.infoHash.isNullOrBlank()) {
|
||||
val cachedStream = StreamItem(
|
||||
name = cached.streamName,
|
||||
url = cached.magnetUri,
|
||||
url = null,
|
||||
infoHash = cached.infoHash,
|
||||
fileIdx = cached.fileIdx,
|
||||
sources = cached.sources,
|
||||
|
|
@ -2307,7 +2398,6 @@ private fun MainAppContent(
|
|||
torrentInfoHash = launch.torrentInfoHash,
|
||||
torrentFileIdx = launch.torrentFileIdx,
|
||||
torrentFilename = launch.torrentFilename,
|
||||
torrentMagnetUri = launch.torrentMagnetUri,
|
||||
torrentTrackers = launch.torrentTrackers,
|
||||
initialPositionMs = launch.initialPositionMs,
|
||||
initialProgressFraction = launch.initialProgressFraction,
|
||||
|
|
@ -2422,51 +2512,53 @@ private fun MainAppContent(
|
|||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<DownloadsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
backStackEntry = backStackEntry,
|
||||
)
|
||||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
composable<DownloadsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
backStackEntry = backStackEntry,
|
||||
)
|
||||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamTitle = item.streamTitle,
|
||||
streamSubtitle = item.streamSubtitle,
|
||||
providerName = item.providerName,
|
||||
providerAddonId = item.providerAddonId,
|
||||
contentType = item.contentType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
coroutineScope.launch { openExternalPlayback(playerLaunch) }
|
||||
return@DownloadsScreen
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamTitle = item.streamTitle,
|
||||
streamSubtitle = item.streamSubtitle,
|
||||
providerName = item.providerName,
|
||||
providerAddonId = item.providerAddonId,
|
||||
contentType = item.contentType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
coroutineScope.launch { openExternalPlayback(playerLaunch) }
|
||||
return@DownloadsScreen
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<AddonsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
|
|
@ -2733,15 +2825,6 @@ private fun MainAppContent(
|
|||
AppLaunchOverlay(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Auto-dismiss profile switch overlay
|
||||
if (profileSwitchLoading) {
|
||||
LaunchedEffect(Unit) {
|
||||
// Brief loading screen while home refreshes for the new profile
|
||||
kotlinx.coroutines.delay(1200)
|
||||
profileSwitchLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
NuvioFloatingPrompt(
|
||||
visible = resumePromptItem != null,
|
||||
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
||||
|
|
@ -2799,6 +2882,7 @@ private fun rememberGuardedPopBackStack(
|
|||
private fun AppTabHost(
|
||||
selectedTab: AppScreenTab,
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
searchFocusRequestCount: Int = 0,
|
||||
rootActionsEnabled: Boolean = true,
|
||||
homeScrollToTopRequests: Flow<Unit>,
|
||||
|
|
@ -2856,6 +2940,7 @@ private fun AppTabHost(
|
|||
AppScreenTab.Search -> {
|
||||
SearchScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topChromePadding = topChromePadding,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
searchFocusRequestCount = searchFocusRequestCount,
|
||||
|
|
@ -2866,6 +2951,7 @@ private fun AppTabHost(
|
|||
AppScreenTab.Library -> {
|
||||
LibraryScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topChromePadding = topChromePadding,
|
||||
scrollToTopRequests = libraryScrollToTopRequests,
|
||||
onPosterClick = onLibraryPosterClick,
|
||||
onPosterLongClick = onLibraryPosterLongClick,
|
||||
|
|
@ -2901,6 +2987,260 @@ private fun AppTabHost(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHoverSidebar(
|
||||
selectedTab: AppScreenTab,
|
||||
onTabSelected: (AppScreenTab) -> Unit,
|
||||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
val activeProfile = profileState.activeProfile
|
||||
val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile)
|
||||
val hoverSource = remember { MutableInteractionSource() }
|
||||
val hovered by hoverSource.collectIsHoveredAsState()
|
||||
var profileStackVisible by remember { mutableStateOf(false) }
|
||||
val sidebarExpanded = hovered || profileStackVisible
|
||||
val profileTopPadding = statusBarPadding + 18.dp
|
||||
fun selectTab(tab: AppScreenTab) {
|
||||
profileStackVisible = false
|
||||
onTabSelected(tab)
|
||||
}
|
||||
val sidebarWidth by animateDpAsState(
|
||||
targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "desktop_sidebar_width",
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(sidebarWidth)
|
||||
.fillMaxHeight()
|
||||
.hoverable(hoverSource)
|
||||
.zIndex(NuvioTokens.Z.navigation),
|
||||
color = tokens.colors.background,
|
||||
contentColor = tokens.colors.textPrimary,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = profileTopPadding)
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { profileStackVisible = !profileStackVisible },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DesktopSidebarProfileTrigger(
|
||||
profile = activeProfile,
|
||||
avatars = avatars,
|
||||
label = activeProfileName,
|
||||
expanded = sidebarExpanded,
|
||||
)
|
||||
}
|
||||
|
||||
if (profileStackVisible) {
|
||||
SidebarProfileSwitcherStack(
|
||||
onProfileSelected = onProfileSelected,
|
||||
onAddProfileRequested = onAddProfileRequested,
|
||||
onDismissRequest = { profileStackVisible = false },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = profileTopPadding + 58.dp)
|
||||
.width(DesktopSidebarExpandedContentWidth),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_home),
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Home) },
|
||||
) { color ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = stringResource(Res.string.compose_nav_home),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_search),
|
||||
selected = selectedTab == AppScreenTab.Search,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Search) },
|
||||
) { color ->
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.sidebar_search),
|
||||
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_library),
|
||||
selected = selectedTab == AppScreenTab.Library,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Library) },
|
||||
) { color ->
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.sidebar_library),
|
||||
contentDescription = stringResource(Res.string.compose_nav_library),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_settings_page_root),
|
||||
selected = selectedTab == AppScreenTab.Settings,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Settings) },
|
||||
) { color ->
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(Res.string.compose_settings_page_root),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSidebarProfileTrigger(
|
||||
profile: NuvioProfile?,
|
||||
avatars: List<AvatarCatalogItem>,
|
||||
label: String,
|
||||
expanded: Boolean,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(
|
||||
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(DesktopSidebarIconSlotSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ActiveProfileMiniAvatar(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
selected = false,
|
||||
size = 28,
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSidebarItem(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable (Color) -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted
|
||||
val iconColor = if (selected) tokens.colors.onAccent else contentColor
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(
|
||||
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(DesktopSidebarIconSlotSize),
|
||||
color = if (selected) tokens.colors.accent else Color.Transparent,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon(iconColor)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabletFloatingTopBar(
|
||||
selectedTab: AppScreenTab,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ interface Platform {
|
|||
|
||||
expect fun getPlatform(): Platform
|
||||
|
||||
internal expect val isIos: Boolean
|
||||
internal expect val isIos: Boolean
|
||||
internal expect val isDesktop: Boolean
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ enum class TrailerPlaybackMode {
|
|||
|
||||
expect object AppFeaturePolicy {
|
||||
val pluginsEnabled: Boolean
|
||||
val downloadsEnabled: Boolean
|
||||
val notificationsEnabled: Boolean
|
||||
val p2pEnabled: Boolean
|
||||
val trailerPlaybackMode: TrailerPlaybackMode
|
||||
val heroTrailerPlaybackSupported: Boolean
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
expect object AppVersionPolicy {
|
||||
val displayVersionName: String
|
||||
val displayVersionCode: Int
|
||||
val basedOnVersionName: String?
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
|
|
@ -78,8 +79,17 @@ object ProfileSettingsSync {
|
|||
@Volatile
|
||||
private var skipNextPushSignature: String? = null
|
||||
|
||||
@Volatile
|
||||
private var preservedRemotePlayerSettings: JsonObject? = null
|
||||
|
||||
@Volatile
|
||||
private var preservedRemotePlayerSettingsProfileId: Int? = null
|
||||
|
||||
private var observeJob: Job? = null
|
||||
|
||||
private val syncPlayerSettings: Boolean
|
||||
get() = !isDesktop
|
||||
|
||||
fun startObserving() {
|
||||
if (observeJob?.isActive == true) return
|
||||
ensureRepositoriesLoaded()
|
||||
|
|
@ -91,19 +101,13 @@ object ProfileSettingsSync {
|
|||
return syncMutex.withLock {
|
||||
isServerSyncInFlight = true
|
||||
try {
|
||||
val localBlob = exportSettingsBlob()
|
||||
val localSignature = buildSignature(localBlob)
|
||||
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params)
|
||||
val response = result.decodeList<SettingsBlobResponse>().firstOrNull()
|
||||
val remoteJson = response?.settingsJson
|
||||
val remoteJson = fetchRemoteSettingsJson(profileId)
|
||||
|
||||
if (remoteJson == null) {
|
||||
log.i { "pull(profileId=$profileId) — no remote settings blob found" }
|
||||
clearPreservedRemotePlayerSettings(profileId)
|
||||
val localBlob = exportSettingsBlob(profileId)
|
||||
val localSignature = buildSignature(localBlob)
|
||||
if (localSignature != defaultSignature()) {
|
||||
pushToRemoteLocked(profileId, localBlob)
|
||||
}
|
||||
|
|
@ -119,13 +123,17 @@ object ProfileSettingsSync {
|
|||
return@withLock false
|
||||
}
|
||||
|
||||
preserveRemotePlayerSettings(profileId, remoteBlob)
|
||||
|
||||
val localBlob = exportSettingsBlob(profileId)
|
||||
val localSignature = buildSignature(localBlob)
|
||||
val remoteSignature = buildSignature(remoteBlob)
|
||||
if (remoteSignature == localSignature) {
|
||||
log.d { "pull(profileId=$profileId) — remote matches local" }
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
applyRemoteBlob(remoteBlob)
|
||||
applyRemoteBlob(profileId, remoteBlob)
|
||||
skipNextPushSignature = currentObservedStateSignature()
|
||||
} finally {
|
||||
isApplyingRemoteBlob = false
|
||||
|
|
@ -146,7 +154,8 @@ object ProfileSettingsSync {
|
|||
ensureRepositoriesLoaded()
|
||||
syncMutex.withLock {
|
||||
runCatching {
|
||||
pushToRemoteLocked(ProfileRepository.activeProfileId, exportSettingsBlob())
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
pushToRemoteLocked(profileId, exportSettingsBlob(profileId))
|
||||
}.onFailure { error ->
|
||||
log.e(error) { "pushCurrentProfileToRemote() — FAILED" }
|
||||
}
|
||||
|
|
@ -155,23 +164,25 @@ object ProfileSettingsSync {
|
|||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeLocalChangesAndPush() {
|
||||
val signatureFlows = listOf(
|
||||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
StreamBadgeSettingsRepository.uiState.map { "stream_badges" },
|
||||
DebridSettingsRepository.uiState.map { "debrid" },
|
||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
|
||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
||||
)
|
||||
val signatureFlows = buildList {
|
||||
add(ThemeSettingsRepository.selectedTheme.map { "theme" })
|
||||
add(ThemeSettingsRepository.amoledEnabled.map { "amoled" })
|
||||
add(ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" })
|
||||
add(PosterCardStyleRepository.uiState.map { "poster_card_style" })
|
||||
if (syncPlayerSettings) {
|
||||
add(PlayerSettingsRepository.uiState.map { "player" })
|
||||
}
|
||||
add(StreamBadgeSettingsRepository.uiState.map { "stream_badges" })
|
||||
add(DebridSettingsRepository.uiState.map { "debrid" })
|
||||
add(TmdbSettingsRepository.uiState.map { "tmdb" })
|
||||
add(MdbListSettingsRepository.uiState.map { "mdblist" })
|
||||
add(MetaScreenSettingsRepository.uiState.map { "meta" })
|
||||
add(CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" })
|
||||
add(ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" })
|
||||
add(TraktSettingsRepository.uiState.map { "trakt_settings" })
|
||||
add(TraktCommentsSettings.enabled.map { "trakt_comments" })
|
||||
add(EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" })
|
||||
}
|
||||
|
||||
observeJob = scope.launch {
|
||||
combine(signatureFlows) { currentObservedStateSignature() }
|
||||
|
|
@ -192,22 +203,23 @@ object ProfileSettingsSync {
|
|||
}
|
||||
|
||||
private suspend fun pushToRemoteLocked(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
val blobToPush = withPreservedDesktopPlayerSettings(profileId, blob)
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob))
|
||||
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blobToPush))
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params)
|
||||
log.d { "pushToRemoteLocked(profileId=$profileId) — success" }
|
||||
}
|
||||
|
||||
private fun exportSettingsBlob(): MobileProfileSettingsBlob {
|
||||
private fun exportSettingsBlob(profileId: Int = ProfileRepository.activeProfileId): MobileProfileSettingsBlob {
|
||||
ensureRepositoriesLoaded()
|
||||
return MobileProfileSettingsBlob(
|
||||
features = MobileProfileSettingsFeatures(
|
||||
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
||||
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
||||
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
||||
playerSettings = exportPlayerSettingsPayload(profileId),
|
||||
streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(),
|
||||
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
|
||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||
|
|
@ -224,15 +236,19 @@ object ProfileSettingsSync {
|
|||
)
|
||||
}
|
||||
|
||||
private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) {
|
||||
private fun applyRemoteBlob(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings)
|
||||
ThemeSettingsRepository.onProfileChanged()
|
||||
|
||||
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
|
||||
PosterCardStyleRepository.onProfileChanged()
|
||||
|
||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||
PlayerSettingsRepository.onProfileChanged()
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||
PlayerSettingsRepository.onProfileChanged()
|
||||
} else {
|
||||
preserveRemotePlayerSettings(profileId, blob)
|
||||
}
|
||||
|
||||
StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings)
|
||||
StreamBadgeSettingsRepository.onProfileChanged()
|
||||
|
|
@ -268,7 +284,9 @@ object ProfileSettingsSync {
|
|||
private fun ensureRepositoriesLoaded() {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
PosterCardStyleRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
}
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
|
|
@ -287,23 +305,90 @@ object ProfileSettingsSync {
|
|||
private fun defaultSignature(): String =
|
||||
buildSignature(MobileProfileSettingsBlob())
|
||||
|
||||
private fun currentObservedStateSignature(): String = listOf(
|
||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
"stream_badges=${StreamBadgeSettingsRepository.uiState.value}",
|
||||
"debrid=${DebridSettingsRepository.uiState.value}",
|
||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
|
||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||
).joinToString(separator = "||")
|
||||
private fun currentObservedStateSignature(): String = buildList {
|
||||
add("theme=${ThemeSettingsRepository.selectedTheme.value.name}")
|
||||
add("amoled=${ThemeSettingsRepository.amoledEnabled.value}")
|
||||
add("liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}")
|
||||
add("poster_card_style=${PosterCardStyleRepository.uiState.value}")
|
||||
if (syncPlayerSettings) {
|
||||
add("player=${PlayerSettingsRepository.uiState.value}")
|
||||
}
|
||||
add("stream_badges=${StreamBadgeSettingsRepository.uiState.value}")
|
||||
add("debrid=${DebridSettingsRepository.uiState.value}")
|
||||
add("tmdb=${TmdbSettingsRepository.uiState.value}")
|
||||
add("mdblist=${MdbListSettingsRepository.uiState.value}")
|
||||
add("meta=${MetaScreenSettingsRepository.uiState.value}")
|
||||
add("collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}")
|
||||
add("continue=${ContinueWatchingPreferencesRepository.uiState.value}")
|
||||
add("trakt_settings=${TraktSettingsRepository.uiState.value}")
|
||||
add("trakt_comments=${TraktCommentsSettings.enabled.value}")
|
||||
add("episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}")
|
||||
}.joinToString(separator = "||")
|
||||
|
||||
private fun exportPlayerSettingsPayload(profileId: Int): JsonObject =
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsStorage.exportToSyncPayload()
|
||||
} else {
|
||||
preservedRemotePlayerSettingsFor(profileId) ?: JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
private fun preserveRemotePlayerSettings(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
if (!syncPlayerSettings) {
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = blob.features.playerSettings
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPreservedRemotePlayerSettings(profileId: Int) {
|
||||
if (!syncPlayerSettings) {
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun preservedRemotePlayerSettingsFor(profileId: Int): JsonObject? =
|
||||
preservedRemotePlayerSettings
|
||||
?.takeIf { preservedRemotePlayerSettingsProfileId == profileId }
|
||||
|
||||
private suspend fun withPreservedDesktopPlayerSettings(
|
||||
profileId: Int,
|
||||
blob: MobileProfileSettingsBlob,
|
||||
): MobileProfileSettingsBlob {
|
||||
if (syncPlayerSettings) return blob
|
||||
|
||||
val remoteBlobResult = runCatching {
|
||||
fetchRemoteSettingsJson(profileId)
|
||||
?.let { remoteJson ->
|
||||
json.decodeFromJsonElement(MobileProfileSettingsBlob.serializer(), remoteJson)
|
||||
}
|
||||
}
|
||||
val remotePlayerSettings = if (remoteBlobResult.isSuccess) {
|
||||
remoteBlobResult.getOrNull()?.features?.playerSettings ?: JsonObject(emptyMap())
|
||||
} else {
|
||||
val error = remoteBlobResult.exceptionOrNull()
|
||||
if (error != null) {
|
||||
log.e(error) { "pushToRemoteLocked(profileId=$profileId) — failed to preserve remote player settings" }
|
||||
}
|
||||
preservedRemotePlayerSettingsFor(profileId) ?: throw (error ?: IllegalStateException("Missing remote player settings"))
|
||||
}
|
||||
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = remotePlayerSettings
|
||||
return blob.copy(
|
||||
features = blob.features.copy(
|
||||
playerSettings = remotePlayerSettings,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteSettingsJson(profileId: Int): JsonObject? {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params)
|
||||
return result.decodeList<SettingsBlobResponse>().firstOrNull()?.settingsJson
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
internal const val MOBILE_SYNC_PLATFORM = "mobile"
|
||||
internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared"
|
||||
internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
||||
internal enum class NuvioDesktopImageScaling {
|
||||
Auto,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal expect fun NuvioAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
error: Painter? = null,
|
||||
fallback: Painter? = error,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality? = null,
|
||||
clipToBounds: Boolean = true,
|
||||
desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto,
|
||||
)
|
||||
|
|
@ -100,7 +100,7 @@ fun NuvioScreen(
|
|||
)
|
||||
}
|
||||
|
||||
internal fun Modifier.nuvioBlockPointerPassthrough(): Modifier =
|
||||
internal fun Modifier.nuvioConsumePointerEvents(): Modifier =
|
||||
pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
|
@ -144,45 +144,53 @@ fun NuvioScreenHeader(
|
|||
val tokens = MaterialTheme.nuvio
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.nuvioBlockPointerPassthrough()
|
||||
.background(tokens.colors.background)
|
||||
.padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(tokens.colors.background)
|
||||
.nuvioConsumePointerEvents(),
|
||||
) {}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.action_back),
|
||||
tint = tokens.colors.textPrimary,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.action_back),
|
||||
tint = tokens.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = title,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "screen_header_title",
|
||||
) { currentTitle ->
|
||||
Text(
|
||||
text = currentTitle,
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = title,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "screen_header_title",
|
||||
) { currentTitle ->
|
||||
Text(
|
||||
text = currentTitle,
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
|
|
@ -133,7 +132,7 @@ private fun ContinueWatchingSheetHeader(
|
|||
) {
|
||||
val artwork = item.poster ?: item.imageUrl
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
NuvioAsyncImage(
|
||||
model = cloudLibraryDisplayArtworkUrl(artwork),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
|
@ -191,7 +190,7 @@ fun NuvioFloatingPrompt(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
NuvioAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ 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 coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -189,7 +188,7 @@ private fun PosterSheetHeader(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
AsyncImage(
|
||||
NuvioAsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -15,8 +15,12 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
|
|
@ -28,15 +32,18 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.isDesktop
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.home_view_all
|
||||
import nuvio.composeapp.generated.resources.poster_logo_content_description
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class NuvioPosterShape {
|
||||
Poster,
|
||||
|
|
@ -64,6 +71,7 @@ fun <T> NuvioShelfSection(
|
|||
itemContent: @Composable (T) -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val rowState = rememberLazyListState()
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
|
||||
|
|
@ -78,6 +86,8 @@ fun <T> NuvioShelfSection(
|
|||
)
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.desktopShelfDragScroll(rowState),
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
) {
|
||||
|
|
@ -97,6 +107,47 @@ fun <T> NuvioShelfSection(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Modifier.desktopShelfDragScroll(
|
||||
state: LazyListState,
|
||||
): Modifier {
|
||||
if (!isDesktop) return this
|
||||
|
||||
return pointerInput(state) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(pass = PointerEventPass.Initial)
|
||||
var totalDx = 0f
|
||||
var totalDy = 0f
|
||||
var dragging = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
|
||||
val delta = change.position - change.previousPosition
|
||||
totalDx += delta.x
|
||||
totalDy += delta.y
|
||||
|
||||
if (!dragging) {
|
||||
val horizontalDrag =
|
||||
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
|
||||
val verticalDrag =
|
||||
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
|
||||
|
||||
when {
|
||||
verticalDrag -> break
|
||||
horizontalDrag -> dragging = true
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
|
||||
state.dispatchRawDelta(-delta.x)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioPosterCard(
|
||||
title: String,
|
||||
|
|
@ -135,7 +186,7 @@ fun NuvioPosterCard(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
NuvioAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
@ -160,7 +211,7 @@ fun NuvioPosterCard(
|
|||
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10),
|
||||
) {
|
||||
if (!bottomLeftLogoUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
NuvioAsyncImage(
|
||||
model = bottomLeftLogoUrl,
|
||||
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
|
||||
modifier = Modifier
|
||||
|
|
@ -344,6 +395,7 @@ internal fun Modifier.posterCardClickable(
|
|||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.secondaryClick(onLongClick)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
internal expect fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier
|
||||
|
|
@ -44,7 +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 coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioIconActionButton
|
||||
import com.nuvio.app.core.ui.NuvioInfoBadge
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ object FolderDetailRepository {
|
|||
items = tab.items,
|
||||
availableItemCount = tab.items.size,
|
||||
supportsPagination = tab.supportsPagination,
|
||||
genre = tab.genre,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioPosterCard
|
||||
import com.nuvio.app.core.ui.NuvioPosterShape
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid
|
|||
import com.nuvio.app.features.streams.StreamClientResolve
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal interface DebridProviderApi {
|
||||
val provider: DebridProvider
|
||||
|
|
@ -35,6 +36,7 @@ internal object DebridProviderApis {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class DebridDeviceAuthorization(
|
||||
val providerId: String,
|
||||
val deviceCode: String,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage {
|
|||
fun saveStreamNameTemplate(template: String)
|
||||
fun loadStreamDescriptionTemplate(): String?
|
||||
fun saveStreamDescriptionTemplate(template: String)
|
||||
fun loadPendingDeviceAuthorization(providerId: String): String?
|
||||
fun savePendingDeviceAuthorization(providerId: String, payload: String)
|
||||
fun clearPendingDeviceAuthorization(providerId: String)
|
||||
fun exportToSyncPayload(): JsonObject
|
||||
fun replaceFromSyncPayload(payload: JsonObject)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
|
|
@ -59,7 +60,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.build.TrailerPlaybackMode
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
|
|
@ -111,6 +112,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
|||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -189,20 +191,30 @@ fun MetaDetailsScreen(
|
|||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||
var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) }
|
||||
|
||||
val shouldShowComments = commentsEnabled &&
|
||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||
displayedMeta != null &&
|
||||
displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" }
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, shouldShowComments) {
|
||||
if (!shouldShowComments || displayedMeta == null) {
|
||||
LaunchedEffect(displayedMeta?.id) {
|
||||
deferredMetaWorkAllowed = false
|
||||
if (displayedMeta != null) {
|
||||
delay(250)
|
||||
deferredMetaWorkAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) {
|
||||
if (displayedMeta == null || !shouldShowComments) {
|
||||
comments = emptyList()
|
||||
commentsCurrentPage = 0
|
||||
commentsPageCount = 0
|
||||
commentsError = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!deferredMetaWorkAllowed) return@LaunchedEffect
|
||||
isCommentsLoading = true
|
||||
commentsError = null
|
||||
try {
|
||||
|
|
@ -216,8 +228,9 @@ fun MetaDetailsScreen(
|
|||
isCommentsLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) {
|
||||
val metaForRatings = displayedMeta
|
||||
if (!deferredMetaWorkAllowed) return@LaunchedEffect
|
||||
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
|
|
@ -462,7 +475,8 @@ fun MetaDetailsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(meta.type, debridWarmupTarget) {
|
||||
LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) {
|
||||
if (!deferredMetaWorkAllowed) return@LaunchedEffect
|
||||
AddonStreamWarmupRepository.preload(
|
||||
type = meta.type,
|
||||
videoId = debridWarmupTarget.videoId,
|
||||
|
|
@ -509,11 +523,16 @@ fun MetaDetailsScreen(
|
|||
var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) }
|
||||
var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) }
|
||||
val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) {
|
||||
LaunchedEffect(
|
||||
heroTrailerPlaybackEnabled,
|
||||
heroTrailerCandidate?.id,
|
||||
heroTrailerCandidate?.key,
|
||||
deferredMetaWorkAllowed,
|
||||
) {
|
||||
heroTrailerPlaybackSource = null
|
||||
heroTrailerReady = false
|
||||
heroTrailerFinished = false
|
||||
if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) {
|
||||
if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val resolvedSource = runCatching {
|
||||
|
|
@ -718,7 +737,7 @@ fun MetaDetailsScreen(
|
|||
savedProgress?.lastPositionMs,
|
||||
)
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val listState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
val safeAreaTopPx = with(density) {
|
||||
WindowInsets.statusBars
|
||||
|
|
@ -728,7 +747,20 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
|
||||
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
|
||||
val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f
|
||||
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset.toFloat()
|
||||
} else {
|
||||
heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
val heroScrollOffset = detailScrollOffsetPx.toInt()
|
||||
val headerTarget = if (
|
||||
heroHeightPx > 0 &&
|
||||
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)
|
||||
) {
|
||||
1f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val heroTrailerSourceUrl = heroTrailerPlaybackSource
|
||||
?.videoUrl
|
||||
?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails }
|
||||
|
|
@ -737,7 +769,7 @@ fun MetaDetailsScreen(
|
|||
?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() }
|
||||
val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null &&
|
||||
!isLeavingDetails &&
|
||||
(heroHeightPx == 0 || scrollState.value <= thresholdPx)
|
||||
(heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx)
|
||||
val headerProgress by animateFloatAsState(
|
||||
targetValue = headerTarget,
|
||||
animationSpec = tween(
|
||||
|
|
@ -749,9 +781,10 @@ fun MetaDetailsScreen(
|
|||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val isTablet = maxWidth >= 720.dp
|
||||
val viewportHeight = maxHeight
|
||||
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
|
||||
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
|
||||
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground
|
||||
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (cinematicEnabled) {
|
||||
|
|
@ -772,123 +805,121 @@ fun MetaDetailsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(1f)
|
||||
.verticalScroll(scrollState),
|
||||
.zIndex(1f),
|
||||
) {
|
||||
DetailHero(
|
||||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
scrollOffset = scrollState.value,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
heroTrailerSourceUrl = heroTrailerSourceUrl,
|
||||
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
|
||||
heroTrailerReady = heroTrailerReady,
|
||||
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
|
||||
heroTrailerMuted = heroTrailerMuted,
|
||||
onHeroTrailerMuteToggle = {
|
||||
HeroTrailerAudioState.toggleMuted()
|
||||
},
|
||||
onHeroTrailerReady = {
|
||||
if (!heroTrailerFinished) {
|
||||
heroTrailerReady = true
|
||||
}
|
||||
},
|
||||
onHeroTrailerEnded = {
|
||||
heroTrailerReady = false
|
||||
heroTrailerFinished = true
|
||||
},
|
||||
onHeroTrailerError = {
|
||||
heroTrailerReady = false
|
||||
heroTrailerFinished = true
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = contentHorizontalPadding)
|
||||
.widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ConfiguredMetaSections(
|
||||
settings = metaScreenSettingsUiState,
|
||||
item(key = "detail-hero") {
|
||||
DetailHero(
|
||||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
playButtonLabel = playButtonLabel,
|
||||
isSaved = isSaved,
|
||||
isWatched = isWatched,
|
||||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
onSaveLongClick = openLibraryListPicker,
|
||||
onWatchedClick = toggleWatched,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
hasAdditionalInfoSection = hasAdditionalInfoSection,
|
||||
hasCollectionSection = hasCollectionSection,
|
||||
hasMoreLikeThisSection = hasMoreLikeThisSection,
|
||||
shouldShowComments = shouldShowComments,
|
||||
comments = comments,
|
||||
isCommentsLoading = isCommentsLoading,
|
||||
isCommentsLoadingMore = isCommentsLoadingMore,
|
||||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
commentsError = null
|
||||
try {
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true)
|
||||
comments = result.items
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||
}
|
||||
isCommentsLoading = false
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
scrollOffset = heroScrollOffset,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
heroTrailerSourceUrl = heroTrailerSourceUrl,
|
||||
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
|
||||
heroTrailerReady = heroTrailerReady,
|
||||
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
|
||||
heroTrailerMuted = heroTrailerMuted,
|
||||
onHeroTrailerMuteToggle = {
|
||||
HeroTrailerAudioState.toggleMuted()
|
||||
},
|
||||
onHeroTrailerReady = {
|
||||
if (!heroTrailerFinished) {
|
||||
heroTrailerReady = true
|
||||
}
|
||||
},
|
||||
onLoadMoreComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoadingMore = true
|
||||
try {
|
||||
val nextPage = commentsCurrentPage + 1
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
|
||||
val existingIds = comments.map { it.id }.toSet()
|
||||
val newComments = result.items.filter { it.id !in existingIds }
|
||||
comments = comments + newComments
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (_: Exception) { }
|
||||
isCommentsLoadingMore = false
|
||||
}
|
||||
onHeroTrailerEnded = {
|
||||
heroTrailerReady = false
|
||||
heroTrailerFinished = true
|
||||
},
|
||||
onHeroTrailerError = {
|
||||
heroTrailerReady = false
|
||||
heroTrailerFinished = true
|
||||
},
|
||||
onCommentClick = { review -> selectedComment = review },
|
||||
onTrailerClick = resolveTrailer,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodePlayClick,
|
||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onSeasonLongPress = { season -> selectedSeasonForActions = season },
|
||||
onOpenMeta = onOpenMeta,
|
||||
onCastClick = onCastClick,
|
||||
onCompanyClick = onCompanyClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
|
||||
configuredMetaSectionItems(
|
||||
settings = metaScreenSettingsUiState,
|
||||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
contentHorizontalPadding = contentHorizontalPadding,
|
||||
contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified,
|
||||
playButtonLabel = playButtonLabel,
|
||||
isSaved = isSaved,
|
||||
isWatched = isWatched,
|
||||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
onSaveLongClick = openLibraryListPicker,
|
||||
onWatchedClick = toggleWatched,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
hasAdditionalInfoSection = hasAdditionalInfoSection,
|
||||
hasCollectionSection = hasCollectionSection,
|
||||
hasMoreLikeThisSection = hasMoreLikeThisSection,
|
||||
shouldShowComments = shouldShowComments,
|
||||
comments = comments,
|
||||
isCommentsLoading = isCommentsLoading,
|
||||
isCommentsLoadingMore = isCommentsLoadingMore,
|
||||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
commentsError = null
|
||||
try {
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true)
|
||||
comments = result.items
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (e: Exception) {
|
||||
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
|
||||
}
|
||||
isCommentsLoading = false
|
||||
}
|
||||
},
|
||||
onLoadMoreComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoadingMore = true
|
||||
try {
|
||||
val nextPage = commentsCurrentPage + 1
|
||||
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
|
||||
val existingIds = comments.map { it.id }.toSet()
|
||||
val newComments = result.items.filter { it.id !in existingIds }
|
||||
comments = comments + newComments
|
||||
commentsCurrentPage = result.currentPage
|
||||
commentsPageCount = result.pageCount
|
||||
} catch (_: Exception) { }
|
||||
isCommentsLoadingMore = false
|
||||
}
|
||||
},
|
||||
onCommentClick = { review -> selectedComment = review },
|
||||
onTrailerClick = resolveTrailer,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodePlayClick,
|
||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onSeasonLongPress = { season -> selectedSeasonForActions = season },
|
||||
onOpenMeta = onOpenMeta,
|
||||
onCastClick = onCastClick,
|
||||
onCompanyClick = onCompanyClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
|
||||
item(key = "detail-bottom-spacer") {
|
||||
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp)))
|
||||
}
|
||||
}
|
||||
|
|
@ -901,7 +932,7 @@ fun MetaDetailsScreen(
|
|||
.fillMaxWidth()
|
||||
.height(132.dp)
|
||||
.graphicsLayer {
|
||||
translationY = heroHeightPx.toFloat() - scrollState.value
|
||||
translationY = heroHeightPx.toFloat() - detailScrollOffsetPx
|
||||
}
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
|
|
@ -1263,6 +1294,228 @@ private fun MetaDetails.toMetaPreview(): MetaPreview =
|
|||
genres = genres,
|
||||
)
|
||||
|
||||
private fun LazyListScope.configuredMetaSectionItems(
|
||||
settings: MetaScreenSettingsUiState,
|
||||
meta: MetaDetails,
|
||||
isTablet: Boolean,
|
||||
contentHorizontalPadding: Dp,
|
||||
contentMaxWidth: Dp,
|
||||
playButtonLabel: String,
|
||||
isSaved: Boolean,
|
||||
isWatched: Boolean,
|
||||
onPrimaryPlayClick: () -> Unit,
|
||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||
onSaveClick: () -> Unit,
|
||||
onSaveLongClick: (() -> Unit)?,
|
||||
onWatchedClick: () -> Unit,
|
||||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
hasEpisodes: Boolean,
|
||||
hasAdditionalInfoSection: Boolean,
|
||||
hasCollectionSection: Boolean,
|
||||
hasMoreLikeThisSection: Boolean,
|
||||
shouldShowComments: Boolean,
|
||||
comments: List<TraktCommentReview>,
|
||||
isCommentsLoading: Boolean,
|
||||
isCommentsLoadingMore: Boolean,
|
||||
commentsCurrentPage: Int,
|
||||
commentsPageCount: Int,
|
||||
commentsError: String?,
|
||||
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||
onRetryComments: () -> Unit,
|
||||
onLoadMoreComments: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onEpisodeClick: (MetaVideo) -> Unit,
|
||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||
onSeasonLongPress: (Int) -> Unit,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
onCastClick: ((MetaPerson, String?) -> Unit)?,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
) {
|
||||
val enabledItems = settings.items.filter { it.enabled }
|
||||
fun sectionHasContent(key: MetaScreenSectionKey): Boolean =
|
||||
metaSectionHasContent(
|
||||
key = key,
|
||||
meta = meta,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
hasAdditionalInfoSection = hasAdditionalInfoSection,
|
||||
hasCollectionSection = hasCollectionSection,
|
||||
hasMoreLikeThisSection = hasMoreLikeThisSection,
|
||||
shouldShowComments = shouldShowComments,
|
||||
comments = comments,
|
||||
isCommentsLoading = isCommentsLoading,
|
||||
commentsError = commentsError,
|
||||
)
|
||||
|
||||
fun addSectionItem(
|
||||
key: String,
|
||||
sectionItems: List<MetaScreenSectionItem>,
|
||||
forceTabLayout: Boolean = settings.tabLayout,
|
||||
) {
|
||||
item(key = key) {
|
||||
DetailSectionContainer(
|
||||
horizontalPadding = contentHorizontalPadding,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
) {
|
||||
ConfiguredMetaSections(
|
||||
settings = settings.copy(
|
||||
items = sectionItems,
|
||||
tabLayout = forceTabLayout,
|
||||
),
|
||||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
playButtonLabel = playButtonLabel,
|
||||
isSaved = isSaved,
|
||||
isWatched = isWatched,
|
||||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = onSaveClick,
|
||||
onSaveLongClick = onSaveLongClick,
|
||||
onWatchedClick = onWatchedClick,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
hasAdditionalInfoSection = hasAdditionalInfoSection,
|
||||
hasCollectionSection = hasCollectionSection,
|
||||
hasMoreLikeThisSection = hasMoreLikeThisSection,
|
||||
shouldShowComments = shouldShowComments,
|
||||
comments = comments,
|
||||
isCommentsLoading = isCommentsLoading,
|
||||
isCommentsLoadingMore = isCommentsLoadingMore,
|
||||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = onRetryComments,
|
||||
onLoadMoreComments = onLoadMoreComments,
|
||||
onCommentClick = onCommentClick,
|
||||
onTrailerClick = onTrailerClick,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
onSeasonLongPress = onSeasonLongPress,
|
||||
onOpenMeta = onOpenMeta,
|
||||
onCastClick = onCastClick,
|
||||
onCompanyClick = onCompanyClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.tabLayout) {
|
||||
enabledItems
|
||||
.filter { sectionHasContent(it.key) }
|
||||
.forEach { section ->
|
||||
addSectionItem(
|
||||
key = "detail-section-${section.key.name}",
|
||||
sectionItems = listOf(section),
|
||||
forceTabLayout = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val processedGroups = mutableSetOf<Int>()
|
||||
enabledItems.forEach { section ->
|
||||
val groupId = section.tabGroup
|
||||
if (groupId == null) {
|
||||
if (sectionHasContent(section.key)) {
|
||||
addSectionItem(
|
||||
key = "detail-section-${section.key.name}",
|
||||
sectionItems = listOf(section),
|
||||
forceTabLayout = true,
|
||||
)
|
||||
}
|
||||
} else if (groupId !in processedGroups) {
|
||||
processedGroups.add(groupId)
|
||||
val groupMembers = enabledItems.filter { item ->
|
||||
item.tabGroup == groupId && sectionHasContent(item.key)
|
||||
}
|
||||
if (groupMembers.isNotEmpty()) {
|
||||
addSectionItem(
|
||||
key = "detail-section-group-$groupId",
|
||||
sectionItems = groupMembers,
|
||||
forceTabLayout = groupMembers.size > 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailSectionContainer(
|
||||
horizontalPadding: Dp,
|
||||
contentMaxWidth: Dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.padding(bottom = 20.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (contentMaxWidth == Dp.Unspecified) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.widthIn(max = contentMaxWidth)
|
||||
},
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun metaSectionHasContent(
|
||||
key: MetaScreenSectionKey,
|
||||
meta: MetaDetails,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
hasEpisodes: Boolean,
|
||||
hasAdditionalInfoSection: Boolean,
|
||||
hasCollectionSection: Boolean,
|
||||
hasMoreLikeThisSection: Boolean,
|
||||
shouldShowComments: Boolean,
|
||||
comments: List<TraktCommentReview>,
|
||||
isCommentsLoading: Boolean,
|
||||
commentsError: String?,
|
||||
): Boolean =
|
||||
when (key) {
|
||||
MetaScreenSectionKey.ACTIONS -> true
|
||||
MetaScreenSectionKey.OVERVIEW -> true
|
||||
MetaScreenSectionKey.PRODUCTION -> hasProductionSection
|
||||
MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty()
|
||||
MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())
|
||||
MetaScreenSectionKey.TRAILERS -> hasTrailersSection
|
||||
MetaScreenSectionKey.EPISODES -> hasEpisodes
|
||||
MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection
|
||||
MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection
|
||||
MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun ConfiguredMetaSections(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.core.i18n.localizedShortMonthName
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.AppIconResource
|
||||
import com.nuvio.app.core.ui.appIconPainter
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_play
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -107,6 +108,7 @@ fun DetailActionButtons(
|
|||
onLongClick = onPlayLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.secondaryClick(onPlayLongClick)
|
||||
.height(buttonHeight),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -249,7 +251,8 @@ private fun DetailIconAction(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
role = Role.Button,
|
||||
),
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.isIos
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -54,6 +55,7 @@ fun DetailHero(
|
|||
isTablet: Boolean = false,
|
||||
scrollOffset: Int = 0,
|
||||
contentMaxWidth: Dp = 560.dp,
|
||||
viewportHeight: Dp = 0.dp,
|
||||
onHeightChanged: (Int) -> Unit = {},
|
||||
heroTrailerSourceUrl: String? = null,
|
||||
heroTrailerSourceAudioUrl: String? = null,
|
||||
|
|
@ -69,7 +71,7 @@ fun DetailHero(
|
|||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val heroHeight = detailHeroHeight(maxWidth, isTablet)
|
||||
val heroHeight = detailHeroHeight(maxWidth, viewportHeight, isTablet)
|
||||
val trailerAlpha by animateFloatAsState(
|
||||
targetValue = if (heroTrailerReady) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
|
|
@ -95,6 +97,7 @@ fun DetailHero(
|
|||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
val imageUrl = meta.background ?: meta.poster
|
||||
val backdropScale = if (isTablet) 1f else 1.08f
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
|
|
@ -103,11 +106,12 @@ fun DetailHero(
|
|||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationY = scrollOffset * 0.5f
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
scaleX = backdropScale
|
||||
scaleY = backdropScale
|
||||
},
|
||||
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
|
|
@ -127,8 +131,8 @@ fun DetailHero(
|
|||
.graphicsLayer {
|
||||
alpha = trailerAlpha
|
||||
translationY = scrollOffset * 0.5f
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
scaleX = backdropScale
|
||||
scaleY = backdropScale
|
||||
},
|
||||
onReady = onHeroTrailerReady,
|
||||
onEnded = onHeroTrailerEnded,
|
||||
|
|
@ -233,9 +237,13 @@ fun DetailHero(
|
|||
}
|
||||
}
|
||||
|
||||
private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||
private fun detailHeroHeight(maxWidth: Dp, viewportHeight: Dp, isTablet: Boolean): Dp =
|
||||
if (!isTablet) {
|
||||
(maxWidth * 1.33f).coerceIn(420.dp, 760.dp)
|
||||
} else {
|
||||
(maxWidth * 0.42f).coerceIn(300.dp, 420.dp)
|
||||
val viewportLimit = viewportHeight
|
||||
.takeIf { it > 0.dp }
|
||||
?.let { it * 0.72f }
|
||||
?: 1080.dp
|
||||
minOf(maxWidth * 9f / 16f, viewportLimit).coerceIn(420.dp, 1080.dp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.features.details.MetaCompany
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
|
|
|
|||
|
|
@ -58,13 +58,14 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaEpisodeCardStyle
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
|
|
@ -408,6 +409,7 @@ private fun SeasonTextChipScrollRow(
|
|||
) {
|
||||
items(seasons, key = { season -> season }) { season ->
|
||||
val isSelected = season == currentSeason
|
||||
val onSecondaryClick = onLongPress?.let { handler -> { handler(season) } }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(sizing.seasonChipRadius))
|
||||
|
|
@ -420,8 +422,9 @@ private fun SeasonTextChipScrollRow(
|
|||
)
|
||||
.combinedClickable(
|
||||
onClick = { onSelect(season) },
|
||||
onLongClick = onLongPress?.let { handler -> { handler(season) } },
|
||||
onLongClick = onSecondaryClick,
|
||||
)
|
||||
.secondaryClick(onSecondaryClick)
|
||||
.padding(
|
||||
horizontal = sizing.seasonChipHorizontalPadding,
|
||||
vertical = sizing.seasonChipVerticalPadding,
|
||||
|
|
@ -508,7 +511,8 @@ private fun SeasonPosterButton(
|
|||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -677,7 +681,8 @@ private fun EpisodeHorizontalCard(
|
|||
enabled = onClick != null || onLongPress != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
)
|
||||
.secondaryClick(onLongPress),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
|
|
@ -1045,7 +1050,8 @@ private fun EpisodeListCard(
|
|||
enabled = onClick != null || onLongPress != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
)
|
||||
.secondaryClick(onLongPress),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ 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.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.features.details.MetaTrailer
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.detail_tab_trailer
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ 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.sync.MOBILE_SYNC_PLATFORM
|
||||
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
|
||||
|
|
@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@Serializable
|
||||
|
|
@ -53,6 +55,30 @@ private data class SupabaseHomeCatalogSettingsBlob(
|
|||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
private data class RemoteHomeCatalogSettings(
|
||||
val platform: String,
|
||||
val payload: SyncHomeCatalogPayload,
|
||||
val updatedAt: String?,
|
||||
val hasHideUnreleasedContent: Boolean,
|
||||
val hasHideCatalogUnderline: Boolean,
|
||||
)
|
||||
|
||||
private data class PullToken(
|
||||
val userId: String,
|
||||
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")
|
||||
|
|
@ -62,6 +88,8 @@ object HomeCatalogSettingsSyncService {
|
|||
}
|
||||
|
||||
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"
|
||||
|
||||
@Volatile
|
||||
var isSyncingFromRemote: Boolean = false
|
||||
|
|
@ -69,6 +97,12 @@ object HomeCatalogSettingsSyncService {
|
|||
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()
|
||||
|
|
@ -76,48 +110,28 @@ object HomeCatalogSettingsSyncService {
|
|||
|
||||
suspend fun pullFromServer(profileId: Int) {
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params)
|
||||
val blobs = result.decodeList<SupabaseHomeCatalogSettingsBlob>()
|
||||
val blob = blobs.firstOrNull()
|
||||
val pullToken = currentPullToken(profileId) ?: return
|
||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
val remote = fetchBestRemotePayload(profileId, localPayload)
|
||||
|
||||
if (blob == null) {
|
||||
log.i { "pullFromServer — no remote home catalog settings found" }
|
||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
if (localPayload.items.isNotEmpty()) {
|
||||
pushToRemote(profileId)
|
||||
}
|
||||
if (remote == null) {
|
||||
log.i { "pullFromServer — no remote home catalog settings found; preserving local" }
|
||||
markInitialPullComplete(pullToken)
|
||||
return
|
||||
}
|
||||
|
||||
val remotePayload = runCatching {
|
||||
json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson)
|
||||
}.getOrNull()
|
||||
|
||||
if (remotePayload == null) {
|
||||
log.w { "pullFromServer — failed to parse remote home catalog settings" }
|
||||
return
|
||||
}
|
||||
val remotePayload = remote.payload
|
||||
|
||||
if (remotePayload.items.isEmpty()) {
|
||||
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
|
||||
isSyncingFromRemote = true
|
||||
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
|
||||
isSyncingFromRemote = false
|
||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
if (localPayload.items.isNotEmpty()) {
|
||||
pushToRemote(profileId)
|
||||
}
|
||||
applyRemotePayload(remotePayload, pullToken)
|
||||
markInitialPullComplete(pullToken)
|
||||
return
|
||||
}
|
||||
|
||||
isSyncingFromRemote = true
|
||||
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
|
||||
isSyncingFromRemote = false
|
||||
applyRemotePayload(remotePayload, pullToken)
|
||||
log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" }
|
||||
markInitialPullComplete(pullToken)
|
||||
}.onFailure { e ->
|
||||
isSyncingFromRemote = false
|
||||
log.e(e) { "pullFromServer — FAILED" }
|
||||
|
|
@ -125,28 +139,28 @@ object HomeCatalogSettingsSyncService {
|
|||
}
|
||||
|
||||
fun triggerPush() {
|
||||
val requestedToken = currentPullToken()
|
||||
if (requestedToken == null || !hasCompletedInitialPull(requestedToken)) {
|
||||
log.d { "triggerPush — skipped before initial home catalog pull completed" }
|
||||
return
|
||||
}
|
||||
pushJob?.cancel()
|
||||
pushJob = scope.launch {
|
||||
delay(500)
|
||||
if (isSyncingFromRemote) return@launch
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
|
||||
pushToRemote()
|
||||
if (currentPullToken() != requestedToken) return@launch
|
||||
pushToRemote(requestedToken.profileId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushToRemote() {
|
||||
pushToRemote(ProfileRepository.activeProfileId)
|
||||
}
|
||||
|
||||
private suspend fun pushToRemote(profileId: Int) {
|
||||
runCatching {
|
||||
val payload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload)
|
||||
val jsonElement = mergedSharedPayloadJson(profileId, payload)
|
||||
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM)
|
||||
put("p_settings_json", jsonElement)
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params)
|
||||
|
|
@ -160,16 +174,178 @@ object HomeCatalogSettingsSyncService {
|
|||
private fun observeLocalChangesAndPush() {
|
||||
observeJob = scope.launch {
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
.map { it.signature }
|
||||
.map { state ->
|
||||
val token = currentPullToken()
|
||||
ObservedHomeCatalogChange(
|
||||
signature = state.signature,
|
||||
token = token,
|
||||
initialPullCompleteAtEmission = token?.let(::hasCompletedInitialPull) == true,
|
||||
)
|
||||
}
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.debounce(PUSH_DEBOUNCE_MS)
|
||||
.collect {
|
||||
.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
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
|
||||
pushToRemote()
|
||||
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
|
||||
return PullToken(
|
||||
userId = authState.userId,
|
||||
profileId = profileId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCompletedInitialPull(token: PullToken): Boolean =
|
||||
completedInitialPull == token
|
||||
|
||||
private fun markInitialPullComplete(token: PullToken) {
|
||||
completedInitialPull = token
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchBestRemotePayload(
|
||||
profileId: Int,
|
||||
localPayload: SyncHomeCatalogPayload,
|
||||
): RemoteHomeCatalogSettings? {
|
||||
val shared = fetchRemotePayload(
|
||||
profileId = profileId,
|
||||
platform = HOME_CATALOG_SHARED_SYNC_PLATFORM,
|
||||
localPayload = localPayload,
|
||||
)
|
||||
val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS
|
||||
.mapNotNull { platform ->
|
||||
fetchRemotePayload(
|
||||
profileId = profileId,
|
||||
platform = platform,
|
||||
localPayload = localPayload,
|
||||
)
|
||||
}
|
||||
val rows = listOfNotNull(shared) + legacyRows
|
||||
val selected = rows
|
||||
.filter { it.payload.items.isNotEmpty() }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
?: shared
|
||||
?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
|
||||
return selected?.withNewestStandaloneSettings(rows)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemotePayload(
|
||||
profileId: Int,
|
||||
platform: String,
|
||||
localPayload: SyncHomeCatalogPayload,
|
||||
): RemoteHomeCatalogSettings? {
|
||||
val blob = fetchRemoteBlob(profileId, platform) ?: return null
|
||||
val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload)
|
||||
if (payload == null) {
|
||||
log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" }
|
||||
return null
|
||||
}
|
||||
return RemoteHomeCatalogSettings(
|
||||
platform = platform,
|
||||
payload = payload,
|
||||
updatedAt = blob.updatedAt,
|
||||
hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY),
|
||||
hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings(
|
||||
rows: List<RemoteHomeCatalogSettings>,
|
||||
): RemoteHomeCatalogSettings {
|
||||
val hideUnreleasedSource = rows
|
||||
.filter { it.hasHideUnreleasedContent }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
val hideUnderlineSource = rows
|
||||
.filter { it.hasHideCatalogUnderline }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
|
||||
return copy(
|
||||
payload = payload.copy(
|
||||
hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent
|
||||
?: payload.hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline
|
||||
?: payload.hideCatalogUnderline,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteBlob(
|
||||
profileId: Int,
|
||||
platform: String,
|
||||
): SupabaseHomeCatalogSettingsBlob? {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", platform)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params)
|
||||
return result.decodeList<SupabaseHomeCatalogSettingsBlob>().firstOrNull()
|
||||
}
|
||||
|
||||
private fun decodePayloadPreservingLocalDefaults(
|
||||
settingsJson: JsonObject,
|
||||
localPayload: SyncHomeCatalogPayload,
|
||||
): SyncHomeCatalogPayload? = runCatching {
|
||||
val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson)
|
||||
decoded.copy(
|
||||
hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) {
|
||||
decoded.hideUnreleasedContent
|
||||
} else {
|
||||
localPayload.hideUnreleasedContent
|
||||
},
|
||||
hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) {
|
||||
decoded.hideCatalogUnderline
|
||||
} else {
|
||||
localPayload.hideCatalogUnderline
|
||||
},
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private suspend fun mergedSharedPayloadJson(
|
||||
profileId: Int,
|
||||
payload: SyncHomeCatalogPayload,
|
||||
): JsonObject {
|
||||
val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject
|
||||
val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson
|
||||
return buildJsonObject {
|
||||
remoteJson?.forEach { (key, value) -> put(key, value) }
|
||||
localJson.forEach { (key, value) -> put(key, value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ data class HomeCatalogSection(
|
|||
val items: List<MetaPreview>,
|
||||
val availableItemCount: Int = items.size,
|
||||
val supportsPagination: Boolean = false,
|
||||
val genre: String? = null,
|
||||
)
|
||||
|
||||
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
|
|
@ -104,19 +105,21 @@ fun HomeScreen(
|
|||
onFirstCatalogRendered: (() -> Unit)? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
withContext(Dispatchers.Default) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeSettingsUiState by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeListState = rememberLazyListState()
|
||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -124,14 +127,8 @@ fun HomeScreen(
|
|||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by remember {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.isAuthenticated
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle()
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scrollToTopRequests) {
|
||||
|
|
@ -650,6 +647,7 @@ fun HomeScreen(
|
|||
modifier = Modifier,
|
||||
viewportHeight = maxHeight,
|
||||
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
|
||||
sectionPadding = if (isDesktop) homeSectionPadding else null,
|
||||
)
|
||||
|
||||
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
|
||||
|
|
@ -657,6 +655,7 @@ fun HomeScreen(
|
|||
modifier = Modifier,
|
||||
viewportHeight = maxHeight,
|
||||
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
|
||||
sectionPadding = if (isDesktop) homeSectionPadding else null,
|
||||
listState = homeListState,
|
||||
onItemClick = onPosterClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
|
|
@ -53,6 +53,7 @@ import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
|||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -803,7 +804,8 @@ private fun ContinueWatchingWideCard(
|
|||
enabled = onClick != null || onLongClick != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
) {
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -47,7 +48,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -86,6 +89,7 @@ fun HomeHeroSection(
|
|||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
listState: LazyListState? = null,
|
||||
onItemClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -108,6 +112,7 @@ fun HomeHeroSection(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
|
||||
val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() }
|
||||
|
|
@ -163,136 +168,32 @@ fun HomeHeroSection(
|
|||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.02f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.12f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDesktop) {
|
||||
DesktopHomeHeroFrame(
|
||||
items = items,
|
||||
visiblePages = visiblePages,
|
||||
layout = layout,
|
||||
heroWidthPx = heroWidthPx,
|
||||
heroScrollScale = heroScrollScale,
|
||||
heroScrollTranslationY = heroScrollTranslationY,
|
||||
contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0f),
|
||||
MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
} else {
|
||||
DefaultHomeHeroFrame(
|
||||
items = items,
|
||||
visiblePages = visiblePages,
|
||||
currentItem = currentItem,
|
||||
layout = layout,
|
||||
heroWidthPx = heroWidthPx,
|
||||
heroScrollScale = heroScrollScale,
|
||||
heroScrollTranslationY = heroScrollTranslationY,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
HeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout.isTablet) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(currentItem)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.home_view_details),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.size > 1) {
|
||||
Spacer(modifier = Modifier.height(if (layout.isTablet) 14.dp else 12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items.forEachIndexed { index, _ ->
|
||||
val activeFraction = heroPageVisibility(pagerState, index)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.graphicsLayer {
|
||||
alpha = 0.35f + (0.57f * activeFraction)
|
||||
}
|
||||
.width(8.dp + (24.dp * activeFraction))
|
||||
.height(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -304,6 +205,284 @@ private data class HeroPageLayer(
|
|||
val offset: Float,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun DefaultHomeHeroFrame(
|
||||
items: List<MetaPreview>,
|
||||
visiblePages: List<HeroPageLayer>,
|
||||
currentItem: MetaPreview,
|
||||
layout: HomeHeroLayout,
|
||||
heroWidthPx: Float,
|
||||
heroScrollScale: Float,
|
||||
heroScrollTranslationY: Float,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.02f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.12f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0f),
|
||||
MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
HeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout.isTablet) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(currentItem)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.home_view_details),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HeroPageIndicatorRow(
|
||||
itemCount = items.size,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
modifier = Modifier.padding(top = if (layout.isTablet) 14.dp else 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHomeHeroFrame(
|
||||
items: List<MetaPreview>,
|
||||
visiblePages: List<HeroPageLayer>,
|
||||
layout: HomeHeroLayout,
|
||||
heroWidthPx: Float,
|
||||
heroScrollScale: Float,
|
||||
heroScrollTranslationY: Float,
|
||||
contentHorizontalPadding: Dp,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.64f)
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = 1.02f * heroScrollScale
|
||||
scaleY = 1.02f * heroScrollScale
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to backgroundColor,
|
||||
0.34f to backgroundColor,
|
||||
0.58f to backgroundColor.copy(alpha = 0.78f),
|
||||
0.78f to backgroundColor.copy(alpha = 0.18f),
|
||||
1f to backgroundColor.copy(alpha = 0f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = contentHorizontalPadding, end = contentHorizontalPadding)
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
DesktopHeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HeroPageIndicatorRow(
|
||||
itemCount = items.size,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(
|
||||
start = contentHorizontalPadding,
|
||||
bottom = layout.contentVerticalPadding,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroPageIndicatorRow(
|
||||
itemCount: Int,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (itemCount <= 1) return
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(itemCount) { index ->
|
||||
val activeFraction = heroPageVisibility(pagerState, index)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.graphicsLayer {
|
||||
alpha = 0.35f + (0.57f * activeFraction)
|
||||
}
|
||||
.width(8.dp + (24.dp * activeFraction))
|
||||
.height(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun heroPageOffset(
|
||||
pagerState: PagerState,
|
||||
page: Int,
|
||||
|
|
@ -331,6 +510,7 @@ fun HomeHeroReservedSpace(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
|
||||
Spacer(
|
||||
|
|
@ -408,6 +588,97 @@ private fun HeroContentBlock(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHeroContentBlock(
|
||||
item: MetaPreview,
|
||||
layout: HomeHeroLayout,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(item)
|
||||
},
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
if (item.logo != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(desktopHeroLogoSlotHeight(layout)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.logo,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(desktopHeroLogoWidthFraction(layout))
|
||||
.fillMaxHeight(),
|
||||
alignment = Alignment.CenterStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
clipToBounds = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Black,
|
||||
textAlign = TextAlign.Start,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
val genreText = desktopHeroGenreText(item)
|
||||
if (genreText.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = genreText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.76f),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
item.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.82f),
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun desktopHeroLogoWidthFraction(layout: HomeHeroLayout): Float =
|
||||
when {
|
||||
layout.contentMaxWidth >= 640.dp -> 0.74f
|
||||
layout.contentMaxWidth >= 520.dp -> 0.74f
|
||||
else -> 0.8f
|
||||
}
|
||||
|
||||
private fun desktopHeroLogoSlotHeight(layout: HomeHeroLayout): Dp =
|
||||
when {
|
||||
layout.contentMaxWidth >= 640.dp -> 156.dp
|
||||
layout.contentMaxWidth >= 520.dp -> 136.dp
|
||||
else -> 104.dp
|
||||
}
|
||||
|
||||
private fun desktopHeroGenreText(item: MetaPreview): String =
|
||||
item.genres
|
||||
.take(3)
|
||||
.joinToString(" • ")
|
||||
.ifBlank { item.type.replaceFirstChar(Char::uppercase) }
|
||||
|
||||
@Composable
|
||||
private fun HeroMetaText(text: String) {
|
||||
Text(
|
||||
|
|
@ -424,6 +695,7 @@ internal fun homeHeroLayout(
|
|||
maxWidthDp: Float,
|
||||
viewportHeightDp: Float? = null,
|
||||
mobileBelowSectionHeightHintDp: Float? = null,
|
||||
preferDesktopLayout: Boolean = false,
|
||||
): HomeHeroLayout =
|
||||
when {
|
||||
maxWidthDp >= 1200f -> HomeHeroLayout(
|
||||
|
|
@ -456,6 +728,16 @@ internal fun homeHeroLayout(
|
|||
bottomFadeHeight = 170.dp,
|
||||
logoWidthFraction = 0.54f,
|
||||
)
|
||||
preferDesktopLayout -> HomeHeroLayout(
|
||||
isTablet = true,
|
||||
heroHeight = (maxWidthDp * 0.68f).dp.coerceIn(300.dp, 360.dp),
|
||||
contentMaxWidth = 360.dp,
|
||||
contentWidthFraction = 0.56f,
|
||||
contentHorizontalPadding = 16.dp,
|
||||
contentVerticalPadding = 18.dp,
|
||||
bottomFadeHeight = 150.dp,
|
||||
logoWidthFraction = 0.64f,
|
||||
)
|
||||
else -> HomeHeroLayout(
|
||||
isTablet = false,
|
||||
heroHeight = mobileHeroHeight(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.geometry.Offset
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
|
|
@ -65,6 +66,7 @@ fun HomeSkeletonHero(
|
|||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
) {
|
||||
val brush = rememberHomeSkeletonBrush()
|
||||
|
||||
|
|
@ -77,8 +79,10 @@ fun HomeSkeletonHero(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
val containerWidth = maxWidth
|
||||
val contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -121,7 +125,7 @@ fun HomeSkeletonHero(
|
|||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
horizontal = contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti
|
|||
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.library_local_tab_title
|
||||
import nuvio.composeapp.generated.resources.library_other
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -33,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
|
|
@ -41,6 +37,11 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.library_local_tab_title
|
||||
import nuvio.composeapp.generated.resources.library_other
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
@Serializable
|
||||
private data class StoredLibraryPayload(
|
||||
|
|
@ -476,11 +477,16 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
|
||||
private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library"
|
||||
private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other"
|
||||
|
||||
internal fun localLibraryListTab(): TraktListTab =
|
||||
TraktListTab(
|
||||
key = LOCAL_LIBRARY_LIST_KEY,
|
||||
title = runBlocking { getString(Res.string.library_local_tab_title) },
|
||||
title = localizedStringOrDefault(
|
||||
resource = Res.string.library_local_tab_title,
|
||||
fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE,
|
||||
),
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
|
|
@ -552,7 +558,7 @@ private fun PosterShape.toSyncName(): String =
|
|||
|
||||
internal fun String.toLibraryDisplayTitle(): String {
|
||||
val normalized = trim()
|
||||
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
|
||||
if (normalized.isBlank()) return localizedLibraryOtherTitle()
|
||||
|
||||
return normalized
|
||||
.split('-', '_', ' ')
|
||||
|
|
@ -560,5 +566,15 @@ internal fun String.toLibraryDisplayTitle(): String {
|
|||
.joinToString(" ") { token ->
|
||||
token.lowercase().replaceFirstChar { char -> char.uppercase() }
|
||||
}
|
||||
.ifBlank { runBlocking { getString(Res.string.library_other) } }
|
||||
.ifBlank { localizedLibraryOtherTitle() }
|
||||
}
|
||||
|
||||
private fun localizedLibraryOtherTitle(): String =
|
||||
localizedStringOrDefault(
|
||||
resource = Res.string.library_other,
|
||||
fallback = DEFAULT_LIBRARY_OTHER_TITLE,
|
||||
)
|
||||
|
||||
private fun localizedStringOrDefault(resource: StringResource, fallback: String): String =
|
||||
runCatching { runBlocking { getString(resource) } }
|
||||
.getOrDefault(fallback)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
|||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
|
||||
import com.nuvio.app.core.ui.nuvioConsumePointerEvents
|
||||
import com.nuvio.app.features.cloud.CloudLibraryFile
|
||||
import com.nuvio.app.features.cloud.CloudLibraryItem
|
||||
import com.nuvio.app.features.cloud.CloudLibraryItemType
|
||||
|
|
@ -88,6 +88,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
@Composable
|
||||
fun LibraryScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
scrollToTopRequests: Flow<Unit> = emptyFlow(),
|
||||
onPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||
|
|
@ -174,33 +175,40 @@ fun LibraryScreen(
|
|||
NuvioScreen(
|
||||
modifier = modifier,
|
||||
horizontalPadding = 0.dp,
|
||||
topPadding = if (topChromePadding != null) 0.dp else null,
|
||||
listState = listState,
|
||||
) {
|
||||
stickyHeader {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nuvioBlockPointerPassthrough()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = if (sourceMode == LibraryViewMode.Cloud) {
|
||||
stringResource(Res.string.library_title)
|
||||
} else if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_title)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.nuvioConsumePointerEvents(),
|
||||
)
|
||||
LibrarySourceSwitch(
|
||||
selectedMode = sourceMode,
|
||||
onModeSelected = { mode ->
|
||||
sourceModeName = mode.name
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = if (sourceMode == LibraryViewMode.Cloud) {
|
||||
stringResource(Res.string.library_title)
|
||||
} else if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_title)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topPadding = topChromePadding,
|
||||
)
|
||||
LibrarySourceSwitch(
|
||||
selectedMode = sourceMode,
|
||||
onModeSelected = { mode ->
|
||||
sourceModeName = mode.name
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,6 @@ object P2pSettingsRepository {
|
|||
if (p2pEnabled == enabled) return
|
||||
p2pEnabled = enabled
|
||||
P2pSettingsStorage.saveP2pEnabled(enabled)
|
||||
if (!enabled) {
|
||||
P2pStreamingEngine.shutdown()
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +94,6 @@ data class P2pStreamRequest(
|
|||
val infoHash: String,
|
||||
val fileIdx: Int?,
|
||||
val filename: String? = null,
|
||||
val magnetUri: String? = null,
|
||||
val trackers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -123,8 +119,6 @@ class P2pStreamingException(message: String) : Exception(message)
|
|||
|
||||
expect object P2pStreamingEngine {
|
||||
val state: StateFlow<P2pStreamingState>
|
||||
fun warmup()
|
||||
fun cooldownWarmup()
|
||||
suspend fun startStream(request: P2pStreamRequest): String
|
||||
fun stopStream()
|
||||
fun shutdown()
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.nuvio.app.features.player
|
|||
|
||||
/**
|
||||
* Orchestrates the full external player launch flow:
|
||||
* fetches subtitles if forwarding is enabled, then returns an enriched request
|
||||
* for the caller to dispatch.
|
||||
* fetches subtitles if forwarding is enabled, downloads them to local cache,
|
||||
* then returns an enriched request for the caller to dispatch.
|
||||
*/
|
||||
suspend fun prepareExternalPlayerLaunch(
|
||||
request: ExternalPlayerPlaybackRequest,
|
||||
|
|
@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch(
|
|||
)
|
||||
|
||||
if (subtitles != null) {
|
||||
onOverlayMessage("Downloading subtitles...")
|
||||
val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles)
|
||||
if (cachedSubtitles != null) {
|
||||
return request.copy(subtitles = cachedSubtitles)
|
||||
}
|
||||
// Fallback: use original URLs if caching fails
|
||||
return request.copy(subtitles = subtitles)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest(
|
|||
val sourceHeaders: Map<String, String> = emptyMap(),
|
||||
val resumePositionMs: Long = 0L,
|
||||
val subtitles: List<SubtitleInput>? = null,
|
||||
)
|
||||
val season: Int? = null,
|
||||
val episode: Int? = null,
|
||||
val episodeTitle: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Builds a display title for external players.
|
||||
* For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title"
|
||||
* For movies: just the content name (title).
|
||||
*/
|
||||
fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String {
|
||||
if (season == null || episode == null) return title
|
||||
val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
|
||||
return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) {
|
||||
"$title - $seasonEp - $episodeTitle"
|
||||
} else {
|
||||
"$title - $seasonEp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ExternalPlayerOpenResult {
|
||||
Opened,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,233 @@ interface PlayerEngineController {
|
|||
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
|
||||
}
|
||||
|
||||
enum class PlayerControlsAction {
|
||||
ToggleChrome,
|
||||
RevealLockedOverlay,
|
||||
Back,
|
||||
TogglePlayback,
|
||||
KeyboardTogglePlayback,
|
||||
SeekBack,
|
||||
KeyboardSeekBack,
|
||||
SeekForward,
|
||||
KeyboardSeekForward,
|
||||
ResizeMode,
|
||||
Speed,
|
||||
Subtitles,
|
||||
Audio,
|
||||
Sources,
|
||||
Episodes,
|
||||
OpenExternalPlayer,
|
||||
SubmitIntro,
|
||||
LockToggle,
|
||||
VideoSettings,
|
||||
DoubleTapSeekBack,
|
||||
DoubleTapSeekForward,
|
||||
}
|
||||
|
||||
data class PlayerControlsState(
|
||||
val title: String = "",
|
||||
val episodeText: String = "",
|
||||
val streamTitle: String = "",
|
||||
val providerName: String = "",
|
||||
val pauseOverlayWatchingLabel: String = "You're watching",
|
||||
val pauseOverlayLogo: String? = null,
|
||||
val pauseOverlayEpisodeInfo: String = "",
|
||||
val pauseOverlayEpisodeTitle: String = "",
|
||||
val pauseOverlayDescription: String = "",
|
||||
val resizeModeLabel: String = "Fit",
|
||||
val playbackSpeedLabel: String = "1x",
|
||||
val subtitlesLabel: String = "Subs",
|
||||
val audioLabel: String = "Audio",
|
||||
val sourcesLabel: String = "Sources",
|
||||
val episodesLabel: String = "Episodes",
|
||||
val externalPlayerLabel: String = "External",
|
||||
val playLabel: String = "Play",
|
||||
val pauseLabel: String = "Pause",
|
||||
val closeLabel: String = "Close player",
|
||||
val lockLabel: String = "Lock player controls",
|
||||
val unlockLabel: String = "Unlock player controls",
|
||||
val submitIntroLabel: String = "Submit Intro",
|
||||
val videoSettingsLabel: String = "Video settings",
|
||||
val tapToUnlockLabel: String = "Tap to unlock",
|
||||
val playbackErrorTitle: String = "Playback error",
|
||||
val playbackErrorMessage: String = "",
|
||||
val playbackErrorActionLabel: String = "Go back",
|
||||
val sourcesPanelTitle: String = "Sources",
|
||||
val episodesPanelTitle: String = "Episodes",
|
||||
val streamsPanelTitle: String = "Streams",
|
||||
val allFilterLabel: String = "All",
|
||||
val reloadLabel: String = "Reload",
|
||||
val backLabel: String = "Back",
|
||||
val panelCloseLabel: String = "Close",
|
||||
val cancelLabel: String = "Cancel",
|
||||
val playingLabel: String = "Playing",
|
||||
val noStreamsLabel: String = "No streams found",
|
||||
val noEpisodesLabel: String = "No episodes available",
|
||||
val submitIntroPanelTitle: String = "Submit Timestamps",
|
||||
val submitIntroSegmentTypeLabel: String = "SEGMENT TYPE",
|
||||
val submitIntroSegmentIntroLabel: String = "Intro",
|
||||
val submitIntroSegmentRecapLabel: String = "Recap",
|
||||
val submitIntroSegmentOutroLabel: String = "Outro",
|
||||
val submitIntroStartTimeLabel: String = "START TIME (MM:SS)",
|
||||
val submitIntroEndTimeLabel: String = "END TIME (MM:SS)",
|
||||
val submitIntroCaptureLabel: String = "Capture",
|
||||
val submitIntroSubmitLabel: String = "Submit",
|
||||
val p2pConsentTitle: String = "P2P Streaming",
|
||||
val p2pConsentBody: String = "",
|
||||
val p2pConsentEnableLabel: String = "Enable P2P",
|
||||
val p2pConsentCancelLabel: String = "Cancel",
|
||||
val subtitlesPanelTitle: String = "Subtitles",
|
||||
val subtitleBuiltInTabLabel: String = "Built-in",
|
||||
val subtitleAddonsTabLabel: String = "Addons",
|
||||
val subtitleStyleTabLabel: String = "Style",
|
||||
val noneLabel: String = "None",
|
||||
val fetchSubtitlesLabel: String = "Tap to fetch subtitles",
|
||||
val subtitleDelayLabel: String = "Subtitle Delay",
|
||||
val resetLabel: String = "Reset",
|
||||
val autoSyncLabel: String = "Auto Sync",
|
||||
val reloadSmallLabel: String = "Reload",
|
||||
val captureLineLabel: String = "Capture",
|
||||
val selectAddonSubtitleFirstLabel: String = "Select an addon subtitle first",
|
||||
val loadingSubtitleLinesLabel: String = "Loading subtitle lines...",
|
||||
val fontSizeLabel: String = "Font Size",
|
||||
val outlineLabel: String = "Outline",
|
||||
val boldLabel: String = "Bold",
|
||||
val bottomOffsetLabel: String = "Bottom Offset",
|
||||
val colorLabel: String = "Color",
|
||||
val textOpacityLabel: String = "Text Opacity",
|
||||
val outlineColorLabel: String = "Outline Color",
|
||||
val resetDefaultsLabel: String = "Reset Defaults",
|
||||
val onLabel: String = "On",
|
||||
val offLabel: String = "Off",
|
||||
val themeAccentColor: String = "#2f6fed",
|
||||
val themeAccentStrongColor: String = "#3c7bff",
|
||||
val themeOnAccentColor: String = "#ffffff",
|
||||
val themeFocusColor: String = "#9ecaff",
|
||||
val themeSelectedSurfaceColor: String = "#26384f",
|
||||
val themeSelectedSurfaceHoverColor: String = "#2d4565",
|
||||
val themeSelectedRingColor: String = "rgba(47, 111, 237, .35)",
|
||||
val themeTimelineFillColor: String = "#ffffff",
|
||||
val themeTimelineTrackColor: String = "rgba(255, 255, 255, .28)",
|
||||
val themeBufferingColor: String = "#ffffff",
|
||||
val themeBufferingTrackColor: String = "rgba(255, 255, 255, .28)",
|
||||
val themeControlForegroundColor: String = "#ffffff",
|
||||
val isPlaying: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isLocked: Boolean = false,
|
||||
val lockedOverlayVisible: Boolean = false,
|
||||
val controlsVisible: Boolean = true,
|
||||
val parentalWarnings: List<ParentalWarning> = emptyList(),
|
||||
val showParentalGuide: Boolean = false,
|
||||
val showOpeningOverlay: Boolean = false,
|
||||
val openingArtwork: String? = null,
|
||||
val openingLogo: String? = null,
|
||||
val openingTitle: String = "",
|
||||
val openingMessage: String? = null,
|
||||
val openingProgress: Float? = null,
|
||||
val skipPromptVisible: Boolean = false,
|
||||
val skipPromptLabel: String = "Skip",
|
||||
val skipPromptStartMs: Long = 0L,
|
||||
val skipPromptEndMs: Long = 0L,
|
||||
val skipPromptDismissed: Boolean = false,
|
||||
val nextEpisodeVisible: Boolean = false,
|
||||
val nextEpisodeHeaderLabel: String = "Next episode",
|
||||
val nextEpisodeTitle: String = "",
|
||||
val nextEpisodeThumbnail: String = "",
|
||||
val nextEpisodeStatus: String = "",
|
||||
val nextEpisodeActionLabel: String = "Play",
|
||||
val nextEpisodePlayable: Boolean = false,
|
||||
val showSubmitIntro: Boolean = false,
|
||||
val showVideoSettings: Boolean = false,
|
||||
val showSources: Boolean = false,
|
||||
val showEpisodes: Boolean = false,
|
||||
val showExternalPlayer: Boolean = false,
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val sourceIsLoading: Boolean = false,
|
||||
val sourceFilters: List<PlayerControlFilterItem> = emptyList(),
|
||||
val sourceItems: List<PlayerControlSourceItem> = emptyList(),
|
||||
val episodeItems: List<PlayerControlEpisodeItem> = emptyList(),
|
||||
val episodeSeasons: List<PlayerControlSeasonItem> = emptyList(),
|
||||
val episodeStreamsVisible: Boolean = false,
|
||||
val episodeStreamsIsLoading: Boolean = false,
|
||||
val selectedEpisodeLabel: String = "",
|
||||
val episodeStreamFilters: List<PlayerControlFilterItem> = emptyList(),
|
||||
val episodeStreamItems: List<PlayerControlSourceItem> = emptyList(),
|
||||
val submitIntroSegmentType: String = "intro",
|
||||
val submitIntroStartTime: String = "00:00",
|
||||
val submitIntroEndTime: String = "00:00",
|
||||
val isSubmitIntroSubmitting: Boolean = false,
|
||||
val submitIntroStatusMessage: String = "",
|
||||
val showP2pConsent: Boolean = false,
|
||||
val subtitleActiveTab: String = "BuiltIn",
|
||||
val addonSubtitleItems: List<PlayerControlAddonSubtitleItem> = emptyList(),
|
||||
val isLoadingAddonSubtitles: Boolean = false,
|
||||
val selectedAddonSubtitleId: String = "",
|
||||
val useCustomSubtitles: Boolean = false,
|
||||
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
|
||||
val subtitleDelayMs: Int = 0,
|
||||
val hasSelectedAddonSubtitle: Boolean = false,
|
||||
val subtitleAutoSyncCapturedPositionMs: Long = -1L,
|
||||
val subtitleAutoSyncCues: List<PlayerControlSubtitleCueItem> = emptyList(),
|
||||
val subtitleAutoSyncIsLoading: Boolean = false,
|
||||
val subtitleAutoSyncErrorMessage: String = "",
|
||||
val closeModalsToken: Long = 0L,
|
||||
)
|
||||
|
||||
data class PlayerControlFilterItem(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val hasError: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSeasonItem(
|
||||
val season: Int = 0,
|
||||
val label: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSourceItem(
|
||||
val index: Int = 0,
|
||||
val filterId: String = "",
|
||||
val label: String = "",
|
||||
val subtitle: String = "",
|
||||
val addonName: String = "",
|
||||
val isCurrent: Boolean = false,
|
||||
val isEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
data class PlayerControlEpisodeItem(
|
||||
val index: Int = 0,
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val code: String = "",
|
||||
val overview: String = "",
|
||||
val thumbnail: String = "",
|
||||
val season: Int = 0,
|
||||
val episode: Int = 0,
|
||||
val isCurrent: Boolean = false,
|
||||
val isWatched: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlAddonSubtitleItem(
|
||||
val index: Int = 0,
|
||||
val id: String = "",
|
||||
val display: String = "",
|
||||
val languageLabel: String = "",
|
||||
val addonName: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSubtitleCueItem(
|
||||
val index: Int = 0,
|
||||
val timeMs: Long = 0L,
|
||||
val timeLabel: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {
|
||||
val rawHeaders = headers ?: return emptyMap()
|
||||
if (rawHeaders.isEmpty()) return emptyMap()
|
||||
|
|
@ -70,7 +297,13 @@ expect fun PlatformPlayerSurface(
|
|||
modifier: Modifier = Modifier,
|
||||
playWhenReady: Boolean = true,
|
||||
resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
|
||||
initialPositionMs: Long = 0L,
|
||||
useNativeController: Boolean = false,
|
||||
playerControlsState: PlayerControlsState = PlayerControlsState(),
|
||||
onPlayerControlsAction: (PlayerControlsAction) -> Boolean = { false },
|
||||
onPlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false },
|
||||
onPlayerControlsScrubChange: (Long) -> Boolean = { false },
|
||||
onPlayerControlsScrubFinished: (Long) -> Boolean = { false },
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import androidx.compose.ui.text.font.FontStyle
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ data class PlayerLaunch(
|
|||
val torrentInfoHash: String? = null,
|
||||
val torrentFileIdx: Int? = null,
|
||||
val torrentFilename: String? = null,
|
||||
val torrentMagnetUri: String? = null,
|
||||
val torrentTrackers: List<String> = emptyList(),
|
||||
val initialPositionMs: Long = 0L,
|
||||
val initialProgressFraction: Float? = null,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ fun PlayerScreen(
|
|||
torrentInfoHash: String? = null,
|
||||
torrentFileIdx: Int? = null,
|
||||
torrentFilename: String? = null,
|
||||
torrentMagnetUri: String? = null,
|
||||
torrentTrackers: List<String> = emptyList(),
|
||||
initialPositionMs: Long = 0L,
|
||||
initialProgressFraction: Float? = null,
|
||||
|
|
@ -68,7 +67,6 @@ fun PlayerScreen(
|
|||
torrentInfoHash = torrentInfoHash,
|
||||
torrentFileIdx = torrentFileIdx,
|
||||
torrentFilename = torrentFilename,
|
||||
torrentMagnetUri = torrentMagnetUri,
|
||||
torrentTrackers = torrentTrackers,
|
||||
initialPositionMs = initialPositionMs,
|
||||
initialProgressFraction = initialProgressFraction,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ internal data class PlayerScreenArgs(
|
|||
val torrentInfoHash: String?,
|
||||
val torrentFileIdx: Int?,
|
||||
val torrentFilename: String?,
|
||||
val torrentMagnetUri: String?,
|
||||
val torrentTrackers: List<String>,
|
||||
val initialPositionMs: Long,
|
||||
val initialProgressFraction: Float?,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
|||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.isDesktop
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -89,7 +90,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
activeTorrentInfoHash,
|
||||
activeTorrentFileIdx,
|
||||
activeTorrentFilename,
|
||||
activeTorrentMagnetUri,
|
||||
activeTorrentTrackers,
|
||||
p2pSettingsUiState.p2pEnabled,
|
||||
) {
|
||||
|
|
@ -106,7 +106,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
p2pResolvedSourceUrl = null
|
||||
val requestedFileIdx = activeTorrentFileIdx
|
||||
val requestedFilename = activeTorrentFilename
|
||||
val requestedMagnetUri = activeTorrentMagnetUri
|
||||
val requestedTrackers = activeTorrentTrackers
|
||||
errorMessage = null
|
||||
playerController = null
|
||||
|
|
@ -120,7 +119,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
infoHash = infoHash,
|
||||
fileIdx = requestedFileIdx,
|
||||
filename = requestedFilename,
|
||||
magnetUri = requestedMagnetUri,
|
||||
trackers = requestedTrackers,
|
||||
),
|
||||
)
|
||||
|
|
@ -233,6 +231,10 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
initialSeekApplied = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (isDesktop && activeInitialPositionMs > 0L) {
|
||||
initialSeekApplied = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
controller.seekTo(targetPositionMs)
|
||||
initialSeekApplied = true
|
||||
|
|
|
|||
|
|
@ -165,10 +165,33 @@ internal fun PlayerScreenRuntime.togglePlayback() {
|
|||
controlsVisible = true
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareTogglePlaybackForNativeFallback(revealControls: Boolean = true) {
|
||||
shouldPlay = !playbackSnapshot.isPlaying
|
||||
if (revealControls) {
|
||||
controlsVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) {
|
||||
playerController?.seekBy(offsetMs)
|
||||
applySeekByControlFeedback(offsetMs)
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareSeekByForNativeFallback(
|
||||
offsetMs: Long,
|
||||
revealControls: Boolean = true,
|
||||
) {
|
||||
applySeekByControlFeedback(offsetMs, revealControls)
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.applySeekByControlFeedback(
|
||||
offsetMs: Long,
|
||||
revealControls: Boolean = true,
|
||||
) {
|
||||
scheduleProgressSyncAfterSeek()
|
||||
controlsVisible = true
|
||||
if (revealControls) {
|
||||
controlsVisible = true
|
||||
}
|
||||
when {
|
||||
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
|
||||
offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
|
||||
|
|
@ -176,6 +199,17 @@ internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) {
|
|||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) {
|
||||
handleDoubleTapSeek(direction, sendToController = true)
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareDoubleTapSeekForNativeFallback(direction: PlayerSeekDirection) {
|
||||
handleDoubleTapSeek(direction, sendToController = false)
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.handleDoubleTapSeek(
|
||||
direction: PlayerSeekDirection,
|
||||
sendToController: Boolean,
|
||||
) {
|
||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||
val currentSeekState = accumulatedSeekState
|
||||
val nextState = if (currentSeekState?.direction == direction) {
|
||||
|
|
@ -199,7 +233,9 @@ internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirect
|
|||
maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
|
||||
}
|
||||
}
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
if (sendToController) {
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
}
|
||||
scheduleProgressSyncAfterSeek()
|
||||
showSeekFeedback(direction, nextState.amountMs)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,58 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?)
|
|||
internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean =
|
||||
stream.needsLocalDebridResolve && stream.p2pInfoHash != null
|
||||
|
||||
internal fun StreamItem.playerSourceIdentityKey(): String? {
|
||||
p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
return "torrent:$hash:${fileIdx ?: -1}"
|
||||
}
|
||||
|
||||
clientResolve?.let { resolve ->
|
||||
val raw = resolve.stream?.raw
|
||||
val keyParts = listOf(
|
||||
addonId,
|
||||
resolve.service,
|
||||
resolve.serviceIndex?.toString(),
|
||||
resolve.infoHash?.trim()?.lowercase(),
|
||||
resolve.fileIdx?.toString(),
|
||||
resolve.magnetUri,
|
||||
resolve.torrentName,
|
||||
resolve.filename,
|
||||
raw?.torrentName,
|
||||
raw?.filename,
|
||||
raw?.size?.toString(),
|
||||
behaviorHints.filename,
|
||||
behaviorHints.videoSize?.toString(),
|
||||
streamLabel,
|
||||
streamSubtitle,
|
||||
).map { it.orEmpty().trim() }
|
||||
if (keyParts.any { it.isNotBlank() }) {
|
||||
return "resolve:${keyParts.joinToString("|")}"
|
||||
}
|
||||
}
|
||||
|
||||
behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}"
|
||||
}
|
||||
|
||||
playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url ->
|
||||
return "url:$url"
|
||||
}
|
||||
|
||||
val fallbackParts = listOf(
|
||||
addonId,
|
||||
addonName,
|
||||
streamLabel,
|
||||
streamSubtitle.orEmpty(),
|
||||
behaviorHints.filename.orEmpty(),
|
||||
behaviorHints.videoSize?.toString().orEmpty(),
|
||||
sourceName.orEmpty(),
|
||||
sources.joinToString(","),
|
||||
).map { it.trim() }
|
||||
return fallbackParts
|
||||
.takeIf { parts -> parts.any { it.isNotBlank() } }
|
||||
?.joinToString(separator = "|", prefix = "meta:")
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.stopActiveP2pStream() {
|
||||
if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) {
|
||||
P2pStreamingEngine.stopStream()
|
||||
|
|
@ -56,7 +108,6 @@ internal fun PlayerScreenRuntime.stopActiveP2pStream() {
|
|||
activeTorrentInfoHash = null
|
||||
activeTorrentFileIdx = null
|
||||
activeTorrentFilename = null
|
||||
activeTorrentMagnetUri = null
|
||||
activeTorrentTrackers = emptyList()
|
||||
p2pResolvedSourceUrl = null
|
||||
}
|
||||
|
|
@ -84,12 +135,11 @@ internal fun PlayerScreenRuntime.saveP2pStreamForReuse(
|
|||
addonId = stream.addonId,
|
||||
requestHeaders = emptyMap(),
|
||||
responseHeaders = emptyMap(),
|
||||
filename = stream.p2pFilename,
|
||||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
infoHash = infoHash,
|
||||
fileIdx = stream.p2pFileIdx,
|
||||
magnetUri = stream.torrentMagnetUri,
|
||||
sources = stream.p2pSourceHints,
|
||||
fileIdx = stream.fileIdx,
|
||||
sources = stream.sources,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
}
|
||||
|
|
@ -110,15 +160,15 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
|
|||
season = activeSeasonNumber,
|
||||
episode = activeEpisodeNumber,
|
||||
)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
|
||||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeTorrentInfoHash = infoHash
|
||||
activeTorrentFileIdx = stream.p2pFileIdx
|
||||
activeTorrentFilename = stream.p2pFilename
|
||||
activeTorrentMagnetUri = stream.torrentMagnetUri
|
||||
activeTorrentFileIdx = stream.fileIdx
|
||||
activeTorrentFilename = stream.behaviorHints.filename
|
||||
activeTorrentTrackers = stream.p2pTrackers
|
||||
activeSourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
@ -152,14 +202,13 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
|
|||
season = episode.season,
|
||||
episode = episode.episode,
|
||||
)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
|
||||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeTorrentInfoHash = infoHash
|
||||
activeTorrentFileIdx = stream.p2pFileIdx
|
||||
activeTorrentFilename = stream.p2pFilename
|
||||
activeTorrentMagnetUri = stream.torrentMagnetUri
|
||||
activeTorrentFileIdx = stream.fileIdx
|
||||
activeTorrentFilename = stream.behaviorHints.filename
|
||||
activeTorrentTrackers = stream.p2pTrackers
|
||||
applyEpisodeStreamMetadata(stream, episode, resume)
|
||||
}
|
||||
|
|
@ -190,7 +239,11 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
|
|||
return
|
||||
}
|
||||
val url = stream.playableDirectUrl ?: return
|
||||
if (url == activeSourceUrl) return
|
||||
val sourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
if (url == activeSourceUrl) {
|
||||
activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey
|
||||
return
|
||||
}
|
||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||
flushWatchProgress()
|
||||
stopActiveP2pStream()
|
||||
|
|
@ -202,6 +255,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
|
||||
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
|
||||
activeSourceIdentityKey = sourceIdentityKey
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
@ -275,6 +329,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeSourceIdentityKey = null
|
||||
activeStreamTitle = downloadItem.streamTitle.ifBlank {
|
||||
episode.title.ifBlank { title }
|
||||
}
|
||||
|
|
@ -380,6 +435,7 @@ private fun PlayerScreenRuntime.applyEpisodeStreamMetadata(
|
|||
episode: MetaVideo,
|
||||
resume: EpisodeResume,
|
||||
) {
|
||||
activeSourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ internal class PlayerScreenRuntime(
|
|||
val torrentInfoHash: String? get() = args.torrentInfoHash
|
||||
val torrentFileIdx: Int? get() = args.torrentFileIdx
|
||||
val torrentFilename: String? get() = args.torrentFilename
|
||||
val torrentMagnetUri: String? get() = args.torrentMagnetUri
|
||||
val torrentTrackers: List<String> get() = args.torrentTrackers
|
||||
val initialPositionMs: Long get() = args.initialPositionMs
|
||||
val initialProgressFraction: Float? get() = args.initialProgressFraction
|
||||
|
|
@ -67,8 +66,8 @@ internal class PlayerScreenRuntime(
|
|||
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
|
||||
var watchedUiState: WatchedUiState = WatchedUiState()
|
||||
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
|
||||
var sourceStreamsState: StreamsUiState = StreamsUiState()
|
||||
var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
|
||||
var sourceStreamsState by mutableStateOf(StreamsUiState())
|
||||
var episodeStreamsRepoState by mutableStateOf(StreamsUiState())
|
||||
var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
|
||||
var addonsUiState: AddonsUiState = AddonsUiState()
|
||||
var addonSubtitles: List<AddonSubtitle> = emptyList()
|
||||
|
|
@ -99,9 +98,13 @@ internal class PlayerScreenRuntime(
|
|||
var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
|
||||
var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
|
||||
var activeTorrentFilename by mutableStateOf(torrentFilename)
|
||||
var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri)
|
||||
var activeTorrentTrackers by mutableStateOf(torrentTrackers)
|
||||
var p2pResolvedSourceUrl by mutableStateOf<String?>(null)
|
||||
var activeSourceIdentityKey by mutableStateOf(
|
||||
torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
"torrent:$hash:${torrentFileIdx ?: -1}"
|
||||
} ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" },
|
||||
)
|
||||
var activeStreamTitle by mutableStateOf(streamTitle)
|
||||
var activeStreamSubtitle by mutableStateOf(streamSubtitle)
|
||||
var activeProviderName by mutableStateOf(providerName)
|
||||
|
|
@ -152,6 +155,12 @@ internal class PlayerScreenRuntime(
|
|||
var submitIntroSegmentType by mutableStateOf("intro")
|
||||
var submitIntroStartTimeStr by mutableStateOf("00:00")
|
||||
var submitIntroEndTimeStr by mutableStateOf("00:00")
|
||||
var submitIntroStartTimeSec by mutableStateOf<Double?>(0.0)
|
||||
var submitIntroEndTimeSec by mutableStateOf<Double?>(0.0)
|
||||
var isSubmitIntroSubmitting by mutableStateOf(false)
|
||||
var submitIntroStatusMessage by mutableStateOf<String?>(null)
|
||||
var playerControlsPendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
|
||||
var playerControlsCloseModalsToken by mutableStateOf(0L)
|
||||
var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState())
|
||||
var playerMetaVideos by mutableStateOf<List<MetaVideo>>(emptyList())
|
||||
var skipIntervals by mutableStateOf<List<SkipInterval>>(emptyList())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -63,6 +63,7 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "sources",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -84,6 +85,7 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "episodeStreams",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -119,6 +121,7 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
private fun fetchStreams(
|
||||
panelName: String,
|
||||
type: String,
|
||||
videoId: String,
|
||||
season: Int?,
|
||||
|
|
@ -137,9 +140,11 @@ object PlayerStreamsRepository {
|
|||
requestKeyHolder() == requestKey &&
|
||||
(current.groups.isNotEmpty() || current.emptyStateReason != null || current.isAnyLoading)
|
||||
) {
|
||||
log.d { "skip $panelName request=$requestKey reason=already-active ${current.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
log.d { "start $panelName request=$requestKey force=$forceRefresh previous=${current.streamDiagnostics()}" }
|
||||
setRequestKey(requestKey)
|
||||
jobHolder()?.cancel()
|
||||
stateFlow.value = StreamsUiState()
|
||||
|
|
@ -147,7 +152,7 @@ object PlayerStreamsRepository {
|
|||
val streamBadgeRules = StreamBadgeSettingsRepository.snapshot()
|
||||
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
|
||||
if (embeddedStreams.isNotEmpty()) {
|
||||
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
|
||||
log.d { "using embedded $panelName request=$requestKey streams=${embeddedStreams.size}" }
|
||||
val group = AddonStreamGroup(
|
||||
addonName = embeddedStreams.first().addonName,
|
||||
addonId = "embedded",
|
||||
|
|
@ -163,6 +168,7 @@ object PlayerStreamsRepository {
|
|||
activeAddonIds = setOf("embedded"),
|
||||
isAnyLoading = false,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +189,7 @@ object PlayerStreamsRepository {
|
|||
isAnyLoading = false,
|
||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +216,10 @@ object PlayerStreamsRepository {
|
|||
isAnyLoading = false,
|
||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
|
||||
)
|
||||
log.d {
|
||||
"finish $panelName request=$requestKey reason=no-compatible-addons " +
|
||||
"installed=${installedAddons.size} ${stateFlow.value.streamDiagnostics()}"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +233,10 @@ object PlayerStreamsRepository {
|
|||
.associateBy { it.addonId }
|
||||
}
|
||||
val warmedAddonIds = warmedAddonGroups.keys
|
||||
log.d {
|
||||
"targets $panelName request=$requestKey installed=${installedAddons.size} " +
|
||||
"compatible=${streamAddons.size} plugins=${pluginScrapers.size} warmed=${warmedAddonIds.size}"
|
||||
}
|
||||
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
||||
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
|
||||
addonName = addon.addonName,
|
||||
|
|
@ -243,6 +258,7 @@ object PlayerStreamsRepository {
|
|||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||
isAnyLoading = isInitiallyLoading,
|
||||
)
|
||||
log.d { "state $panelName request=$requestKey stage=initial ${stateFlow.value.streamDiagnostics()}" }
|
||||
|
||||
val job = scope.launch {
|
||||
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
|
||||
|
|
@ -271,6 +287,7 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
fun publishStreamGroup(group: AddonStreamGroup) {
|
||||
var nextState: StreamsUiState? = null
|
||||
stateFlow.update { current ->
|
||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||
groups = current.groups.map { currentGroup ->
|
||||
|
|
@ -283,7 +300,14 @@ object PlayerStreamsRepository {
|
|||
groups = updated,
|
||||
isAnyLoading = anyLoading,
|
||||
emptyStateReason = emptyStateReason(updated, anyLoading),
|
||||
)
|
||||
).also { nextState = it }
|
||||
}
|
||||
nextState?.let { state ->
|
||||
log.d {
|
||||
"state $panelName request=$requestKey stage=publish addon=${group.addonName} " +
|
||||
"streams=${group.streams.size} loading=${group.isLoading} " +
|
||||
"error=${!group.error.isNullOrBlank()} ${state.streamDiagnostics()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,14 +353,16 @@ object PlayerStreamsRepository {
|
|||
|
||||
val displayName = addon.addonName
|
||||
runCatching {
|
||||
log.d { "fetch $panelName request=$requestKey addon=$displayName" }
|
||||
val payload = httpGetText(url)
|
||||
StreamParser.parse(payload, displayName, addon.addonId)
|
||||
}.fold(
|
||||
onSuccess = { streams ->
|
||||
log.d { "fetched $panelName request=$requestKey addon=$displayName streams=${streams.size}" }
|
||||
AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false)
|
||||
},
|
||||
onFailure = { err ->
|
||||
log.w(err) { "Failed: ${displayName}" }
|
||||
log.w(err) { "failed $panelName request=$requestKey addon=$displayName" }
|
||||
AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message)
|
||||
},
|
||||
)
|
||||
|
|
@ -345,6 +371,7 @@ object PlayerStreamsRepository {
|
|||
|
||||
val pluginJobs = pluginScrapers.map { scraper ->
|
||||
async {
|
||||
log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = pluginContentId(
|
||||
|
|
@ -357,6 +384,7 @@ object PlayerStreamsRepository {
|
|||
episode = episode,
|
||||
).fold(
|
||||
onSuccess = { results ->
|
||||
log.d { "fetched $panelName request=$requestKey plugin=${scraper.name} streams=${results.size}" }
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
|
|
@ -365,7 +393,7 @@ object PlayerStreamsRepository {
|
|||
)
|
||||
},
|
||||
onFailure = { err ->
|
||||
log.w(err) { "Plugin scraper failed: ${scraper.name}" }
|
||||
log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
|
|
@ -392,6 +420,7 @@ object PlayerStreamsRepository {
|
|||
for (availabilityJob in debridAvailabilityJobs) {
|
||||
availabilityJob.join()
|
||||
}
|
||||
log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" }
|
||||
launch {
|
||||
DirectDebridStreamPreparer.prepare(
|
||||
streams = stateFlow.value.groups
|
||||
|
|
@ -426,6 +455,25 @@ private data class PlayerInstalledStreamAddonTarget(
|
|||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
/**
|
||||
* Platform-specific subtitle caching for external players.
|
||||
*
|
||||
* On Android, downloads subtitle files from remote URLs to local cache and returns
|
||||
* content:// URIs so external players can access them (via intent extras + ClipData).
|
||||
*
|
||||
* On iOS, returns subtitles unchanged — players like Infuse accept remote URLs
|
||||
* directly via their URL scheme parameters.
|
||||
*/
|
||||
expect object SubtitleCacheProvider {
|
||||
/**
|
||||
* Caches subtitle files locally and returns updated [SubtitleInput] list
|
||||
* with local URIs instead of remote HTTP URLs.
|
||||
*
|
||||
* Returns null if caching fails or no subtitles could be downloaded.
|
||||
* On platforms where caching is not needed, returns the input list unchanged.
|
||||
*/
|
||||
suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>?
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ 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.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_title_format
|
||||
import nuvio.composeapp.generated.resources.detail_btn_play
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -62,6 +65,7 @@ object ProfileRepository {
|
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("ProfileRepository")
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val profileSwitchMutex = Mutex()
|
||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||
|
||||
private val _state = MutableStateFlow(ProfileState())
|
||||
|
|
@ -141,7 +145,15 @@ object ProfileRepository {
|
|||
}
|
||||
}
|
||||
|
||||
fun selectProfile(profileIndex: Int) {
|
||||
suspend fun switchToProfile(profileIndex: Int) {
|
||||
profileSwitchMutex.withLock {
|
||||
withContext(Dispatchers.Default) {
|
||||
selectProfile(profileIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProfile(profileIndex: Int) {
|
||||
activeProfileIndex = profileIndex
|
||||
val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex }
|
||||
_state.value = _state.value.copy(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -176,7 +176,6 @@ fun ProfileSelectionScreen(
|
|||
} else if (profile.pinEnabled) {
|
||||
pinDialogProfile = profile
|
||||
} else {
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
}
|
||||
},
|
||||
|
|
@ -217,7 +216,6 @@ fun ProfileSelectionScreen(
|
|||
} else if (profile.pinEnabled) {
|
||||
pinDialogProfile = profile
|
||||
} else {
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
}
|
||||
},
|
||||
|
|
@ -282,7 +280,6 @@ fun ProfileSelectionScreen(
|
|||
onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) },
|
||||
onVerified = {
|
||||
pinDialogProfile = null
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
},
|
||||
onDismiss = { pinDialogProfile = null },
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.isIos
|
||||
|
|
@ -85,6 +85,7 @@ fun ProfileSwitcherTab(
|
|||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
triggerContent: (@Composable (selected: Boolean) -> Unit)? = null,
|
||||
openPopupOnClick: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
|
@ -201,7 +202,13 @@ fun ProfileSwitcherTab(
|
|||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
onClick = {
|
||||
if (openPopupOnClick && profiles.isNotEmpty()) {
|
||||
showPopup = true
|
||||
} else {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
)
|
||||
.pointerInput(profiles) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
|
|
@ -336,6 +343,358 @@ fun ProfileSwitcherTab(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SidebarProfileSwitcherStack(
|
||||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val activeProfile = profileState.activeProfile
|
||||
val profiles = profileState.profiles
|
||||
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
AvatarRepository.fetchAvatars()
|
||||
AvatarRepository.refreshAvatars()
|
||||
}
|
||||
|
||||
fun chooseProfile(profile: NuvioProfile) {
|
||||
if (profile.profileIndex == activeProfile?.profileIndex) {
|
||||
onDismissRequest()
|
||||
return
|
||||
}
|
||||
if (profile.pinEnabled) {
|
||||
pinProfile = profile
|
||||
} else {
|
||||
onProfileSelected(profile)
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
profiles.forEach { profile ->
|
||||
SidebarProfileSwitcherRow(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
isActive = profile.profileIndex == activeProfile?.profileIndex,
|
||||
onClick = { chooseProfile(profile) },
|
||||
)
|
||||
}
|
||||
|
||||
if (profiles.size < 4) {
|
||||
SidebarAddProfileRow(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAddProfileRequested()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = pinProfile != null,
|
||||
enter = expandVertically() + fadeIn(tween(160)),
|
||||
exit = shrinkVertically(tween(120)) + fadeOut(tween(90)),
|
||||
) {
|
||||
pinProfile?.let { profile ->
|
||||
SidebarCompactPinEntry(
|
||||
profileName = profile.name,
|
||||
onVerified = {
|
||||
onProfileSelected(profile)
|
||||
pinProfile = null
|
||||
onDismissRequest()
|
||||
},
|
||||
onCancel = { pinProfile = null },
|
||||
verifyPin = { pin ->
|
||||
ProfileRepository.verifyPin(profile.profileIndex, pin)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarProfileSwitcherRow(
|
||||
profile: NuvioProfile,
|
||||
avatars: List<AvatarCatalogItem>,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(
|
||||
if (isActive) {
|
||||
tokens.colors.overlaySelected
|
||||
} else {
|
||||
tokens.colors.surface.copy(alpha = 0f)
|
||||
},
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(30.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ActiveProfileMiniAvatar(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
selected = isActive,
|
||||
size = 26,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = profile.name.ifBlank {
|
||||
stringResource(Res.string.profile_label_number, profile.profileIndex)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isActive) tokens.colors.textPrimary else tokens.colors.textMuted,
|
||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (profile.pinEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Lock,
|
||||
contentDescription = null,
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarAddProfileRow(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surfaceCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(Res.string.compose_profile_add_profile),
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_profile_add_profile),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarCompactPinEntry(
|
||||
profileName: String,
|
||||
onVerified: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
verifyPin: suspend (String) -> PinVerifyResult,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
var pin by remember(profileName) { mutableStateOf("") }
|
||||
var error by remember(profileName) { mutableStateOf<String?>(null) }
|
||||
var isVerifying by remember(profileName) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(tokens.colors.surfaceCard.copy(alpha = 0.72f))
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.pin_enter_for, profileName),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
repeat(4) { index ->
|
||||
val filled = index < pin.length
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.then(
|
||||
if (filled) {
|
||||
Modifier.background(tokens.colors.accent)
|
||||
} else {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.avatar)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = error.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.danger,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SidebarCompactPinKeypad(
|
||||
onDigit = { digit ->
|
||||
if (pin.length < 4 && !isVerifying) {
|
||||
error = null
|
||||
pin += digit
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
if (pin.length == 4) {
|
||||
isVerifying = true
|
||||
scope.launch {
|
||||
val result = verifyPin(pin)
|
||||
if (result.unlocked) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onVerified()
|
||||
} else {
|
||||
error = if (result.retryAfterSeconds > 0) {
|
||||
getString(Res.string.pin_locked_try_again, result.retryAfterSeconds)
|
||||
} else {
|
||||
getString(Res.string.pin_incorrect)
|
||||
}
|
||||
pin = ""
|
||||
}
|
||||
isVerifying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onBackspace = {
|
||||
if (pin.isNotEmpty() && !isVerifying) {
|
||||
pin = pin.dropLast(1)
|
||||
error = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.pin_cancel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.accent,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.clickable(onClick = onCancel)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarCompactPinKeypad(
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("", "0", "⌫"),
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
when (key) {
|
||||
"" -> Spacer(modifier = Modifier.size(30.dp))
|
||||
"⌫" -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surface)
|
||||
.clickable(onClick = onBackspace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.Backspace,
|
||||
contentDescription = stringResource(Res.string.pin_backspace),
|
||||
tint = tokens.colors.textPrimary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surface)
|
||||
.clickable { onDigit(key) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = key,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PopupAddProfileBubble(
|
||||
delayMs: Int,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ 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.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioDropdownChip
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import com.nuvio.app.core.ui.NuvioInputField
|
|||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
|
||||
import com.nuvio.app.core.ui.nuvioConsumePointerEvents
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
|
|
@ -83,6 +83,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
@Composable
|
||||
fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
searchFocusRequestCount: Int = 0,
|
||||
|
|
@ -238,43 +239,50 @@ fun SearchScreen(
|
|||
|
||||
NuvioScreen(
|
||||
horizontalPadding = 0.dp,
|
||||
topPadding = if (topChromePadding != null) 0.dp else null,
|
||||
listState = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
stickyHeader {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nuvioBlockPointerPassthrough()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = headerTitle,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.nuvioConsumePointerEvents(),
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp))
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
NuvioInputField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = stringResource(Res.string.compose_search_placeholder),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
trailingContent = if (query.isNotBlank()) {
|
||||
{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = stringResource(Res.string.compose_search_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = headerTitle,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topPadding = topChromePadding,
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp))
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
NuvioInputField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = stringResource(Res.string.compose_search_placeholder),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
trailingContent = if (query.isNotBlank()) {
|
||||
{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = stringResource(Res.string.compose_search_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -38,7 +38,10 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||
|
|
@ -56,6 +59,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
|
|||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
|
||||
|
|
@ -67,7 +72,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
internal fun LazyListScope.appearanceSettingsContent(
|
||||
isTablet: Boolean,
|
||||
selectedTheme: AppTheme,
|
||||
|
|
@ -89,30 +93,57 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
val themes = listOf(AppTheme.WHITE) + AppTheme.entries.filterNot { it == AppTheme.WHITE }
|
||||
FlowRow(
|
||||
val horizontalPadding = if (isTablet) 20.dp else 16.dp
|
||||
val verticalPadding = if (isTablet) 18.dp else 14.dp
|
||||
val themeSpacing = if (isTablet) 16.dp else 12.dp
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = if (isTablet) 20.dp else 16.dp,
|
||||
vertical = if (isTablet) 18.dp else 14.dp,
|
||||
horizontal = horizontalPadding,
|
||||
vertical = verticalPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp),
|
||||
) {
|
||||
themes.forEach { theme ->
|
||||
ThemeChip(
|
||||
theme = theme,
|
||||
isSelected = theme == selectedTheme,
|
||||
onClick = { onThemeSelected(theme) },
|
||||
)
|
||||
val preferredColumns = if (isTablet) 4 else 3
|
||||
val minThemeCellWidth = if (isTablet) 92.dp else 78.dp
|
||||
val themeColumns = ((maxWidth + themeSpacing) / (minThemeCellWidth + themeSpacing))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
.coerceAtMost(preferredColumns)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(themeSpacing),
|
||||
) {
|
||||
themes.chunked(themeColumns).forEach { rowThemes ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(themeSpacing),
|
||||
) {
|
||||
rowThemes.forEach { theme ->
|
||||
ThemeChip(
|
||||
theme = theme,
|
||||
isSelected = theme == selectedTheme,
|
||||
onClick = { onThemeSelected(theme) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
repeat(themeColumns - rowThemes.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
var showLanguageSheet by remember { mutableStateOf(false) }
|
||||
var showDesktopNavigationSheet by remember { mutableStateOf(false) }
|
||||
val desktopNavigationLayout by remember {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
ThemeSettingsRepository.desktopNavigationLayout
|
||||
}.collectAsStateWithLifecycle()
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_appearance_section_display),
|
||||
isTablet = isTablet,
|
||||
|
|
@ -135,6 +166,16 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
onCheckedChange = onLiquidGlassNativeTabBarToggle,
|
||||
)
|
||||
}
|
||||
if (isDesktop) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_appearance_desktop_navigation),
|
||||
description = stringResource(desktopNavigationLayout.labelRes),
|
||||
icon = Icons.Rounded.Style,
|
||||
isTablet = isTablet,
|
||||
onClick = { showDesktopNavigationSheet = true },
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_appearance_app_language),
|
||||
|
|
@ -146,6 +187,17 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (showDesktopNavigationSheet) {
|
||||
DesktopNavigationLayoutBottomSheet(
|
||||
selectedLayout = desktopNavigationLayout,
|
||||
onLayoutSelected = {
|
||||
ThemeSettingsRepository.setDesktopNavigationLayout(it)
|
||||
showDesktopNavigationSheet = false
|
||||
},
|
||||
onDismiss = { showDesktopNavigationSheet = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showLanguageSheet) {
|
||||
AppearanceLanguageBottomSheet(
|
||||
selectedLanguage = selectedAppLanguage,
|
||||
|
|
@ -184,6 +236,66 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DesktopNavigationLayoutBottomSheet(
|
||||
selectedLayout: DesktopNavigationLayout,
|
||||
onLayoutSelected: (DesktopNavigationLayout) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NuvioModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(DesktopNavigationLayout.entries) { index, layout ->
|
||||
if (index > 0) {
|
||||
NuvioBottomSheetDivider()
|
||||
}
|
||||
NuvioBottomSheetActionRow(
|
||||
title = stringResource(layout.labelRes),
|
||||
onClick = {
|
||||
onLayoutSelected(layout)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (layout == selectedLayout) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.cd_selected),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AppLanguageSheetOption(
|
||||
val language: AppLanguage,
|
||||
val labelRes: StringResource,
|
||||
|
|
@ -262,41 +374,48 @@ private fun ThemeChip(
|
|||
theme: AppTheme,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = ThemeColors.getColorPalette(theme)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 1.5.dp,
|
||||
color = palette.focusRing,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.secondary),
|
||||
.size(56.dp)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 1.5.dp,
|
||||
color = palette.focusRing,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.cd_selected),
|
||||
tint = palette.onSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.secondary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.cd_selected),
|
||||
tint = palette.onSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +431,9 @@ private fun ThemeChip(
|
|||
},
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod
|
|||
import com.nuvio.app.features.debrid.DebridProviders
|
||||
import com.nuvio.app.features.debrid.DebridSettings
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.debrid.DebridSettingsStorage
|
||||
import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults
|
||||
import com.nuvio.app.features.debrid.DebridStreamAudioChannel
|
||||
import com.nuvio.app.features.debrid.DebridStreamAudioTag
|
||||
|
|
@ -74,6 +75,9 @@ import com.nuvio.app.features.debrid.DebridStreamVisualTag
|
|||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_clear
|
||||
|
|
@ -1467,12 +1471,21 @@ private fun DebridDeviceAuthDialog(
|
|||
isStarting = true
|
||||
isPolling = false
|
||||
statusMessage = null
|
||||
if (restartNonce == 0) {
|
||||
loadPendingDeviceAuthorization(provider.id)?.let { pendingSession ->
|
||||
session = pendingSession
|
||||
isStarting = false
|
||||
statusMessage = waitingMessage
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
val startResult = runCatching {
|
||||
DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio")
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
session = startResult.getOrNull()
|
||||
session?.let(::savePendingDeviceAuthorization)
|
||||
isStarting = false
|
||||
statusMessage = if (session == null) {
|
||||
startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") }
|
||||
|
|
@ -1504,6 +1517,7 @@ private fun DebridDeviceAuthDialog(
|
|||
isPolling = false
|
||||
when (result) {
|
||||
is DebridDeviceAuthorizationTokenResult.Authorized -> {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
onConnected(result.accessToken)
|
||||
onDismiss()
|
||||
return@LaunchedEffect
|
||||
|
|
@ -1514,16 +1528,19 @@ private fun DebridDeviceAuthDialog(
|
|||
}
|
||||
|
||||
DebridDeviceAuthorizationTokenResult.Expired -> {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
statusMessage = expiredMessage
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
is DebridDeviceAuthorizationTokenResult.Failed -> {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
DebridDeviceAuthorizationTokenResult.Unsupported -> {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
statusMessage = failedMessage
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
|
@ -1621,6 +1638,7 @@ private fun DebridDeviceAuthDialog(
|
|||
if (isConnected) {
|
||||
Button(
|
||||
onClick = {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
onDisconnect()
|
||||
onDismiss()
|
||||
},
|
||||
|
|
@ -1629,7 +1647,12 @@ private fun DebridDeviceAuthDialog(
|
|||
}
|
||||
}
|
||||
if (!isConnected && !isStarting && session == null) {
|
||||
TextButton(onClick = { restartNonce += 1 }) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
clearPendingDeviceAuthorization(provider.id)
|
||||
restartNonce += 1
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
|
|
@ -1649,6 +1672,34 @@ private fun DebridDeviceAuthDialog(
|
|||
}
|
||||
}
|
||||
|
||||
private val debridDeviceAuthorizationJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun loadPendingDeviceAuthorization(providerId: String): DebridDeviceAuthorization? =
|
||||
DebridSettingsStorage.loadPendingDeviceAuthorization(providerId)
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { payload ->
|
||||
runCatching {
|
||||
debridDeviceAuthorizationJson.decodeFromString<DebridDeviceAuthorization>(payload)
|
||||
}.getOrNull()
|
||||
}
|
||||
?.takeIf { it.providerId == providerId }
|
||||
|
||||
private fun savePendingDeviceAuthorization(session: DebridDeviceAuthorization) {
|
||||
DebridSettingsStorage.savePendingDeviceAuthorization(
|
||||
providerId = session.providerId,
|
||||
payload = debridDeviceAuthorizationJson.encodeToString(session),
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearPendingDeviceAuthorization(providerId: String) {
|
||||
DebridSettingsStorage.clearPendingDeviceAuthorization(providerId)
|
||||
}
|
||||
|
||||
private fun Throwable.isCancelledHttpRequest(): Boolean {
|
||||
val text = listOfNotNull(message, toString())
|
||||
.joinToString(" ")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
enum class DesktopNavigationLayout(
|
||||
val labelRes: StringResource,
|
||||
) {
|
||||
Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar),
|
||||
TopBar(Res.string.settings_appearance_desktop_navigation_top_bar),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val Default = Sidebar
|
||||
|
||||
fun fromName(name: String?): DesktopNavigationLayout =
|
||||
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.features.cloud.PremiumizeCloudLibraryPosterUrl
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -50,7 +51,7 @@ import com.nuvio.app.core.ui.NuvioActionLabel
|
|||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
|
||||
import com.nuvio.app.core.ui.nuvioConsumePointerEvents
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon
|
||||
|
|
@ -116,31 +117,38 @@ internal fun TabletPageHeader(
|
|||
onBack: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nuvioBlockPointerPassthrough(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (showBack) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.size(36.dp),
|
||||
shape = tokens.shapes.compactCard,
|
||||
containerColor = tokens.colors.surface,
|
||||
contentColor = tokens.colors.textPrimary,
|
||||
buttonSize = NuvioTokens.Space.s36,
|
||||
iconSize = tokens.icons.md,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.nuvioConsumePointerEvents(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
) {
|
||||
if (showBack) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.size(36.dp),
|
||||
shape = tokens.shapes.compactCard,
|
||||
containerColor = tokens.colors.surface,
|
||||
contentColor = tokens.colors.textPrimary,
|
||||
buttonSize = NuvioTokens.Space.s36,
|
||||
iconSize = tokens.icons.md,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.build.AppVersionConfig
|
||||
import com.nuvio.app.core.build.AppVersionPolicy
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_about_based_on_version_format
|
||||
import nuvio.composeapp.generated.resources.compose_about_made_with
|
||||
import nuvio.composeapp.generated.resources.compose_about_version_format
|
||||
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||
|
|
@ -75,6 +76,8 @@ internal fun LazyListScope.settingsRootContent(
|
|||
onDownloadsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onSwitchProfileClick: (() -> Unit)? = null,
|
||||
showDownloadsEntry: Boolean = true,
|
||||
showNotificationsEntry: Boolean = true,
|
||||
showAccountSection: Boolean = true,
|
||||
showGeneralSection: Boolean = true,
|
||||
showAboutSection: Boolean = true,
|
||||
|
|
@ -138,14 +141,16 @@ internal fun LazyListScope.settingsRootContent(
|
|||
isTablet = isTablet,
|
||||
onClick = onContentDiscoveryClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_root_downloads_title),
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
isTablet = isTablet,
|
||||
onClick = onDownloadsClick,
|
||||
)
|
||||
if (showDownloadsEntry) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_root_downloads_title),
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
isTablet = isTablet,
|
||||
onClick = onDownloadsClick,
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_playback),
|
||||
|
|
@ -170,14 +175,16 @@ internal fun LazyListScope.settingsRootContent(
|
|||
isTablet = isTablet,
|
||||
onClick = onIntegrationsClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_notifications),
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
isTablet = isTablet,
|
||||
onClick = onNotificationsClick,
|
||||
)
|
||||
if (showNotificationsEntry) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_notifications),
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
isTablet = isTablet,
|
||||
onClick = onNotificationsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,14 +259,26 @@ internal fun LazyListScope.settingsRootContent(
|
|||
Text(
|
||||
text = stringResource(
|
||||
Res.string.compose_about_version_format,
|
||||
AppVersionConfig.VERSION_NAME,
|
||||
AppVersionConfig.VERSION_CODE,
|
||||
AppVersionPolicy.displayVersionName,
|
||||
AppVersionPolicy.displayVersionCode,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AppVersionPolicy.basedOnVersionName?.let { basedOnVersionName ->
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.compose_about_based_on_version_format,
|
||||
basedOnVersionName,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,12 @@ fun SettingsScreen(
|
|||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||
val previousPage = page.previousPage()
|
||||
|
||||
LaunchedEffect(page) {
|
||||
if (!page.isEnabledByFeaturePolicy()) {
|
||||
currentPage = SettingsPage.Root.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rootActionRequests, rootActionsEnabled, page) {
|
||||
rootActionRequests.collect {
|
||||
if (!rootActionsEnabled) return@collect
|
||||
|
|
@ -234,7 +240,9 @@ fun SettingsScreen(
|
|||
?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() }
|
||||
?: return@LaunchedEffect
|
||||
if (!rootActionsEnabled) return@LaunchedEffect
|
||||
currentPage = targetPage.name
|
||||
if (targetPage.isEnabledByFeaturePolicy()) {
|
||||
currentPage = targetPage.name
|
||||
}
|
||||
onRequestedPageConsumed()
|
||||
}
|
||||
|
||||
|
|
@ -432,6 +440,8 @@ private fun MobileSettingsScreen(
|
|||
}
|
||||
val searchEntries = settingsSearchEntries(
|
||||
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||
downloadsEnabled = AppFeaturePolicy.downloadsEnabled,
|
||||
notificationsEnabled = AppFeaturePolicy.notificationsEnabled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
switchProfileAvailable = onSwitchProfile != null,
|
||||
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||
|
|
@ -454,7 +464,11 @@ private fun MobileSettingsScreen(
|
|||
SettingsPage.MetaScreen -> onMetaScreenClick()
|
||||
else -> onPageChange(target.page)
|
||||
}
|
||||
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||
SettingsSearchTarget.Downloads -> {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
onDownloadsClick()
|
||||
}
|
||||
}
|
||||
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||
|
|
@ -514,6 +528,8 @@ private fun MobileSettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showDownloadsEntry = AppFeaturePolicy.downloadsEnabled,
|
||||
showNotificationsEntry = AppFeaturePolicy.notificationsEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -564,10 +580,12 @@ private fun MobileSettingsScreen(
|
|||
isTablet = false,
|
||||
rememberLastProfileEnabled = rememberLastProfileEnabled,
|
||||
)
|
||||
SettingsPage.Notifications -> notificationsSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) {
|
||||
notificationsSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
}
|
||||
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
|
||||
isTablet = false,
|
||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
|
|
@ -635,6 +653,13 @@ private fun MobileSettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun SettingsPage.isEnabledByFeaturePolicy(): Boolean =
|
||||
when (this) {
|
||||
SettingsPage.Notifications -> AppFeaturePolicy.notificationsEnabled
|
||||
SettingsPage.Plugins -> AppFeaturePolicy.pluginsEnabled
|
||||
else -> true
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSettingsRootSearchRevealConnection(
|
||||
page: SettingsPage,
|
||||
|
|
@ -795,6 +820,8 @@ private fun TabletSettingsScreen(
|
|||
val hapticScope = rememberCoroutineScope()
|
||||
val searchEntries = settingsSearchEntries(
|
||||
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||
downloadsEnabled = AppFeaturePolicy.downloadsEnabled,
|
||||
notificationsEnabled = AppFeaturePolicy.notificationsEnabled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
switchProfileAvailable = onSwitchProfile != null,
|
||||
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||
|
|
@ -802,8 +829,16 @@ private fun TabletSettingsScreen(
|
|||
|
||||
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||
when (target) {
|
||||
is SettingsSearchTarget.Page -> openInlinePage(target.page)
|
||||
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||
is SettingsSearchTarget.Page -> {
|
||||
if (target.page.isEnabledByFeaturePolicy()) {
|
||||
openInlinePage(target.page)
|
||||
}
|
||||
}
|
||||
SettingsSearchTarget.Downloads -> {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
onDownloadsClick()
|
||||
}
|
||||
}
|
||||
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||
|
|
@ -893,6 +928,8 @@ private fun TabletSettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showDownloadsEntry = AppFeaturePolicy.downloadsEnabled,
|
||||
showNotificationsEntry = AppFeaturePolicy.notificationsEnabled,
|
||||
showAccountSection = activeCategory == SettingsCategory.Account,
|
||||
showGeneralSection = activeCategory == SettingsCategory.General,
|
||||
showAboutSection = activeCategory == SettingsCategory.About,
|
||||
|
|
@ -947,10 +984,12 @@ private fun TabletSettingsScreen(
|
|||
isTablet = true,
|
||||
rememberLastProfileEnabled = rememberLastProfileEnabled,
|
||||
)
|
||||
SettingsPage.Notifications -> notificationsSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) {
|
||||
notificationsSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
}
|
||||
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
|
||||
isTablet = true,
|
||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ internal data class SettingsSearchEntry(
|
|||
@Composable
|
||||
internal fun settingsSearchEntries(
|
||||
pluginsEnabled: Boolean,
|
||||
downloadsEnabled: Boolean,
|
||||
notificationsEnabled: Boolean,
|
||||
liquidGlassNativeTabBarSupported: Boolean,
|
||||
switchProfileAvailable: Boolean,
|
||||
checkForUpdatesAvailable: Boolean,
|
||||
|
|
@ -225,14 +227,16 @@ internal fun settingsSearchEntries(
|
|||
description = stringResource(Res.string.compose_settings_root_content_discovery_description),
|
||||
icon = Icons.Rounded.Extension,
|
||||
)
|
||||
add(
|
||||
key = "downloads",
|
||||
title = downloadsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
category = generalCategory,
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
target = SettingsSearchTarget.Downloads,
|
||||
)
|
||||
if (downloadsEnabled) {
|
||||
add(
|
||||
key = "downloads",
|
||||
title = downloadsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
category = generalCategory,
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
target = SettingsSearchTarget.Downloads,
|
||||
)
|
||||
}
|
||||
addPage(
|
||||
page = SettingsPage.Playback,
|
||||
key = "playback",
|
||||
|
|
@ -254,13 +258,15 @@ internal fun settingsSearchEntries(
|
|||
description = stringResource(Res.string.compose_settings_root_integrations_description),
|
||||
icon = Icons.Rounded.Link,
|
||||
)
|
||||
addPage(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notifications",
|
||||
title = notificationsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
if (notificationsEnabled) {
|
||||
addPage(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notifications",
|
||||
title = notificationsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
}
|
||||
addPage(
|
||||
page = SettingsPage.SupportersContributors,
|
||||
key = "supporters",
|
||||
|
|
@ -463,6 +469,15 @@ internal fun settingsSearchEntries(
|
|||
val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering)
|
||||
val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments)
|
||||
val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode)
|
||||
addRow(
|
||||
page = SettingsPage.Streams,
|
||||
key = "stream-addon-logo",
|
||||
title = stringResource(Res.string.settings_stream_addon_logo_title),
|
||||
description = stringResource(Res.string.settings_stream_addon_logo_description),
|
||||
pageLabel = streamsPage,
|
||||
section = stringResource(Res.string.settings_stream_display_section),
|
||||
icon = Icons.Rounded.Style,
|
||||
)
|
||||
addRow(
|
||||
page = SettingsPage.Streams,
|
||||
key = "stream-size-badges",
|
||||
|
|
@ -785,24 +800,26 @@ internal fun settingsSearchEntries(
|
|||
)
|
||||
}
|
||||
|
||||
val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "episode-release-alerts",
|
||||
title = stringResource(Res.string.settings_notifications_episode_release_alerts),
|
||||
description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
|
||||
pageLabel = notificationsPage,
|
||||
section = notificationsAlerts,
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notification-test",
|
||||
title = stringResource(Res.string.settings_notifications_test_title),
|
||||
pageLabel = notificationsPage,
|
||||
section = stringResource(Res.string.settings_notifications_section_test),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
if (notificationsEnabled) {
|
||||
val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "episode-release-alerts",
|
||||
title = stringResource(Res.string.settings_notifications_episode_release_alerts),
|
||||
description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
|
||||
pageLabel = notificationsPage,
|
||||
section = notificationsAlerts,
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notification-test",
|
||||
title = stringResource(Res.string.settings_notifications_test_title),
|
||||
pageLabel = notificationsPage,
|
||||
section = stringResource(Res.string.settings_notifications_section_test),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
}
|
||||
|
||||
addRow(
|
||||
page = SettingsPage.TraktAuthentication,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title
|
|||
import nuvio.composeapp.generated.resources.settings_stream_badges_section
|
||||
import nuvio.composeapp.generated.resources.settings_stream_size_badges_description
|
||||
import nuvio.composeapp.generated.resources.settings_stream_size_badges_title
|
||||
import nuvio.composeapp.generated.resources.settings_stream_addon_logo_title
|
||||
import nuvio.composeapp.generated.resources.settings_stream_addon_logo_description
|
||||
import nuvio.composeapp.generated.resources.settings_stream_display_section
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) {
|
||||
|
|
@ -129,6 +132,21 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_stream_display_section),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_stream_addon_logo_title),
|
||||
description = stringResource(Res.string.settings_stream_addon_logo_description),
|
||||
checked = currentSettings.showAddonLogo,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = StreamBadgeSettingsRepository::setShowAddonLogo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showBadgeImportDialog) {
|
||||
BadgeUrlManagerDialog(
|
||||
currentRules = currentRules,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ object ThemeSettingsRepository {
|
|||
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
|
||||
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
|
||||
|
||||
private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default)
|
||||
val desktopNavigationLayout: StateFlow<DesktopNavigationLayout> = _desktopNavigationLayout.asStateFlow()
|
||||
|
||||
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
||||
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
||||
|
||||
|
|
@ -36,6 +39,7 @@ object ThemeSettingsRepository {
|
|||
_selectedTheme.value = AppTheme.WHITE
|
||||
_amoledEnabled.value = false
|
||||
_liquidGlassNativeTabBarEnabled.value = false
|
||||
_desktopNavigationLayout.value = DesktopNavigationLayout.Default
|
||||
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||
|
|
@ -59,6 +63,9 @@ object ThemeSettingsRepository {
|
|||
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
||||
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
||||
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
|
||||
_desktopNavigationLayout.value = DesktopNavigationLayout.fromName(
|
||||
ThemeSettingsStorage.loadDesktopNavigationLayout(),
|
||||
)
|
||||
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
|
||||
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
|
||||
_selectedAppLanguage.value = appLanguage
|
||||
|
|
@ -87,6 +94,13 @@ object ThemeSettingsRepository {
|
|||
NativeTabBridge.publishLiquidGlassEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) {
|
||||
ensureLoaded()
|
||||
if (_desktopNavigationLayout.value == layout) return
|
||||
_desktopNavigationLayout.value = layout
|
||||
ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name)
|
||||
}
|
||||
|
||||
fun setAppLanguage(language: AppLanguage) {
|
||||
ensureLoaded()
|
||||
if (_selectedAppLanguage.value == language) return
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue