Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost

This commit is contained in:
Luqman Fadlli 2026-06-12 16:17:26 +07:00 committed by GitHub
commit 83bc15441b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
230 changed files with 19814 additions and 1966 deletions

View file

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

View file

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

View file

@ -0,0 +1,2 @@
VERSION_NAME=0.1.1
VERSION_CODE=2

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.Modifier
internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.core.build
expect object AppVersionPolicy {
val displayVersionName: String
val displayVersionCode: Int
val basedOnVersionName: String?
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.Modifier
internal expect fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier

View file

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

View file

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

View file

@ -421,6 +421,7 @@ object FolderDetailRepository {
items = tab.items,
availableItemCount = tab.items.size,
supportsPagination = tab.supportsPagination,
genre = tab.genre,
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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