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

This commit is contained in:
Luqman Fadlli 2026-06-19 17:20:16 +07:00 committed by GitHub
commit a3e309fef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
258 changed files with 4799 additions and 19939 deletions

View file

@ -32,8 +32,12 @@ jobs:
return labels.includes(name.toLowerCase());
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractSection(title) {
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
server

View file

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

View file

@ -5,13 +5,9 @@ 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
@ -31,10 +27,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
abstract val appVersionCode: Property<Int>
@get:Input
abstract val desktopAppVersionName: Property<String>
abstract val supabaseUrl: Property<String>
@get:Input
abstract val desktopAppVersionCode: Property<Int>
abstract val supabaseAnonKey: Property<String>
@get:Input
abstract val nuvioSupabaseUrl: Property<String>
@get:Input
abstract val nuvioSupabaseAnonKey: Property<String>
@get:Input
abstract val syncBackendManifestUrl: Property<String>
@TaskAction
fun generate() {
@ -49,8 +54,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|package com.nuvio.app.core.network
|
|object SupabaseConfig {
| const val URL = "${props.getProperty("SUPABASE_URL", "")}"
| const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}"
| const val URL = "${supabaseUrl.get()}"
| const val ANON_KEY = "${supabaseAnonKey.get()}"
| const val NUVIO_URL = "${nuvioSupabaseUrl.get()}"
| const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}"
|}
""".trimMargin()
)
resolve("SyncBackendBootstrapConfig.kt").writeText(
"""
|package com.nuvio.app.core.network
|
|object SyncBackendBootstrapConfig {
| const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}"
|}
""".trimMargin()
)
@ -122,8 +138,6 @@ 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()
)
@ -160,23 +174,6 @@ 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)
@ -200,31 +197,6 @@ 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")
@ -241,7 +213,6 @@ 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()
@ -254,362 +225,28 @@ val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName ->
taskName.startsWith("bundlefull") ||
taskName.endsWith("bundle")
}
val runtimeLocalProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
fun runtimeConfigValue(key: String, fallback: String = ""): String =
runtimeLocalProperties.getProperty(key)?.trim()?.takeIf { it.isNotBlank() }
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
?: fallback
val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
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)
}
supabaseUrl.set(runtimeConfigValue("SUPABASE_URL"))
supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY"))
nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL"))
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {
@ -622,12 +259,6 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
val iosTargets = listOf(
iosArm64(),
@ -692,16 +323,6 @@ 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)
@ -730,46 +351,6 @@ 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"))

View file

@ -21,6 +21,8 @@
kotlinx.serialization.KSerializer serializer(...);
}
-keep class com.nuvio.app.features.catalog.CatalogTargetKind { *; }
# Avoid R8 merging/optimizing the stream badge chip used in lazy stream rows.
-keep class com.nuvio.app.features.streams.StreamBadgeChipKt { *; }
-keep class com.nuvio.app.features.streams.StreamBadgeChipSize { *; }

View file

@ -2,8 +2,6 @@ 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

@ -1,7 +0,0 @@
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,6 +26,4 @@ internal object PluginStorage {
internal fun currentPluginPlatform(): String = "android"
internal fun currentPluginPlatformTags(): Set<String> = setOf(currentPluginPlatform())
internal fun currentEpochMillis(): Long = System.currentTimeMillis()

View file

@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.network.SyncBackendStorage
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext)
AuthStorage.initialize(applicationContext)
SyncBackendStorage.initialize(applicationContext)
LibraryStorage.initialize(applicationContext)
WatchedStorage.initialize(applicationContext)
MetaScreenSettingsStorage.initialize(applicationContext)

View file

@ -8,5 +8,4 @@ class AndroidPlatform : Platform {
actual fun getPlatform(): Platform = AndroidPlatform()
internal actual val isIos: Boolean = false
internal actual val isDesktop: Boolean = false
internal actual val isIos: Boolean = false

View file

@ -0,0 +1,58 @@
package com.nuvio.app.core.network
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Proxy
import java.util.concurrent.TimeUnit
internal actual object SyncBackendStorage {
private const val PREFS_NAME = "nuvio_sync_backend"
private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
actual fun loadSelectionPayload(): String? =
preferences?.getString(KEY_SELECTION_PAYLOAD, null)
actual fun saveSelectionPayload(payload: String) {
preferences
?.edit()
?.putString(KEY_SELECTION_PAYLOAD, payload)
?.apply()
}
}
private val syncBackendHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.proxy(Proxy.NO_PROXY)
.build()
internal actual suspend fun fetchSyncBackendManifestText(url: String): String =
withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(url)
.get()
.header("Accept", "application/json")
.build()
syncBackendHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Sync backend manifest request failed with HTTP ${response.code}")
}
response.body?.string()?.takeIf { it.isNotBlank() }
?: error("Sync backend manifest response was empty")
}
}

View file

@ -1,50 +0,0 @@
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

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

View file

@ -20,6 +20,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkManager
import com.nuvio.app.core.storage.ProfileScopedKey
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
@ -171,7 +172,7 @@ internal actual object EpisodeReleaseNotificationPlatform {
preferences(context)
.edit()
.putStringSet(scheduledIdsKey, scheduledIds.toSet())
.putStringSet(scopedScheduledIdsKey(), scheduledIds.toSet())
.apply()
}
}
@ -183,7 +184,7 @@ internal actual object EpisodeReleaseNotificationPlatform {
cancelTrackedWork(workManager)
preferences(context)
.edit()
.remove(scheduledIdsKey)
.remove(scopedScheduledIdsKey())
.apply()
}
}
@ -255,7 +256,7 @@ internal actual object EpisodeReleaseNotificationPlatform {
private fun cancelTrackedWork(workManager: WorkManager) {
val context = appContext ?: return
preferences(context)
.getStringSet(scheduledIdsKey, emptySet())
.getStringSet(scopedScheduledIdsKey(), emptySet())
.orEmpty()
.forEach { requestId ->
awaitOperation(workManager.cancelUniqueWork(uniqueWorkName(requestId)))
@ -269,6 +270,8 @@ internal actual object EpisodeReleaseNotificationPlatform {
private fun preferences(context: Context) =
context.getSharedPreferences(platformPreferencesName, Context.MODE_PRIVATE)
private fun scopedScheduledIdsKey(): String = ProfileScopedKey.of(scheduledIdsKey)
private fun triggerAtEpochMs(releaseDateIso: String): Long? = runCatching {
LocalDate.parse(releaseDateIso)
.atTime(EpisodeReleaseNotificationHour, EpisodeReleaseNotificationMinute)

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.p2p
import android.content.Context
import android.util.Log
import com.nuvio.app.core.i18n.localizedP2pUnknownTorrentError
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -93,7 +94,7 @@ actual object P2pStreamingEngine {
throw e
} catch (e: Exception) {
if (isCurrentGeneration(generation)) {
_state.value = P2pStreamingState.Error(e.message ?: "Unknown torrent error")
_state.value = P2pStreamingState.Error(e.message ?: localizedP2pUnknownTorrentError())
}
throw e
}

View file

@ -2,16 +2,20 @@ package com.nuvio.app.features.player
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
internal fun playbackMediaItemFromUrl(
url: String,
responseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
): MediaItem {
val builder = MediaItem.Builder().setUri(url)
inferPlaybackMimeType(
url = url,
responseHeaders = responseHeaders,
streamType = streamType,
)?.let(builder::setMimeType)
return builder.build()
}
@ -19,10 +23,26 @@ internal fun playbackMediaItemFromUrl(
private fun inferPlaybackMimeType(
url: String,
responseHeaders: Map<String, String>,
streamType: String?,
): String? =
inferMimeTypeFromResponseHeaders(responseHeaders)
inferMimeTypeFromStreamType(streamType)
?: inferMimeTypeFromResponseHeaders(responseHeaders)
?: inferMimeTypeFromPath(url)
private fun inferMimeTypeFromStreamType(streamType: String?): String? {
val normalized = streamType
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotBlank() }
?: return null
return when (normalized) {
"hls", "m3u8" -> MimeTypes.APPLICATION_M3U8
"dash", "mpd" -> MimeTypes.APPLICATION_MPD
"smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS
else -> null
}
}
private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): String? {
if (headers.isEmpty()) return null
@ -50,7 +70,7 @@ private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): Stri
return inferMimeTypeFromPath(filename)
}
private fun normalizeMimeType(contentType: String?): String? {
internal fun normalizeMimeType(contentType: String?): String? {
val normalized = contentType
?.substringBefore(';')
?.trim()
@ -169,12 +189,44 @@ private fun inferMimeTypeFromQuery(query: String): String? {
private fun inferMimeTypeFromDelimitedToken(value: String): String? =
when {
DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_HLS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD
DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS
else -> null
}
internal fun probeMimeType(url: String, headers: Map<String, String>): String? {
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 3_000
readTimeout = 3_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "Mozilla/5.0")
setRequestProperty("Accept", "*/*")
headers.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
val responseCode = connection.responseCode
if (responseCode in 200..299) {
val contentType = connection.contentType
normalizeMimeType(contentType)
} else null
} finally {
connection.disconnect()
}
}.getOrNull()?.let { return it }
}
return null
}
private const val MIME_VIDEO_QUICK_TIME = "video/quicktime"
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&-])m3u8($|[=/_.?&-])")
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&-])mpd($|[=/_.?&-])")
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&-])(ism|isml)($|[=/_.?&-])")
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&%-])m3u8($|[=/_.?&%-])")
private val DELIMITED_HLS_PATTERN = Regex("(^|[=/_.?&%-])hls($|[=/_.?&%-])")
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&%-])mpd($|[=/_.?&%-])")
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&%-])(ism|isml)($|[=/_.?&%-])")

View file

@ -60,6 +60,7 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.CaptionStyleCompat
import com.nuvio.app.R
import com.nuvio.app.features.streams.normalizeStreamType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
@ -81,17 +82,12 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
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,
@ -113,6 +109,9 @@ actual fun PlatformPlayerSurface(
val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) {
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
}
val normalizedStreamType = remember(streamType) {
normalizeStreamType(streamType)
}
val useLibass = playerSettings.useLibass
val libassRenderType = runCatching {
LibassRenderType.valueOf(playerSettings.libassRenderType)
@ -122,6 +121,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl.orEmpty(),
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType.orEmpty(),
useYoutubeChunkedPlayback,
)
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
@ -133,6 +133,17 @@ actual fun PlatformPlayerSurface(
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val volumeBoostAudioProcessor = remember(playerSourceKey) { VolumeBoostAudioProcessor() }
val initialMediaItem = remember(playerSourceKey) {
playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
)
}
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
var probeAttempted by remember(playerSourceKey) { mutableStateOf(false) }
val extractorsFactory = remember {
DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
@ -175,6 +186,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl,
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType,
useYoutubeChunkedPlayback,
effectiveDecoderPriority,
) {
@ -235,17 +247,13 @@ actual fun PlatformPlayerSurface(
.build()
}
player.apply {
setPlaybackMediaItem(
videoMediaItem = playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
),
startPositionMs = fallbackStartPositionMs,
)
prepare()
this.playWhenReady = playWhenReady
}
player
}
LaunchedEffect(exoPlayer, resolvedMediaItem) {
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
exoPlayer.prepare()
}
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
@ -270,25 +278,54 @@ actual fun PlatformPlayerSurface(
exoPlayer.pause()
}
fun reportPlayerError(error: PlaybackException) {
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
}
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
syncPlayerViewKeepScreenOn()
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ||
error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED ||
error.cause?.toString()?.contains("UnrecognizedInputFormatException") == true
if (isSourceError && !probeAttempted) {
probeAttempted = true
coroutineScope.launch {
val probedMime = withContext(Dispatchers.IO) {
probeMimeType(sourceUrl, sanitizedSourceHeaders)
}
if (probedMime != null) {
Log.d(TAG, "Playback failed with source error. Probed MIME type: $probedMime. Retrying...")
resolvedMediaItem = MediaItem.Builder()
.setUri(sourceUrl)
.setMimeType(probedMime)
.build()
latestOnError.value(null)
return@launch
}
reportPlayerError(error)
}
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
reportPlayerError(error)
}
override fun onPlaybackStateChanged(playbackState: Int) {

View file

@ -23,6 +23,7 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value"
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
private const val externalPlayerIdKey = "external_player_id"
@ -85,6 +86,7 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
touchGesturesEnabledKey,
externalPlayerEnabledKey,
externalPlayerForwardSubtitlesKey,
externalPlayerIdKey,
@ -209,6 +211,23 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadTouchGesturesEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(touchGesturesEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, true)
} else {
null
}
}
actual fun saveTouchGesturesEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(touchGesturesEnabledKey), enabled)
?.apply()
}
actual fun loadExternalPlayerEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(externalPlayerEnabledKey)
@ -958,6 +977,7 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) }
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
@ -1024,6 +1044,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled)
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles)
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)

View file

@ -18,13 +18,11 @@ 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
@ -70,16 +68,6 @@ 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
@ -105,7 +93,6 @@ 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) {
@ -116,7 +103,6 @@ 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

@ -2,8 +2,6 @@ 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

@ -1,7 +0,0 @@
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

@ -355,7 +355,7 @@
<string name="settings_continue_watching_resume_prompt_title">Προτροπή συνέχισης κατά την εκκίνηση</string>
<string name="settings_continue_watching_section_card_style">ΣΤΥΛ ΚΑΡΤΑΣ</string>
<string name="settings_continue_watching_section_on_launch">ΚΑΤΑ ΤΗΝ ΕΚΚΙΝΗΣΗ</string>
<string name="settings_continue_watching_section_up_next_behavior">ΣΥΜΠΕΡΙΦΟΡΑ UP NEXT</string>
<string name="settings_continue_watching_section_up_next_behavior">ΣΥΜΠΕΡΙΦΟΡΑ ΕΠΟΜΕΝΟΥ</string>
<string name="settings_continue_watching_section_visibility">ΟΡΑΤΟΤΗΤΑ</string>
<string name="settings_continue_watching_show_description">Εμφάνιση της ενότητας Συνέχεια παρακολούθησης στην Αρχική οθόνη.</string>
<string name="settings_continue_watching_show_title">Εμφάνιση συνέχειας παρακολούθησης</string>
@ -363,8 +363,8 @@
<string name="settings_continue_watching_style_poster_description">Κάρτα αφίσας με προτεραιότητα στο artwork</string>
<string name="settings_continue_watching_style_wide">Πλατύ</string>
<string name="settings_continue_watching_style_wide_description">Οριζόντια κάρτα με πλούσιες πληροφορίες</string>
<string name="settings_continue_watching_up_next_description">Όταν είναι ενεργοποιημένο, το Up Next συνεχίζει πάντα από το πιο απομακρυσμένο παρακολουθημένο επεισόδιο. Όταν είναι απενεργοποιημένο, ακολουθεί το πιο πρόσφατα παρακολουθηθέν επεισόδιο. Χρήσιμο αν ξαναβλέπετε παλαιότερα επεισόδια.</string>
<string name="settings_continue_watching_up_next_title">Up Next από το πιο απομακρυσμένο επεισόδιο</string>
<string name="settings_continue_watching_up_next_description">Εμφάνιση επόμενου επεισοδίου με βάση το πιο προχωρημένο προβληθέν επεισόδιο. Απενεργοποιήστε το για επαναπροβολές ώστε να χρησιμοποιείται το πιο πρόσφατα προβληθέν επεισόδιο.</string>
<string name="settings_continue_watching_up_next_title">Επόμενο από το πιο προχωρημένο επεισόδιο</string>
<string name="settings_content_discovery_section_home">ΑΡΧΙΚΗ</string>
<string name="settings_content_discovery_section_sources">ΠΗΓΕΣ</string>
<string name="settings_content_discovery_addons_description">Εγκατάσταση, αφαίρεση, ανανέωση και ταξινόμηση των πηγών περιεχομένου σας.</string>

View file

@ -300,7 +300,6 @@
<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>
@ -345,6 +344,8 @@
<string name="compose_player_font_size_value">%1$dsp</string>
<string name="compose_player_lock_controls">Lock player controls</string>
<string name="compose_player_loading_lines">Loading subtitle lines...</string>
<string name="compose_player_no_subtitle_lines_found">No subtitle lines found</string>
<string name="compose_player_subtitle_lines_load_error">Unable to load subtitle lines</string>
<string name="compose_player_no_audio_tracks_available">No audio tracks available</string>
<string name="compose_player_no_episodes_available">No episodes available</string>
<string name="compose_player_no_streams_found">No streams found</string>
@ -450,7 +451,7 @@
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached thumbnails, titles, and enrichment data for Continue Watching</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string>
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
<string name="settings_licenses_attributions_section_data">DATA &amp; SERVICES</string>
@ -531,15 +532,12 @@
<string name="settings_account_status">Status</string>
<string name="settings_account_status_anonymous">Anonymous</string>
<string name="settings_account_status_signed_in">Signed in</string>
<string name="settings_account_sync_backend">Sync backend</string>
<string name="settings_appearance_amoled_black">AMOLED Black</string>
<string name="settings_appearance_amoled_description">Use pure black backgrounds for OLED screens.</string>
<string name="settings_appearance_app_language">App Language</string>
<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>
@ -615,7 +613,7 @@
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
<string name="settings_continue_watching_section_up_next_behavior">NEXT UP BEHAVIOR</string>
<string name="settings_continue_watching_section_visibility">VISIBILITY</string>
<string name="settings_continue_watching_show_description">Display the Continue Watching shelf on the Home screen.</string>
<string name="settings_continue_watching_show_title">Show Continue Watching</string>
@ -626,7 +624,7 @@
<string name="settings_continue_watching_style_wide">Wide</string>
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
<string name="settings_continue_watching_up_next_title">Next Up From Furthest Episode</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Prefer episode thumbnails when available.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Prefer Episode Thumbnails in Continue Watching</string>
<string name="settings_content_discovery_section_home">HOME</string>
@ -698,6 +696,10 @@
<string name="settings_stream_badge_urls_title">Fusion badge URLs</string>
<string name="settings_stream_badge_urls_description">Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately.</string>
<string name="settings_stream_badge_urls_search_description">Manage imported Fusion-style stream badge JSON URLs.</string>
<string name="settings_stream_badge_import_failed">Badge import failed.</string>
<string name="settings_stream_badge_enter_url">Enter a badge JSON URL.</string>
<string name="settings_stream_badge_url_scheme_invalid">Badge URL must start with http:// or https://.</string>
<string name="settings_stream_badge_import_limit">You can import up to %1$d badge URLs.</string>
<string name="settings_fusion_badges_summary">%1$d/%2$d URLs, %3$d active Fusion badges</string>
<string name="settings_fusion_badges_empty">No Fusion badge URLs imported.</string>
<string name="settings_fusion_badge_url_label">Fusion badge JSON URL</string>
@ -775,6 +777,10 @@
<string name="settings_notifications_test_title">Test notification</string>
<string name="community_section_title">Community</string>
<string name="community_section_description">See the people building and supporting Nuvio across Mobile, TV, and Web.</string>
<string name="community_donation_progress_title">This month&apos;s server &amp; maintenance</string>
<string name="community_donation_progress_complete">Covered. Additional support now goes to development.</string>
<string name="community_donation_progress_remaining">After 100%%, additional support goes to development.</string>
<string name="community_loading_donation_progress">Loading funding progress...</string>
<string name="community_supporters_not_configured">Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.</string>
<string name="community_tab_contributors">Contributors</string>
<string name="community_tab_supporters">Supporters</string>
@ -845,6 +851,8 @@
<string name="settings_playback_hold_speed">Hold Speed</string>
<string name="settings_playback_hold_to_speed">Hold To Speed</string>
<string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string>
<string name="settings_playback_touch_gestures">Touch Gestures</string>
<string name="settings_playback_touch_gestures_description">Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust volume.</string>
<string name="settings_playback_invalid_regex_pattern">Invalid regex pattern</string>
<string name="settings_playback_last_link_cache_duration">Last Link Cache Duration</string>
<string name="settings_playback_map_dv7_to_hevc">DV7 - HEVC Fallback</string>
@ -1149,6 +1157,7 @@
<string name="app_exit_title">Exit app</string>
<string name="catalog_empty_message">This catalog did not return any items.</string>
<string name="catalog_empty_title">No titles found</string>
<string name="details_actions_menu_label">More actions</string>
<string name="details_check_connection">Check your Wi-Fi or mobile data connection and try again.</string>
<string name="details_director">Director</string>
<string name="details_failed_to_load">Failed to load</string>
@ -1184,7 +1193,7 @@
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
<string name="episode_mark_unwatched">Mark as unwatched</string>
<string name="episode_mark_watched">Mark as watched</string>
<string name="home_continue_watching_up_next">Up next</string>
<string name="home_continue_watching_up_next">Next Up</string>
<string name="home_continue_watching_watched">%1$s watched</string>
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm left</string>
<string name="home_continue_watching_minutes_left">%1$dm left</string>
@ -1331,8 +1340,8 @@
<string name="auth_sign_out_failed">Sign-out failed</string>
<string name="auth_sign_up_failed">Sign-up failed</string>
<string name="catalog_load_failed">Unable to load catalog items.</string>
<string name="continue_watching_up_next">Up Next</string>
<string name="continue_watching_up_next_episode">Up Next • S%1$dE%2$d</string>
<string name="continue_watching_up_next">Next Up</string>
<string name="continue_watching_up_next_episode">Next Up • S%1$dE%2$d</string>
<string name="detail_logo_content_description">%1$s logo</string>
<string name="details_comments_load_failed">Failed to load comments</string>
<string name="details_load_failed_all_addons">Could not load details from any addon.</string>
@ -1392,6 +1401,16 @@
<string name="trakt_sign_in_complete_failed">Failed to complete Trakt sign in</string>
<string name="trakt_user_fallback">Trakt user</string>
<string name="trakt_watchlist">Watchlist</string>
<!-- Trakt library sync errors -->
<string name="trakt_error_authorization_expired">Trakt authorization expired</string>
<string name="trakt_error_list_not_found">Trakt list not found</string>
<string name="trakt_error_list_limit_reached">Trakt list limit reached</string>
<string name="trakt_error_rate_limit_reached">Trakt rate limit reached</string>
<string name="trakt_error_request_failed">Trakt request failed</string>
<string name="trakt_error_add_watchlist_failed">Failed to add to Trakt watchlist</string>
<string name="trakt_error_add_list_failed">Failed to add to Trakt list</string>
<string name="trakt_error_missing_ids">Missing compatible Trakt IDs</string>
<string name="trakt_error_empty_response">Empty response body</string>
<string name="tmdb_sources_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
<string name="tmdb_sources_collection_fallback_title">TMDB Collection %1$d</string>
<string name="tmdb_sources_collection_not_found">TMDB collection not found</string>
@ -1865,6 +1884,7 @@
<string name="p2p_consent_body">This stream uses peer-to-peer (P2P) technology. By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You confirm you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears no liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P can be disabled anytime in Settings.</string>
<string name="p2p_consent_enable">Enable P2P</string>
<string name="p2p_consent_cancel">Cancel</string>
<string name="p2p_error_unknown">Unknown torrent error</string>
<string name="settings_p2p_title">P2P Streaming</string>
<string name="settings_p2p_subtitle">Allow peer-to-peer (torrent) streams</string>
<string name="settings_p2p_hide_stats_title">Hide torrent stats</string>

File diff suppressed because it is too large Load diff

View file

@ -6,5 +6,4 @@ interface Platform {
expect fun getPlatform(): Platform
internal expect val isIos: Boolean
internal expect val isDesktop: Boolean
internal expect val isIos: Boolean

View file

@ -2,6 +2,7 @@ package com.nuvio.app.core.auth
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.storage.LocalAccountDataCleaner
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email
@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@ -35,35 +37,42 @@ object AuthRepository {
if (initialized) return
initialized = true
val savedAnonId = AuthStorage.loadAnonymousUserId()
if (savedAnonId != null) {
_state.value = AuthState.Authenticated(
userId = savedAnonId,
email = null,
isAnonymous = true,
)
}
scope.launch {
SupabaseProvider.client.auth.sessionStatus.collect { status ->
if (AuthStorage.loadAnonymousUserId() != null) return@collect
when (status) {
is SessionStatus.Authenticated -> {
val user = status.session.user
_state.value = AuthState.Authenticated(
userId = user?.id ?: "",
email = user?.email,
isAnonymous = false,
)
}
is SessionStatus.NotAuthenticated -> {
_state.value = AuthState.Unauthenticated
}
is SessionStatus.Initializing -> {
if (savedAnonId == null) _state.value = AuthState.Loading
}
is SessionStatus.RefreshFailure -> {
_state.value = AuthState.Unauthenticated
SyncBackendRepository.state.collectLatest { backendState ->
if (!backendState.isLoaded) return@collectLatest
AuthStorage.loadAnonymousUserId()?.let { savedAnonId ->
_state.value = AuthState.Authenticated(
userId = savedAnonId,
email = null,
isAnonymous = true,
)
} ?: run {
_state.value = AuthState.Loading
}
SupabaseProvider.client.auth.sessionStatus.collect { status ->
if (AuthStorage.loadAnonymousUserId() != null) return@collect
when (status) {
is SessionStatus.Authenticated -> {
val user = status.session.user
_state.value = AuthState.Authenticated(
userId = user?.id ?: "",
email = user?.email,
isAnonymous = false,
)
}
is SessionStatus.NotAuthenticated -> {
_state.value = AuthState.Unauthenticated
}
is SessionStatus.Initializing -> {
if (AuthStorage.loadAnonymousUserId() == null) {
_state.value = AuthState.Loading
}
}
is SessionStatus.RefreshFailure -> {
_state.value = AuthState.Unauthenticated
}
}
}
}
@ -119,6 +128,26 @@ object AuthRepository {
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun resetForSyncBackendChange(): Result<Unit> = runCatching {
_error.value = null
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
AuthStorage.clearAnonymousUserId()
if (!wasAnonymous) {
runCatching {
SupabaseProvider.client.auth.signOut()
}.onFailure { e ->
log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" }
}
}
_state.value = AuthState.Unauthenticated
LocalAccountDataCleaner.wipe()
}.onFailure { e ->
log.e(e) { "Sync backend auth reset failed" }
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun deleteAccount(): Result<Unit> = runCatching {
_error.value = null
SupabaseProvider.client.functions.invoke("delete-account")

View file

@ -7,8 +7,6 @@ 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

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

View file

@ -8,6 +8,8 @@ import nuvio.composeapp.generated.resources.action_resume
import nuvio.composeapp.generated.resources.action_resume_episode
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found
import nuvio.composeapp.generated.resources.compose_player_subtitle_lines_load_error
import nuvio.composeapp.generated.resources.continue_watching_up_next
import nuvio.composeapp.generated.resources.continue_watching_up_next_episode
import nuvio.composeapp.generated.resources.date_month_april
@ -40,6 +42,11 @@ import nuvio.composeapp.generated.resources.media_movie
import nuvio.composeapp.generated.resources.media_movies
import nuvio.composeapp.generated.resources.media_series
import nuvio.composeapp.generated.resources.media_tv
import nuvio.composeapp.generated.resources.p2p_error_unknown
import nuvio.composeapp.generated.resources.settings_stream_badge_enter_url
import nuvio.composeapp.generated.resources.settings_stream_badge_import_failed
import nuvio.composeapp.generated.resources.settings_stream_badge_import_limit
import nuvio.composeapp.generated.resources.settings_stream_badge_url_scheme_invalid
import nuvio.composeapp.generated.resources.unit_bytes_b
import nuvio.composeapp.generated.resources.unit_bytes_gb
import nuvio.composeapp.generated.resources.unit_bytes_kb
@ -93,11 +100,11 @@ fun localizedResumeLabel(seasonNumber: Int?, episodeNumber: Int?): String {
fun localizedUpNextLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
resourceString("Up Next • S${seasonNumber}E${episodeNumber}") {
resourceString("Next Up • S${seasonNumber}E${episodeNumber}") {
getString(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber)
}
} else {
resourceString("Up Next") { getString(Res.string.continue_watching_up_next) }
resourceString("Next Up") { getString(Res.string.continue_watching_up_next) }
}
fun localizedMonthName(month: Int): String =
@ -134,6 +141,27 @@ fun localizedShortMonthName(month: Int): String =
else -> month.toString()
}
fun localizedNoSubtitleLinesFound(): String =
resourceString("No subtitle lines found") { getString(Res.string.compose_player_no_subtitle_lines_found) }
fun localizedSubtitleLinesLoadError(): String =
resourceString("Unable to load subtitle lines") { getString(Res.string.compose_player_subtitle_lines_load_error) }
fun localizedBadgeImportFailed(): String =
resourceString("Badge import failed.") { getString(Res.string.settings_stream_badge_import_failed) }
fun localizedBadgeEnterUrl(): String =
resourceString("Enter a badge JSON URL.") { getString(Res.string.settings_stream_badge_enter_url) }
fun localizedBadgeUrlSchemeInvalid(): String =
resourceString("Badge URL must start with http:// or https://.") { getString(Res.string.settings_stream_badge_url_scheme_invalid) }
fun localizedBadgeImportLimit(limit: Int): String =
resourceString("You can import up to $limit badge URLs.") { getString(Res.string.settings_stream_badge_import_limit, limit) }
fun localizedP2pUnknownTorrentError(): String =
resourceString("Unknown torrent error") { getString(Res.string.p2p_error_unknown) }
fun localizedByteUnit(unit: String): String =
when (unit) {
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }

View file

@ -136,9 +136,10 @@ object NetworkStatusRepository {
return NetworkCondition.NoInternet
}
val backendConfig = SyncBackendRepository.selectedBackend
val supabaseReachable = probeReachable(
url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/",
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/",
headers = mapOf("apikey" to backendConfig.anonKey),
)
if (!supabaseReachable) {
return NetworkCondition.ServersUnreachable

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.network
import com.nuvio.app.core.build.AppVersionConfig
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.annotations.SupabaseInternal
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
@ -10,12 +11,34 @@ import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders
object SupabaseProvider {
private data class ClientHolder(
val backend: SyncBackendConfig,
val client: SupabaseClient,
)
private var holder: ClientHolder? = null
val selectedBackend: SyncBackendConfig
get() = SyncBackendRepository.selectedBackend
@OptIn(SupabaseInternal::class)
val client by lazy {
val client: SupabaseClient
get() = clientFor(selectedBackend)
fun rebuildClient() {
holder = null
}
@OptIn(SupabaseInternal::class)
private fun clientFor(config: SyncBackendConfig): SupabaseClient {
holder
?.takeIf { it.backend.hasSameConnectionIdentity(config) }
?.let { return it.client }
val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}"
createSupabaseClient(
supabaseUrl = SupabaseConfig.URL,
supabaseKey = SupabaseConfig.ANON_KEY,
val nextClient = createSupabaseClient(
supabaseUrl = config.normalizedSupabaseUrl,
supabaseKey = config.anonKey,
) {
httpConfig {
defaultRequest {
@ -26,5 +49,7 @@ object SupabaseProvider {
install(Postgrest)
install(Functions)
}
holder = ClientHolder(backend = config, client = nextClient)
return nextClient
}
}

View file

@ -0,0 +1,121 @@
package com.nuvio.app.core.network
import kotlinx.serialization.Serializable
internal const val SYNC_BACKEND_HOSTED_ID = "hosted"
internal const val SYNC_BACKEND_NUVIO_ID = "nuvio"
@Serializable
data class SyncBackendConfig(
val id: String,
val displayName: String,
val supabaseUrl: String,
val anonKey: String,
val avatarPublicBaseUrl: String,
val schemaVersion: Int = 1,
) {
val normalizedSupabaseUrl: String
get() = supabaseUrl.trim().trimEnd('/')
val normalizedAvatarPublicBaseUrl: String
get() = avatarPublicBaseUrl.trim().trimEnd('/')
fun avatarStorageUrl(storagePath: String): String =
"${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}"
fun normalized(): SyncBackendConfig =
copy(
id = id.trim().lowercase(),
supabaseUrl = normalizedSupabaseUrl,
anonKey = anonKey.trim(),
avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl,
)
}
@Serializable
data class SyncBackendManifest(
val version: Int = 1,
val activeBackend: String,
val revision: String = "",
val forceLogoutOnChange: Boolean = true,
)
data class SyncBackendState(
val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(),
val appliedRevision: String = "",
val isLoaded: Boolean = false,
val lastManifestError: String? = null,
)
sealed interface SyncBackendRefreshResult {
data object NotConfigured : SyncBackendRefreshResult
data object Unchanged : SyncBackendRefreshResult
data class Applied(
val backend: SyncBackendConfig,
val revision: String,
) : SyncBackendRefreshResult
data class RequiresLogout(
val currentBackend: SyncBackendConfig,
val targetBackend: SyncBackendConfig,
val revision: String,
val forceLogout: Boolean,
) : SyncBackendRefreshResult
data class Failed(val message: String) : SyncBackendRefreshResult
}
@Serializable
internal data class StoredSyncBackendSelection(
val backend: SyncBackendConfig? = null,
val backendId: String = "",
val appliedRevision: String = "",
)
object SyncBackendDefaults {
fun hosted(): SyncBackendConfig =
SyncBackendConfig(
id = SYNC_BACKEND_HOSTED_ID,
displayName = "Hosted",
supabaseUrl = SupabaseConfig.URL,
anonKey = SupabaseConfig.ANON_KEY,
avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
schemaVersion = 1,
).normalized()
fun nuvio(): SyncBackendConfig =
SyncBackendConfig(
id = SYNC_BACKEND_NUVIO_ID,
displayName = "Nuvio",
supabaseUrl = SupabaseConfig.NUVIO_URL,
anonKey = SupabaseConfig.NUVIO_ANON_KEY,
avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
schemaVersion = 1,
).normalized()
fun byId(id: String): SyncBackendConfig? =
when (id.trim().lowercase()) {
SYNC_BACKEND_HOSTED_ID -> hosted()
SYNC_BACKEND_NUVIO_ID -> nuvio()
else -> null
}
}
internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean =
id == other.id &&
normalizedSupabaseUrl == other.normalizedSupabaseUrl &&
anonKey.trim() == other.anonKey.trim() &&
schemaVersion == other.schemaVersion
internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? {
if (version != 1) return null
val activeId = activeBackend.trim().lowercase()
return SyncBackendDefaults.byId(activeId)
?.takeIf { it.isUsableClientConfig() }
}
private fun SyncBackendConfig.isUsableClientConfig(): Boolean =
id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) &&
normalizedSupabaseUrl.startsWith("https://") &&
anonKey.isNotBlank() &&
!anonKey.startsWith("<") &&
normalizedAvatarPublicBaseUrl.startsWith("https://")

View file

@ -0,0 +1,122 @@
package com.nuvio.app.core.network
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SyncBackendRepository {
private val log = Logger.withTag("SyncBackendRepository")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _state = MutableStateFlow(SyncBackendState())
val state: StateFlow<SyncBackendState> = _state.asStateFlow()
val selectedBackend: SyncBackendConfig
get() {
ensureLoaded()
return _state.value.selectedBackend
}
fun ensureLoaded() {
if (_state.value.isLoaded) return
val storedSelection = SyncBackendStorage.loadSelectionPayload()
?.takeIf { it.isNotBlank() }
?.let { payload ->
runCatching { json.decodeFromString<StoredSyncBackendSelection>(payload) }
.onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } }
.getOrNull()
}
val backend = storedSelection
?.let { selection ->
selection.backendId.ifBlank { selection.backend?.id.orEmpty() }
}
?.let(SyncBackendDefaults::byId)
?: SyncBackendDefaults.hosted()
_state.value = SyncBackendState(
selectedBackend = backend,
appliedRevision = storedSelection?.appliedRevision.orEmpty(),
isLoaded = true,
)
}
suspend fun refreshFromManifest(): SyncBackendRefreshResult {
ensureLoaded()
val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim()
if (manifestUrl.isBlank()) {
return SyncBackendRefreshResult.NotConfigured
}
val manifest = runCatching {
json.decodeFromString<SyncBackendManifest>(
fetchSyncBackendManifestText(manifestUrl),
)
}.onFailure { error ->
val message = error.message ?: "Failed to fetch sync backend manifest"
log.w(error) { message }
_state.value = _state.value.copy(lastManifestError = message)
}.getOrNull() ?: return SyncBackendRefreshResult.Failed(
_state.value.lastManifestError ?: "Failed to fetch sync backend manifest",
)
val targetBackend = manifest.backendConfigForActiveBackend()
?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid")
val revision = manifest.revision.trim()
val currentBackend = _state.value.selectedBackend
if (currentBackend.hasSameConnectionIdentity(targetBackend)) {
saveSelection(targetBackend, revision)
return SyncBackendRefreshResult.Unchanged
}
if (!manifest.forceLogoutOnChange) {
saveSelection(targetBackend, revision)
return SyncBackendRefreshResult.Applied(targetBackend, revision)
}
return SyncBackendRefreshResult.RequiresLogout(
currentBackend = currentBackend,
targetBackend = targetBackend,
revision = revision,
forceLogout = true,
)
}
fun applyBackendAfterLogout(
backend: SyncBackendConfig,
revision: String,
): SyncBackendConfig {
val normalizedBackend = backend.normalized()
saveSelection(normalizedBackend, revision)
return normalizedBackend
}
private fun saveSelection(
backend: SyncBackendConfig,
revision: String,
) {
val normalizedBackend = backend.normalized()
val payload = json.encodeToString(
StoredSyncBackendSelection(
backendId = normalizedBackend.id,
appliedRevision = revision,
),
)
SyncBackendStorage.saveSelectionPayload(payload)
_state.value = SyncBackendState(
selectedBackend = normalizedBackend,
appliedRevision = revision,
isLoaded = true,
)
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.network
internal expect object SyncBackendStorage {
fun loadSelectionPayload(): String?
fun saveSelectionPayload(payload: String)
}
internal expect suspend fun fetchSyncBackendManifestText(url: String): String

View file

@ -5,4 +5,5 @@ import com.nuvio.app.features.profiles.ProfileRepository
object ProfileScopedKey {
fun of(baseKey: String): String = "${baseKey}_${ProfileRepository.activeProfileId}"
fun of(baseKey: String, profileId: Int): String = "${baseKey}_$profileId"
}

View file

@ -1,7 +1,6 @@
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
@ -79,17 +78,8 @@ 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()
@ -99,15 +89,27 @@ object ProfileSettingsSync {
suspend fun pull(profileId: Int): Boolean {
ensureRepositoriesLoaded()
return syncMutex.withLock {
if (ProfileRepository.activeProfileId != profileId) {
log.d { "pull(profileId=$profileId) — skipped because profile is no longer active" }
return@withLock false
}
isServerSyncInFlight = true
try {
val remoteJson = fetchRemoteSettingsJson(profileId)
val localBlob = exportSettingsBlob()
if (ProfileRepository.activeProfileId != profileId) return@withLock false
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)
if (ProfileRepository.activeProfileId != profileId) return@withLock false
val response = result.decodeList<SettingsBlobResponse>().firstOrNull()
val remoteJson = response?.settingsJson
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)
}
@ -123,17 +125,14 @@ 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(profileId, remoteBlob)
if (ProfileRepository.activeProfileId != profileId) return@withLock false
applyRemoteBlob(remoteBlob)
skipNextPushSignature = currentObservedStateSignature()
} finally {
isApplyingRemoteBlob = false
@ -155,7 +154,9 @@ object ProfileSettingsSync {
syncMutex.withLock {
runCatching {
val profileId = ProfileRepository.activeProfileId
pushToRemoteLocked(profileId, exportSettingsBlob(profileId))
val blob = exportSettingsBlob()
if (ProfileRepository.activeProfileId != profileId) return@runCatching
pushToRemoteLocked(profileId, blob)
}.onFailure { error ->
log.e(error) { "pushCurrentProfileToRemote() — FAILED" }
}
@ -164,25 +165,23 @@ object ProfileSettingsSync {
@OptIn(FlowPreview::class)
private fun observeLocalChangesAndPush() {
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" })
}
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" },
)
observeJob = scope.launch {
combine(signatureFlows) { currentObservedStateSignature() }
@ -203,23 +202,22 @@ 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(), blobToPush))
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob))
}
SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params)
log.d { "pushToRemoteLocked(profileId=$profileId) — success" }
}
private fun exportSettingsBlob(profileId: Int = ProfileRepository.activeProfileId): MobileProfileSettingsBlob {
private fun exportSettingsBlob(): MobileProfileSettingsBlob {
ensureRepositoriesLoaded()
return MobileProfileSettingsBlob(
features = MobileProfileSettingsFeatures(
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = exportPlayerSettingsPayload(profileId),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(),
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
@ -236,19 +234,15 @@ object ProfileSettingsSync {
)
}
private fun applyRemoteBlob(profileId: Int, blob: MobileProfileSettingsBlob) {
private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) {
ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings)
ThemeSettingsRepository.onProfileChanged()
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
PosterCardStyleRepository.onProfileChanged()
if (syncPlayerSettings) {
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
} else {
preserveRemotePlayerSettings(profileId, blob)
}
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings)
StreamBadgeSettingsRepository.onProfileChanged()
@ -284,9 +278,7 @@ object ProfileSettingsSync {
private fun ensureRepositoriesLoaded() {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
if (syncPlayerSettings) {
PlayerSettingsRepository.ensureLoaded()
}
PlayerSettingsRepository.ensureLoaded()
StreamBadgeSettingsRepository.ensureLoaded()
DebridSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
@ -305,90 +297,23 @@ object ProfileSettingsSync {
private fun defaultSignature(): String =
buildSignature(MobileProfileSettingsBlob())
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
}
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 = "||")
}
@Serializable

View file

@ -1,36 +0,0 @@
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

@ -26,6 +26,7 @@ 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
@ -132,7 +133,7 @@ private fun ContinueWatchingSheetHeader(
) {
val artwork = item.poster ?: item.imageUrl
if (artwork != null) {
NuvioAsyncImage(
AsyncImage(
model = cloudLibraryDisplayArtworkUrl(artwork),
contentDescription = item.title,
modifier = Modifier.matchParentSize(),

View file

@ -48,6 +48,7 @@ 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
@ -190,7 +191,7 @@ fun NuvioFloatingPrompt(
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
NuvioAsyncImage(
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize(),

View file

@ -33,6 +33,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.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
@ -188,7 +189,7 @@ private fun PosterSheetHeader(
contentAlignment = Alignment.Center,
) {
if (item.poster != null) {
NuvioAsyncImage(
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.matchParentSize(),

View file

@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.aspectRatio
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.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
@ -30,20 +27,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.semantics.clearAndSetSemantics
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 com.nuvio.app.isDesktop
import coil3.compose.AsyncImage
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,
@ -71,7 +67,6 @@ 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),
@ -86,8 +81,6 @@ fun <T> NuvioShelfSection(
)
}
LazyRow(
state = rowState,
modifier = Modifier.desktopShelfDragScroll(rowState),
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
) {
@ -107,47 +100,6 @@ 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,
@ -186,7 +138,7 @@ fun NuvioPosterCard(
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
NuvioAsyncImage(
AsyncImage(
model = imageUrl,
contentDescription = title,
modifier = Modifier.matchParentSize(),
@ -211,7 +163,7 @@ fun NuvioPosterCard(
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10),
) {
if (!bottomLeftLogoUrl.isNullOrBlank()) {
NuvioAsyncImage(
AsyncImage(
model = bottomLeftLogoUrl,
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
modifier = Modifier
@ -268,38 +220,45 @@ private fun NuvioShelfSectionHeader(
viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default,
) {
val tokens = MaterialTheme.nuvio
Row(
Column(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (showAccent) {
Box(
modifier = Modifier
.padding(top = NuvioTokens.Space.s6)
.width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4)
.height(NuvioTokens.Space.s4)
.background(
color = tokens.colors.accent,
shape = tokens.shapes.chip,
),
)
val viewAllPlaceholderModifier = if (onViewAllClick == null) {
Modifier
.alpha(0f)
.clearAndSetSemantics { }
} else {
Modifier
}
}
if (onViewAllClick != null) {
NuvioViewAllPill(
onClick = onViewAllClick,
size = viewAllPillSize,
modifier = viewAllPlaceholderModifier,
)
}
if (showAccent) {
Box(
modifier = Modifier
.padding(top = NuvioTokens.Space.s6)
.width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4)
.height(NuvioTokens.Space.s4)
.background(
color = tokens.colors.accent,
shape = tokens.shapes.chip,
),
)
}
}
@ -309,38 +268,28 @@ private fun NuvioShelfSectionHeader(
private fun NuvioViewAllPill(
onClick: (() -> Unit)?,
size: NuvioViewAllPillSize,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val horizontalPadding = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s12 else NuvioTokens.Space.s18
val verticalPadding = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s8 + NuvioTokens.Space.s1 else NuvioTokens.Space.s14
val textStyle = if (size == NuvioViewAllPillSize.Compact) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.titleMedium
}
val iconSpacing = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s2 else NuvioTokens.Space.s4
val actionSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s32 else NuvioTokens.Space.s40
val iconSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md
val viewAllText = stringResource(Res.string.home_view_all)
Row(
modifier = Modifier
Box(
modifier = modifier
.size(actionSize)
.background(
color = tokens.colors.surface,
shape = RoundedCornerShape(NuvioTokens.Radius.xl),
)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.spacedBy(iconSpacing),
verticalAlignment = Alignment.CenterVertically,
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.home_view_all),
style = textStyle,
color = tokens.colors.textPrimary,
)
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
contentDescription = viewAllText,
tint = tokens.colors.textMuted,
modifier = Modifier.height(if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md),
modifier = Modifier.size(iconSize),
)
}
}
@ -395,7 +344,6 @@ internal fun Modifier.posterCardClickable(
onClick = { onClick?.invoke() },
onLongClick = onLongClick,
)
.secondaryClick(onLongClick)
} else {
this
}

View file

@ -1,5 +0,0 @@
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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioIconActionButton
import com.nuvio.app.core.ui.NuvioInfoBadge
import com.nuvio.app.core.ui.NuvioInputField

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
const val CATALOG_PAGE_SIZE = 100
private const val DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT = 3
data class CatalogPage(
val items: List<MetaPreview>,
@ -22,6 +23,11 @@ data class CatalogPage(
val nextSkip: Int?,
)
data class CatalogPaginationState(
val nextSkip: Int?,
val consecutiveDuplicatePages: Int = 0,
)
private val inflightMutex = Mutex()
private val inflightRequestScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val inflightRequests = mutableMapOf<String, CompletableDeferred<String>>()
@ -84,7 +90,40 @@ suspend fun fetchCatalogPage(
}
fun AddonCatalog.supportsPagination(): Boolean =
extra.any { property -> property.name == "skip" }
extra.any { property -> property.name.equals("skip", ignoreCase = true) }
fun nextCatalogPaginationState(
supportsPagination: Boolean,
requestedSkip: Int,
page: CatalogPage,
loadedNewItems: Boolean,
consecutiveDuplicatePages: Int,
): CatalogPaginationState {
if (!supportsPagination || page.rawItemCount <= 0 || page.nextSkip == null) {
return CatalogPaginationState(nextSkip = null)
}
if (loadedNewItems) {
return CatalogPaginationState(nextSkip = page.nextSkip)
}
val duplicatePages = consecutiveDuplicatePages + 1
val advancedSkip = if (page.nextSkip > requestedSkip) {
page.nextSkip
} else {
requestedSkip + page.rawItemCount.coerceAtLeast(1)
}
return if (duplicatePages < DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT && advancedSkip > requestedSkip) {
CatalogPaginationState(
nextSkip = advancedSkip,
consecutiveDuplicatePages = duplicatePages,
)
} else {
CatalogPaginationState(
nextSkip = null,
consecutiveDuplicatePages = duplicatePages,
)
}
}
fun mergeCatalogItems(
existing: List<MetaPreview>,

View file

@ -6,6 +6,7 @@ data class CatalogUiState(
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = false,
val nextSkip: Int? = null,
val consecutiveDuplicatePages: Int = 0,
val errorMessage: String? = null,
) {
val canLoadMore: Boolean

View file

@ -1,9 +1,13 @@
package com.nuvio.app.features.catalog
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
import com.nuvio.app.features.collection.catalogRouteKey
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -16,8 +20,6 @@ import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
object CatalogRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CatalogUiState())
@ -28,25 +30,15 @@ object CatalogRepository {
private val scrollPositions = linkedMapOf<CatalogRequest, CatalogScrollPosition>()
fun load(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
force: Boolean = false,
) {
val request = catalogRequest(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
)
val request = catalogRequest(target)
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
return
}
activeRequest = request
if (manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) {
if (target is CatalogTarget.Library) {
fetchInternalLibrary(request)
return
}
@ -68,25 +60,17 @@ object CatalogRepository {
}
fun scrollPosition(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
): CatalogScrollPosition =
scrollPositions[catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)]
scrollPositions[catalogRequest(target)]
?: CatalogScrollPosition()
fun saveScrollPosition(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
) {
val request = catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)
val request = catalogRequest(target)
scrollPositions[request] = CatalogScrollPosition(
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
@ -102,9 +86,10 @@ object CatalogRepository {
activeJob = scope.launch {
runCatching {
val target = request.target as CatalogTarget.Library
LibraryRepository.ensureLoaded()
LibraryRepository.uiState.value.sections
.firstOrNull { it.type == request.catalogId }
.firstOrNull { it.type == target.sectionType }
?.items
.orEmpty()
.map { it.toMetaPreview() }
@ -149,13 +134,22 @@ object CatalogRepository {
activeJob = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = request.manifestUrl,
type = request.type,
catalogId = request.catalogId,
genre = request.genre,
skip = requestedSkip.takeIf { it > 0 },
).withUnreleasedFilter(request.hideUnreleasedContent)
when (val target = request.target) {
is CatalogTarget.Addon -> fetchCatalogPage(
manifestUrl = target.manifestUrl,
type = target.contentType,
catalogId = target.catalogId,
genre = target.genre,
skip = requestedSkip.takeIf { it > 0 },
)
is CatalogTarget.CollectionSource -> fetchCollectionSourcePage(
target = target,
page = requestedSkip.takeIf { it > 0 } ?: 1,
)
is CatalogTarget.Library -> error(getString(Res.string.catalog_load_failed))
}.withUnreleasedFilter(request.hideUnreleasedContent)
}.fold(
onSuccess = { page ->
if (activeRequest != request) return@fold
@ -165,12 +159,20 @@ object CatalogRepository {
} else {
mergeCatalogItems(_uiState.value.items, page.items)
}
val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val supportsPagination = request.target.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > current.items.size
val paginationState = nextCatalogPaginationState(
supportsPagination = supportsPagination,
requestedSkip = requestedSkip,
page = page,
loadedNewItems = loadedNewItems,
consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages,
)
_uiState.value = CatalogUiState(
items = mergedItems,
isLoading = false,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
nextSkip = paginationState.nextSkip,
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
errorMessage = null,
)
},
@ -188,19 +190,9 @@ object CatalogRepository {
}
}
private fun catalogRequest(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
): CatalogRequest =
private fun catalogRequest(target: CatalogTarget): CatalogRequest =
CatalogRequest(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent,
)
}
@ -211,11 +203,26 @@ private fun CatalogPage.withUnreleasedFilter(hideUnreleasedContent: Boolean): Ca
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private suspend fun fetchCollectionSourcePage(
target: CatalogTarget.CollectionSource,
page: Int,
): CatalogPage {
CollectionRepository.initialize()
val source = CollectionRepository.getCollection(target.collectionId)
?.folders
?.firstOrNull { it.id == target.folderId }
?.resolvedSources
?.firstOrNull { it.catalogRouteKey() == target.sourceKey }
?: error(getString(Res.string.catalog_load_failed))
return when {
source.isTmdb -> TmdbCollectionSourceResolver.resolve(source = source, page = page)
source.isTrakt -> TraktPublicListSourceResolver.resolve(source = source, page = page)
else -> error(getString(Res.string.catalog_load_failed))
}
}
private data class CatalogRequest(
val manifestUrl: String,
val type: String,
val catalogId: String,
val genre: String?,
val supportsPagination: Boolean,
val target: CatalogTarget,
val hideUnreleasedContent: Boolean,
)

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
@ -68,11 +68,7 @@ import org.jetbrains.compose.resources.stringResource
fun CatalogScreen(
title: String,
subtitle: String,
manifestUrl: String,
type: String,
catalogId: String,
supportsPagination: Boolean,
genre: String? = null,
target: CatalogTarget,
onBack: () -> Unit,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -87,19 +83,11 @@ fun CatalogScreen(
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val initialScrollPosition = remember(
manifestUrl,
type,
catalogId,
genre,
supportsPagination,
target,
homeCatalogSettingsUiState.hideUnreleasedContent,
) {
CatalogRepository.scrollPosition(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
)
}
val gridState = rememberLazyGridState(
@ -109,26 +97,18 @@ fun CatalogScreen(
var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
LaunchedEffect(target, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
)
}
LaunchedEffect(gridState, manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
LaunchedEffect(gridState, target, homeCatalogSettingsUiState.hideUnreleasedContent) {
snapshotFlow { gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
CatalogRepository.saveScrollPosition(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
firstVisibleItemIndex = index,
firstVisibleItemScrollOffset = offset,
)
@ -148,7 +128,7 @@ fun CatalogScreen(
}
}
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
LaunchedEffect(networkStatusUiState.condition, target) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
@ -160,11 +140,7 @@ fun CatalogScreen(
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
force = true,
)
}
@ -208,11 +184,7 @@ fun CatalogScreen(
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
force = true,
)
},

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.catalog
import kotlinx.serialization.Serializable
sealed interface CatalogTarget {
val contentType: String
val supportsPagination: Boolean
data class Addon(
val manifestUrl: String,
override val contentType: String,
val catalogId: String,
val genre: String? = null,
override val supportsPagination: Boolean = false,
) : CatalogTarget
data class Library(
override val contentType: String,
val sectionType: String,
) : CatalogTarget {
override val supportsPagination: Boolean = false
}
data class CollectionSource(
val collectionId: String,
val folderId: String,
val sourceKey: String,
override val contentType: String,
override val supportsPagination: Boolean = false,
) : CatalogTarget
}
@Serializable
enum class CatalogTargetKind {
ADDON,
LIBRARY,
COLLECTION_SOURCE,
}

View file

@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
)
private fun collectionSourceKey(source: CollectionSource): String =
when {
source.isTmdb -> {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
}
source.isTrakt -> {
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
}
else -> {
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
}
}
source.catalogRouteKey()
private fun selectedMediaTypes(
state: CollectionEditorUiState,

View file

@ -68,6 +68,21 @@ data class CollectionSource(
}
}
internal fun CollectionSource.catalogRouteKey(): String =
when {
isTmdb -> {
"tmdb_${tmdbSourceType}_${tmdbId}_${mediaType}_${sortBy}_${filters.hashCode()}"
}
isTrakt -> {
"trakt_${traktListId}_${mediaType}_${TraktListSort.normalize(sortBy)}_${TraktSortHow.normalize(sortHow)}"
}
else -> {
"addon_${addonId}_${type}_${catalogId}_${genre.orEmpty()}"
}
}
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
isTrakt && (traktListId == null || traktListId <= 0L)

View file

@ -44,11 +44,13 @@ object CollectionSyncService {
}
suspend fun pullFromServer(profileId: Int) {
if (ProfileRepository.activeProfileId != profileId) return
runCatching {
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_collections", params)
if (ProfileRepository.activeProfileId != profileId) return@runCatching
val blobs = result.decodeList<SupabaseCollectionBlob>()
val blob = blobs.firstOrNull()
@ -64,6 +66,7 @@ object CollectionSyncService {
}
val remoteJson = remoteCollectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (ProfileRepository.activeProfileId != profileId) return@runCatching
if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" }
@ -75,6 +78,7 @@ object CollectionSyncService {
}.getOrNull()
if (remoteCollections != null) {
if (ProfileRepository.activeProfileId != profileId) return@runCatching
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
isSyncingFromRemote = false
@ -91,18 +95,21 @@ object CollectionSyncService {
fun triggerPush() {
pushJob?.cancel()
pushJob = scope.launch {
val profileId = ProfileRepository.activeProfileId
delay(500)
if (ProfileRepository.activeProfileId != profileId) return@launch
if (isSyncingFromRemote) return@launch
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
pushToRemote()
pushToRemote(profileId)
}
}
private suspend fun pushToRemote() {
private suspend fun pushToRemote(profileId: Int) {
runCatching {
val profileId = ProfileRepository.activeProfileId
if (ProfileRepository.activeProfileId != profileId) return@runCatching
val collectionsJson = CollectionRepository.exportToJson()
if (ProfileRepository.activeProfileId != profileId) return@runCatching
val jsonElement = runCatching {
json.parseToJsonElement(collectionsJson)
}.getOrDefault(JsonArray(emptyList()))
@ -124,10 +131,11 @@ object CollectionSyncService {
CollectionRepository.localChangeEvents
.debounce(PUSH_DEBOUNCE_MS)
.collect {
val profileId = ProfileRepository.activeProfileId
if (isSyncingFromRemote) return@collect
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
pushToRemote()
pushToRemote(profileId)
}
}
}

View file

@ -4,8 +4,10 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.nextCatalogPaginationState
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
@ -35,6 +37,7 @@ data class FolderTab(
val label: String,
val typeLabel: String = "",
val source: CollectionSource? = null,
val sourceKey: String? = null,
val manifestUrl: String? = null,
val type: String = "",
val catalogId: String = "",
@ -44,6 +47,7 @@ data class FolderTab(
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val nextSkip: Int? = null,
val consecutiveDuplicatePages: Int = 0,
val error: String? = null,
val isAllTab: Boolean = false,
) {
@ -136,7 +140,7 @@ object FolderDetailRepository {
),
)
}
sources.forEach { source ->
sources.forEachIndexed { sourceIndex, source ->
if (source.isTmdb) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
@ -145,6 +149,7 @@ object FolderDetailRepository {
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
typeLabel = "TMDB",
source = source,
sourceKey = source.catalogRouteKey(),
type = type,
catalogId = tmdbCatalogId(source),
supportsPagination = source.tmdbSourceType !in setOf(
@ -172,6 +177,7 @@ object FolderDetailRepository {
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
typeLabel = typeLabel,
source = source,
sourceKey = source.catalogRouteKey(),
type = type,
catalogId = traktCatalogId(source),
supportsPagination = true,
@ -179,7 +185,7 @@ object FolderDetailRepository {
),
)
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val catalogSource = source.addonCatalogSource() ?: return@forEachIndexed
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
val addon = resolvedCatalog?.addon
val catalog = resolvedCatalog?.catalog
@ -191,6 +197,7 @@ object FolderDetailRepository {
label = "$label ($typeLabel)$genreSuffix",
typeLabel = typeLabel,
source = source,
sourceKey = source.catalogRouteKey(),
manifestUrl = addon?.manifestUrl,
type = catalogSource.type,
catalogId = catalogSource.catalogId,
@ -298,6 +305,7 @@ object FolderDetailRepository {
isLoading = true,
isLoadingMore = false,
nextSkip = null,
consecutiveDuplicatePages = 0,
error = null,
)
} else {
@ -340,12 +348,20 @@ object FolderDetailRepository {
}
val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > tab.items.size
val paginationState = nextCatalogPaginationState(
supportsPagination = supportsPagination,
requestedSkip = requestedSkip,
page = page,
loadedNewItems = loadedNewItems,
consecutiveDuplicatePages = if (reset) 0 else tab.consecutiveDuplicatePages,
)
tab.copy(
items = mergedItems,
supportsPagination = supportsPagination,
isLoading = false,
isLoadingMore = false,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
nextSkip = paginationState.nextSkip,
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
error = null,
)
}
@ -408,20 +424,38 @@ object FolderDetailRepository {
fun getCatalogSectionsForRows(): List<HomeCatalogSection> {
val current = _uiState.value
val folder = current.folder ?: return emptyList()
val collectionId = activeCollectionId ?: return emptyList()
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab ->
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.mapNotNull { tab ->
val directSource = tab.source?.let { it.isTmdb || it.isTrakt } == true
val target = if (directSource) {
val sourceKey = tab.sourceKey ?: return@mapNotNull null
CatalogTarget.CollectionSource(
collectionId = collectionId,
folderId = folder.id,
sourceKey = sourceKey,
contentType = tab.type,
supportsPagination = tab.supportsPagination,
)
} else {
val manifestUrl = tab.manifestUrl ?: return@mapNotNull null
CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = tab.type,
catalogId = tab.catalogId,
genre = tab.genre,
supportsPagination = tab.supportsPagination,
)
}
HomeCatalogSection(
key = "folder_${folder.id}_${tab.label}",
title = tab.label,
subtitle = tab.typeLabel,
addonName = "",
type = tab.type,
manifestUrl = tab.manifestUrl.orEmpty(),
catalogId = tab.catalogId,
target = target,
items = tab.items,
availableItemCount = tab.items.size,
supportsPagination = tab.supportsPagination,
genre = tab.genre,
hasMore = tab.canLoadMore,
)
}
}

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader

View file

@ -5,7 +5,7 @@ import com.nuvio.app.features.streams.StreamItem
internal object DebridMagnetBuilder {
fun fromStream(stream: StreamItem): String? {
stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it }
val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null
val hash = stream.p2pInfoHash ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders
import com.nuvio.app.features.streams.normalizeStreamType
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@ -288,6 +289,7 @@ internal object MetaDetailsParser {
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
streamType = normalizeStreamType(obj.string("type")),
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,

View file

@ -60,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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.TrailerPlaybackMode
import com.nuvio.app.core.network.NetworkCondition
@ -88,7 +88,6 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.TmdbService
@ -460,30 +459,6 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) {
if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) {
DetailDebridWarmupTarget(
videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id,
season = seriesAction?.seasonNumber,
episode = seriesAction?.episodeNumber,
)
} else {
DetailDebridWarmupTarget(
videoId = meta.id,
season = null,
episode = null,
)
}
}
LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) {
if (!deferredMetaWorkAllowed) return@LaunchedEffect
AddonStreamWarmupRepository.preload(
type = meta.type,
videoId = debridWarmupTarget.videoId,
season = debridWarmupTarget.season,
episode = debridWarmupTarget.episode,
)
}
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@ -781,7 +756,6 @@ 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 && deferredMetaWorkAllowed
@ -816,7 +790,6 @@ fun MetaDetailsScreen(
meta = meta,
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
viewportHeight = viewportHeight,
scrollOffset = heroScrollOffset,
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
@ -1483,6 +1456,7 @@ private fun DetailSectionContainer(
Modifier.widthIn(max = contentMaxWidth)
},
),
contentAlignment = Alignment.Center,
) {
content()
}
@ -1820,14 +1794,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
private data class DetailDebridWarmupTarget(
val videoId: String,
val season: Int?,
val episode: Int?,
)
private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean =
hasEpisodes || type.equals("series", ignoreCase = true) ||
type.equals("show", ignoreCase = true) ||
type.equals("tv", ignoreCase = true)

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.nuvio.app.core.ui.landscapePosterHeightForWidth

View file

@ -40,9 +40,9 @@ 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 nuvio.composeapp.generated.resources.details_actions_menu_label
import org.jetbrains.compose.resources.stringResource
data class DetailSecondaryAction(
@ -59,7 +59,7 @@ fun DetailActionButtons(
modifier: Modifier = Modifier,
playLabel: String = stringResource(Res.string.action_play),
secondaryActions: List<DetailSecondaryAction> = emptyList(),
actionsMenuLabel: String = "More actions",
actionsMenuLabel: String = stringResource(Res.string.details_actions_menu_label),
isTablet: Boolean = false,
onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,
@ -108,7 +108,6 @@ fun DetailActionButtons(
onLongClick = onPlayLongClick,
role = Role.Button,
)
.secondaryClick(onPlayLongClick)
.height(buttonHeight),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
@ -251,8 +250,7 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.isIos
@ -111,7 +111,7 @@ fun DetailFloatingHeader(
.padding(horizontal = 10.dp),
contentAlignment = Alignment.Center,
) {
if (meta.logo != null && !logoLoadError) {
if (!meta.logo.isNullOrBlank() && !logoLoadError) {
AsyncImage(
model = meta.logo,
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),

View file

@ -32,7 +32,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -43,8 +45,7 @@ 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 com.nuvio.app.core.ui.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -55,7 +56,6 @@ 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,
@ -71,7 +71,7 @@ fun DetailHero(
BoxWithConstraints(
modifier = modifier.fillMaxWidth(),
) {
val heroHeight = detailHeroHeight(maxWidth, viewportHeight, isTablet)
val heroHeight = detailHeroHeight(maxWidth, isTablet)
val trailerAlpha by animateFloatAsState(
targetValue = if (heroTrailerReady) 1f else 0f,
animationSpec = tween(durationMillis = 300),
@ -81,6 +81,10 @@ fun DetailHero(
val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +
8.dp +
((40.dp - muteIconSize) / 2)
var logoLoadError by remember(meta.id, meta.logo) {
mutableStateOf(false)
}
val logoUrl = meta.logo?.takeIf { it.isNotBlank() }
Box(
modifier = Modifier
@ -97,7 +101,6 @@ 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,
@ -106,12 +109,11 @@ fun DetailHero(
.fillMaxSize()
.graphicsLayer {
translationY = scrollOffset * 0.5f
scaleX = backdropScale
scaleY = backdropScale
scaleX = 1.08f
scaleY = 1.08f
},
alignment = Alignment.Center,
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
)
} else {
Box(
@ -131,8 +133,8 @@ fun DetailHero(
.graphicsLayer {
alpha = trailerAlpha
translationY = scrollOffset * 0.5f
scaleX = backdropScale
scaleY = backdropScale
scaleX = 1.08f
scaleY = 1.08f
},
onReady = onHeroTrailerReady,
onEnded = onHeroTrailerEnded,
@ -202,9 +204,9 @@ fun DetailHero(
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (meta.logo != null) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = meta.logo,
model = logoUrl,
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
@ -212,6 +214,7 @@ fun DetailHero(
.height(if (isTablet) 72.dp else 80.dp),
alignment = Alignment.Center,
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
} else {
Text(
@ -237,13 +240,9 @@ fun DetailHero(
}
}
private fun detailHeroHeight(maxWidth: Dp, viewportHeight: Dp, isTablet: Boolean): Dp =
private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp =
if (!isTablet) {
(maxWidth * 1.33f).coerceIn(420.dp, 760.dp)
} else {
val viewportLimit = viewportHeight
.takeIf { it > 0.dp }
?.let { it * 0.72f }
?: 1080.dp
minOf(maxWidth * 9f / 16f, viewportLimit).coerceIn(420.dp, 1080.dp)
(maxWidth * 0.42f).coerceIn(300.dp, 420.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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaCompany
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*

View file

@ -58,14 +58,13 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.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
@ -409,7 +408,6 @@ 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))
@ -422,9 +420,8 @@ private fun SeasonTextChipScrollRow(
)
.combinedClickable(
onClick = { onSelect(season) },
onLongClick = onSecondaryClick,
onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
.secondaryClick(onSecondaryClick)
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
@ -511,8 +508,7 @@ private fun SeasonPosterButton(
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.secondaryClick(onLongClick),
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
@ -681,8 +677,7 @@ private fun EpisodeHorizontalCard(
enabled = onClick != null || onLongPress != null,
onClick = { onClick?.invoke() },
onLongClick = onLongPress,
)
.secondaryClick(onLongPress),
),
) {
val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
@ -1050,8 +1045,7 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaTrailer
import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.detail_tab_trailer

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -173,7 +174,7 @@ fun TrailerPlayerPopup(
sourceUrl = playbackSource.videoUrl,
sourceAudioUrl = playbackSource.audioUrl,
useYoutubeChunkedPlayback = true,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxSize(),
playWhenReady = true,
resizeMode = PlayerResizeMode.Fit,
useNativeController = true,

View file

@ -1,7 +1,7 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogTarget
data class MetaPreview(
val id: String,
@ -33,17 +33,14 @@ data class HomeCatalogSection(
val title: String,
val subtitle: String,
val addonName: String,
val type: String,
val manifestUrl: String,
val catalogId: String,
val target: CatalogTarget,
val items: List<MetaPreview>,
val availableItemCount: Int = items.size,
val supportsPagination: Boolean = false,
val genre: String? = null,
val hasMore: Boolean = false,
)
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
availableItemCount > previewLimit || (supportsPagination && availableItemCount >= CATALOG_PAGE_SIZE)
availableItemCount > previewLimit || hasMore
data class HomeUiState(
val isLoading: Boolean = false,

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.CollectionSource
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
import com.nuvio.app.features.collection.catalogRouteKey
import com.nuvio.app.features.collection.findCollectionCatalog
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
@ -238,12 +240,15 @@ object HomeRepository {
title = defaultTitle,
subtitle = addonName,
addonName = addonName,
type = type,
manifestUrl = manifestUrl,
catalogId = catalogId,
target = CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = type,
catalogId = catalogId,
supportsPagination = supportsPagination,
),
items = emptyList(),
availableItemCount = 0,
supportsPagination = supportsPagination,
hasMore = false,
)
}
@ -252,12 +257,15 @@ object HomeRepository {
title = defaultTitle,
subtitle = addonName,
addonName = addonName,
type = type,
manifestUrl = manifestUrl,
catalogId = catalogId,
target = CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = type,
catalogId = catalogId,
supportsPagination = supportsPagination,
),
items = items,
availableItemCount = page.rawItemCount,
supportsPagination = supportsPagination,
hasMore = supportsPagination && page.nextSkip != null,
)
}
@ -411,19 +419,7 @@ object HomeRepository {
}
private fun collectionSourceKey(source: CollectionSource): String =
listOf(
source.provider,
source.addonId,
source.type,
source.catalogId,
source.genre,
source.tmdbSourceType,
source.tmdbId?.toString(),
source.traktListId?.toString(),
source.mediaType,
source.sortBy,
source.sortHow,
).joinToString(":") { it.orEmpty() }
source.catalogRouteKey()
}
private const val HOME_HERO_ITEM_LIMIT = 8

View file

@ -14,7 +14,6 @@ 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
@ -82,6 +81,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.yield
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight
@ -105,21 +105,19 @@ fun HomeScreen(
onFirstCatalogRendered: (() -> Unit)? = null,
) {
LaunchedEffect(Unit) {
withContext(Dispatchers.Default) {
AddonRepository.initialize()
CollectionRepository.initialize()
ContinueWatchingPreferencesRepository.ensureLoaded()
HomeCatalogSettingsRepository.snapshot()
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
WatchedRepository.ensureLoaded()
WatchProgressRepository.ensureLoaded()
}
AddonRepository.initialize()
CollectionRepository.initialize()
ContinueWatchingPreferencesRepository.ensureLoaded()
WatchedRepository.ensureLoaded()
WatchProgressRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val homeSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val homeListState = rememberLazyListState()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
@ -127,8 +125,14 @@ fun HomeScreen(
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle()
val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
}.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(scrollToTopRequests) {
@ -278,11 +282,20 @@ fun HomeScreen(
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle()
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
LaunchedEffect(activeProfileId, cwCacheClearVersion) {
if (cwCacheClearVersion == 0) return@LaunchedEffect
nextUpItemsBySeries = emptyMap()
processedNextUpContentIds = emptySet()
}
val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) {
ContinueWatchingEnrichmentCache.getSnapshots(activeProfileId)
}
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
@ -453,6 +466,7 @@ fun HomeScreen(
watchProgressSeedKey,
watchedUiState.items,
watchedUiState.isLoaded,
activeProfileId,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
@ -483,7 +497,9 @@ fun HomeScreen(
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit)
val resolutionPlan = planHomeNextUpResolutionCandidates(candidatesToResolve)
val resolutionCandidates = resolutionPlan.initialCandidates
val deferredResolutionCandidates = resolutionPlan.deferredCandidates
val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs }
if (candidatesToResolve.isEmpty()) {
withContext(Dispatchers.Main) {
@ -493,6 +509,7 @@ fun HomeScreen(
}
}
saveContinueWatchingSnapshots(
profileId = activeProfileId,
nextUpItemsBySeries = cachedResolvedNextUpItems,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
@ -561,11 +578,66 @@ fun HomeScreen(
}
saveContinueWatchingSnapshots(
profileId = activeProfileId,
nextUpItemsBySeries = results,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = todayIsoDate,
seedLastWatchedMap = seedLastWatchedMap,
)
if (deferredResolutionCandidates.isEmpty()) {
return@withContext
}
val deferredCandidateBatches = deferredResolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE)
for (batch in deferredCandidateBatches) {
if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) {
break
}
val batchResults = batch.map { completedEntry ->
async {
semaphore.withPermit {
resolveHomeNextUpCandidate(
completedEntry = completedEntry,
watchProgressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys,
isTraktProgressActive = isTraktProgressActive,
)
}
}
}.awaitAll()
batch.forEach { candidate -> processedFreshContentIds += candidate.content.id }
val resolvedBeforeBatch = freshResults.size
batchResults.filterNotNull().forEach { (contentId, item) ->
if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) {
freshResults[contentId] = item
}
}
if (freshResults.size > resolvedBeforeBatch) {
val progressiveResults = cachedResolvedNextUpItems + freshResults
withContext(Dispatchers.Main) {
nextUpItemsBySeries = progressiveResults
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
}
saveContinueWatchingSnapshots(
profileId = activeProfileId,
nextUpItemsBySeries = progressiveResults,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = todayIsoDate,
seedLastWatchedMap = seedLastWatchedMap,
)
}
yield()
}
}
}
@ -647,7 +719,6 @@ fun HomeScreen(
modifier = Modifier,
viewportHeight = maxHeight,
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
sectionPadding = if (isDesktop) homeSectionPadding else null,
)
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
@ -655,7 +726,6 @@ fun HomeScreen(
modifier = Modifier,
viewportHeight = maxHeight,
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
sectionPadding = if (isDesktop) homeSectionPadding else null,
listState = homeListState,
onItemClick = onPosterClick,
)
@ -810,6 +880,19 @@ private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4
private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY
internal data class HomeNextUpResolutionPlan(
val initialCandidates: List<CompletedSeriesCandidate>,
val deferredCandidates: List<CompletedSeriesCandidate>,
)
internal fun planHomeNextUpResolutionCandidates(
candidates: List<CompletedSeriesCandidate>,
): HomeNextUpResolutionPlan =
HomeNextUpResolutionPlan(
initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit),
deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit),
)
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,
@ -1133,6 +1216,7 @@ private data class HomeContinueWatchingCandidate(
)
private fun saveContinueWatchingSnapshots(
profileId: Int,
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
visibleContinueWatchingEntries: List<WatchProgressEntry>,
todayIsoDate: String,
@ -1186,6 +1270,7 @@ private fun saveContinueWatchingSnapshots(
)
}
ContinueWatchingEnrichmentCache.saveSnapshots(
profileId = profileId,
nextUp = nextUpCache,
inProgress = inProgressCache,
)

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
@ -53,7 +53,6 @@ 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
@ -804,8 +803,7 @@ 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,7 +11,6 @@ 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
@ -31,8 +30,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -48,9 +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 com.nuvio.app.isDesktop
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.CoroutineScope
@ -89,7 +88,6 @@ fun HomeHeroSection(
modifier: Modifier = Modifier,
viewportHeight: Dp? = null,
mobileBelowSectionHeightHint: Dp? = null,
sectionPadding: Dp? = null,
listState: LazyListState? = null,
onItemClick: ((MetaPreview) -> Unit)? = null,
) {
@ -112,7 +110,6 @@ 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() }
@ -168,32 +165,136 @@ fun HomeHeroSection(
Box(modifier = Modifier.fillMaxSize())
}
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.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),
),
),
),
)
} else {
DefaultHomeHeroFrame(
items = items,
visiblePages = visiblePages,
currentItem = currentItem,
layout = layout,
heroWidthPx = heroWidthPx,
heroScrollScale = heroScrollScale,
heroScrollTranslationY = heroScrollTranslationY,
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,
),
),
),
)
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),
)
}
}
}
}
}
}
}
@ -205,284 +306,6 @@ 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,
@ -510,7 +333,6 @@ fun HomeHeroReservedSpace(
maxWidthDp = maxWidth.value,
viewportHeightDp = viewportHeight?.value,
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
preferDesktopLayout = isDesktop,
)
Spacer(
@ -527,13 +349,18 @@ private fun HeroContentBlock(
layout: HomeHeroLayout,
onItemClick: ((MetaPreview) -> Unit)?,
) {
var logoLoadError by remember(item.type, item.id, item.logo) {
mutableStateOf(false)
}
val logoUrl = item.logo?.takeIf { it.isNotBlank() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
) {
if (item.logo != null) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = item.logo,
model = logoUrl,
contentDescription = item.name,
modifier = Modifier
.fillMaxWidth(layout.logoWidthFraction)
@ -543,6 +370,7 @@ private fun HeroContentBlock(
},
alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
} else {
Text(
@ -588,97 +416,6 @@ 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(
@ -695,7 +432,6 @@ internal fun homeHeroLayout(
maxWidthDp: Float,
viewportHeightDp: Float? = null,
mobileBelowSectionHeightHintDp: Float? = null,
preferDesktopLayout: Boolean = false,
): HomeHeroLayout =
when {
maxWidthDp >= 1200f -> HomeHeroLayout(
@ -728,16 +464,6 @@ 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(
@ -871,9 +597,10 @@ private fun resolveHeroTargetPage(
abs(velocityX) > HERO_SWIPE_VELOCITY_THRESHOLD
if (!thresholdPassed) return startPage
val currentPage = startPage.coerceIn(0, itemCount - 1)
return when {
totalDx > 0f -> (startPage - 1).coerceAtLeast(0)
totalDx < 0f -> (startPage + 1).coerceAtMost(itemCount - 1)
else -> startPage
totalDx > 0f -> if (currentPage == 0) itemCount - 1 else currentPage - 1
totalDx < 0f -> if (currentPage == itemCount - 1) 0 else currentPage + 1
else -> currentPage
}
}

View file

@ -32,7 +32,6 @@ 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
@ -66,7 +65,6 @@ fun HomeSkeletonHero(
modifier: Modifier = Modifier,
viewportHeight: Dp? = null,
mobileBelowSectionHeightHint: Dp? = null,
sectionPadding: Dp? = null,
) {
val brush = rememberHomeSkeletonBrush()
@ -79,10 +77,8 @@ fun HomeSkeletonHero(
maxWidthDp = maxWidth.value,
viewportHeightDp = viewportHeight?.value,
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
preferDesktopLayout = isDesktop,
)
val containerWidth = maxWidth
val contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding
Box(
modifier = Modifier
@ -125,7 +121,7 @@ fun HomeSkeletonHero(
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(
horizontal = contentHorizontalPadding,
horizontal = layout.contentHorizontalPadding,
vertical = layout.contentVerticalPadding,
),
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.library
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.home.PosterShape
@ -40,6 +41,7 @@ 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 nuvio.composeapp.generated.resources.trakt_lists_update_failed
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@ -79,6 +81,7 @@ object LibraryRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var profileGeneration: Long = 0L
private var itemsById: MutableMap<String, LibraryItem> = mutableMapOf()
private var isPullingNuvioSyncFromServer = false
private var hasCompletedInitialNuvioSyncPull = false
@ -154,6 +157,7 @@ object LibraryRepository {
fun clearLocalState() {
hasLoaded = false
currentProfileId = 1
profileGeneration += 1L
itemsById.clear()
pushJob?.cancel()
isPullingNuvioSyncFromServer = false
@ -165,6 +169,7 @@ object LibraryRepository {
private fun loadFromDisk(profileId: Int) {
currentProfileId = profileId
profileGeneration += 1L
hasLoaded = true
itemsById.clear()
@ -180,11 +185,15 @@ object LibraryRepository {
}
suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId
val operationGeneration = activeOperationGeneration(profileId) ?: run {
log.d { "Skipping library pull for inactive profile $profileId" }
return
}
if (isTraktLibrarySourceActive()) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
if (!isActiveOperation(profileId, operationGeneration)) return
hasCompletedInitialNuvioSyncPull = true
publish()
return
@ -193,6 +202,7 @@ object LibraryRepository {
isPullingNuvioSyncFromServer = true
runCatching {
val serverItems = pullAllLibrarySyncItems(profileId)
if (!isActiveOperation(profileId, operationGeneration)) return@runCatching
if (serverItems.isEmpty() && itemsById.isNotEmpty()) {
log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" }
} else {
@ -212,13 +222,32 @@ object LibraryRepository {
}
}
private fun activeOperationGeneration(profileId: Int): Long? {
if (ProfileRepository.activeProfileId != profileId) return null
if (!hasLoaded || currentProfileId != profileId) {
loadFromDisk(profileId)
}
return profileGeneration
}
private fun isActiveOperation(profileId: Int, generation: Long): Boolean =
currentProfileId == profileId &&
profileGeneration == generation &&
ProfileRepository.activeProfileId == profileId
fun toggleSaved(item: LibraryItem) {
ensureLoaded()
if (isTraktLibrarySourceActive()) {
syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
.onFailure { e ->
log.e(e) { "Failed to toggle Trakt watchlist" }
NuvioToastController.show(
e.message?.takeIf { it.isNotBlank() }
?: getString(Res.string.trakt_lists_update_failed),
)
}
publish()
}
return
@ -354,10 +383,11 @@ object LibraryRepository {
if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return
pushJob?.cancel()
val profileId = currentProfileId
pushJob = syncScope.launch {
delay(500)
if (profileId != currentProfileId) return@launch
runCatching {
val profileId = ProfileRepository.activeProfileId
val syncItems = itemsById.values.map { it.toSyncItem() }
if (syncItems.isEmpty()) return@runCatching
val params = buildJsonObject {

View file

@ -88,7 +88,6 @@ 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,
@ -175,7 +174,6 @@ fun LibraryScreen(
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
topPadding = if (topChromePadding != null) 0.dp else null,
listState = listState,
) {
stickyHeader {
@ -198,7 +196,6 @@ fun LibraryScreen(
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
topPadding = topChromePadding,
)
LibrarySourceSwitch(
selectedMode = sourceMode,

View file

@ -31,233 +31,6 @@ 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()
@ -293,17 +66,12 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
useYoutubeChunkedPlayback: Boolean = false,
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

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -50,15 +49,16 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamCard
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.isSelectableForPlayback
@ -467,6 +467,10 @@ private fun EpisodeStreamsSubView(
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val streamBadgeSettings by remember {
StreamBadgeSettingsRepository.ensureLoaded()
StreamBadgeSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val episode = state.selectedEpisode ?: return
val streamsUiState = state.streamsUiState
@ -606,9 +610,14 @@ private fun EpisodeStreamsSubView(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
) { _, stream ->
EpisodeSourceStreamRow(
StreamCard(
stream = stream,
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
!debridSettings.hasCustomStreamFormatting,
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
showAddonLogo = streamBadgeSettings.showAddonLogo,
badgePlacement = streamBadgeSettings.badgePlacement,
onClick = { onStreamSelected(stream, episode) },
)
}
@ -617,53 +626,3 @@ private fun EpisodeStreamsSubView(
}
}
}
@Composable
private fun EpisodeSourceStreamRow(
stream: StreamItem,
enabled: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.clip(tokens.shapes.compactCard)
.background(tokens.colors.surfaceCard)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = tokens.spacing.cardPadding, vertical = tokens.spacing.listGap),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
color = tokens.colors.textPrimary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
stream.streamSubtitle?.let { subtitle ->
if (subtitle != stream.streamLabel) {
Text(
text = subtitle,
color = tokens.colors.textSecondary,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = stream.addonName,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelXs,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -20,11 +20,13 @@ data class PlayerRoute(
)
data class PlayerLaunch(
val profileId: Int,
val title: String,
val sourceUrl: String,
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val streamType: String? = null,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,

View file

@ -47,6 +47,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -62,7 +65,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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.nuvioTypeScale
import nuvio.composeapp.generated.resources.Res
@ -122,6 +125,8 @@ internal fun OpeningOverlay(
),
label = "openingOverlayContentScale",
)
var logoLoadError by remember(logo) { mutableStateOf(false) }
val logoUrl = logo?.takeIf { it.isNotBlank() }
Box(
modifier = modifier
@ -178,14 +183,14 @@ internal fun OpeningOverlay(
label = "openingOverlayP2pProgress",
)
val progressActive = targetProgress != null
if (logo != null) {
if (logoUrl != null && !logoLoadError) {
Box(
modifier = Modifier
.width(300.dp)
.height(180.dp),
) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
@ -197,10 +202,11 @@ internal fun OpeningOverlay(
}
},
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
if (progressActive) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
@ -377,6 +383,9 @@ internal fun PauseMetadataOverlay(
horizontalSafePadding: Dp,
modifier: Modifier = Modifier,
) {
var logoLoadError by remember(logo) { mutableStateOf(false) }
val logoUrl = logo?.takeIf { it.isNotBlank() }
BoxWithConstraints(
modifier = modifier
.background(
@ -429,13 +438,14 @@ internal fun PauseMetadataOverlay(
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(if (compactHeight) 8.dp else 12.dp))
if (!logo.isNullOrBlank()) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.BottomStart,
modifier = Modifier.height(logoHeight),
onError = { logoLoadError = true },
)
} else {
Text(

View file

@ -5,11 +5,13 @@ import androidx.compose.ui.Modifier
@Composable
fun PlayerScreen(
profileId: Int,
title: String,
sourceUrl: String,
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
providerName: String,
streamTitle: String,
streamSubtitle: String?,
@ -39,11 +41,13 @@ fun PlayerScreen(
) {
PlayerScreenContent(
PlayerScreenArgs(
profileId = profileId,
title = title,
sourceUrl = sourceUrl,
sourceAudioUrl = sourceAudioUrl,
sourceHeaders = sourceHeaders,
sourceResponseHeaders = sourceResponseHeaders,
streamType = streamType,
providerName = providerName,
streamTitle = streamTitle,
streamSubtitle = streamSubtitle,

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.player
import androidx.compose.ui.Modifier
internal data class PlayerScreenArgs(
val profileId: Int,
val title: String,
val sourceUrl: String,
val sourceAudioUrl: String?,
val sourceHeaders: Map<String, String>,
val sourceResponseHeaders: Map<String, String>,
val streamType: String?,
val providerName: String,
val streamTitle: String,
val streamSubtitle: String?,

View file

@ -16,7 +16,6 @@ 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
@ -231,10 +230,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
initialSeekApplied = true
return@LaunchedEffect
}
if (isDesktop && activeInitialPositionMs > 0L) {
initialSeekApplied = true
return@LaunchedEffect
}
controller.seekTo(targetPositionMs)
initialSeekApplied = true
@ -244,8 +239,17 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
BindPlayerMetadataAndSkipEffects()
DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
val effectVideoId = playbackSession.videoId
val effectSourceUrl = activeSourceUrl
val effectSourceAudioUrl = activeSourceAudioUrl
onDispose {
flushWatchProgress()
if (
playbackSession.videoId == effectVideoId &&
activeSourceUrl == effectSourceUrl &&
activeSourceAudioUrl == effectSourceAudioUrl
) {
flushWatchProgress()
}
}
}
@ -551,6 +555,7 @@ internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message:
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName

View file

@ -23,6 +23,7 @@ internal data class PlayerSurfaceGestureCallbacks(
val clearLiveGestureFeedback: State<() -> Unit>,
val revealLockedOverlay: State<() -> Unit>,
val isHoldToSpeedGestureActive: State<Boolean>,
val touchGesturesEnabled: State<Boolean>,
val playerControlsLocked: State<Boolean>,
val currentPositionMs: State<Long>,
val currentDurationMs: State<Long>,
@ -165,33 +166,10 @@ 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()
if (revealControls) {
controlsVisible = true
}
controlsVisible = true
when {
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
@ -199,17 +177,6 @@ private fun PlayerScreenRuntime.applySeekByControlFeedback(
}
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) {
@ -233,9 +200,7 @@ private fun PlayerScreenRuntime.handleDoubleTapSeek(
maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
}
}
if (sendToController) {
playerController?.seekTo(targetPositionMs)
}
playerController?.seekTo(targetPositionMs)
scheduleProgressSyncAfterSeek()
showSeekFeedback(direction, nextState.amountMs)
@ -317,6 +282,10 @@ internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfac
revealLockedOverlay()
return@rememberUpdatedState
}
if (!playerSettingsUiState.touchGesturesEnabled) {
controlsVisible = !controlsVisible
return@rememberUpdatedState
}
when {
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Backward)
@ -338,6 +307,7 @@ internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfac
clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback),
revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay),
isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive),
touchGesturesEnabled = rememberUpdatedState(playerSettingsUiState.touchGesturesEnabled),
playerControlsLocked = rememberUpdatedState(playerControlsLocked),
currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)),
currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs),

View file

@ -17,6 +17,7 @@ internal val PlayerScreenRuntime.activePlaybackIdentity: String
internal val PlayerScreenRuntime.playbackSession: WatchProgressPlaybackSession
get() = WatchProgressPlaybackSession(
profileId = profileId,
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
@ -80,17 +81,40 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
.coerceIn(0f, 100f)
}
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
internal data class TraktScrobbleItemInputs(
val contentType: String,
val parentMetaId: String,
val videoId: String?,
val title: String,
val seasonNumber: Int?,
val episodeNumber: Int?,
val episodeTitle: String?,
)
internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
title = title,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
)
private suspend fun TraktScrobbleItemInputs.buildItem() =
TraktScrobbleRepository.buildItem(
contentType = contentType ?: parentMetaType,
contentType = contentType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
videoId = videoId,
title = title,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
)
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
snapshotTraktScrobbleItemInputs().buildItem()
internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
@ -108,6 +132,7 @@ internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
}
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
profileId = profileId,
item = item,
progressPercent = currentPlaybackProgressPercent(),
)
@ -120,9 +145,11 @@ internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? =
val percent = provided ?: currentPlaybackProgressPercent()
val itemSnapshot = currentTraktScrobbleItem
val inputsSnapshot = snapshotTraktScrobbleItemInputs()
scope.launch(NonCancellable) {
val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
profileId = profileId,
item = item,
progressPercent = percent,
)

View file

@ -51,7 +51,7 @@ internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean =
internal fun StreamItem.playerSourceIdentityKey(): String? {
p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
return "torrent:$hash:${fileIdx ?: -1}"
return "torrent:$hash:${p2pFileIdx ?: -1}"
}
clientResolve?.let { resolve ->
@ -138,7 +138,7 @@ internal fun PlayerScreenRuntime.saveP2pStreamForReuse(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
fileIdx = stream.p2pFileIdx,
sources = stream.sources,
bingeGroup = stream.behaviorHints.bingeGroup,
)
@ -160,12 +160,13 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentTrackers = stream.p2pTrackers
activeSourceIdentityKey = stream.playerSourceIdentityKey()
@ -202,12 +203,13 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
season = episode.season,
episode = episode.episode,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.fileIdx
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentTrackers = stream.p2pTrackers
applyEpisodeStreamMetadata(stream, episode, resume)
@ -255,6 +257,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeSourceIdentityKey = sourceIdentityKey
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
@ -302,6 +305,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
applyEpisodeStreamMetadata(stream, episode, resume)
}
@ -329,6 +333,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeSourceIdentityKey = null
activeStreamTitle = downloadItem.streamTitle.ifBlank {
episode.title.ifBlank { title }
@ -476,5 +481,6 @@ private fun PlayerScreenRuntime.saveDirectStreamForReuse(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}

View file

@ -28,10 +28,12 @@ internal class PlayerScreenRuntime(
var args by mutableStateOf(args)
val title: String get() = args.title
val profileId: Int get() = args.profileId
val sourceUrl: String get() = args.sourceUrl
val sourceAudioUrl: String? get() = args.sourceAudioUrl
val sourceHeaders: Map<String, String> get() = args.sourceHeaders
val sourceResponseHeaders: Map<String, String> get() = args.sourceResponseHeaders
val streamType: String? get() = args.streamType
val providerName: String get() = args.providerName
val streamTitle: String get() = args.streamTitle
val streamSubtitle: String? get() = args.streamSubtitle
@ -66,8 +68,8 @@ internal class PlayerScreenRuntime(
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
var watchedUiState: WatchedUiState = WatchedUiState()
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
var sourceStreamsState by mutableStateOf(StreamsUiState())
var episodeStreamsRepoState by mutableStateOf(StreamsUiState())
var sourceStreamsState: StreamsUiState = StreamsUiState()
var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
var addonsUiState: AddonsUiState = AddonsUiState()
var addonSubtitles: List<AddonSubtitle> = emptyList()
@ -95,6 +97,7 @@ internal class PlayerScreenRuntime(
var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl)
var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
var activeStreamType by mutableStateOf(streamType)
var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
var activeTorrentFilename by mutableStateOf(torrentFilename)
@ -155,12 +158,6 @@ 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

@ -1,5 +1,7 @@
package com.nuvio.app.features.player
import com.nuvio.app.core.i18n.localizedNoSubtitleLinesFound
import com.nuvio.app.core.i18n.localizedSubtitleLinesLoadError
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import kotlinx.coroutines.launch
@ -33,13 +35,13 @@ internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false
subtitleAutoSyncState = subtitleAutoSyncState.copy(
cues = cues,
isLoading = false,
errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
errorMessage = if (cues.isEmpty()) localizedNoSubtitleLinesFound() else null,
)
},
onFailure = { error ->
subtitleAutoSyncState = subtitleAutoSyncState.copy(
isLoading = false,
errorMessage = error.message ?: "Unable to load subtitle lines",
errorMessage = error.message ?: localizedSubtitleLinesLoadError(),
)
},
)

View file

@ -36,6 +36,7 @@ data class PlayerSettingsUiState(
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
val holdToSpeedEnabled: Boolean = true,
val holdToSpeedValue: Float = 2f,
val touchGesturesEnabled: Boolean = true,
val externalPlayerEnabled: Boolean = false,
val externalPlayerForwardSubtitles: Boolean = false,
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
@ -95,6 +96,7 @@ object PlayerSettingsRepository {
private var resizeMode = PlayerResizeMode.Fit
private var holdToSpeedEnabled = true
private var holdToSpeedValue = 2f
private var touchGesturesEnabled = true
private var externalPlayerEnabled = false
private var externalPlayerForwardSubtitles = false
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
@ -159,6 +161,7 @@ object PlayerSettingsRepository {
resizeMode = PlayerResizeMode.Fit
holdToSpeedEnabled = true
holdToSpeedValue = 2f
touchGesturesEnabled = true
externalPlayerEnabled = false
externalPlayerForwardSubtitles = false
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
@ -218,6 +221,7 @@ object PlayerSettingsRepository {
?: PlayerResizeMode.Fit
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
@ -369,6 +373,14 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
}
fun setTouchGesturesEnabled(enabled: Boolean) {
ensureLoaded()
if (touchGesturesEnabled == enabled) return
touchGesturesEnabled = enabled
publish()
PlayerSettingsStorage.saveTouchGesturesEnabled(enabled)
}
fun setExternalPlayerEnabled(enabled: Boolean) {
ensureLoaded()
if (enabled && externalPlayerId.isNullOrBlank()) {
@ -828,6 +840,7 @@ object PlayerSettingsRepository {
resizeMode = resizeMode,
holdToSpeedEnabled = holdToSpeedEnabled,
holdToSpeedValue = holdToSpeedValue,
touchGesturesEnabled = touchGesturesEnabled,
externalPlayerEnabled = externalPlayerEnabled,
externalPlayerForwardSubtitles = externalPlayerForwardSubtitles,
externalPlayerId = externalPlayerId,

View file

@ -11,6 +11,8 @@ internal expect object PlayerSettingsStorage {
fun saveHoldToSpeedEnabled(enabled: Boolean)
fun loadHoldToSpeedValue(): Float?
fun saveHoldToSpeedValue(speed: Float)
fun loadTouchGesturesEnabled(): Boolean?
fun saveTouchGesturesEnabled(enabled: Boolean)
fun loadExternalPlayerEnabled(): Boolean?
fun saveExternalPlayerEnabled(enabled: Boolean)
fun loadExternalPlayerForwardSubtitles(): Boolean?

View file

@ -15,11 +15,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -39,19 +36,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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 com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.streams.StreamBadge
import com.nuvio.app.features.streams.StreamBadgeImage
import com.nuvio.app.features.streams.StreamBadgePlacement
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamFileSizeBadge
import com.nuvio.app.features.streams.StreamCard
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.isSelectableForPlayback
@ -224,12 +215,16 @@ fun PlayerSourcesPanel(
currentUrl = currentStreamUrl,
currentName = currentStreamName,
)
SourceStreamRow(
StreamCard(
stream = stream,
isCurrent = isCurrent,
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
!debridSettings.hasCustomStreamFormatting,
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
showAddonLogo = streamBadgeSettings.showAddonLogo,
badgePlacement = streamBadgeSettings.badgePlacement,
isCurrent = isCurrent,
currentLabel = stringResource(Res.string.compose_player_playing),
onClick = { onStreamSelected(stream) },
)
}
@ -243,150 +238,6 @@ fun PlayerSourcesPanel(
}
}
@Composable
private fun SourceStreamRow(
stream: StreamItem,
isCurrent: Boolean,
enabled: Boolean,
showFileSizeBadges: Boolean,
badgePlacement: StreamBadgePlacement,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val cardShape = tokens.shapes.compactCard
val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = NuvioTokens.Space.s64 + NuvioTokens.Space.s4)
.shadow(
elevation = tokens.elevation.raised,
shape = cardShape,
ambientColor = tokens.colors.overlayScrim.copy(alpha = tokens.opacity.subtle),
spotColor = tokens.colors.overlayScrim.copy(alpha = tokens.opacity.subtle),
)
.clip(cardShape)
.background(
if (isCurrent) tokens.colors.overlaySelected else tokens.colors.surfaceCard,
)
.then(
if (isCurrent) {
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, cardShape)
} else {
Modifier
},
)
.clickable(enabled = enabled, onClick = onClick)
.padding(tokens.spacing.cardPaddingCompact),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Column(modifier = Modifier.weight(1f)) {
if (hasBadgeMetadata && badgePlacement == StreamBadgePlacement.TOP) {
SourceStreamBadgeRow(
badgeImages = badgeImages,
stream = stream,
showFileSizeBadges = showFileSizeBadges,
)
Spacer(modifier = Modifier.height(NuvioTokens.Space.s6))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
Text(
text = stream.streamLabel,
color = tokens.colors.textPrimary,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
letterSpacing = NuvioTokens.LetterSpacing.none,
),
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Box(
modifier = Modifier
.clip(tokens.shapes.chip)
.background(tokens.colors.accent)
.padding(horizontal = NuvioTokens.Space.s8, vertical = NuvioTokens.Space.s3),
) {
Text(
text = stringResource(Res.string.compose_player_playing),
color = tokens.colors.onAccent,
fontSize = NuvioTokens.Type.labelXs,
fontWeight = FontWeight.SemiBold,
)
}
}
}
val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
Spacer(modifier = Modifier.height(NuvioTokens.Space.s2))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = tokens.colors.textSecondary,
)
}
Spacer(modifier = Modifier.height(NuvioTokens.Space.s6))
if (badgePlacement == StreamBadgePlacement.BOTTOM) {
SourceStreamBadgeRow(
badgeImages = badgeImages,
stream = stream,
showFileSizeBadges = showFileSizeBadges,
) {
Text(
text = stream.addonName,
modifier = if (hasBadgeMetadata) Modifier.padding(start = NuvioTokens.Space.s4) else Modifier,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelXs,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
Text(
text = stream.addonName,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelXs,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun SourceStreamBadgeRow(
badgeImages: List<StreamBadge>,
stream: StreamItem,
showFileSizeBadges: Boolean,
modifier: Modifier = Modifier,
trailingContent: @Composable RowScope.() -> Unit = {},
) {
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
) {
badgeImages.forEach { badge ->
StreamBadgeImage(badge = badge)
}
if (showFileSizeBadges) {
StreamFileSizeBadge(stream = stream)
}
trailingContent()
}
}
@Composable
internal fun AddonFilterChip(
label: String,

View file

@ -12,28 +12,35 @@ import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.InstalledStreamAddonTarget
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamBadgePresentation
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLoadCompletion
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.runCatchingUnlessCancelled
import com.nuvio.app.features.streams.sortedForGroupedDisplay
import com.nuvio.app.features.streams.streamAddonInstanceId
import com.nuvio.app.features.streams.toEmptyStateReason
import com.nuvio.app.features.streams.toPluginProviderGroups
import com.nuvio.app.features.streams.toStreamItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
/**
* Dedicated stream fetcher for use inside the player (sources & episodes panels).
@ -63,7 +70,6 @@ object PlayerStreamsRepository {
forceRefresh: Boolean = false,
) {
fetchStreams(
panelName = "sources",
type = type,
videoId = videoId,
season = season,
@ -85,7 +91,6 @@ object PlayerStreamsRepository {
forceRefresh: Boolean = false,
) {
fetchStreams(
panelName = "episodeStreams",
type = type,
videoId = videoId,
season = season,
@ -121,7 +126,6 @@ object PlayerStreamsRepository {
}
private fun fetchStreams(
panelName: String,
type: String,
videoId: String,
season: Int?,
@ -133,18 +137,22 @@ object PlayerStreamsRepository {
jobHolder: () -> Job?,
setJob: (Job) -> Unit,
) {
val requestKey = "$type::$videoId::$season::$episode"
val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.uiState.value
} else {
PluginsUiState(pluginsEnabled = false)
}
val requestKey = "$type::$videoId::$season::$episode::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val current = stateFlow.value
if (
!forceRefresh &&
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()
@ -152,7 +160,7 @@ object PlayerStreamsRepository {
val streamBadgeRules = StreamBadgeSettingsRepository.snapshot()
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
if (embeddedStreams.isNotEmpty()) {
log.d { "using embedded $panelName request=$requestKey streams=${embeddedStreams.size}" }
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
val group = AddonStreamGroup(
addonName = embeddedStreams.first().addonName,
addonId = "embedded",
@ -168,28 +176,28 @@ object PlayerStreamsRepository {
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
)
log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" }
return
}
val installedAddons = AddonRepository.uiState.value.addons.enabledAddons()
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
val debridSettings = DebridSettingsRepository.snapshot()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
} else {
emptyList()
}
val pluginProviderGroups = pluginScrapers.toPluginProviderGroups(
repositories = pluginUiState.repositories,
groupByRepository = pluginUiState.groupStreamsByRepository,
)
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
)
log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" }
return
}
@ -204,50 +212,33 @@ object PlayerStreamsRepository {
}
if (!supportsRequestedStream) return@mapNotNull null
PlayerInstalledStreamAddonTarget(
InstalledStreamAddonTarget(
addonName = addon.displayTitle.ifBlank { manifest.name },
addonId = addon.streamAddonInstanceId(manifest.id),
manifest = manifest,
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
stateFlow.value = StreamsUiState(
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
}
val installedAddonOrder = streamAddons.map { it.addonName }
val warmedAddonGroups = if (forceRefresh) {
emptyMap()
} else {
AddonStreamWarmupRepository
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
.orEmpty()
.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(
AddonStreamGroup(
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
isLoading = true,
)
} + pluginScrapers.map { scraper ->
} + pluginProviderGroups.map { providerGroup ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
addonName = providerGroup.addonName,
addonId = providerGroup.addonId,
streams = emptyList(),
isLoading = true,
)
@ -258,22 +249,23 @@ 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 }
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
val installedAddonNames = installedAddonOrder.toSet()
val pluginRemainingByAddonId = pluginProviderGroups
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size }
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
val debridAvailabilityJobs = mutableListOf<Job>()
fun emptyStateReason(groups: List<AddonStreamGroup>, anyLoading: Boolean) =
if (!anyLoading && groups.all { it.streams.isEmpty() }) {
if (groups.all { !it.error.isNullOrBlank() }) {
com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed
} else {
com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound
}
} else {
null
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late player stream load completion after channel close" }
}
}
fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup {
val badgeGroup = StreamBadgePresentation.apply(
@ -287,7 +279,6 @@ object PlayerStreamsRepository {
}
fun publishStreamGroup(group: AddonStreamGroup) {
var nextState: StreamsUiState? = null
stateFlow.update { current ->
val updated = StreamAutoPlaySelector.orderAddonStreams(
groups = current.groups.map { currentGroup ->
@ -299,15 +290,8 @@ object PlayerStreamsRepository {
current.copy(
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()}"
}
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
}
@ -342,8 +326,8 @@ object PlayerStreamsRepository {
debridAvailabilityJobs += availabilityJob
}
val addonJobs = pendingStreamAddons.map { addon ->
async {
streamAddons.forEach { addon ->
launch {
val url = buildAddonResourceUrl(
manifestUrl = addon.manifest.transportUrl,
resource = "stream",
@ -352,75 +336,123 @@ object PlayerStreamsRepository {
)
val displayName = addon.addonName
runCatching {
log.d { "fetch $panelName request=$requestKey addon=$displayName" }
val group = runCatchingUnlessCancelled {
val payload = httpGetText(url)
StreamParser.parse(payload, displayName, addon.addonId)
StreamParser.parse(
payload = payload,
addonName = displayName,
addonId = addon.addonId,
addonLogo = addon.manifest.logoUrl,
)
}.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 $panelName request=$requestKey addon=$displayName" }
log.w(err) { "Failed: ${displayName}" }
AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message)
},
)
publishCompletion(StreamLoadCompletion.Addon(group))
}
}
val pluginJobs = pluginScrapers.map { scraper ->
async {
log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" }
PluginRepository.executeScraper(
scraper = scraper,
tmdbId = pluginContentId(
videoId = videoId,
pluginProviderGroups.forEach { providerGroup ->
val includeScraperNameInSubtitle = false
providerGroup.scrapers.forEach { scraper ->
launch {
val completion = PluginRepository.executeScraper(
scraper = scraper,
tmdbId = pluginContentId(
videoId = videoId,
season = season,
episode = episode,
),
mediaType = type,
season = season,
episode = episode,
),
mediaType = type,
season = season,
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}",
streams = results.map { it.toStreamItem(scraper) },
isLoading = false,
)
},
onFailure = { err ->
log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" }
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = false,
error = err.message,
)
},
)
).fold(
onSuccess = { results ->
StreamLoadCompletion.PluginScraper(
addonId = providerGroup.addonId,
streams = results.map { result ->
result.toStreamItem(
scraper = scraper,
addonName = providerGroup.addonName,
addonId = providerGroup.addonId,
includeScraperNameInSubtitle = includeScraperNameInSubtitle,
)
},
error = null,
)
},
onFailure = { error ->
log.w(error) { "Plugin scraper failed: ${scraper.name}" }
StreamLoadCompletion.PluginScraper(
addonId = providerGroup.addonId,
streams = emptyList(),
error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name),
)
},
)
publishCompletion(completion)
}
}
}
val jobs = addonJobs + pluginJobs
val completions = Channel<AddonStreamGroup>(capacity = Channel.BUFFERED)
jobs.forEach { deferred ->
launch {
completions.send(deferred.await())
repeat(totalTasks) {
when (val completion = completions.receive()) {
is StreamLoadCompletion.Addon -> {
publishStreamGroupAfterCacheCheck(completion.group)
}
is StreamLoadCompletion.PluginScraper -> {
val remaining = (pluginRemainingByAddonId[completion.addonId] ?: 1) - 1
pluginRemainingByAddonId[completion.addonId] = remaining.coerceAtLeast(0)
if (!completion.error.isNullOrBlank() && pluginFirstErrorByAddonId[completion.addonId].isNullOrBlank()) {
pluginFirstErrorByAddonId[completion.addonId] = completion.error
}
stateFlow.update { current ->
val updated = StreamAutoPlaySelector.orderAddonStreams(
groups = current.groups.map { group ->
if (group.addonId != completion.addonId) {
group
} else {
val mergedStreams = if (completion.streams.isEmpty()) {
group.streams
} else {
(group.streams + completion.streams).sortedForGroupedDisplay()
}
val stillLoading = remaining > 0
val finalError = if (mergedStreams.isEmpty() && !stillLoading) {
pluginFirstErrorByAddonId[completion.addonId]
} else {
null
}
group.copy(
streams = mergedStreams,
isLoading = stillLoading,
error = finalError,
)
}
},
installedOrder = installedAddonOrder,
)
val anyLoading = updated.any { it.isLoading }
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
}
}
}
repeat(jobs.size) {
val result = completions.receive()
publishStreamGroupAfterCacheCheck(result)
}
for (availabilityJob in debridAvailabilityJobs) {
availabilityJob.join()
}
log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" }
launch {
DirectDebridStreamPreparer.prepare(
streams = stateFlow.value.groups
@ -448,68 +480,3 @@ object PlayerStreamsRepository {
setJob(job)
}
}
private data class PlayerInstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun StreamsUiState.streamDiagnostics(): String {
val streamCount = groups.sumOf { it.streams.size }
val loadingCount = groups.count { it.isLoading }
val errorCount = groups.count { !it.error.isNullOrBlank() }
val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group ->
buildString {
append(group.addonName)
append(':')
append(group.streams.size)
if (group.isLoading) append(":loading")
if (!group.error.isNullOrBlank()) append(":error")
}
}
val suffix = if (groups.size > 4) "+${groups.size - 4}" else ""
return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " +
"loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " +
"sample=$sampleGroups$suffix"
}
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
val subtitleParts = listOfNotNull(
quality?.takeIf { it.isNotBlank() },
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
val requestHeaders = headers
.orEmpty()
.mapNotNull { (key, value) ->
val headerName = key.trim()
val headerValue = value.trim()
if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) {
null
} else {
headerName to headerValue
}
}
.toMap()
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
url = url,
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
behaviorHints = if (requestHeaders.isEmpty()) {
com.nuvio.app.features.streams.StreamBehaviorHints()
} else {
com.nuvio.app.features.streams.StreamBehaviorHints(
notWebReady = true,
proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders),
)
},
)
}

View file

@ -44,6 +44,7 @@ internal fun Modifier.playerSurfaceDragGestures(
layoutSize: IntSize,
sideGestureSystemEdgeExclusionPx: Float,
playerControlsLockedState: State<Boolean>,
touchGesturesEnabledState: State<Boolean>,
isHoldToSpeedGestureActiveState: State<Boolean>,
currentPositionMsState: State<Long>,
currentDurationMsState: State<Long>,
@ -67,6 +68,9 @@ internal fun Modifier.playerSurfaceDragGestures(
}
return@awaitEachGesture
}
if (!touchGesturesEnabledState.value) {
return@awaitEachGesture
}
val controller = gestureController
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture

View file

@ -101,7 +101,7 @@ object SubtitleRepository {
}
_addonSubtitles.value = allSubs
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) {
if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name.isSubtitleResourceName() } == true }) {
_error.value = getString(Res.string.compose_player_no_subtitles_found)
}
_isLoading.value = false
@ -123,7 +123,8 @@ private fun String.isSubtitleResourceName(): Boolean =
equals("subtitles", ignoreCase = true) || equals("subtitle", ignoreCase = true)
private fun AddonResource.supportsSubtitleType(type: String, videoId: String): Boolean {
val typeMatches = types.isEmpty() || types.any { it.equals(type, ignoreCase = true) }
val canonical = canonicalSubtitleType(type)
val typeMatches = types.isEmpty() || types.any { canonicalSubtitleType(it).equals(canonical, ignoreCase = true) }
if (!typeMatches) return false
return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
}

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.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

@ -32,22 +32,55 @@ object PlayerNextEpisodeRules {
thresholdPercent: Float,
thresholdMinutesBeforeEnd: Float,
): Boolean {
val outroInterval = skipIntervals.firstOrNull { it.type == "outro" }
return if (outroInterval != null) {
positionMs / 1000.0 >= outroInterval.startTime
} else {
val outroSegments = skipIntervals.filter { it.type in OUTRO_SEGMENT_TYPES }
if (outroSegments.isNotEmpty()) {
if (durationMs <= 0L) return false
when (thresholdMode) {
val latestOutroEndMs = (outroSegments.maxOf { it.endTime } * 1_000.0).toLong()
val postOutroGapMs = durationMs - latestOutroEndMs
// Calculate the user's configured threshold as milliseconds from end.
val userThresholdMs = when (thresholdMode) {
NextEpisodeThresholdMode.PERCENTAGE -> {
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
((1.0 - clampedPercent / 100.0) * durationMs).toLong()
}
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
val remainingMs = durationMs - positionMs
remainingMs <= (clampedMinutes * 60_000f).toLong()
(clampedMinutes * 60_000f).toLong()
}
}
return if (postOutroGapMs > userThresholdMs) {
when (thresholdMode) {
NextEpisodeThresholdMode.PERCENTAGE -> {
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
}
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
val remainingMs = durationMs - positionMs
remainingMs <= (clampedMinutes * 60_000f).toLong()
}
}
} else {
// Outro ends close to the file end — fire at earliest outro start.
positionMs / 1_000.0 >= outroSegments.minOf { it.startTime }
}
}
// Fallback to the settings threshold when no outro data exists.
if (durationMs <= 0L) return false
return when (thresholdMode) {
NextEpisodeThresholdMode.PERCENTAGE -> {
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
}
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
val remainingMs = durationMs - positionMs
remainingMs <= (clampedMinutes * 60_000f).toLong()
}
}
}
@ -76,6 +109,8 @@ object PlayerNextEpisodeRules {
if (m1 != m2) return m1.compareTo(m2)
return d1.compareTo(d2)
}
val OUTRO_SEGMENT_TYPES = setOf("outro", "ed", "mixed-ed")
}
internal expect fun currentDateComponents(): DateComponents

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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import coil3.compose.AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.ui.NuvioInputField

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.profiles
import androidx.compose.ui.graphics.Color
import com.nuvio.app.core.network.SyncBackendRepository
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -77,7 +78,7 @@ val PROFILE_COLORS = listOf(
)
fun avatarStorageUrl(storagePath: String): String =
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
SyncBackendRepository.selectedBackend.avatarStorageUrl(storagePath)
fun normalizedAvatarUrl(url: String?): String? =
url?.trim()?.takeIf { it.isValidAvatarUrl() }

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