diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index 2bea1d468..bf699cdf5 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -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; diff --git a/.gitignore b/.gitignore index c7156d5f7..80df24020 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ asset scripts/scrape_android_compose_animation_docs.py tools AGENTS.md +server \ No newline at end of file diff --git a/composeApp/Configuration/DesktopVersion.properties b/composeApp/Configuration/DesktopVersion.properties deleted file mode 100644 index 8cf3c5393..000000000 --- a/composeApp/Configuration/DesktopVersion.properties +++ /dev/null @@ -1,2 +0,0 @@ -VERSION_NAME=0.1.1 -VERSION_CODE=2 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a38669b52..964b76961 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 @get:Input - abstract val desktopAppVersionName: Property + abstract val supabaseUrl: Property @get:Input - abstract val desktopAppVersionCode: Property + abstract val supabaseAnonKey: Property + + @get:Input + abstract val nuvioSupabaseUrl: Property + + @get:Input + abstract val nuvioSupabaseAnonKey: Property + + @get:Input + abstract val syncBackendManifestUrl: Property @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("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("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("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("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("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().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>().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")) diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar index 565df23bc..3d9346854 100644 Binary files a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index 29e2b9b63..34c5cfd6b 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -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 { *; } diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 9d407edf9..996401f6b 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -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 diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt deleted file mode 100644 index 1d181ca62..000000000 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt +++ /dev/null @@ -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 -} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index f44e40cd2..6e77db321 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -26,6 +26,4 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "android" -internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) - internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index ad83b451b..745c91d55 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index 2c5d6eca2..5ba9da3cc 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -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 \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt new file mode 100644 index 000000000..e87613d29 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt @@ -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") + } + } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt deleted file mode 100644 index fa48a20ca..000000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt +++ /dev/null @@ -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, - ) -} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt deleted file mode 100644 index a01bf27a9..000000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt index 9fe424a19..b1856bf71 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt index 7497c0607..93aecad7c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt @@ -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 } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt index 038a472a2..fc6d4af64 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt @@ -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 = 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, + 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? { if (headers.isEmpty()) return null @@ -50,7 +70,7 @@ private fun inferMimeTypeFromResponseHeaders(headers: Map): 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? { + 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)($|[=/_.?&%-])") diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index dafaedee3..fcd2af127 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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, sourceResponseHeaders: Map, + 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() } @@ -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) { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 2a98502da..32b4c4b1a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 0d8e5b9b6..6a2e1584d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -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) } } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt index 4ceee4668..2b308a232 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt @@ -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 diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt deleted file mode 100644 index 1d181ca62..000000000 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt +++ /dev/null @@ -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 -} diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 55a061cd2..fcb41fc95 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -355,7 +355,7 @@ Προτροπή συνέχισης κατά την εκκίνηση ΣΤΥΛ ΚΑΡΤΑΣ ΚΑΤΑ ΤΗΝ ΕΚΚΙΝΗΣΗ - ΣΥΜΠΕΡΙΦΟΡΑ UP NEXT + ΣΥΜΠΕΡΙΦΟΡΑ ΕΠΟΜΕΝΟΥ ΟΡΑΤΟΤΗΤΑ Εμφάνιση της ενότητας Συνέχεια παρακολούθησης στην Αρχική οθόνη. Εμφάνιση συνέχειας παρακολούθησης @@ -363,8 +363,8 @@ Κάρτα αφίσας με προτεραιότητα στο artwork Πλατύ Οριζόντια κάρτα με πλούσιες πληροφορίες - Όταν είναι ενεργοποιημένο, το Up Next συνεχίζει πάντα από το πιο απομακρυσμένο παρακολουθημένο επεισόδιο. Όταν είναι απενεργοποιημένο, ακολουθεί το πιο πρόσφατα παρακολουθηθέν επεισόδιο. Χρήσιμο αν ξαναβλέπετε παλαιότερα επεισόδια. - Up Next από το πιο απομακρυσμένο επεισόδιο + Εμφάνιση επόμενου επεισοδίου με βάση το πιο προχωρημένο προβληθέν επεισόδιο. Απενεργοποιήστε το για επαναπροβολές ώστε να χρησιμοποιείται το πιο πρόσφατα προβληθέν επεισόδιο. + Επόμενο από το πιο προχωρημένο επεισόδιο ΑΡΧΙΚΗ ΠΗΓΕΣ Εγκατάσταση, αφαίρεση, ανανέωση και ταξινόμηση των πηγών περιεχομένου σας. diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a09f31754..544798123 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -32,9 +32,9 @@ Indisponible Configurer l’addon Supprimer l’addon - Ajoute une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. + Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. Aucun addon installé. - Saisis une URL d’addon. + Saisissez une URL d’addon. URL de l’addon Installer l’addon Chargement des détails du manifeste… @@ -62,7 +62,7 @@ Ajouter un catalogue Ajouter un dossier Tous les genres - Ajoute des catalogues depuis tes addons installés pour définir ce qu’affiche ce dossier. + Ajoutez des catalogues depuis vos addons installés pour définir ce qu’affiche ce dossier. Aucune source de catalogue Choisir Emoji @@ -73,15 +73,15 @@ Terminé Modifier la collection Modifier le dossier - Configure l’identité, la présentation et les sources de catalogue du dossier avec la même structure que l’éditeur principal de collections. - Ajoutes-en un pour commencer. + Configurez l’identité, la présentation et les sources de catalogue du dossier avec la même structure que l’éditeur principal de collections. + Ajoutez-en un pour commencer. Aucun dossier Dossiers Filtre de genre Afficher uniquement l’image de couverture Masquer le titre Nouveau dossier - Affiche cette collection au-dessus de tous les catalogues normaux de l’accueil. Plusieurs collections épinglées suivent l’ordre de création. + Affichez cette collection au-dessus de tous les catalogues normaux de l’accueil. Plusieurs collections épinglées suivent l’ordre de création. Épingler au-dessus des catalogues URL de l’image de fond (facultatif) Nom du dossier @@ -92,7 +92,7 @@ Apparence Informations de base Sources de catalogue - Choisis les catalogues d’addon que ce dossier doit regrouper. + Choisissez les catalogues d’addon que ce dossier doit regrouper. Sélectionner des catalogues Sélectionner un genre %1$d sélectionné(s) @@ -118,14 +118,14 @@ Personne Réalisateur Personnalisé - Choisis une source prédéfinie. Tu peux la modifier ou la supprimer après l’avoir ajoutée. - Colle une URL de liste publique TMDB ou uniquement le numéro de l’URL. - Cherche par nom de studio, ou colle un ID/URL de société TMDB et ajoute-le directement. - Saisis un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. - Cherche le nom d’une collection de films ou colle l’ID de collection TMDB. - Saisis un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. - Saisis un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. - Crée une ligne TMDB dynamique avec des filtres optionnels. Laisse les champs vides si tu n’as pas besoin de ce filtre. + Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l’avoir ajoutée. + Collez une URL de liste publique TMDB ou uniquement le numéro de l’URL. + Cherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. + Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. + Cherchez le nom d’une collection de films ou collez l’ID de collection TMDB. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. + Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n’avez pas besoin de ce filtre. Liste publique TMDB ID de chaîne ID de collection @@ -158,7 +158,7 @@ Les deux Tri Filtres - Laisse les champs vides si tu n’as pas besoin de ce filtre. + Laissez les champs vides si vous n’avez pas besoin de ce filtre. Genres rapides Langues rapides Pays rapides @@ -166,32 +166,32 @@ Studios rapides Chaînes rapides ID de genre - Utilise des numéros de genre TMDB. Sépare plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. + Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. Date de sortie ou de diffusion depuis Date de sortie ou de diffusion jusqu’au - Utilise le format AAAA-MM-JJ, ex. 2024-01-01. + Utilisez le format AAAA-MM-JJ, ex. 2024-01-01. Note minimale Note maximale Note TMDB de 0 à 10. Exemple : 7.0. 7.0 10 Votes minimum - Utilise ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. + Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. Langue originale - Utilise des codes de langue à deux lettres, ex. en, ko, ja, hi. + Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi. Pays d’origine - Utilise des codes de pays à deux lettres, ex. US, KR, JP, IN. + Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN. ID de mots-clés - Utilise des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. + Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. 9715 pour super-héros ID de société - Utilise des ID de studio/société. Les puces rapides remplissent des exemples courants. + Utilisez des ID de studio/société. Les puces rapides remplissent des exemples courants. 420 pour Marvel Studios ID de chaîne - Pour les séries uniquement. Utilise des ID de chaîne comme Netflix 213 ou HBO 49. + Pour les séries uniquement. Utilisez des ID de chaîne comme Netflix 213 ou HBO 49. 213 pour Netflix Année - Utilise une année à quatre chiffres, ex. 2024. + Utilisez une année à quatre chiffres, ex. 2024. Préréglages Rechercher Ajouter une source @@ -200,7 +200,7 @@ Listes Trakt Liste Trakt Rechercher un titre, URL Trakt ou ID de liste - Utilise une URL publique de liste Trakt ou un ID numérique de liste, ou cherche par nom. + Utilisez une URL publique de liste Trakt ou un ID numérique de liste, ou cherchez par nom. Programme du week-end, Lauréats Résultats de recherche Listes en vogue @@ -216,8 +216,8 @@ Populaire Pourcentage Votes - Saisis un nom, une URL ou un ID de liste Trakt - Saisis un ID ou une URL de liste Trakt + Saisissez un nom, une URL ou un ID de liste Trakt + Saisissez un ID ou une URL de liste Trakt Impossible de charger la liste Trakt Aucune liste Trakt trouvée Liste Trakt résolue @@ -263,7 +263,7 @@ Code pays ISO 3166-1 où le titre est disponible. Exemple : US, GB. Régions rapides ID des fournisseurs de visionnage - Utilise les ID de fournisseurs de visionnage TMDB. Sépare-les par des virgules pour ET, ou par des barres verticales pour OU. + Utilisez les ID de fournisseurs de visionnage TMDB. Séparez-les par des virgules pour ET, ou par des barres verticales pour OU. 8|337|350 Fournisseurs rapides Netflix @@ -278,7 +278,7 @@ Personne Réalisateur TMDB Discover - Crées-en une pour organiser tes catalogues. + Créez-en une pour organiser vos catalogues. Aucune collection %1$d dossier(s) Aucun élément trouvé @@ -286,31 +286,31 @@ Collections Importer des collections JSON - Colle le JSON de tes collections ci-dessous. + Collez le JSON de vos collections ci-dessous. Importer Nouvelle collection Épinglé Tout - Tes collections + Vos collections Fait avec ❤️ par Tapframe et ses amis Version %1$s (%2$s) Désactivé Activé Pause Recharger - Tu as déjà un compte ? + Vous avez déjà un compte ? Continuer sans compte Créer un compte Pas encore de compte ? Adresse e-mail ou Mot de passe - Connecte-toi pour accéder à ta bibliothèque et ta progression + Connectez-vous pour accéder à votre bibliothèque et votre progression Se connecter - Inscris-toi pour synchroniser tes données entre appareils + Inscrivez-vous pour synchroniser vos données entre appareils S’inscrire - Tes données seront uniquement stockées localement - Regarde tout, partout + Vos données seront uniquement stockées localement + Regardez tout, partout Bon retour Bibliothèque Bibliothèque Trakt @@ -320,8 +320,11 @@ Rechercher Pistes audio Audio + Synchro auto + Gras Intégré Décalage inférieur + Capturer Fermer le lecteur Couleur En cours de lecture @@ -332,18 +335,24 @@ Taille de police %1$dsp Verrouiller les contrôles du lecteur + Chargement des lignes de sous-titres… + Aucune ligne de sous-titres trouvée + Impossible de charger les lignes de sous-titres Aucune piste audio disponible Aucun épisode disponible Aucun stream trouvé Aucun Contour + Couleur du contour Épisodes Sources Streams Erreur de lecture Lecture en cours - Appuie pour chercher des sous-titres + Appuyez pour chercher des sous-titres Retour + Recharger + Réinitialiser Rétablir les valeurs par défaut Remplir Ajuster @@ -356,28 +365,31 @@ Avancer de 10 secondes Sources Style + Sélectionnez d’abord un sous-titre d’addon Sous-titres + Délai des sous-titres Sous-titres + Opacité du texte Luminosité %1$s Volume %1$s Muet Téléchargé Diffusé À confirmer - Appuie pour déverrouiller + Appuyez pour déverrouiller Piste %1$d Déverrouiller les contrôles du lecteur - Tu regardes + Vous regardez Ajouter un profil Effacer la recherche Découvrir Les addons installés n’ont retourné aucun résultat de recherche valide. La recherche a échoué - Installe et valide au moins un addon avant de rechercher. + Installez et validez au moins un addon avant de rechercher. Aucun addon actif Les catalogues installés n’ont retourné aucun résultat pour cette requête. Aucun résultat trouvé - Tes addons installés n’exposent pas de catalogue de recherche. + Vos addons installés n’exposent pas de catalogue de recherche. Aucun catalogue de recherche Rechercher des films, séries… Recherches récentes @@ -386,6 +398,7 @@ Général Compte Addons + Avancé Apparence Contenu et découverte Continuer à regarder @@ -400,27 +413,38 @@ Plugins Personnalisation des affiches Paramètres + Streams Supporters et contributeurs Enrichissement TMDB Trakt À PROPOS - Gère ton compte, déconnecte-toi ou supprime-le. + Gérez votre compte, déconnectez-vous ou supprimez-le. COMPTE - Ajuste la présentation de l’accueil et les préférences visuelles. + Comportement au démarrage et des profils. + AVANCÉ + Ajustez la présentation de l’accueil et les préférences visuelles. Rechercher de nouvelles versions de l’application. Vérifier les mises à jour - Gère les addons et sources de découverte. - Gère tes films et épisodes téléchargés. + Gérez les addons et sources de découverte. + Gérez vos films et épisodes téléchargés. Téléchargements GÉNÉRAL - Connecte les services TMDB et MDBList. - Gère les alertes de sortie d’épisodes et envoie une notification de test. + Connectez les services TMDB et MDBList. + Gérez les alertes de sortie d’épisodes et envoyez une notification de test. + Affichage des résultats de streams et règles d’URL de badges. Basculer vers un profil différent. Changer de profil - Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt. + Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. Aucun paramètre trouvé. Rechercher dans les paramètres… RÉSULTATS + DÉMARRAGE + CACHE + Mémoriser le dernier profil + Mémorise le dernier profil sélectionné au démarrage + Vider le cache de Continuer à regarder + Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder + Cache vidé LICENCE DE L’APP DONNÉES ET SERVICES LICENCE DE LECTURE @@ -447,8 +471,8 @@ AndroidX Media3 ExoPlayer 1.8.0 Utilisé pour la lecture sur les builds Android. Distribué sous la licence Apache, version 2.0. - Chargement de tes listes Trakt… - Choisis où enregistrer ce titre dans Trakt + Chargement de vos listes Trakt… + Choisissez où enregistrer ce titre dans Trakt Faire un don Voir les détails Supprimer @@ -479,7 +503,7 @@ Vu Saison %1$d Spéciaux - Reprendre où tu en étais + Reprendre où vous en étiez Ajouter à la bibliothèque Marquer comme non vu Marquer comme vu @@ -489,13 +513,13 @@ Logo de %1$s Compte Supprimer le compte - Cela supprimera définitivement ton compte et toutes les données associées. - Cette action est irréversible. Toutes tes données, profils et historique de synchronisation seront définitivement supprimés. + Cela supprimera définitivement votre compte et toutes les données associées. + Cette action est irréversible. Toutes vos données, profils et historique de synchronisation seront définitivement supprimés. Supprimer le compte ? Adresse e-mail Non connecté Se déconnecter - Tu seras redirigé vers l’écran de connexion. + Vous serez redirigé vers l’écran de connexion. Se déconnecter ? Statut Anonyme @@ -513,7 +537,7 @@ THÈME Collection • %1$s Nom affiché - Installe un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l’écran d’accueil. + Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l’écran d’accueil. Aucun catalogue d’accueil Source Hero Masqué @@ -521,7 +545,7 @@ %1$s • Limite atteinte (max. %2$d) Aucune source Hero sélectionnée Absent du Hero - Retire l’épingle de la collection pour la déplacer + Retirez l’épingle de la collection pour la déplacer Épinglé Épinglé en haut Réorganiser @@ -532,13 +556,13 @@ SOURCES HERO %1$d sur %2$d sélectionnés Afficher le Hero - Afficher un carrousel Hero en vedette en haut de l’accueil. Choisis jusqu’à 2 catalogues sources ci-dessous. + Afficher un carrousel Hero en vedette en haut de l’accueil. Choisissez jusqu’à 2 catalogues sources ci-dessous. Masquer le contenu non sorti Masque les films et séries qui ne sont pas encore sortis. Masquer le soulignement des catalogues Supprime la ligne d’accent sous les titres de catalogues et de collections dans toute l’app. %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées - Ouvre un catalogue uniquement si tu as besoin de le renommer ou de le réorganiser. + Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. Visible Masquer la valeur Lecteur, sous-titres et lecture automatique @@ -566,11 +590,11 @@ Grand Standard Afficher la valeur - Afficher une invite pour reprendre là où tu en étais à l’ouverture de l’application après avoir quitté le lecteur. + Afficher une invite pour reprendre là où vous en étiez à l’ouverture de l’application après avoir quitté le lecteur. Invite de reprise au démarrage Floute les vignettes du prochain épisode dans Continuer à regarder pour éviter les spoilers. Flouter le contenu non vu dans Continuer à regarder - Inclus les épisodes à venir dans Continuer à regarder avant leur diffusion. + Incluez les épisodes à venir dans Continuer à regarder avant leur diffusion. Afficher les épisodes à venir non diffusés ORDRE DE TRI Ordre de tri @@ -584,39 +608,41 @@ VISIBILITÉ Afficher le bandeau Continuer à regarder sur l’écran d’accueil. Afficher Continuer à regarder + Carte + Carte paysage façon TV Affiche Carte d’affiche centrée sur la couverture Large Carte horizontale riche en informations - Quand activé, La suite reprend toujours depuis l’épisode le plus avancé vu. Quand désactivé, suit l’épisode le plus récemment visionné. Utile si tu revois des épisodes précédents. + Quand activé, La suite reprend toujours depuis l’épisode le plus avancé vu. Quand désactivé, suit l’épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. La suite depuis l’épisode le plus avancé Préfère les vignettes d’épisode quand elles sont disponibles. Préférer les vignettes d’épisode dans Continuer à regarder ACCUEIL SOURCES - Installe, supprime, mets à jour et ordonne tes sources de contenu. - Installe des dépôts de scrapers JavaScript et teste des fournisseurs en interne. + Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. + Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. Contrôle quels catalogues apparaissent à l’accueil et dans quel ordre. - Désactive des sections de détails et réorganise tout sous le Hero. - Crée des regroupements de catalogues personnalisés avec des dossiers affichés à l’accueil. + Désactivez des sections de détails et réorganisez tout sous le Hero. + Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l’accueil. INTÉGRATIONS - Enrichis les pages de détails avec de l’art, des crédits, des métadonnées d’épisodes et plus depuis TMDB. - Ajoute des notes externes d’IMDb, Rotten Tomatoes, Metacritic et d’autres aux pages de détails. - Connecte des comptes pour les liens et l’accès à la bibliothèque + Enrichissez les pages de détails avec de l’art, des crédits, des métadonnées d’épisodes et plus depuis TMDB. + Ajoutez des notes externes d’IMDb, Rotten Tomatoes, Metacritic et d’autres aux pages de détails. + Connectez des comptes pour les liens et l’accès à la bibliothèque Services connectés Ces intégrations sont expérimentales et peuvent être conservées, modifiées ou supprimées plus tard. Cloud library - Parcours et lis les fichiers déjà présents dans tes comptes connectés. + Parcourez et lisez les fichiers déjà présents dans vos comptes connectés. Résoudre les liens lisibles Demande à un service connecté des liens lisibles quand un résultat le nécessite. Cela peut ajouter l’élément à ce service. Résoudre avec - Choisis quel compte connecté gère les liens lisibles. - Connecte d’abord un compte. + Choisissez quel compte connecté gère les liens lisibles. + Connectez d’abord un compte. Comptes - Connecte ton compte %1$s. - Lie ton compte %1$s dans le navigateur. + Connectez votre compte %1$s. + Liez votre compte %1$s dans le navigateur. Clé API %1$s - Saisis ta clé API %1$s. + Saisissez votre clé API %1$s. Saisir la clé API %1$s Non défini Connecté @@ -625,18 +651,18 @@ Déconnecter %1$s est connecté sur cet appareil. Démarrage de la connexion sécurisée… - Ouvre le lien et saisis ce code pour approuver Nuvio. + Ouvrez le lien et saisissez ce code pour approuver Nuvio. Code copié. Ouvrir le lien En attente de l’approbation… Impossible de démarrer la connexion. Cette méthode de connexion n’est pas configurée dans ce build. - Ce code a expiré. Réessaie. + Ce code a expiré. Réessayez. Préparation des liens Préparer les liens Résoudre les liens lisibles avant le démarrage de la lecture. Liens à préparer - Utilise un nombre plus faible si possible. Les services connectés peuvent limiter le nombre de liens résolus sur une période donnée. Ouvrir un film ou un épisode peut compter dans ces limites même si tu n’appuies pas sur Regarder, car les liens sont préparés à l’avance. + Utilisez un nombre plus faible si possible. Les services connectés peuvent limiter le nombre de liens résolus sur une période donnée. Ouvrir un film ou un épisode peut compter dans ces limites même si vous n’appuyez pas sur Regarder, car les liens sont préparés à l’avance. 1 lien %1$d liens Mise en forme @@ -646,10 +672,42 @@ Contrôle les métadonnées affichées sous chaque résultat. Réinitialiser la mise en forme Restaurer la mise en forme par défaut des résultats. + Style Fusion + Badges de taille + Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur. + Logo de l’addon + Affiche le logo et le nom de l’addon à côté des sources de streams. + Affichage + Position des badges + Choisissez si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux. + Position des badges + Choisissez où les badges de flux apparaissent sur les cartes de flux. + En haut + En bas + URL de badges Fusion + Importez jusqu’à %1$d URL JSON de badges de flux de style Fusion. Chaque URL peut être mise à jour ou supprimée séparément. + Gère les URL JSON de badges de flux de style Fusion importées. + Échec de l’import du badge. + Saisissez une URL JSON de badge. + L’URL du badge doit commencer par http:// ou https://. + Vous pouvez importer jusqu’à %1$d URL de badges. + %1$d/%2$d URL, %3$d badges Fusion actifs + Aucune URL de badge Fusion importée. + URL JSON du badge Fusion + %1$d/%2$d URL Fusion importées + Active + Inactive + %1$s · %2$d badges activés · %3$d groupes + Aperçu + Aperçu du badge Fusion + %1$d badges de style Fusion depuis cette URL + Aucune image de badge de style Fusion dans cette URL. + Groupe %1$d + Autres badges Fusion Clé API validée. Impossible de valider cette clé API. - Ajoute ta clé API MDBList ci-dessous avant d’activer les notes. - Obtiens une clé sur https://mdblist.com/preferences et colle-la ici. + Ajoutez votre clé API MDBList ci-dessous avant d’activer les notes. + Obtenez une clé sur https://mdblist.com/preferences et collez-la ici. Clé API Clé API MDBList Activer les notes MDBList @@ -669,8 +727,10 @@ Section de commentaires Trakt. Détails Durée, statut, date de sortie, langue et informations associées. + Lecture des bandes-annonces dans le Hero + Lit un aperçu de la bande-annonce dans le Hero des métadonnées quand une bande-annonce est disponible. Cartes d’épisodes - Choisis comment les épisodes sont affichés sur l’écran de métadonnées. + Choisissez comment les épisodes sont affichés sur l’écran de métadonnées. Horizontal Cartes en ligne style fond Liste @@ -697,18 +757,18 @@ Les notifications sont actuellement désactivées dans Nuvio. Alertes de sortie d’épisodes Programme des notifications locales lorsqu’un nouvel épisode d’une série sauvegardée est disponible. - Les notifications système sont désactivées pour Nuvio. Active-les pour recevoir des alertes et des notifications de test. + Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test. Il y a actuellement %1$d alertes de sortie programmées sur cet appareil. ALERTES TEST Envoyer une notification de test Envoi de la notification de test… Envoyer une notification locale de test pour %1$s. - Enregistre d’abord une série dans ta bibliothèque pour tester les notifications. + Enregistrez d’abord une série dans votre bibliothèque pour tester les notifications. Notification de test Communauté - Découvre les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L’API des supporters n’est pas configurée. Ajoute DONATIONS_BASE_URL dans local.properties. + Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. + L’API des supporters n’est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -744,11 +804,11 @@ Plugins autorisés Anime Skip ID client AnimeSkip - Saisis ton ID client API AnimeSkip. Obtiens-en un sur anime-skip.com. + Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. Activer la soumission d’intro Affiche un bouton pour soumettre des timestamps d’intro/outro à la base de données communautaire. Clé API IntroDB - Saisis ta clé API IntroDB pour soumettre des timestamps. Requise pour la soumission. + Saisissez votre clé API IntroDB pour soumettre des timestamps. Requise pour la soumission. Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). Lecture automatique de l’épisode suivant Rechercher et lire automatiquement l’épisode suivant lorsque le seuil est atteint. @@ -756,8 +816,8 @@ Préférer l’application (FFmpeg) Préférer l’appareil Priorité du décodeur - Appuie en dehors pour fermer - Appuie en dehors pour enregistrer et fermer + Appuyez en dehors pour fermer + Appuyez en dehors pour enregistrer et fermer %1$d jour %1$d jours %1$d heure @@ -768,10 +828,18 @@ App de lecteur externe Ouvre les nouvelles lectures avec l’app vidéo par défaut d’Android ou le sélecteur système. Ouvre les nouvelles lectures avec le lecteur installé sélectionné. + Transmettre les sous-titres au lecteur externe + Récupère les sous-titres d’addons dans votre langue préférée et les transmet au lecteur externe. Aucun lecteur externe compatible n’est installé + Lecteur + Interne + Externe + Choisissez quel lecteur gère les nouvelles lectures. Vitesse au maintien Maintenir pour accélérer - Garde le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement. + Gardez le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement. + Gestes tactiles + Autorise les balayages et les doubles appuis sur le lecteur pour avancer ou reculer, ajuster la luminosité ou le volume. Modèle regex invalide Durée du cache du dernier lien Mapper DV7 vers HEVC @@ -785,6 +853,13 @@ Langue de l’appareil Forcé Aucun + Tous les sous-titres + Récupère et affiche tous les sous-titres d’addons pour la vidéo. + Démarrage rapide + Ignore la récupération automatique des sous-titres d’addons jusqu’à ce que vous la demandiez dans le lecteur. + Sous-titres d’addons au démarrage + Préférés uniquement + Récupère les sous-titres d’addons, mais n’affiche que ceux dans vos langues préférées. Préférer le groupe binge Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel. Réutiliser le groupe de binge @@ -815,7 +890,7 @@ Canvas superposé OpenGL superposé Réutiliser le dernier lien - Lire automatiquement ton dernier stream fonctionnel pour ce même film/épisode lorsque le cache est encore valide. + Lire automatiquement votre dernier stream fonctionnel pour ce même film/épisode lorsque le cache est encore valide. Langue audio secondaire Langue des sous-titres secondaire DÉCODEUR @@ -829,6 +904,20 @@ %1$d sélectionné(s) Afficher la superposition de chargement Afficher la superposition de chargement initiale pendant le démarrage d’un stream. + Couleur d’arrière-plan + Gras + Utilise une graisse de police plus épaisse pour les sous-titres. + Transparent + Contour + Couleur du contour + Dessine une bordure autour du texte des sous-titres. + Afficher uniquement les langues préférées + N’affiche que les sous-titres correspondant à vos langues de sous-titres préférées. + Taille des sous-titres + Couleur du texte + Utiliser les sous-titres forcés + Privilégie les sous-titres forcés selon vos préférences de langue de sous-titres. + Décalage vertical Passer l’intro/outro/récap Afficher un bouton de saut lors des segments d’intro, d’outro et de récapitulatif détectés. Périmètre des sources @@ -861,11 +950,11 @@ Illimité Lecture tunnelisée Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo. - Ajoute ta propre clé API TMDB ci-dessous avant d’activer l’enrichissement. + Ajoutez votre propre clé API TMDB ci-dessous avant d’activer l’enrichissement. Clé API TMDB Activer l’enrichissement TMDB - Utiliser ta clé API TMDB pour enrichir les métadonnées de l’addon sur l’écran de détails lorsqu’un ID TMDB ou IMDb est disponible. - Saisis ta clé API v3 TMDB. + Utiliser votre clé API TMDB pour enrichir les métadonnées de l’addon sur l’écran de détails lorsqu’un ID TMDB ou IMDb est disponible. + Saisissez votre clé API v3 TMDB. Code de langue Visuels Remplacer le fond, l’affiche et le logo par les visuels TMDB. @@ -891,12 +980,12 @@ Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails. Clé API personnelle Langue préférée - Configure le code de langue TMDB utilisé pour les métadonnées localisées, ex. `en`, `en-US` ou `pt-BR`. + Configurez le code de langue TMDB utilisé pour les métadonnées localisées, ex. `en`, `en-US` ou `pt-BR`. IDENTIFIANTS LOCALISATION MODULES TMDB - Après approbation, tu seras redirigé automatiquement. + Après approbation, vous serez redirigé automatiquement. AUTHENTIFICATION Commentaires Afficher les commentaires Trakt dans les détails des films et séries @@ -906,32 +995,38 @@ Déconnecter Impossible d’ouvrir le navigateur FONCTIONNALITÉS - Termine la connexion Trakt dans ton navigateur - Suis ce que tu regardes, enregistre dans ta liste ou tes listes personnalisées et garde ta bibliothèque synchronisée avec Trakt. + Terminez la connexion Trakt dans votre navigateur + Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt. Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Ouvrir la connexion Trakt - Tes actions d’enregistrement peuvent maintenant cibler la watchlist Trakt et tes listes personnelles. - Connecte-toi à Trakt pour activer l’enregistrement par liste et le mode bibliothèque Trakt. + Vos actions d’enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. + Connectez-vous à Trakt pour activer l’enregistrement par liste et le mode bibliothèque Trakt. Source de la bibliothèque - Choisis quelle bibliothèque utiliser pour enregistrer et consulter ta collection + Choisissez quelle bibliothèque utiliser pour enregistrer et consulter votre collection Source de la bibliothèque - Choisis où enregistrer et gérer les éléments de ta bibliothèque + Choisissez où enregistrer et gérer les éléments de votre bibliothèque Trakt Bibliothèque Nuvio Bibliothèque Trakt sélectionnée Bibliothèque Nuvio sélectionnée Progression de visionnage - Choisis quelle source de progression alimente la reprise et Continuer à regarder + Choisissez quelle source de progression alimente la reprise et Continuer à regarder Progression de visionnage - Choisis si la reprise et Continuer à regarder doivent utiliser Trakt ou Nuvio Sync pendant que le scrobbling Trakt reste actif. + Choisissez si la reprise et Continuer à regarder doivent utiliser Trakt ou Nuvio Sync pendant que le scrobbling Trakt reste actif. Trakt Nuvio Sync Source de progression réglée sur Trakt Source de progression réglée sur Nuvio Sync + Source de la section Similaires + Choisissez la provenance des recommandations sur les pages de détails + Source de la section Similaires + Sélectionnez la source des recommandations affichées sur les pages de détails. + Trakt + TMDB Fenêtre de Continuer à regarder Historique Trakt pris en compte pour Continuer à regarder Fenêtre de Continuer à regarder - Choisis quelle quantité d’activité Trakt apparaît dans Continuer à regarder. + Choisissez quelle quantité d’activité Trakt apparaît dans Continuer à regarder. Tout l’historique %1$d jours Score du public @@ -1045,19 +1140,22 @@ Non Mettre à jour Oui - Veux-tu quitter l’application ? + Voulez-vous quitter l’application ? Quitter l’application Ce catalogue n’a renvoyé aucun élément. Aucun titre trouvé - Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie. + Plus d’actions + Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. Réalisateur Échec du chargement À voir aussi + Données fournies par TMDB + Données fournies par Trakt Saisons Cet addon a renvoyé des vidéos pour la série, mais aucune n’incluait de numéros de saison ou d’épisode. Cet addon n’a fourni aucune métadonnée d’épisode pour cette série. Cet addon n’a pas encore publié d’épisodes. - Ton appareil est en ligne, mais Nuvio n’a pas pu se connecter aux serveurs nécessaires. + Votre appareil est en ligne, mais Nuvio n’a pas pu se connecter aux serveurs nécessaires. Afficher moins Afficher plus ▾ Scénariste @@ -1070,7 +1168,7 @@ Aucun catalogue de découverte Le catalogue et les filtres sélectionnés n’ont retourné aucun élément. Aucun titre trouvé - Installe et valide au moins un addon avant d’explorer les catalogues dans Découvrir. + Installez et validez au moins un addon avant d’explorer les catalogues dans Découvrir. Sélectionner un catalogue Sélectionner un genre Sélectionner un type @@ -1084,7 +1182,9 @@ Marquer comme vu À suivre %1$s vu - Installe et valide au moins un addon avant de charger des lignes de catalogue à l’accueil. + %1$d h %2$d min restantes + %1$d min restantes + Installez et validez au moins un addon avant de charger des lignes de catalogue à l’accueil. Les addons installés n’exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. Aucune ligne d’accueil disponible Voir les détails @@ -1124,16 +1224,16 @@ Saisir le code PIN pour %1$s Code PIN oublié ? Code PIN incorrect - Bloqué. Réessaie dans %1$ds + Bloqué. Réessayez dans %1$ds Les options d’avatar apparaîtront ici une fois le catalogue chargé. Avatar : %1$s - Saisis une URL d’image http:// ou https:// valide. + Saisissez une URL d’image http:// ou https:// valide. Choisir un avatar - Choisis un avatar ci-dessous. + Choisissez un avatar ci-dessous. Créer un profil URL d’avatar personnalisée sélectionnée. URL d’avatar personnalisée - Colle un lien d’image, ou laisse vide pour utiliser le catalogue d’avatars intégré. + Collez un lien d’image, ou laissez vide pour utiliser le catalogue d’avatars intégré. https://example.com/avatar.png Toutes les données de "%1$s" seront définitivement supprimées. Supprimer le profil @@ -1152,9 +1252,9 @@ Supprimer le verrouillage PIN Enregistrement… Sécurité - Ajoute un code PIN si tu souhaites que ce profil soit verrouillé avant d’y accéder. + Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d’y accéder. Ce profil est protégé par un code PIN. - Sélectionne un avatar pour ce profil. + Sélectionnez un avatar pour ce profil. Configurer le verrouillage PIN Profil sans nom Utiliser les addons principaux @@ -1170,15 +1270,16 @@ Ouvrir dans le lecteur interne Les addons de streams installés n’ont pas renvoyé de réponse valide. Impossible de charger les streams - Installe d’abord un addon pour charger les streams de ce titre. - Tes addons installés ne fournissent pas de streams pour ce type de titre. + Installez d’abord un addon pour charger les streams de ce titre. + Vos addons installés ne fournissent pas de streams pour ce type de titre. Aucun addon de streams disponible - Aucun de tes addons installés n’a renvoyé de stream pour ce titre. + Aucun de vos addons installés n’a renvoyé de stream pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s Récupération… Recherche de la source… + Chargement des sous-titres depuis les addons… Recherche des streams… Lien du stream copié Aucun lien direct du stream disponible @@ -1188,12 +1289,12 @@ Reprendre depuis %1$s TAILLE %1$s Ce type de stream n’est pas pris en charge - Connecte un compte dans les paramètres. + Connectez un compte dans les paramètres. Non mis en cache sur Torbox. Ce lien a expiré. Actualisation des résultats. Impossible d’ouvrir ce lien. Impossible d’ouvrir le lecteur externe - Choisis d’abord un lecteur externe dans les paramètres + Choisissez d’abord un lecteur externe dans les paramètres Aucun lecteur externe disponible Fermer la bande-annonce Impossible de lire la bande-annonce @@ -1204,8 +1305,8 @@ Échec du téléchargement Téléchargement %1$d % Impossible de démarrer l’installation - Tu utilises la version la plus récente. - Active l’installation d’applications pour Nuvio puis reviens pour continuer. + Vous utilisez la version la plus récente. + Activez l’installation d’applications pour Nuvio puis revenez pour continuer. Téléchargement de la mise à jour… Aucune mise à jour trouvée. Une nouvelle version est prête à être installée. @@ -1216,7 +1317,7 @@ Mise à jour disponible Statut de la mise à jour Cet addon est déjà installé. - Saisis une URL d’addon valide + Saisissez une URL d’addon valide Impossible de charger le manifeste Nuvio Impossible de supprimer le compte @@ -1241,7 +1342,7 @@ En pause %1$s Supprimer Retirer %1$s de %2$s ? - Supprimer %1$s de ta bibliothèque ? + Supprimer %1$s de votre bibliothèque ? Retirer de la bibliothèque ? Film Alertes lorsqu’un nouvel épisode d’une série enregistrée est disponible. @@ -1250,18 +1351,18 @@ Notification de test envoyée pour %1$s. Moteur de lecture MPV indisponible. Reconstruis l’app. Impossible de lire ce stream. - Le code PIN de ce profil a changé. Connecte-toi une fois pour mettre à jour le verrouillage sur cet appareil. - Impossible de supprimer le verrouillage PIN. Réessaie. - Connecte-toi à Internet pour supprimer le verrouillage PIN. - Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connecte-toi une fois et déverrouille-le en ligne d’abord. - Impossible de définir le code PIN. Réessaie. - Connecte-toi à Internet pour définir un code PIN. + Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. + Impossible de supprimer le verrouillage PIN. Réessayez. + Connectez-vous à Internet pour supprimer le verrouillage PIN. + Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d’abord. + Impossible de définir le code PIN. Réessayez. + Connectez-vous à Internet pour définir un code PIN. Ce profil utilise les addons principaux. Impossible de charger %1$s Source Intégré Autorisation refusée - Termine la connexion Trakt dans ton navigateur + Terminez la connexion Trakt dans votre navigateur Callback Trakt invalide État du callback Trakt invalide Réponse de jeton Trakt invalide @@ -1271,7 +1372,7 @@ Identifiants Trakt manquants Impossible de charger la progression Trakt Liste publique Trakt - Saisis un ID ou une URL de liste Trakt valide + Saisissez un ID ou une URL de liste Trakt valide %1$d éléments %1$d j’aime Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID). @@ -1283,7 +1384,17 @@ Impossible de terminer la connexion Trakt Utilisateur Trakt Liste de suivi - Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. + + Autorisation Trakt expirée + Liste Trakt introuvable + Limite de listes Trakt atteinte + Limite de requêtes Trakt atteinte + Échec de la requête Trakt + Échec de l’ajout à votre liste de suivi Trakt + Échec de l’ajout à la liste Trakt + Identifiants Trakt compatibles manquants + Réponse vide du serveur + Ajoutez une clé API TMDB dans les paramètres pour utiliser les sources TMDB. Collection TMDB %1$d Collection TMDB introuvable Production TMDB %1$d @@ -1291,7 +1402,7 @@ Réalisateur TMDB %1$d La découverte TMDB n’a retourné aucune donnée Découverte TMDB - Saisis un ID ou une URL TMDB valide. + Saisissez un ID ou une URL TMDB valide. Liste TMDB %1$d Liste TMDB introuvable Impossible de charger la source TMDB @@ -1377,25 +1488,25 @@ Impossible d’écrire dans le fichier de téléchargement partiel %1$s - %2$s Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails. - Ta bibliothèque est vide + Votre bibliothèque est vide Impossible de charger la bibliothèque Autre Cloud Enregistrés Bibliothèque - Connecte Trakt et enregistre des titres dans ta liste de suivi ou tes listes personnelles. - Ta bibliothèque Trakt est vide + Connectez Trakt et enregistrez des titres dans votre liste de suivi ou vos listes personnelles. + Votre bibliothèque Trakt est vide Impossible de charger la bibliothèque Trakt Bibliothèque Trakt Connecter un compte - Connecte un compte dans les paramètres Services connectés pour parcourir les fichiers lisibles de ta cloud library. + Connectez un compte dans les paramètres Services connectés pour parcourir les fichiers lisibles de votre cloud library. Aucun compte cloud connecté Ouvrir Services connectés - Active la cloud library dans les paramètres Services connectés pour parcourir les fichiers des comptes connectés. + Activez la cloud library dans les paramètres Services connectés pour parcourir les fichiers des comptes connectés. Cloud library désactivée Aucun fichier cloud lisible ne correspond aux filtres actuels. Rien ici pour l’instant - Choisis un fichier à lire + Choisissez un fichier à lire Impossible de charger la cloud library %1$s Cet élément n’expose aucun fichier vidéo lisible. Aucun fichier lisible @@ -1472,7 +1583,7 @@ Compatibilité Au plus près de l’ancien comportement MPV iOS. Personnalisé - Utilise tes valeurs avancées ci-dessous. + Utilisez vos valeurs avancées ci-dessous. Désactivé Sortie vidéo iOS @@ -1483,12 +1594,17 @@ Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif. Primaires cibles Transfert cible + Sortie audio iOS + Sortie audio + Essaie d’abord AVFoundation, puis bascule sur AudioUnit. + Prise en charge expérimentale de l’audio spatial et de la sortie multicanal. + Utilise l’ancienne sortie AudioUnit. Gestion des résultats Nombre max de résultats Limite le nombre de résultats affichés. Tri des résultats - Choisis l’ordre dans lequel les résultats apparaissent. + Choisissez l’ordre dans lequel les résultats apparaissent. Limite par résolution Plafonne les résultats répétés en 2160p, 1080p, 720p après le tri. Limite par qualité @@ -1498,7 +1614,8 @@ En savoir plus Format par défaut - Saisis un groupe par ligne. + Format d’origine + Saisissez un groupe par ligne. Ordre original Meilleure qualité d’abord @@ -1582,6 +1699,7 @@ Capturer Décodeur matériel + Sortie audio Primaires cibles Transfert cible @@ -1589,10 +1707,10 @@ TMDB Discover US, KR, JP, IN - Saisis un nom de liste Trakt, une URL ou un ID - Saisis un ID ou une URL TMDB valide. + Saisissez un nom de liste Trakt, une URL ou un ID + Saisissez un ID ou une URL TMDB valide. Impossible de charger la source TMDB - Saisis un ID ou une URL de liste Trakt + Saisissez un ID ou une URL de liste Trakt Impossible de charger la liste Trakt Canada @@ -1623,14 +1741,14 @@ Collection TMDB %1$s Réalisateur TMDB %1$s Découverte TMDB - Saisis un ID ou une URL TMDB valide. + Saisissez un ID ou une URL TMDB valide. Liste TMDB %1$s Chaîne TMDB %1$s Personne TMDB %1$s Production TMDB %1$s Impossible de charger la source TMDB ID %1$s - Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. + Ajoutez une clé API TMDB dans les paramètres pour utiliser les sources TMDB. Collection TMDB introuvable Société TMDB introuvable Découverte TMDB n’a retourné aucune donnée @@ -1643,7 +1761,7 @@ Personne TMDB introuvable Identifiants Trakt manquants. %1$s (%2$d) - Saisis un ID ou une URL de liste Trakt valide + Saisissez un ID ou une URL de liste Trakt valide %1$d éléments %1$d j’aime Liste Trakt introuvable ou non publique @@ -1663,7 +1781,7 @@ Bibliothèque Nuvio Problème de connexion Corps de réponse vide - Vérifie ta connexion et réessaie. + Vérifiez votre connexion et réessayez. Échec de la requête (HTTP %1$d) %1$s (%2$s) Moteur de lecture MPV indisponible. Reconstruis l’app. @@ -1682,13 +1800,13 @@ Supprimer le dépôt de plugin Actualiser le dépôt de plugin Aucun fournisseur disponible pour l’instant. - Ajoute une URL de dépôt pour installer des plugins fournisseurs. + Ajoutez une URL de dépôt pour installer des plugins fournisseurs. Aucun dépôt de plugin installé pour l’instant. Utilise les fournisseurs de plugins pendant la recherche de streams. Activer les fournisseurs de plugins Ce dépôt de plugin est déjà installé. - Saisis une URL de dépôt de plugin. - Saisis une URL de plugin valide. + Saisissez une URL de dépôt de plugin. + Saisissez une URL de plugin valide. Impossible d’installer le dépôt de plugin Fournisseur introuvable Impossible d’actualiser le dépôt @@ -1718,7 +1836,7 @@ Erreur Échec du test du fournisseur Résultats du test (%1$d) - Les fournisseurs de plugins nécessitent une clé API TMDB. Configure-la dans l’écran TMDB, sinon les fournisseurs ne fonctionneront pas correctement. + Les fournisseurs de plugins nécessitent une clé API TMDB. Configurez-la dans l’écran TMDB, sinon les fournisseurs ne fonctionneront pas correctement. Aucun résultat de recherche pour %1$s. Clé API invalide ou échec de connexion Dépôt de plugin @@ -1760,4 +1878,21 @@ Liste Trakt %1$s Lecteur système Android + Streaming P2P + Ce flux utilise la technologie pair-à-pair (P2P). En activant le P2P, vous reconnaissez et acceptez que :\n\n• Votre adresse IP sera visible par les autres pairs du réseau\n• Vous êtes seul responsable du contenu auquel vous accédez\n• Vous confirmez avoir le droit légal de visionner ce contenu dans votre juridiction\n• Nuvio n’héberge, ne distribue ni ne contrôle aucun contenu P2P\n• Nuvio ne peut être tenu responsable des conséquences légales liées à votre utilisation du streaming P2P\n\nVous utilisez cette fonctionnalité entièrement à vos propres risques. Le P2P peut être désactivé à tout moment dans les paramètres. + Activer le P2P + Annuler + Erreur torrent inconnue + Streaming P2P + Autoriser les flux pair-à-pair (torrent) + Masquer les statistiques torrent + Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture + %1$d sources · %2$d pairs + %1$s en mémoire tampon · %2$s · %3$s + %1$s · %2$s + %1$d pairs · %2$d sources · %3$d %% + Connexion aux pairs… + Démarrage du moteur P2P… + Impossible de démarrer le torrent : %1$s + Erreur de torrent : %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 725e8cc2e..2b4589e4e 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -55,8 +55,8 @@ Versão %1$s Selecionado Copiar JSON - %1$d coleção(ões), %2$d pasta(s) - Eliminar "%1$s"? Esta ação não pode ser desfeita. + Coleções: %1$d · Pastas: %2$d + Eliminar \"%1$s\"? Esta ação não pode ser desfeita. Eliminar coleção Adicionar catálogo Adicionar pasta @@ -97,14 +97,14 @@ %1$d selecionados %1$d catálogos %1$d selecionados - Póster + Cartaz Quadrado Panorâmico Combinar todos os catálogos num único separador Mostrar separador \"Tudo\" Reproduzir o GIF configurado em vez da capa estática, quando disponível. Mostrar GIF quando configurado - %1$d fonte(s) · %2$s + Fontes: %1$d · %2$s Formato do mosaico Linhas Separadores @@ -115,7 +115,7 @@ Canal/Plataforma Coleção Pessoa - Realizador + Realização Personalizado Escolhe uma fonte já preparada. Podes editá-la ou removê-la depois de a adicionares. Cola o URL de uma lista pública do TMDB ou apenas o número contido no URL. @@ -124,7 +124,7 @@ Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção a partir do TMDB. Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos do elenco. Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos de realização. - Cria uma linha dinâmica do TMDB usando filtros opcionais. Deixa os campos vazios se não precisares do filtro. + Cria uma linha dinâmica do TMDB com filtros opcionais. Deixa os campos vazios se não precisares do filtro. Lista pública do TMDB ID do canal/plataforma ID da coleção @@ -165,30 +165,40 @@ Estúdios rápidos Canais/Plataformas rápidos IDs de género - Usa os números de género do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU". + Utiliza os números de género do TMDB. Separa vários com vírgulas para \"E\", ou com barras verticais para \"OU\". + 28,12 + 18,35 Lançamento ou emissão desde Lançamento ou emissão até - Usa o formato AAAA-MM-DD, por exemplo, 2024-01-01. + Utiliza o formato AAAA-MM-DD, por exemplo, 2024-01-01. + 2020-01-01 + 2024-12-31 Classificação mínima Classificação máxima Classificação do TMDB de 0 a 10. Exemplo: 7.0. + 7.0 + 10 Votos mínimos - Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + Utiliza isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + 100 Idioma original - Usa códigos de idioma de duas letras, por exemplo, en, ko, ja, hi. + Utiliza códigos de idioma de duas letras, por exemplo, en, ko, ja, hi. + en, ko, ja, hi País de origem - Usa códigos de país de duas letras, por exemplo, US, KR, JP, IN. + Utiliza códigos de país de duas letras, por exemplo, US, KR, JP, IN. + US, KR, JP, IN IDs de palavras-chave - Usa os números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. + Utiliza os números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. 9715 para super-herói IDs das produtoras - Usa os IDs de estúdios/produtoras. Os botões rápidos preenchem exemplos comuns. + Utiliza os IDs de estúdios/produtoras. Os botões rápidos preenchem exemplos comuns. 420 para Marvel Studios IDs dos canais/plataformas - Apenas para séries. Usa IDs como Netflix 213 ou HBO 49. + Apenas para séries. Utiliza IDs como Netflix 213 ou HBO 49. 213 para Netflix Ano - Usa um ano com quatro dígitos, por exemplo, 2024. + Utiliza um ano com quatro dígitos, por exemplo, 2024. + 2024 Predefinições Pesquisar Adicionar fonte @@ -197,7 +207,7 @@ Listas do Trakt Lista do Trakt Pesquisa pelo título, URL do Trakt ou ID da lista - Usa o URL de uma lista pública do Trakt, o ID numérico da lista ou pesquisa pelo nome. + Utiliza o URL de uma lista pública do Trakt, o ID numérico da lista ou pesquisa pelo nome. Para ver ao fim de semana, Vencedores de prémios Resultados da pesquisa Listas do momento @@ -213,22 +223,28 @@ Popularidade Percentagem Votos + Introduz o nome, URL ou ID de uma lista do Trakt + Introduz o ID ou URL de uma lista do Trakt + Não foi possível carregar a lista do Trakt + Nenhuma lista do Trakt encontrada + Lista do Trakt resolvida + Lista do Trakt %1$d Ação Aventura Animação Comédia Terror - Ficção Científica + Ficção científica Drama Crime - Reality Show + Reality show Inglês Coreano Japonês Hindi Espanhol Estados Unidos - Coreia + Coreia do Sul Japão Índia Reino Unido @@ -251,13 +267,13 @@ Mais votados Recentes Mais votos - Região de transmissão + Região de disponibilidade Código de país ISO 3166-1 onde o título está disponível. Exemplo: PT, BR, US. Regiões rápidas - IDs dos fornecedores - Usa os IDs de fornecedores do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU". + IDs dos serviços + Utiliza os IDs dos serviços do TMDB. Separa vários com vírgulas para \"E\", ou com barras verticais para \"OU\". 8|337|350 - Fornecedores rápidos + Serviços rápidos Netflix Prime Video Disney+ @@ -268,11 +284,11 @@ Produtora Canal/Plataforma Pessoa - Realizador + Realização Explorar TMDB Cria uma para organizares os teus catálogos. Ainda sem coleções - %1$d pasta(s) + Pastas: %1$d Nenhum item encontrado Pasta não encontrada Coleções @@ -312,8 +328,11 @@ Pesquisar Faixas de áudio Áudio + Sincronização automática + Negrito Integrado Afastamento inferior + Capturar Fechar leitor Cor A reproduzir @@ -324,11 +343,13 @@ Tamanho da letra %1$dsp Bloquear controlos do leitor + A carregar linhas de legendas... Nenhuma faixa de áudio disponível Nenhum episódio disponível Nenhum stream encontrado Nenhum Contorno + Cor do contorno Episódios Fontes Streams @@ -336,10 +357,12 @@ A reproduzir Toca para procurar legendas Voltar + Recarregar + Repor Repor predefinições Preencher Ajustar - Ir para o zoom + Zoom Recuar 10 segundos -%1$ds +%1$ds @@ -348,13 +371,16 @@ Avançar 10 segundos Fontes Estilo + Seleciona primeiro uma legenda de um addon Legendas + Atraso das legendas Legendas + Opacidade do texto Brilho %1$s Volume %1$s Sem som Transferido - Emitido em + Estreia em A anunciar Toca para desbloquear Faixa %1$d @@ -371,34 +397,38 @@ Nenhum resultado encontrado Os teus addons instalados não disponibilizam pesquisa de catálogos. Sem catálogos pesquisáveis - Pesquisa filmes, séries... + Pesquisa filmes e séries... Pesquisas recentes Remover pesquisa recente - Sobre + Acerca Geral Conta Addons + Avançadas Esquema - Conteúdo e Descoberta - Continuar a Ver - Serviços Ligados - Esquema do Ecrã Inicial + Conteúdo e descoberta + Continuar a ver + Serviços ligados + Esquema da página inicial Integrações - Licenças e Atribuição + Licenças e atribuição Classificações MDBList - Página de Detalhes + Página de detalhes Notificações Reprodução Plugins - Estilo dos Cartões de Póster + Estilo dos cartões com cartaz Definições - Apoiantes e Colaboradores + Streams + Apoiantes e colaboradores Enriquecimento TMDB Trakt - SOBRE + ACERCA Conta e estado de sincronização CONTA - Estrutura inicial e estilos de póster + Arranque e comportamento dos perfis. + AVANÇADO + Estrutura da página inicial e estilos dos cartazes. Transferir a versão mais recente Procurar atualizações Gere addons e fontes de descoberta. @@ -407,12 +437,20 @@ GERAL Gere as integrações disponíveis Gere alertas de novos episódios e envia uma notificação de teste. + Apresentação dos resultados de streams e regras dos URLs de emblemas. Muda para um perfil diferente. - Mudar de Perfil - Abrir ecrã de ligação ao Trakt + Mudar de perfil + Abrir página de ligação ao Trakt Nenhuma definição encontrada. Pesquisar definições... RESULTADOS + ARRANQUE + CACHE + Lembrar último perfil + Lembra o último perfil selecionado ao iniciar. + Limpar cache do Continuar a Ver + Remove os dados em cache do Continuar a Ver e atualiza o progresso de visualização. + Cache limpa LICENÇA DA APP DADOS E SERVIÇOS LICENÇA DE REPRODUÇÃO @@ -420,24 +458,24 @@ O código-fonte e os termos de licença estão disponíveis no repositório do projeto. Licenciado sob a GNU General Public License v3.0. The Movie Database (TMDB) - O Nuvio usa a API do TMDB para metadados de filmes e séries, imagens, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto usa a API do TMDB mas não é endossado ou certificado pelo TMDB. + O Nuvio utiliza a API do TMDB para metadados de filmes e séries, imagens, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto utiliza a API do TMDB, mas não é apoiado nem certificado pelo TMDB. Conjuntos de dados não comerciais do IMDb - O Nuvio usa os conjuntos de dados não comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informação cedida gentilmente pelo IMDb (https://www.imdb.com). Usado com permissão. Os dados do IMDb destinam-se a uso pessoal e não comercial sob os termos do IMDb. + O Nuvio utiliza os conjuntos de dados não comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informação cedida gentilmente pelo IMDb (https://www.imdb.com). Utilizado com permissão. Os dados do IMDb destinam-se a uso pessoal e não comercial sob os termos do IMDb. Trakt - O Nuvio liga-se ao Trakt para autenticação de conta, histórico de visualizações, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado nem endossado pelo Trakt. + O Nuvio liga-se ao Trakt para autenticação de conta, histórico de visualizações, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado ao Trakt nem apoiado por ele. Premiumize - O Nuvio liga-se ao Premiumize para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo Premiumize. + O Nuvio liga-se ao Premiumize para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado ao Premiumize nem apoiado por ele. TorBox - O Nuvio liga-se ao TorBox para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo TorBox. + O Nuvio liga-se ao TorBox para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado ao TorBox nem apoiado por ele. MDBList - O Nuvio usa o MDBList para classificações e dados de fornecedores de pontuações externas. O Nuvio não é afiliado nem endossado pelo MDBList. + O Nuvio utiliza o MDBList para classificações e dados de fornecedores de pontuações externas. O Nuvio não é afiliado ao MDBList nem apoiado por ele. IntroDB - O Nuvio usa a API do IntroDB para marcas de tempo de introduções, resumos, créditos e antevisões fornecidas pela comunidade, utilizadas pelos controlos de salto. O Nuvio não é afiliado nem endossado pelo IntroDB. + O Nuvio utiliza a API do IntroDB para marcas de tempo de introduções, resumos, créditos e antevisões fornecidas pela comunidade, utilizadas pelos controlos de salto. O Nuvio não é afiliado ao IntroDB nem apoiado por ele. MPVKit - Usado para a reprodução em versões iOS. + Utilizado para a reprodução em versões iOS. O código-fonte do MPVKit, por si só, está licenciado sob a LGPL v3.0. Os pacotes do MPVKit, incluindo as bibliotecas libmpv e FFmpeg, também estão licenciados sob a LGPL v3.0. AndroidX Media3 ExoPlayer 1.8.0 - Usado para a reprodução em versões Android. + Utilizado para a reprodução em versões Android. Licenciado sob a Apache License, Versão 2.0. A carregar as tuas listas do Trakt… Escolhe onde queres guardar este título no Trakt @@ -459,7 +497,7 @@ Trailers Nenhum episódio concluído Ainda sem transferências - %1$d episódio(s) transferido(s) + Episódios transferidos: %1$d Ativas Filmes Séries @@ -487,26 +525,26 @@ E-mail Sessão não iniciada Terminar sessão - Irás regressar ao ecrã de início de sessão. + Irás regressar à página de início de sessão. Terminar sessão? Estado Anónimo Sessão iniciada Preto AMOLED - Usa fundos pretos puros para ecrãs OLED. + Utiliza fundos pretos puros para ecrãs OLED. Idioma da app Escolher idioma Definições para a secção Continuar a Ver. Liquid Glass - Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo. + Utiliza a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo. Ajusta a largura dos cartões e o raio dos cantos. - ECRÃ - ECRÃ INICIAL + APRESENTAÇÃO + PÁGINA INICIAL TEMA Coleção • %1$s Nome a apresentar - Instala um addon com catálogos compatíveis com o painel para configurares as linhas do Ecrã Inicial. - Sem catálogos no ecrã inicial + Instala um addon com catálogos compatíveis com o painel para configurares as linhas da página inicial. + Sem catálogos na página inicial Fonte do destaque Oculto Manter Início focado @@ -520,11 +558,11 @@ CATÁLOGOS CATÁLOGOS E COLEÇÕES COLEÇÕES - Esquema do Ecrã Inicial - Catálogos em Destaque + Esquema da página inicial + Catálogos em destaque %1$d de %2$d selecionados - Mostrar secção de Destaques - Apresenta o carrossel de destaques no topo do ecrã inicial. + Mostrar secção de destaques + Apresenta o carrossel de destaques no topo da página inicial. Ocultar conteúdo não lançado Ocultar filmes e séries que ainda não foram lançados. Ocultar sublinhado dos catálogos @@ -535,12 +573,12 @@ Ocultar valor Leitor, legendas e reprodução automática Raio dos cantos - Estilo dos cartões de póster + Estilo dos cartões com cartaz Largura Personalizado Ajusta a largura dos cartões e o raio dos cantos. Ocultar etiquetas - Pósteres em modo horizontal + Cartazes em modo horizontal Pré-visualização em tempo real %1$s (%2$s) Raio dos cantos: %1$ddp @@ -549,14 +587,14 @@ Clássico Formato pílula Arredondado - Afiado + Cantos retos Subtil Equilibrado Confortável Compacto Denso Grande - Padrão + Normal Mostrar valor Mostra uma mensagem para continuares de onde ficaste quando abrires a app após teres saído do leitor. Confirmar retoma ao iniciar @@ -568,39 +606,41 @@ Ordem de ordenação Predefinida Ordena todos os itens pelos mais recentes - Estilo Plataforma de Streaming + Estilo de plataforma de streaming Itens já lançados primeiro, os próximos no fim - Estilo dos Cartões de Póster + Estilo dos cartões com cartaz AO INICIAR COMPORTAMENTO DO SEGUINTE VISIBILIDADE - Apresenta a linha Continuar a Ver no ecrã inicial. + Apresenta a linha Continuar a Ver na página inicial. Mostrar Continuar a Ver - Póster - Cartão de póster focado na imagem + Cartão + Cartão panorâmico ao estilo TV + Cartaz + Cartão focado no cartaz Panorâmico Cartão horizontal com mais informação - Mostra o próximo episódio com base no episódio visto mais avançado. Desativa para repetições de séries, usando assim o episódio visto mais recentemente. + Mostra o próximo episódio com base no episódio visto mais avançado. Desativa para repetições de séries; nesse caso, é utilizado o episódio visto mais recentemente. Seguinte a partir do mais avançado Preferir miniaturas dos episódios quando disponíveis. Preferir miniaturas de episódios no Continuar a Ver - INÍCIO + PÁGINA INICIAL FONTES Instala, remove, atualiza e ordena as tuas fontes de conteúdo. - Instala repositórios de scrapers em JavaScript e testa fornecedores internamente. - Ajusta o esquema inicial, a visibilidade do conteúdo e o comportamento dos pósteres - Definições para os ecrãs de detalhes e de episódios. - Cria agrupamentos de catálogos personalizados com pastas apresentadas no Início. + Instala repositórios de scrapers JavaScript e testa fornecedores internamente. + Ajusta o esquema da página inicial, a visibilidade do conteúdo e o comportamento dos cartazes. + Definições para as páginas de detalhes e de episódios. + Cria agrupamentos personalizados de catálogos com pastas apresentadas no Início. Integrações Controlos de enriquecimento de metadados Fornecedores de classificações externas Liga contas para obter links e acesso à biblioteca - Serviços Ligados + Serviços ligados Estas integrações são experimentais e podem ser mantidas, alteradas ou removidas mais tarde. Biblioteca na nuvem Explora e reproduz ficheiros que já se encontram nas tuas contas ligadas. Resolver links reproduzíveis - Solicita links reproduzíveis a um serviço ligado sempre que um resultado precisar. Isto pode adicionar o item a esse serviço. + Pede links reproduzíveis a um serviço ligado sempre que um resultado precisar. Isto pode adicionar o item a esse serviço. Resolver com Escolhe qual das contas ligadas deve gerir os links reproduzíveis. Liga uma conta primeiro. @@ -624,20 +664,48 @@ Não foi possível iniciar o início de sessão. Este método de início de sessão não está configurado nesta versão da app. Este código expirou. Tenta novamente. - Preparação de Links + Preparação de links Preparar links Resolve links reproduzíveis antes de a reprodução começar. Links a preparar - Usa um número mais baixo quando possível. Os serviços ligados podem limitar a taxa de links que podem ser resolvidos num determinado período de tempo. Abrir um filme ou episódio pode contar para esses limites mesmo que não carregues em Ver, porque os links são preparados antecipadamente. + Utiliza um número mais baixo quando possível. Os serviços ligados podem limitar a frequência com que os links são resolvidos num determinado período. Abrir um filme ou episódio pode contar para esses limites mesmo que não toques em Reproduzir, porque os links são preparados antecipadamente. 1 link %1$d links Formatação Modelo de nome - Controla a forma como os nomes dos resultados aparecem. + Controla a forma como os nomes dos resultados aparecem. Deixa em branco para utilizar o nome original. Modelo de descrição - Controla os metadados apresentados sob cada resultado. + Controla os metadados apresentados sob cada resultado. Deixa em branco para utilizar os detalhes originais. Repor formatação - Restaurar a formatação predefinida dos resultados. + Restaura a formatação predefinida dos resultados. + Estilo Fusion + Emblemas de tamanho + Mostra emblemas com o tamanho do ficheiro nos resultados de streams e nos painéis de fontes do leitor. + Logótipo do addon + Mostra o logótipo e o nome do addon junto das fontes de stream. + APRESENTAÇÃO + Posição dos emblemas + Escolhe se os emblemas Fusion e de tamanho aparecem acima ou abaixo dos cartões de stream. + Posição dos emblemas + Seleciona onde os emblemas de stream aparecem nos cartões de stream. + Em cima + Em baixo + URLs de emblemas Fusion + Importa até %1$d URLs JSON de emblemas de streams ao estilo Fusion. Cada URL pode ser atualizado ou eliminado separadamente. + Gere os URLs JSON de emblemas de streams ao estilo Fusion importados. + %1$d/%2$d URLs, %3$d emblemas Fusion ativos + Nenhum URL de emblemas Fusion importado. + URL JSON de emblemas Fusion + %1$d/%2$d URLs Fusion importados + Ativo + Inativo + %1$s, %2$d emblemas ativados, %3$d grupos + Pré-visualizar + Pré-visualização de emblemas Fusion + %1$d emblemas ao estilo Fusion deste URL + Nenhuma imagem de emblema ao estilo Fusion neste URL. + Grupo %1$d + Outros emblemas Fusion Chave API validada. Não foi possível validar esta chave API. Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. @@ -645,7 +713,7 @@ Chave API Chave API Ativar classificações do MDBList - Obtém classificações de fornecedores externos no ecrã de detalhes dos metadados + Obtém classificações de fornecedores externos na página de detalhes. Chave API Fornecedores de classificações externos Classificações MDBList @@ -661,19 +729,21 @@ Críticas do Trakt Detalhes Duração, estado, lançamento, idioma e informações relacionadas. + Reprodução do trailer em destaque + Reproduz pré-visualizações de trailers no destaque dos metadados quando houver um trailer disponível. Cartões de episódios - Escolhe como os episódios são apresentados no ecrã de metadados. + Escolhe como os episódios são apresentados na página de metadados. Horizontal - Cartões em linha estilo miniatura de fundo + Cartões em linha com imagem de fundo. Lista - Cartões empilhados focados nos detalhes + Cartões empilhados com foco nos detalhes. Episódios Lista de temporadas e episódios para séries. Desfocar episódios não vistos Desfoca as miniaturas dos episódios até serem vistos para evitar spoilers. Grupo %1$d - Mais como este - Miniaturas de recomendações do TMDB na página de detalhes + Títulos semelhantes + Imagens de fundo de recomendações do TMDB na página de detalhes. Nenhum Sinopse Sinopse, classificações, géneros e créditos principais. @@ -683,7 +753,7 @@ SECÇÕES Grupo de separadores %1$d Esquema de separadores - Agrupa as secções em separadores como na app de TV. Atribui até 3 secções por grupo de separadores. + Agrupa as secções em separadores como na app para TV. Atribui até 3 secções por grupo. Trailers Linha de trailers e atalhos de reprodução. As notificações estão atualmente desativadas no Nuvio. @@ -754,29 +824,44 @@ %1$d dias %1$d hora %1$d horas - Usar libass para legendas ASS/SSA + Utilizar libass para legendas ASS/SSA Experimental: renderização avançada de ASS/SSA (estilos, posicionamento, animações) Leitor externo App de leitor externo Abre a nova reprodução com a app de vídeo predefinida do Android ou com o seletor do sistema. Abre a nova reprodução com o leitor instalado selecionado. + Enviar legendas para o leitor externo + Obtém legendas dos addons no teu idioma preferido e envia-as para o leitor externo. Nenhum leitor externo compatível instalado + Leitor + Interno + Externo + Escolhe que leitor deve tratar novas reproduções. Velocidade ao manter premido Manter premido para acelerar - Prime longamente em qualquer parte da superfície do leitor para aumentar temporariamente a velocidade de reprodução. - Padrão regex inválido + Mantém premido em qualquer parte da superfície do leitor para aumentar temporariamente a velocidade de reprodução. + Gestos táteis + Permite deslizar e tocar duas vezes na área do leitor para avançar/recuar, ajustar o brilho ou ajustar o volume. + Expressão regex inválida Duração da cache do último link Alternativa DV7 para HEVC - Mapeia o Dolby Vision Profile 7 para HEVC padrão em dispositivos sem suporte de hardware para DV + Mapeia o Dolby Vision Profile 7 para HEVC standard em dispositivos sem suporte de hardware para DV Minutos de tolerância Alternativa quando não existe uma marca de tempo para o final. %1$s min Nenhum item disponível Não definido - Predefinido (ficheiro de média) + Predefinido (ficheiro multimédia) Idioma do dispositivo Forçadas Nenhum + Todas as legendas + Obtém e mostra todas as legendas dos addons para o vídeo. + Arranque rápido + Ignora a obtenção automática de legendas dos addons até as pedires no leitor. + Carregamento inicial de legendas dos addons + Apenas preferidas + Obtém legendas dos addons, mas mostra apenas correspondências nos idiomas preferidos. Preferir grupo de maratona (Próximo episódio) Tenta primeiro o mesmo perfil de fonte (mesmo addon/grupo de qualidade) antes das regras normais de reprodução automática. Reutilizar grupo de maratona @@ -785,8 +870,8 @@ Idioma preferido Predefinições Corresponde ao nome/título/descrição do stream, addon ou URL. Exemplo: 4K|2160p|Remux - Padrão Regex - Nenhum padrão definido. Exemplo: 4K|2160p|Remux + Expressão regex + Nenhuma expressão definida. Exemplo: 4K|2160p|Remux Qualquer 1080p+ AVC / x264 Qualidade BluRay @@ -796,7 +881,7 @@ HEVC / x265 Sem CAM/TS Sem REMUX/HDR - 1080p Padrão + 1080p standard 4K / Remux 720p / Menor Fontes WEB @@ -820,14 +905,28 @@ RENDERIZAÇÃO DE LEGENDAS %1$d selecionados Sobreposição de carregamento - Mostra o ecrã de carregamento até que surja o primeiro fotograma do vídeo. + Mostra o ecrã de carregamento até surgir o primeiro fotograma do vídeo. + Cor de fundo + Negrito + Utiliza uma letra mais forte nas legendas. + Transparente + Contorno + Cor do contorno + Desenha um contorno à volta do texto das legendas. + Mostrar apenas idiomas preferidos + Mostra apenas legendas que correspondam aos idiomas preferidos. + Tamanho das legendas + Cor do texto + Utilizar legendas forçadas + Dá preferência a legendas forçadas quando corresponderem às definições de idioma das legendas. + Afastamento vertical Saltar introdução - Usa o introdb.app para detetar introduções e resumos. + Utiliza o introdb.app para detetar introduções e resumos. Âmbito da reprodução automática Todos os addons instalados A reprodução automática apenas considera streams provenientes dos teus addons instalados. Todas as fontes - A reprodução automática pode usar tanto os addons instalados como os plugins ativos. + A reprodução automática pode utilizar addons instalados e plugins ativos. Apenas plugins ativos A reprodução automática apenas considera streams provenientes de plugins ativos. Apenas addons instalados @@ -838,9 +937,9 @@ Manual (escolher stream) Mostra sempre a lista de fontes e deixa-me escolher. Reprodução automática por correspondência regex - Reproduz a primeira fonte cujo texto corresponda ao teu padrão regex. + Reproduz a primeira fonte cujo texto corresponda à tua expressão regex. Tempo limite de seleção de stream - Tempo de espera pelos addons antes de efetuar a seleção. + Tempo de espera pelos addons antes da seleção. Minutos de tolerância Modo de tolerância do próximo episódio Minutos antes do fim @@ -856,34 +955,34 @@ Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento. Chave API Ativar enriquecimento do TMDB - Usa o TMDB como fonte de metadados para melhorar os dados dos addons + Utiliza o TMDB como fonte de metadados para melhorar os dados dos addons. Introduz a tua chave API v3 do TMDB. Código do idioma - Imagens artísticas - Imagens de logótipos e de fundo do TMDB + Imagens + Logótipos e imagens de fundo (dados do TMDB). Informação básica - Descrição, géneros e classificação do TMDB + Sinopse, géneros e classificação (dados do TMDB). Coleções - Coleções de filmes do TMDB por ordem de lançamento + Coleções de filmes por ordem de lançamento (dados do TMDB). Créditos - Elenco com fotos, realizador e guionista do TMDB + Mostra o elenco com fotografias e a equipa técnica (dados do TMDB). Detalhes - Duração, estado, país e idioma do TMDB + Duração, estado, país e idioma (dados do TMDB). Episódios - Títulos dos episódios, sinopses, miniaturas e duração do TMDB - Mais como este - Miniaturas de recomendações do TMDB na página de detalhes - Canais / Redes - Canais com logótipos do TMDB + Títulos, sinopses, miniaturas e duração dos episódios (dados do TMDB). + Títulos semelhantes + Imagens de fundo de recomendações na página de detalhes (dados do TMDB). + Canais/redes + Canais/redes com logótipos (dados do TMDB). Produtoras - Empresas produtoras do TMDB - Pósteres das temporadas - Usa os pósteres das temporadas do TMDB no seletor de temporadas do ecrã de metadados das séries. + Produtoras (dados do TMDB). + Cartazes das temporadas + Utiliza cartazes de temporadas (dados do TMDB) no seletor de temporadas da página de detalhes das séries. Trailers - Trailers candidatos a partir de vídeos do TMDB para a secção de trailers dos detalhes + Trailers para a secção de trailers da página de detalhes (dados do TMDB). Chave API pessoal Idioma - Idioma dos metadados do TMDB para o título, logótipo e campos ativos + Idioma dos metadados do TMDB para título, logótipo e campos ativos. CREDENCIAIS LOCALIZAÇÃO MÓDULOS @@ -902,28 +1001,34 @@ Sincroniza a tua lista de conteúdos, o progresso de visualização, o Continuar a Ver, os scrobbles e as listas pessoais com o Trakt. Faltam as credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Abrir início de sessão do Trakt - As tuas ações de Guardar podem agora visar a lista de conteúdos do Trakt e as listas pessoais. - Inicia sessão com o Trakt para ativares a gravação baseada em listas e o modo de biblioteca do Trakt. + As ações de Guardar podem agora utilizar a lista de conteúdos do Trakt e as tuas listas pessoais. + Inicia sessão com o Trakt para ativares a gravação em listas e o modo de biblioteca do Trakt. Fonte da biblioteca - Escolhe que biblioteca usar para guardar e ver a tua coleção + Escolhe que biblioteca utilizar para guardar e ver a tua coleção. Fonte da biblioteca - Escolhe onde guardar e gerir os itens da tua biblioteca + Escolhe onde guardar e gerir os itens da tua biblioteca. Trakt Biblioteca Nuvio Biblioteca do Trakt selecionada Biblioteca do Nuvio selecionada Progresso de visualização - Escolhe qual a fonte de progresso que alimenta o retomar e o continuar a ver + Define a fonte de progresso utilizada para retomar e continuar a ver. Progresso de visualização - Escolhe se o retomar e o continuar a ver devem usar o Trakt ou o Nuvio Sync enquanto o scrobbling do Trakt permanece ativo. + Escolhe se Retomar e Continuar a Ver devem utilizar o Trakt ou o Nuvio Sync enquanto o scrobbling do Trakt permanece ativo. Trakt Nuvio Sync Fonte de progresso de visualização definida para Trakt Fonte de progresso de visualização definida para Nuvio Sync + Fonte dos títulos semelhantes + Escolhe a origem das recomendações nas páginas de detalhes. + Fonte dos títulos semelhantes + Seleciona a fonte das recomendações apresentadas nas páginas de detalhes. + Trakt + TMDB Janela de Continuar a Ver - Histórico do Trakt considerado para o continuar a ver + Histórico do Trakt considerado no Continuar a Ver. Janela de Continuar a Ver - Escolhe quanta atividade do Trakt deve aparecer no continuar a ver. + Escolhe quanto histórico do Trakt deve aparecer no Continuar a Ver. Todo o histórico %1$d dias Classificação do público @@ -951,7 +1056,7 @@ Saltar encerramento Saltar resumo Nenhuma legenda encontrada - Africânder + Afrikaans Albanês Amárico Árabe @@ -1042,9 +1147,11 @@ Este catálogo não devolveu nenhum item. Nenhum título encontrado Verifica a tua ligação Wi-Fi ou de dados móveis e tenta novamente. - Realizador + Realização Falha ao carregar - Mais como este + Títulos semelhantes + Com dados do TMDB + Com dados do Trakt Temporadas Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio. Este addon não forneceu metadados de episódios para esta série. @@ -1052,7 +1159,7 @@ O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. Mostrar menos Mostrar mais ▾ - Guionista + Argumento Todos os géneros Catálogo %1$s • %2$s @@ -1075,8 +1182,10 @@ Marcar como não visto Marcar como visto A seguir - %1$s visto(s) - Instala e valida pelo menos um addon antes de carregares as linhas de catálogo na Página Inicial. + %1$s visto + Faltam %1$dh %2$dm + Faltam %1$dm + Instala e valida pelo menos um addon antes de carregares as linhas de catálogo na página inicial. Os addons instalados não expõem atualmente catálogos compatíveis com o painel sem extras obrigatórios. Nenhuma linha disponível na página inicial Ver detalhes @@ -1090,7 +1199,7 @@ Detalhes Lista de temporadas e episódios para séries. Linha de recomendações. - Mais como este + Títulos semelhantes Sinopse, classificações, géneros e créditos principais. Sinopse Estúdios e canais/redes. @@ -1098,11 +1207,13 @@ Linha de trailers e atalhos de reprodução. Novamente online Não é possível contactar os servidores + Corpo da resposta vazio + O pedido falhou com HTTP %1$d Sem ligação à internet (%1$d anos) Nascimento: %1$s%2$s Falecimento: %1$s - Conhecido(a) por: %1$s + Conhecido por: %1$s Mais recente Não foi possível carregar os detalhes de %1$s Popular @@ -1120,12 +1231,12 @@ Introduz um URL de imagem http:// ou https:// válido. Escolhe um avatar Escolhe um avatar abaixo. - Criar Perfil + Criar perfil URL de avatar personalizado selecionado. URL de avatar personalizado - Cola um link de imagem, ou deixa em branco para usar o catálogo de avatares integrado. + Cola um link de imagem ou deixa em branco para utilizar o catálogo de avatares integrado. https://exemplo.com/avatar.png - Todos os dados de "%1$s" serão eliminados permanentemente. + Todos os dados de \"%1$s\" serão eliminados permanentemente. Eliminar perfil Adicionar perfil Editar perfil @@ -1142,12 +1253,12 @@ Remover bloqueio por PIN A guardar... Segurança - Adiciona un PIN se queres que este perfil seja bloqueado antes de mudares para ele. + Adiciona um PIN se queres que este perfil seja bloqueado antes de mudares para ele. Este perfil está protegido com um PIN. Seleciona um avatar para este perfil. Definir bloqueio por PIN Perfil sem nome - Usar addons principais + Utilizar addons principais Partilha a configuração de addons do perfil principal em vez de gerires uma lista separada. Quem está a ver? Transferido @@ -1169,6 +1280,7 @@ T%1$dE%2$d - %3$s A obter… A procurar fonte… + A carregar legendas dos addons… A procurar streams… Link do stream copiado Nenhum link de stream direto disponível @@ -1192,10 +1304,12 @@ %1$s • %2$s Falha na verificação de atualizações Falha na transferência + Corpo da transferência vazio + O ficheiro de atualização transferido está em falta. A transferir %1$d%% Não foi possível iniciar a instalação - Estás a usar a versão mais recente. - Ativa as instalações de apps para o Nuvio, depois volta e continua. + Estás a utilizar a versão mais recente. + Ativa a instalação de apps para o Nuvio e depois volta para continuar. A transferir atualização... Nenhuma atualização encontrada. Uma nova versão está pronta para ser instalada. @@ -1222,7 +1336,7 @@ Canais/Redes Nenhum addon fornece metadados para este conteúdo. Falha na transferência - Mostra o progresso e os controlos de transferências ao vivo. + Mostra o progresso e os controlos de transferências em tempo real. Transferências Transferência concluída A transferir %1$s • %2$s @@ -1239,18 +1353,21 @@ Falha ao enviar uma notificação de teste. Notificação de teste enviada para %1$s. Não foi possível reproduzir este stream. + O motor do leitor MPV não está disponível. Recompila a app. O PIN deste perfil foi alterado. Liga-te à internet uma vez para atualizar o bloqueio neste dispositivo. Não foi possível remover o bloqueio por PIN. Tenta novamente. Liga-te à internet para remover o bloqueio por PIN. Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te primeiro à internet e desbloqueia-o online. Não foi possível definir o PIN. Tenta novamente. Liga-te à internet para definir um PIN. - Este perfil usa os addons principais. + Este perfil utiliza os addons principais. Falha ao carregar %1$s Stream Integrado Autorização recusada Conclui o início de sessão do Trakt no teu navegador + Ligado ao Trakt + Desligado do Trakt Callback do Trakt inválido Estado de callback do Trakt inválido Resposta de token do Trakt inválida @@ -1259,9 +1376,39 @@ O Trakt não devolveu um código de autorização Faltam as credenciais do Trakt Falha ao carregar o progresso do Trakt + Lista pública do Trakt + Introduz um ID ou URL válido de uma lista do Trakt + %1$d itens + %1$d gostos + Credenciais do Trakt em falta em local.properties (TRAKT_CLIENT_ID). + ID da lista do Trakt em falta + A lista do Trakt não incluía um ID numérico + Lista do Trakt não encontrada ou não pública + Limite de pedidos do Trakt atingido + O pedido ao Trakt falhou Falha ao concluir o início de sessão do Trakt Utilizador do Trakt Lista de conteúdos + Adiciona uma chave API do TMDB nas Definições para utilizar fontes TMDB. + Coleção TMDB %1$d + Coleção TMDB não encontrada + Produtora TMDB %1$d + Produtora TMDB não encontrada + Realização TMDB %1$d + A exploração do TMDB não devolveu dados + Explorar TMDB + Introduz um ID ou URL válido do TMDB. + Lista TMDB %1$d + Lista TMDB não encontrada + Não foi possível carregar a fonte TMDB + ID da coleção TMDB em falta + ID da lista TMDB em falta + ID da pessoa TMDB em falta + Canal/Plataforma TMDB %1$d + Canal/plataforma TMDB não encontrado + Créditos da pessoa no TMDB não encontrados + Pessoa TMDB %1$d + Pessoa TMDB não encontrada Trailer Desconhecido Addon @@ -1270,13 +1417,15 @@ Retomar %1$s O JSON está vazio. A coleção %1$d tem o ID em branco. - A coleção '%1$s' tem o título em branco. - A pasta %1$d em '%2$s' tem o ID em branco. - A pasta '%1$s' em '%2$s' tem o título em branco. - A fonte %1$d na pasta '%2$s' tem campos em branco. - A fonte %1$d na pasta '%2$s' não tem um ID de lista do Trakt. + A coleção '%1$s' tem o título em branco. + A pasta %1$d em '%2$s' tem o ID em branco. + A pasta '%1$s' em '%2$s' tem o título em branco. + A fonte %1$d na pasta '%2$s' tem campos em branco. + A fonte %1$d na pasta '%2$s' não tem um ID de lista do Trakt. JSON inválido: %1$s Addon não encontrado: %1$s + Lista de filmes do Trakt + Lista de séries do Trakt Janeiro Fevereiro Março @@ -1314,7 +1463,7 @@ País de origem Informações de lançamento Duração - Pósteres + Cartazes Texto Detalhes da série Estado @@ -1325,26 +1474,30 @@ Transferência iniciada Formato de stream não suportado para transferências Corpo de resposta vazio - A solicitação falhou com HTTP %1$d + Falha ao finalizar o ficheiro de transferência + O pedido falhou com HTTP %1$d O sistema de transferências não está inicializado - Falha na solicitação de transferência + Falha ao abrir o ficheiro parcial da transferência + O ficheiro parcial da transferência não está aberto + Falha no pedido de transferência + Falha ao escrever no ficheiro parcial da transferência %1$s - %2$s - Os títulos guardados vão aparecer aqui depois de clicares em Guardar no ecrã de detalhes. + Os títulos guardados vão aparecer aqui depois de tocares em Guardar na página de detalhes. A tua biblioteca está vazia Não foi possível carregar a biblioteca Outro Nuven Guardado Biblioteca - Liga o Trakt e guarda títulos na tua lista de conteúdos ou listas pessoais. + Liga o Trakt e guarda títulos na tua lista de conteúdos ou nas tuas listas pessoais. A tua biblioteca do Trakt está vazia Não foi possível carregar a biblioteca do Trakt Biblioteca do Trakt Ligar conta - Liga uma conta nas definições de Serviços Ligados para navegares pelos ficheiros reproduzíveis da tua biblioteca na nuvem. + Liga uma conta nas definições de Serviços ligados para navegares pelos ficheiros reproduzíveis da tua biblioteca na nuvem. Nenhuma conta na nuvem ligada - Abrir Serviços Ligados - Ativa a biblioteca na Nuvem nas definições de Serviços Ligados para navegares pelos ficheiros das contas ligadas. + Abrir Serviços ligados + Ativa a biblioteca na nuvem nas definições de Serviços ligados para navegares pelos ficheiros das contas ligadas. A biblioteca na nuvem está desativada Nenhum ficheiro na nuvem reproduzível corresponde aos filtros atuais. Ainda não há nada aqui @@ -1387,9 +1540,9 @@ Moderado Grave Violência - Criador(a) - Realizador(a) - Guionista + Criação + Realização + Argumento Classificação do público Nenhum stream de trailer reproduzível encontrado. Temporada %1$d - %2$s @@ -1397,12 +1550,272 @@ KB MB GB - Emitido a %1$s - Emitido hoje - Emitido amanhã + Enviar introdução + Definições de vídeo + A biblioteca na nuvem não está disponível para %1$s. + Vídeo + Repor ajustes + Predefinição de saída + Deteção de pico HDR + Estima o brilho máximo HDR quando os metadados estão incorretos ou em falta. + Mapeamento de tons + Redução de banding + Reduz o banding de cor com um pequeno custo de desempenho. + Interpolação de fotogramas + Suaviza o movimento quando o mpv consegue utilizar corretamente a sincronização do ecrã. + Brilho + Contraste + Saturação + Gama + EDR nativo + Melhor para iPhones e iPads compatíveis com HDR. + SDR com mapeamento de tons + Brancos e pretos mais previsíveis em saída ao estilo SDR. + Compatibilidade + O comportamento mais próximo do MPV antigo no iOS. + Personalizado + Utiliza os valores avançados abaixo. + Desativado + Saída de vídeo do iOS + Descodificador por hardware + Gama dinâmica alargada + Modo de saída Metal predefinido para novas sessões de reprodução. + Sugestão de cor do ecrã + Permite que o mpv use por predefinição o espaço de cor do ecrã ativo. + Primárias de destino + Transferência de destino + SAÍDA DE ÁUDIO DO iOS + Saída de áudio + Tenta primeiro AVFoundation e, se necessário, recorre ao AudioUnit. + Suporte experimental para Áudio Espacial e saída multicanal. + Utiliza a saída AudioUnit antiga. + Gestão dos resultados + Máximo de resultados + Limita quantos resultados aparecem. + Ordenar resultados + Escolhe como os resultados são ordenados. + Limite por resolução + Limita resultados repetidos em 2160p, 1080p e 720p depois da ordenação. + Limite por qualidade + Limita resultados repetidos em BluRay, WEB-DL, REMUX e outras qualidades depois da ordenação. + Intervalo de tamanho + Filtra os resultados pelo tamanho do ficheiro. + Saber mais + Formato predefinido + Formato original + Introduz um grupo por linha. + Ordem original + Melhor qualidade primeiro + Maior primeiro + Menor primeiro + Melhor áudio primeiro + Idioma primeiro + Qualquer + %1$d selecionados + Todos os resultados + %1$d resultados + Até %1$d GB + %1$d GB+ + %1$d-%2$d GB + Resoluções preferidas + Ordena primeiro as resoluções selecionadas, pela ordem predefinida. + Resoluções obrigatórias + Mostra apenas as resoluções selecionadas. + Resoluções excluídas + Oculta as resoluções selecionadas. + Qualidades preferidas + Ordena primeiro as qualidades selecionadas, pela ordem predefinida. + Qualidades obrigatórias + Mostra apenas as qualidades selecionadas. + Qualidades excluídas + Oculta as qualidades selecionadas. + Etiquetas visuais preferidas + Ordena etiquetas como DV, HDR, 10bit, IMAX e semelhantes. + Etiquetas visuais obrigatórias + Exige etiquetas como DV, HDR, 10bit, IMAX, SDR e semelhantes. + Etiquetas visuais excluídas + Oculta etiquetas como DV, HDR, 10bit, 3D e semelhantes. + Etiquetas de áudio preferidas + Ordena etiquetas como Atmos, TrueHD, DTS, AAC e semelhantes. + Etiquetas de áudio obrigatórias + Exige etiquetas como Atmos, TrueHD, DTS, AAC e semelhantes. + Etiquetas de áudio excluídas + Oculta as etiquetas de áudio selecionadas. + Canais preferidos + Ordena primeiro as configurações de canais preferidas. + Canais obrigatórios + Mostra apenas as configurações de canais selecionadas. + Canais excluídos + Oculta as configurações de canais selecionadas. + Codificações preferidas + Ordena AV1, HEVC, AVC e codificações semelhantes. + Codificações obrigatórias + Exige AV1, HEVC, AVC e codificações semelhantes. + Codificações excluídas + Oculta as codificações selecionadas. + Idiomas preferidos + Ordena primeiro os idiomas de áudio preferidos. + Idiomas obrigatórios + Mostra apenas resultados com os idiomas selecionados. + Idiomas excluídos + Oculta resultados quando todos os idiomas estiverem excluídos. + Grupos de release obrigatórios + Mostra apenas os grupos de release selecionados. + Grupos de release excluídos + Oculta os grupos de release selecionados. + Enviar tempos + TIPO DE SEGMENTO + Introdução + Recapitulação + Encerramento + TEMPO INICIAL (MM:SS) + TEMPO FINAL (MM:SS) + Enviar + Capturar + Descodificador por hardware + Saída de áudio + Primárias de destino + Transferência de destino + Lista do Trakt resolvida + Explorar TMDB + US, KR, JP, IN + Introduz o nome, URL ou ID de uma lista do Trakt + Introduz um ID ou URL válido do TMDB. + Não foi possível carregar a fonte TMDB + Introduz o ID ou URL de uma lista do Trakt + Não foi possível carregar a lista do Trakt + Canadá + Austrália + Alemanha + Filmes + Séries + A biblioteca na nuvem está desativada. + Chave API inválida ou falha na ligação + Campo em falta no manifesto: \"%1$s\" + Coleção TMDB %1$s + Realização TMDB %1$s + Explorar TMDB + Introduz um ID ou URL válido do TMDB. + Lista TMDB %1$s + Canal/Plataforma TMDB %1$s + Pessoa TMDB %1$s + Produtora TMDB %1$s + Não foi possível carregar a fonte TMDB + ID %1$s + Adiciona uma chave API do TMDB nas Definições para utilizar fontes TMDB. + Coleção TMDB não encontrada + Produtora TMDB não encontrada + A exploração do TMDB não devolveu dados + Lista TMDB não encontrada + ID da coleção TMDB em falta + ID da lista TMDB em falta + ID da pessoa TMDB em falta + Canal/plataforma TMDB não encontrado + Créditos da pessoa no TMDB não encontrados + Pessoa TMDB não encontrada + Credenciais do Trakt em falta. + %1$s (%2$d) + Introduz um ID ou URL válido de uma lista do Trakt + %1$d itens + %1$d gostos + Lista do Trakt não encontrada ou não pública + ID da lista do Trakt em falta + A lista do Trakt não incluía um ID numérico + Lista pública do Trakt + Limite de pedidos do Trakt atingido + O pedido ao Trakt falhou + Falha ao carregar os comentários do Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Falha ao finalizar o ficheiro de transferência + Falha ao abrir o ficheiro parcial da transferência + O ficheiro parcial da transferência não está aberto + Falha ao escrever no ficheiro parcial da transferência + Biblioteca Nuvio + Problema de ligação + Corpo da resposta vazio + Verifica a tua ligação e tenta novamente. + O pedido falhou com HTTP %1$d + %1$s (%2$s) + O motor do leitor MPV não está disponível. Recompila a app. + Não foi possível reproduzir este stream. + Plugins desativados + Plugins ativados + %1$d fornecedores + A atualizar + %1$d repositórios + Chave API do TMDB em falta + Chave API do TMDB definida + Instalar repositório de plugins + A instalar… + Testar fornecedor + A testar… + Eliminar repositório de plugins + Atualizar repositório de plugins + Ainda não existem fornecedores disponíveis. + Adiciona o URL de um repositório para instalares plugins de fornecedores de streams. + Ainda não existem repositórios de plugins instalados. + Utiliza fornecedores de plugins durante a descoberta de streams. + Ativar fornecedores de plugins globalmente + Esse repositório de plugins já está instalado. + Introduz o URL de um repositório de plugins. + Introduz um URL de plugin válido. + Não foi possível instalar o repositório de plugins + Fornecedor não encontrado + Não foi possível atualizar o repositório + Os plugins não estão disponíveis nesta versão. + Em Streams, mostra um fornecedor por repositório em vez de um por fonte. + Agrupar fornecedores de plugins por repositório + URL do manifesto do plugin + O nome do manifesto está em falta. + O manifesto não tem fornecedores. + A versão do manifesto está em falta. + O nome do manifesto está em falta. + O manifesto não tem fornecedores. + A versão do manifesto está em falta. + %1$s instalado. + Desativado pelo repositório + Sem descrição + v%1$s + Repositório de plugins + Versão %1$s + Esse repositório de plugins já está instalado. + Não foi possível instalar o repositório de plugins + Não foi possível atualizar o repositório + ADICIONAR REPOSITÓRIO + REPOSITÓRIOS INSTALADOS + VISÃO GERAL + FORNECEDORES + Erro + O teste do fornecedor falhou + Resultados do teste (%1$d) + Os fornecedores de plugins precisam de uma chave API do TMDB. Define-a na página do TMDB; caso contrário, podem não funcionar corretamente. + Nenhum resultado de pesquisa devolvido por %1$s. + Chave API inválida ou falha na ligação + Repositório de plugins + Enviar introdução + Enviar + Capturar + TEMPO FINAL (MM:SS) + TIPO DE SEGMENTO + TEMPO INICIAL (MM:SS) + Ligado ao Trakt + Desligado do Trakt + TB + Nenhum recurso APK encontrado na versão + A transferência falhou com HTTP %1$d + O ficheiro de atualização transferido está em falta. + Corpo da transferência vazio + Erro da API de versões do GitHub: %1$d + Ainda não foi publicada nenhuma atualização. + A versão não tem etiqueta nem nome + Estreia a %1$s + Estreia hoje + Estreia amanhã - Emitido em %1$d dia - Emitido em %1$d dias + Estreia dentro de %1$d dia + Estreia dentro de %1$d dias %1$s Hoje @@ -1413,4 +1826,22 @@ Novo episódio Nova temporada + Lista do Trakt %1$s + Leitor do sistema Android + Streaming P2P + Este stream utiliza tecnologia ponto a ponto (P2P). Ao ativar o P2P, reconheces e aceitas que:\n\n• O teu endereço IP ficará visível para outros peers na rede\n• És o único responsável pelo conteúdo a que acedes\n• Confirmas que tens o direito legal de transmitir este conteúdo na tua jurisdição\n• O Nuvio não aloja, distribui nem controla qualquer conteúdo P2P\n• O Nuvio não assume responsabilidade por quaisquer consequências legais resultantes da utilização de streaming P2P\n\nUtilizas esta funcionalidade inteiramente por tua conta e risco. O P2P pode ser desativado a qualquer momento nas Definições. + Ativar P2P + Cancelar + Streaming P2P + Permitir streams ponto a ponto (torrent) + Ocultar estatísticas de torrent + Oculta buffer, seeds, peers e velocidade de transferência durante o carregamento e a reprodução + %1$d seeds · %2$d peers + %1$s em buffer · %2$s · %3$s + %1$s · %2$s + %1$d peers · %2$d seeds · %3$d%% + A ligar aos peers… + A iniciar motor P2P… + Falha ao iniciar o torrent: %1$s + Erro de torrent: %1$s diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f849d9339..eddd4964a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -300,7 +300,6 @@ Pinned All Your Collections - Based on Nuvio %1$s Made with ❤️ by Tapframe and friends Version %1$s (%2$s) Off @@ -345,6 +344,8 @@ %1$dsp Lock player controls Loading subtitle lines... + No subtitle lines found + Unable to load subtitle lines No audio tracks available No episodes available No streams found @@ -450,7 +451,7 @@ Remember Last Profile Remember last selected profile at startup Clear Continue Watching Cache - Remove cached thumbnails, titles, and enrichment data for Continue Watching + Remove cached Continue Watching data and refresh watch progress Cache cleared APP LICENSE DATA & SERVICES @@ -531,15 +532,12 @@ Status Anonymous Signed in + Sync backend AMOLED Black Use pure black backgrounds for OLED screens. App Language Choose Language Settings for the Continue Watching section. - Desktop Navigation - Choose Desktop Navigation - Sidebar - Top Bar Liquid Glass Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. Tune card width and corner radius. @@ -615,7 +613,7 @@ Released items first, upcoming at the end Poster Card Style ON LAUNCH - UP NEXT BEHAVIOR + NEXT UP BEHAVIOR VISIBILITY Display the Continue Watching shelf on the Home screen. Show Continue Watching @@ -626,7 +624,7 @@ Wide Info-dense horizontal card Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. - Up Next From Furthest Episode + Next Up From Furthest Episode Prefer episode thumbnails when available. Prefer Episode Thumbnails in Continue Watching HOME @@ -698,6 +696,10 @@ Fusion badge URLs Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately. Manage imported Fusion-style stream badge JSON URLs. + Badge import failed. + Enter a badge JSON URL. + Badge URL must start with http:// or https://. + You can import up to %1$d badge URLs. %1$d/%2$d URLs, %3$d active Fusion badges No Fusion badge URLs imported. Fusion badge JSON URL @@ -775,6 +777,10 @@ Test notification Community See the people building and supporting Nuvio across Mobile, TV, and Web. + This month's server & maintenance + Covered. Additional support now goes to development. + After 100%%, additional support goes to development. + Loading funding progress... Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties. Contributors Supporters @@ -845,6 +851,8 @@ Hold Speed Hold To Speed Long-press anywhere on the player surface to temporarily boost playback speed. + Touch Gestures + Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust volume. Invalid regex pattern Last Link Cache Duration DV7 - HEVC Fallback @@ -1149,6 +1157,7 @@ Exit app This catalog did not return any items. No titles found + More actions Check your Wi-Fi or mobile data connection and try again. Director Failed to load @@ -1184,7 +1193,7 @@ Mark previous seasons as watched Mark as unwatched Mark as watched - Up next + Next Up %1$s watched %1$dh %2$dm left %1$dm left @@ -1331,8 +1340,8 @@ Sign-out failed Sign-up failed Unable to load catalog items. - Up Next - Up Next • S%1$dE%2$d + Next Up + Next Up • S%1$dE%2$d %1$s logo Failed to load comments Could not load details from any addon. @@ -1392,6 +1401,16 @@ Failed to complete Trakt sign in Trakt user Watchlist + + Trakt authorization expired + Trakt list not found + Trakt list limit reached + Trakt rate limit reached + Trakt request failed + Failed to add to Trakt watchlist + Failed to add to Trakt list + Missing compatible Trakt IDs + Empty response body Add a TMDB API key in Settings to use TMDB sources. TMDB Collection %1$d TMDB collection not found @@ -1865,6 +1884,7 @@ 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. Enable P2P Cancel + Unknown torrent error P2P Streaming Allow peer-to-peer (torrent) streams Hide torrent stats diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 458c752c5..a431641b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,7 +3,6 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,9 +11,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.Image -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -23,18 +19,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -62,10 +55,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -91,6 +81,9 @@ import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.network.SyncBackendRefreshResult +import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.sync.AppForegroundMonitor import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.SyncManager @@ -117,7 +110,8 @@ import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen -import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.catalog.CatalogTarget +import com.nuvio.app.features.catalog.CatalogTargetKind import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem @@ -163,15 +157,12 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders -import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar -import com.nuvio.app.features.profiles.AvatarCatalogItem import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab -import com.nuvio.app.features.profiles.SidebarProfileSwitcherStack import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen @@ -181,16 +172,13 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen -import com.nuvio.app.features.settings.DesktopNavigationLayout import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository -import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionSyncService -import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository @@ -208,7 +196,6 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository @@ -221,13 +208,10 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -321,12 +305,64 @@ data class StreamRoute( data class CatalogRoute( val title: String, val subtitle: String, - val manifestUrl: String, - val type: String, - val catalogId: String, + val targetKind: String, + val contentType: String, val supportsPagination: Boolean = false, + val manifestUrl: String? = null, + val addonCatalogId: String? = null, val genre: String? = null, -) + val librarySectionType: String? = null, + val collectionId: String? = null, + val folderId: String? = null, + val sourceKey: String? = null, +) { + constructor( + title: String, + subtitle: String, + target: CatalogTarget, + ) : this( + title = title, + subtitle = subtitle, + targetKind = when (target) { + is CatalogTarget.Addon -> CatalogTargetKind.ADDON + is CatalogTarget.Library -> CatalogTargetKind.LIBRARY + is CatalogTarget.CollectionSource -> CatalogTargetKind.COLLECTION_SOURCE + }.name, + contentType = target.contentType, + supportsPagination = target.supportsPagination, + manifestUrl = (target as? CatalogTarget.Addon)?.manifestUrl, + addonCatalogId = (target as? CatalogTarget.Addon)?.catalogId, + genre = (target as? CatalogTarget.Addon)?.genre, + librarySectionType = (target as? CatalogTarget.Library)?.sectionType, + collectionId = (target as? CatalogTarget.CollectionSource)?.collectionId, + folderId = (target as? CatalogTarget.CollectionSource)?.folderId, + sourceKey = (target as? CatalogTarget.CollectionSource)?.sourceKey, + ) + + fun toCatalogTarget(): CatalogTarget = + when (CatalogTargetKind.valueOf(targetKind)) { + CatalogTargetKind.ADDON -> CatalogTarget.Addon( + manifestUrl = requireNotNull(manifestUrl), + contentType = contentType, + catalogId = requireNotNull(addonCatalogId), + genre = genre, + supportsPagination = supportsPagination, + ) + + CatalogTargetKind.LIBRARY -> CatalogTarget.Library( + contentType = contentType, + sectionType = requireNotNull(librarySectionType), + ) + + CatalogTargetKind.COLLECTION_SOURCE -> CatalogTarget.CollectionSource( + collectionId = requireNotNull(collectionId), + folderId = requireNotNull(folderId), + sourceKey = requireNotNull(sourceKey), + contentType = contentType, + supportsPagination = supportsPagination, + ) + } +} private data class PosterActionTarget( val preview: MetaPreview, @@ -341,11 +377,6 @@ enum class AppScreenTab { Settings, } -private val DesktopSidebarCollapsedWidth = 76.dp -private val DesktopSidebarExpandedWidth = 184.dp -private val DesktopSidebarExpandedContentWidth = 144.dp -private val DesktopSidebarIconSlotSize = 36.dp - private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { AppScreenTab.Home -> NativeNavigationTab.Home AppScreenTab.Search -> NativeNavigationTab.Search @@ -376,34 +407,33 @@ private enum class AppGateScreen { Loading, Auth, ProfileSelection, - ProfileSwitching, ProfileEdit, Main, } -private data class PendingProfileSwitch( - val profile: NuvioProfile, - val syncOnEnter: Boolean, -) +private suspend fun refreshSyncBackendSelection() { + SyncBackendRepository.ensureLoaded() -private suspend fun warmProfileBoundRepositories() { - withContext(Dispatchers.Default) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - DownloadsRepository.ensureLoaded() - EpisodeReleaseNotificationsRepository.ensureLoaded() - HomeCatalogSettingsRepository.snapshot() - LibraryRepository.ensureLoaded() - P2pSettingsRepository.ensureLoaded() - PlayerSettingsRepository.ensureLoaded() - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() - CollectionSyncService.startObserving() - HomeCatalogSettingsSyncService.startObserving() - ProfileSettingsSync.startObserving() + when (val result = SyncBackendRepository.refreshFromManifest()) { + SyncBackendRefreshResult.NotConfigured, + is SyncBackendRefreshResult.Failed, + SyncBackendRefreshResult.Unchanged, + -> Unit + is SyncBackendRefreshResult.Applied -> { + SupabaseProvider.rebuildClient() + NetworkStatusRepository.requestRefresh(force = true) + } + is SyncBackendRefreshResult.RequiresLogout -> { + AuthRepository.resetForSyncBackendChange() + .onSuccess { + SyncBackendRepository.applyBackendAfterLogout( + backend = result.targetBackend, + revision = result.revision, + ) + SupabaseProvider.rebuildClient() + NetworkStatusRepository.requestRefresh(force = true) + } + } } } @@ -429,9 +459,16 @@ fun App() { val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { + refreshSyncBackendSelection() AuthRepository.initialize() } + LaunchedEffect(Unit) { + AppForegroundMonitor.events().collect { + refreshSyncBackendSelection() + } + } + LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() @@ -469,7 +506,6 @@ fun App() { var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } - var pendingProfileSwitch by remember { mutableStateOf(null) } fun rememberedStartupProfile(profiles: List): NuvioProfile? { val currentProfileState = ProfileRepository.state.value @@ -485,12 +521,6 @@ fun App() { ?.takeUnless { it.pinEnabled } } - fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) { - autoSkipProfileSelection = false - pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter) - gateScreen = AppGateScreen.ProfileSwitching.name - } - fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { if (profiles.isEmpty()) { autoSkipProfileSelection = true @@ -499,7 +529,12 @@ fun App() { } rememberedStartupProfile(profiles)?.let { profile -> - requestProfileSwitch(profile, syncOnEnter) + ProfileRepository.selectProfile(profile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(profile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false return } @@ -510,40 +545,18 @@ fun App() { gateScreen = AppGateScreen.ProfileSelection.name return } - requestProfileSwitch(onlyProfile, syncOnEnter) + ProfileRepository.selectProfile(onlyProfile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false } else { gateScreen = AppGateScreen.ProfileSelection.name } } - LaunchedEffect(gateScreen, pendingProfileSwitch) { - if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) { - gateScreen = AppGateScreen.Loading.name - } - } - - LaunchedEffect(pendingProfileSwitch) { - val request = pendingProfileSwitch ?: return@LaunchedEffect - runCatching { - ProfileRepository.switchToProfile(request.profile.profileIndex) - warmProfileBoundRepositories() - if (request.syncOnEnter) { - SyncManager.pullAllForProfile(request.profile.profileIndex) - } - }.onSuccess { - pendingProfileSwitch = null - autoSkipProfileSelection = false - gateScreen = AppGateScreen.Main.name - }.onFailure { - pendingProfileSwitch = null - autoSkipProfileSelection = false - gateScreen = AppGateScreen.ProfileSelection.name - } - } - LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { - if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect - val cachedProfiles = profileState.profiles val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && @@ -601,10 +614,10 @@ fun App() { gateScreen == AppGateScreen.ProfileSelection.name ) { rememberedStartupProfile(profileState.profiles)?.let { profile -> - requestProfileSwitch( - profile = profile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(profile.profileIndex) + SyncManager.pullAllForProfile(profile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false return@LaunchedEffect } @@ -613,10 +626,10 @@ fun App() { val onlyProfile = profileState.profiles.first() if (onlyProfile.pinEnabled) return@LaunchedEffect - requestProfileSwitch( - profile = onlyProfile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(onlyProfile.profileIndex) + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false } } @@ -629,9 +642,15 @@ fun App() { }, ) { currentGate -> when (currentGate) { - AppGateScreen.Loading.name, - AppGateScreen.ProfileSwitching.name -> { - AppLaunchOverlay(modifier = Modifier.fillMaxSize()) + AppGateScreen.Loading.name -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.nuvio.colors.background), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) + } } AppGateScreen.Auth.name -> { AuthScreen(modifier = Modifier.fillMaxSize()) @@ -644,10 +663,11 @@ fun App() { } ProfileSelectionScreen( onProfileSelected = { profile -> - requestProfileSwitch( - profile = profile, - syncOnEnter = authState is AuthState.Authenticated, - ) + ProfileRepository.selectProfile(profile.profileIndex) + if (authState is AuthState.Authenticated) { + SyncManager.pullAllForProfile(profile.profileIndex) + } + gateScreen = AppGateScreen.Main.name }, onEditProfile = { profile -> editingProfile = profile @@ -693,6 +713,18 @@ private fun MainAppContent( ) { val navController = rememberNavController() val appUpdaterController = rememberAppUpdaterController() + remember { + EpisodeReleaseNotificationsRepository.ensureLoaded() + } + remember { + CollectionSyncService.startObserving() + } + remember { + HomeCatalogSettingsSyncService.startObserving() + } + remember { + ProfileSettingsSync.startObserving() + } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } @@ -701,17 +733,10 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } - - LaunchedEffect(Unit) { - warmProfileBoundRepositories() - } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() - val desktopNavigationLayout by remember { - ThemeSettingsRepository.desktopNavigationLayout - }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterActionTarget by remember { mutableStateOf(null) } @@ -724,14 +749,32 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } - val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() - val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle() + val addonsUiState by remember { + AddonRepository.initialize() + AddonRepository.uiState + }.collectAsStateWithLifecycle() + val libraryUiState by remember { + LibraryRepository.ensureLoaded() + LibraryRepository.uiState + }.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() - val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle() - val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() - val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle() + val playerSettingsUiState by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pSettingsUiState by remember { + P2pSettingsRepository.ensureLoaded() + P2pSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val downloadsUiState by remember { + DownloadsRepository.ensureLoaded() + DownloadsRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() @@ -877,7 +920,6 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect val hasPlayableDownload = downloadsUiState.completedItems.any { DownloadsRepository.playableLocalFileUri(it) != null } @@ -903,6 +945,7 @@ private fun MainAppContent( var profileSwitchLoading by remember { mutableStateOf(false) } var resumePromptItem by remember { mutableStateOf(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } + val activePlaybackProfileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId val launchExternalPlayer = rememberExternalPlayerLauncher { result -> if (result != null && result.positionMs > 0L) { coroutineScope.launch { @@ -912,27 +955,30 @@ private fun MainAppContent( } else { null } - if (TraktAuthRepository.isAuthenticated.value && progressPercent != null) { + val playerLaunch = lastExternalPlayerLaunch + if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) { val scrobbleItem = TraktScrobbleRepository.buildItem( - contentType = lastExternalPlayerLaunch?.parentMetaType ?: "", - parentMetaId = lastExternalPlayerLaunch?.parentMetaId ?: "", - videoId = lastExternalPlayerLaunch?.videoId, - title = lastExternalPlayerLaunch?.title, - seasonNumber = lastExternalPlayerLaunch?.seasonNumber, - episodeNumber = lastExternalPlayerLaunch?.episodeNumber, - episodeTitle = lastExternalPlayerLaunch?.episodeTitle, + contentType = playerLaunch.parentMetaType, + parentMetaId = playerLaunch.parentMetaId, + videoId = playerLaunch.videoId, + title = playerLaunch.title, + seasonNumber = playerLaunch.seasonNumber, + episodeNumber = playerLaunch.episodeNumber, + episodeTitle = playerLaunch.episodeTitle, ) if (scrobbleItem != null) { runCatching { TraktScrobbleRepository.scrobbleStop( + profileId = playerLaunch.profileId, item = scrobbleItem, progressPercent = progressPercent, ) } } } - lastExternalPlayerLaunch?.let { playerLaunch -> + playerLaunch?.let { playerLaunch -> val session = WatchProgressPlaybackSession( + profileId = playerLaunch.profileId, contentType = playerLaunch.contentType ?: playerLaunch.parentMetaType, parentMetaId = playerLaunch.parentMetaId, parentMetaType = playerLaunch.parentMetaType, @@ -965,7 +1011,10 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by remember { + ContinueWatchingPreferencesRepository.ensureLoaded() + ContinueWatchingPreferencesRepository.uiState + }.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -1000,11 +1049,9 @@ private fun MainAppContent( } AppDeepLink.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { - launchSingleTop = true - } + selectedTab = AppScreenTab.Settings + navController.navigate(DownloadsSettingsRoute) { + launchSingleTop = true } AppDeepLinkRepository.markConsumed(deepLink) } @@ -1081,6 +1128,7 @@ private fun MainAppContent( ?.takeIf { it.isNotBlank() } ?: file.name.ifBlank { item.name } val playerLaunch = PlayerLaunch( + profileId = activePlaybackProfileId, title = playbackTitle, sourceUrl = resolved.url, streamTitle = playbackTitle, @@ -1131,7 +1179,7 @@ private fun MainAppContent( val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction - if (!manualSelection && AppFeaturePolicy.downloadsEnabled) { + if (!manualSelection) { val downloadedItem = DownloadsRepository.findPlayableDownload( parentMetaId = parentMetaId, seasonNumber = seasonNumber, @@ -1141,29 +1189,30 @@ private fun MainAppContent( val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { val playerLaunch = PlayerLaunch( - title = title, - sourceUrl = localSourceUrl, - sourceHeaders = emptyMap(), - sourceResponseHeaders = emptyMap(), - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - streamTitle = downloadedItem.streamTitle.ifBlank { title }, - streamSubtitle = downloadedItem.streamSubtitle, - pauseDescription = pauseDescription, - providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel }, - providerAddonId = downloadedItem.providerAddonId, - contentType = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - initialPositionMs = targetResumePositionMs, - initialProgressFraction = targetResumeProgressFraction, - ) + profileId = activePlaybackProfileId, + title = title, + sourceUrl = localSourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + streamTitle = downloadedItem.streamTitle.ifBlank { title }, + streamSubtitle = downloadedItem.streamSubtitle, + pauseDescription = pauseDescription, + providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel }, + providerAddonId = downloadedItem.providerAddonId, + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + initialPositionMs = targetResumePositionMs, + initialProgressFraction = targetResumeProgressFraction, + ) if (playerSettingsUiState.externalPlayerEnabled) { coroutineScope.launch { openExternalPlayback(playerLaunch) } return @@ -1176,6 +1225,7 @@ private fun MainAppContent( val streamLaunchId = StreamLaunchStore.put( StreamLaunch( + profileId = activePlaybackProfileId, type = type, videoId = videoId, parentMetaId = parentMetaId, @@ -1251,11 +1301,7 @@ private fun MainAppContent( CatalogRoute( title = section.title, subtitle = section.subtitle, - manifestUrl = section.manifestUrl, - type = section.type, - catalogId = section.catalogId, - supportsPagination = section.supportsPagination, - genre = section.genre, + target = section.target, ), ) } @@ -1271,10 +1317,10 @@ private fun MainAppContent( CatalogRoute( title = section.displayTitle, subtitle = librarySectionSubtitle, - manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, - type = section.items.firstOrNull()?.type ?: "movie", - catalogId = section.type, - supportsPagination = false, + target = CatalogTarget.Library( + contentType = section.items.firstOrNull()?.type ?: "movie", + sectionType = section.type, + ), ), ) } @@ -1388,31 +1434,12 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady - val useDesktopSidebar = isDesktop && - isTabletLayout && - !useNativeBottomTabs && - desktopNavigationLayout == DesktopNavigationLayout.Sidebar - val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar - val topChromePadding = if (useFloatingTopBar) { - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - max(statusBarPadding + 24.dp, 48.dp) + 64.dp - } else { - null - } val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home - coroutineScope.launch { - try { - ProfileRepository.switchToProfile(profile.profileIndex) - warmProfileBoundRepositories() - SyncManager.pullAllForProfile(profile.profileIndex) - delay(300) - } finally { - profileSwitchLoading = false - } - } + ProfileRepository.selectProfile(profile.profileIndex) + com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } Scaffold( @@ -1464,10 +1491,8 @@ private fun MainAppContent( AppTabHost( modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp), + .padding(innerPadding), selectedTab = selectedTab, - topChromePadding = topChromePadding, searchFocusRequestCount = searchFocusRequestCount, rootActionsEnabled = tabsRouteActive, homeScrollToTopRequests = homeScrollToTopRequests, @@ -1523,11 +1548,7 @@ private fun MainAppContent( onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { - if (AppFeaturePolicy.downloadsEnabled) { - navController.navigate(DownloadsSettingsRoute) - } - }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { @@ -1563,14 +1584,7 @@ private fun MainAppContent( ) } - if (useDesktopSidebar) { - DesktopHoverSidebar( - selectedTab = selectedTab, - onTabSelected = ::handleRootTabClick, - onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, - ) - } else if (useFloatingTopBar) { + if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, @@ -1803,7 +1817,10 @@ private fun MainAppContent( hasResolvedVideoId = true } - val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val playerSettings by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" @@ -1815,7 +1832,7 @@ private fun MainAppContent( replaceStreamRoute: Boolean, ) { val infoHash = stream.p2pInfoHash ?: return - val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx) + val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, @@ -1835,16 +1852,18 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.fileIdx, + fileIdx = stream.p2pFileIdx, sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) } val playerLaunch = PlayerLaunch( + profileId = launch.profileId, title = launch.title, sourceUrl = sentinelUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1863,7 +1882,7 @@ private fun MainAppContent( parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, torrentInfoHash = infoHash, - torrentFileIdx = stream.fileIdx, + torrentFileIdx = stream.p2pFileIdx, torrentFilename = stream.behaviorHints.filename, torrentTrackers = stream.p2pTrackers, initialPositionMs = resolvedResumePositionMs ?: 0L, @@ -1960,30 +1979,32 @@ private fun MainAppContent( return@LaunchedEffect } val playerLaunch = PlayerLaunch( - title = launch.title, - sourceUrl = cached.url, - sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), - sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), - logo = launch.logo, - poster = launch.poster, - background = launch.background, - seasonNumber = launch.seasonNumber, - episodeNumber = launch.episodeNumber, - episodeTitle = launch.episodeTitle, - episodeThumbnail = launch.episodeThumbnail, - streamTitle = cached.streamName, - streamSubtitle = null, - bingeGroup = cached.bingeGroup, - pauseDescription = pauseDescription, - providerName = cached.addonName, - providerAddonId = cached.addonId, - contentType = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId ?: effectiveVideoId, - parentMetaType = launch.parentMetaType ?: launch.type, - initialPositionMs = launch.resumePositionMs ?: 0L, - initialProgressFraction = launch.resumeProgressFraction, - ) + profileId = launch.profileId, + title = launch.title, + sourceUrl = cached.url, + sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + streamType = cached.streamType, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + streamTitle = cached.streamName, + streamSubtitle = null, + bingeGroup = cached.bingeGroup, + pauseDescription = pauseDescription, + providerName = cached.addonName, + providerAddonId = cached.addonId, + contentType = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + initialPositionMs = launch.resumePositionMs ?: 0L, + initialProgressFraction = launch.resumeProgressFraction, + ) if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) StreamsRepository.setOverlayVisible(false) @@ -2089,33 +2110,36 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( - title = launch.title, - sourceUrl = sourceUrl, - sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - logo = launch.logo, - poster = launch.poster, - background = launch.background, - seasonNumber = launch.seasonNumber, - episodeNumber = launch.episodeNumber, - episodeTitle = launch.episodeTitle, - episodeThumbnail = launch.episodeThumbnail, - streamTitle = stream.streamLabel, - streamSubtitle = stream.streamSubtitle, - bingeGroup = stream.behaviorHints.bingeGroup, - pauseDescription = pauseDescription, - providerName = stream.addonName, - providerAddonId = stream.addonId, - contentType = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId ?: effectiveVideoId, - parentMetaType = launch.parentMetaType ?: launch.type, - initialPositionMs = launch.resumePositionMs ?: 0L, - initialProgressFraction = launch.resumeProgressFraction, - ) + profileId = launch.profileId, + title = launch.title, + sourceUrl = sourceUrl, + sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + streamTitle = stream.streamLabel, + streamSubtitle = stream.streamSubtitle, + bingeGroup = stream.behaviorHints.bingeGroup, + pauseDescription = pauseDescription, + providerName = stream.addonName, + providerAddonId = stream.addonId, + contentType = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + initialPositionMs = launch.resumePositionMs ?: 0L, + initialProgressFraction = launch.resumeProgressFraction, + ) if (playerSettings.externalPlayerEnabled) { openExternalPlayback(playerLaunch) StreamsRepository.consumeAutoPlay() @@ -2213,13 +2237,16 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( + profileId = launch.profileId, title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2373,11 +2400,13 @@ private fun MainAppContent( launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) } } PlayerScreen( + profileId = launch.profileId, title = launch.title, sourceUrl = launch.sourceUrl, sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + streamType = launch.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2408,6 +2437,7 @@ private fun MainAppContent( }, onOpenInExternalPlayer = { request -> val playerLaunch = PlayerLaunch( + profileId = launch.profileId, title = launch.title, sourceUrl = request.sourceUrl, sourceHeaders = request.sourceHeaders, @@ -2455,14 +2485,11 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val target = route.toCatalogTarget() CatalogScreen( title = route.title, subtitle = route.subtitle, - manifestUrl = route.manifestUrl, - type = route.type, - catalogId = route.catalogId, - supportsPagination = route.supportsPagination, - genre = route.genre, + target = target, onBack = { CatalogRepository.clear() navController.popBackStack() @@ -2472,11 +2499,11 @@ private fun MainAppContent( }, onPosterLongClick = { meta -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterActionTarget = if (route.manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) { + selectedPosterActionTarget = if (target is CatalogTarget.Library) { PosterActionTarget( preview = meta, libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L), - libraryListKey = route.catalogId, + libraryListKey = target.sectionType, ) } else { PosterActionTarget(preview = meta) @@ -2512,53 +2539,52 @@ private fun MainAppContent( onBack = onBack, ) } - if (AppFeaturePolicy.downloadsEnabled) { - composable { backStackEntry -> - val onBack = rememberGuardedPopBackStack( - navController = navController, - backStackEntry = backStackEntry, - ) - DownloadsScreen( - onBack = onBack, - onOpenDownload = { item -> - val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen - val resumeEntry = item.videoId - .takeIf { it.isNotBlank() } - ?.let(WatchProgressRepository::progressForVideo) - ?.takeIf { it.isResumable } + composable { backStackEntry -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + backStackEntry = backStackEntry, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = { item -> + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen + val resumeEntry = item.videoId + .takeIf { it.isNotBlank() } + ?.let(WatchProgressRepository::progressForVideo) + ?.takeIf { it.isResumable } - val playerLaunch = PlayerLaunch( - title = item.title, - sourceUrl = sourceUrl, - sourceHeaders = emptyMap(), - sourceResponseHeaders = emptyMap(), - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - streamTitle = item.streamTitle, - streamSubtitle = item.streamSubtitle, - providerName = item.providerName, - providerAddonId = item.providerAddonId, - contentType = item.contentType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, - initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, - ) - if (playerSettingsUiState.externalPlayerEnabled) { - coroutineScope.launch { openExternalPlayback(playerLaunch) } - return@DownloadsScreen - } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) - }, - ) - } + val playerLaunch = PlayerLaunch( + profileId = activePlaybackProfileId, + title = item.title, + sourceUrl = sourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + streamTitle = item.streamTitle, + streamSubtitle = item.streamSubtitle, + providerName = item.providerName, + providerAddonId = item.providerAddonId, + contentType = item.contentType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, + initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + coroutineScope.launch { openExternalPlayback(playerLaunch) } + return@DownloadsScreen + } + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + }, + ) } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( @@ -2825,6 +2851,15 @@ private fun MainAppContent( AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } + // Auto-dismiss profile switch overlay + if (profileSwitchLoading) { + LaunchedEffect(Unit) { + // Brief loading screen while home refreshes for the new profile + kotlinx.coroutines.delay(1200) + profileSwitchLoading = false + } + } + NuvioFloatingPrompt( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, @@ -2882,7 +2917,6 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, - topChromePadding: Dp? = null, searchFocusRequestCount: Int = 0, rootActionsEnabled: Boolean = true, homeScrollToTopRequests: Flow, @@ -2940,7 +2974,6 @@ private fun AppTabHost( AppScreenTab.Search -> { SearchScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, @@ -2951,7 +2984,6 @@ private fun AppTabHost( AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, @@ -2987,260 +3019,6 @@ private fun AppTabHost( } } -@Composable -private fun DesktopHoverSidebar( - selectedTab: AppScreenTab, - onTabSelected: (AppScreenTab) -> Unit, - onProfileSelected: (NuvioProfile) -> Unit, - onAddProfileRequested: () -> Unit, - modifier: Modifier = Modifier, -) { - val tokens = MaterialTheme.nuvio - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() - val activeProfile = profileState.activeProfile - val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile) - val hoverSource = remember { MutableInteractionSource() } - val hovered by hoverSource.collectIsHoveredAsState() - var profileStackVisible by remember { mutableStateOf(false) } - val sidebarExpanded = hovered || profileStackVisible - val profileTopPadding = statusBarPadding + 18.dp - fun selectTab(tab: AppScreenTab) { - profileStackVisible = false - onTabSelected(tab) - } - val sidebarWidth by animateDpAsState( - targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth, - animationSpec = tween(durationMillis = 180), - label = "desktop_sidebar_width", - ) - - Surface( - modifier = modifier - .width(sidebarWidth) - .fillMaxHeight() - .hoverable(hoverSource) - .zIndex(NuvioTokens.Z.navigation), - color = tokens.colors.background, - contentColor = tokens.colors.textPrimary, - ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = profileTopPadding) - .fillMaxWidth() - .height(52.dp) - .padding(horizontal = 10.dp, vertical = 4.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { profileStackVisible = !profileStackVisible }, - ), - contentAlignment = Alignment.Center, - ) { - DesktopSidebarProfileTrigger( - profile = activeProfile, - avatars = avatars, - label = activeProfileName, - expanded = sidebarExpanded, - ) - } - - if (profileStackVisible) { - SidebarProfileSwitcherStack( - onProfileSelected = onProfileSelected, - onAddProfileRequested = onAddProfileRequested, - onDismissRequest = { profileStackVisible = false }, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = profileTopPadding + 58.dp) - .width(DesktopSidebarExpandedContentWidth), - ) - } - - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_home), - selected = selectedTab == AppScreenTab.Home, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Home) }, - ) { color -> - Icon( - imageVector = Icons.Filled.Home, - contentDescription = stringResource(Res.string.compose_nav_home), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_search), - selected = selectedTab == AppScreenTab.Search, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Search) }, - ) { color -> - Icon( - painter = painterResource(Res.drawable.sidebar_search), - contentDescription = stringResource(Res.string.compose_nav_search), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_nav_library), - selected = selectedTab == AppScreenTab.Library, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Library) }, - ) { color -> - Icon( - painter = painterResource(Res.drawable.sidebar_library), - contentDescription = stringResource(Res.string.compose_nav_library), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - DesktopSidebarItem( - label = stringResource(Res.string.compose_settings_page_root), - selected = selectedTab == AppScreenTab.Settings, - expanded = sidebarExpanded, - onClick = { selectTab(AppScreenTab.Settings) }, - ) { color -> - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(Res.string.compose_settings_page_root), - modifier = Modifier.size(NuvioTokens.Space.s20), - tint = color, - ) - } - } - } - } -} - -@Composable -private fun DesktopSidebarProfileTrigger( - profile: NuvioProfile?, - avatars: List, - label: String, - expanded: Boolean, -) { - val tokens = MaterialTheme.nuvio - - Surface( - modifier = Modifier.fillMaxSize(), - color = Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.width( - if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(DesktopSidebarIconSlotSize), - contentAlignment = Alignment.Center, - ) { - ActiveProfileMiniAvatar( - profile = profile, - avatars = avatars, - selected = false, - size = 28, - ) - } - if (expanded) { - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelLarge, - color = tokens.colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -} - -@Composable -private fun DesktopSidebarItem( - label: String, - selected: Boolean, - expanded: Boolean, - onClick: () -> Unit, - icon: @Composable (Color) -> Unit, -) { - val tokens = MaterialTheme.nuvio - val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted - val iconColor = if (selected) tokens.colors.onAccent else contentColor - - Surface( - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - .padding(horizontal = 10.dp, vertical = 4.dp) - .clickable(onClick = onClick), - color = Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.width( - if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(DesktopSidebarIconSlotSize), - color = if (selected) tokens.colors.accent else Color.Transparent, - shape = RoundedCornerShape(14.dp), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - icon(iconColor) - } - } - if (expanded) { - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelLarge, - color = contentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } -} - @Composable private fun TabletFloatingTopBar( selectedTab: AppScreenTab, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt index 20d374409..5b1cae86f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -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 \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 7e5f9d85d..11c318fc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -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 = 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 = runCatching { _error.value = null SupabaseProvider.client.functions.invoke("delete-account") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index f231462b3..8e62eb8ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt deleted file mode 100644 index 24e0a028c..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -expect object AppVersionPolicy { - val displayVersionName: String - val displayVersionCode: Int - val basedOnVersionName: String? -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt index ca955abb2..10356040a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt index 4288603fc..626732718 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt index 77d7090c8..0e317985e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt @@ -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 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt new file mode 100644 index 000000000..ca45da22e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -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://") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt new file mode 100644 index 000000000..db3065293 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -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 = _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(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( + 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, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt new file mode 100644 index 000000000..748a8c18b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/ProfileScopedKey.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/ProfileScopedKey.kt index cdc75ad36..0b3b67849 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/ProfileScopedKey.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/ProfileScopedKey.kt @@ -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" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 969acc3c4..26616f916 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -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().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().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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt deleted file mode 100644 index c773a3a7d..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt +++ /dev/null @@ -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, -) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 6e841b468..f19d3b21e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt index bf5c3dac9..e6214c9e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index bf2e6149d..6ad66ea4a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index aa3932acc..1569c849a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -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 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 NuvioShelfSection( ) } LazyRow( - state = rowState, - modifier = Modifier.desktopShelfDragScroll(rowState), contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -107,47 +100,6 @@ fun 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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt deleted file mode 100644 index eea6d19b2..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal expect fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 87abf3882..7e12666dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt index f2f763a95..90154cac0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -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, @@ -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>() @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt index da473cb27..e78987b70 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt @@ -6,6 +6,7 @@ data class CatalogUiState( val items: List = emptyList(), val isLoading: Boolean = false, val nextSkip: Int? = null, + val consecutiveDuplicatePages: Int = 0, val errorMessage: String? = null, ) { val canLoadMore: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index 716346a88..2d2c4909f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -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() 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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index a251166e4..e60bb169e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -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, ) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt new file mode 100644 index 000000000..e3ae93036 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt @@ -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, +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index c3843bdd5..e4cec4b55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List): 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 578445ae7..0203771f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index e10467123..76aac7689 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -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() 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) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 77704a7e7..2cdbd37ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -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 { 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 008b1cf54..5881ce2e4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt index e45d32bbf..11e95c0c4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index adcf68116..073ae7f7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index aef463c9a..566776088 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 9c9686127..f209f7f2f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 0803aacbe..04abb4ca7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index b9bd0b6d5..b0c97f51a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -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 = 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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index a6b498cee..306152a5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 700aa41ee..1b7ecaf8a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 5754a2508..a97e8ee6d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index bdb1b6e8d..b9308252b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -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.* diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 82740e74c..3601b1d01 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index f5226e9b8..e9ef5fa82 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index c12ead6b9..09976eba1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 0b4b7404a..0b2c774ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -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, 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 54b1e30e7..e0711eb12 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 2f052064d..4e20144d0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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>>(emptyMap()) } var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(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, + val deferredCandidates: List, +) + +internal fun planHomeNextUpResolutionCandidates( + candidates: List, +): HomeNextUpResolutionPlan = + HomeNextUpResolutionPlan( + initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit), + deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit), + ) + internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, isTraktProgressActive: Boolean, @@ -1133,6 +1216,7 @@ private data class HomeContinueWatchingCandidate( ) private fun saveContinueWatchingSnapshots( + profileId: Int, nextUpItemsBySeries: Map>, visibleContinueWatchingEntries: List, todayIsoDate: String, @@ -1186,6 +1270,7 @@ private fun saveContinueWatchingSnapshots( ) } ContinueWatchingEnrichmentCache.saveSnapshots( + profileId = profileId, nextUp = nextUpCache, inProgress = inProgressCache, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 9ad94cb45..99f36172d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 734bea9d0..2cfebe197 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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, - visiblePages: List, - 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, - visiblePages: List, - 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 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index a2bcd03c2..d95b26bc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index c4c86bb08..e317b0a76 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -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 = 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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 4826efa00..c64c449eb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -88,7 +88,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun LibraryScreen( modifier: Modifier = Modifier, - topChromePadding: Dp? = null, scrollToTopRequests: Flow = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index fd38064ec..b7c8876ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -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 = 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 = emptyList(), - val sourceItems: List = emptyList(), - val episodeItems: List = emptyList(), - val episodeSeasons: List = emptyList(), - val episodeStreamsVisible: Boolean = false, - val episodeStreamsIsLoading: Boolean = false, - val selectedEpisodeLabel: String = "", - val episodeStreamFilters: List = emptyList(), - val episodeStreamItems: List = 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 = 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 = 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?): Map { val rawHeaders = headers ?: return emptyMap() if (rawHeaders.isEmpty()) return emptyMap() @@ -293,17 +66,12 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 288fafda1..7a0af09c1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -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, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 8441b89da..e5c4d81c1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -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 = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val streamType: String? = null, val logo: String? = null, val poster: String? = null, val background: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index eeaa2ef92..6ff03c858 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 725ecf228..a4cce8c28 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -5,11 +5,13 @@ import androidx.compose.ui.Modifier @Composable fun PlayerScreen( + profileId: Int, title: String, sourceUrl: String, sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt index 71705dc8e..6f5c229da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -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, val sourceResponseHeaders: Map, + val streamType: String?, val providerName: String, val streamTitle: String, val streamSubtitle: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index e1e5f81fc..0b2d5f3e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt index f16bcc54b..e0768366f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt @@ -23,6 +23,7 @@ internal data class PlayerSurfaceGestureCallbacks( val clearLiveGestureFeedback: State<() -> Unit>, val revealLockedOverlay: State<() -> Unit>, val isHoldToSpeedGestureActive: State, + val touchGesturesEnabled: State, val playerControlsLocked: State, val currentPositionMs: State, val currentDurationMs: State, @@ -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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 22828532e..8f7be4f9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 12b82dc3b..ade131f57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index ed4f44ea0..5b00ba95f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -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 get() = args.sourceHeaders val sourceResponseHeaders: Map 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 = 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(0.0) - var submitIntroEndTimeSec by mutableStateOf(0.0) - var isSubmitIntroSubmitting by mutableStateOf(false) - var submitIntroStatusMessage by mutableStateOf(null) - var playerControlsPendingP2pSwitch by mutableStateOf(null) - var playerControlsCloseModalsToken by mutableStateOf(0L) var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState()) var playerMetaVideos by mutableStateOf>(emptyList()) var skipIntervals by mutableStateOf>(emptyList()) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt index fa9c8afcb..0823f8207 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt @@ -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(), ) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 0731e8742..f3097de9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -7,47 +7,21 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged -import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.p2p.P2pSettingsRepository import com.nuvio.app.features.p2p.P2pStreamingState import com.nuvio.app.features.p2p.formatP2pMegabytes import com.nuvio.app.features.p2p.formatP2pSpeed -import com.nuvio.app.features.player.skip.SkipIntroRepository -import com.nuvio.app.features.streams.AddonStreamGroup -import com.nuvio.app.features.streams.StreamItem -import com.nuvio.app.features.streams.isSelectableForPlayback -import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.features.watching.application.WatchingState -import com.nuvio.app.isDesktop import com.nuvio.app.isIos import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.stringResource - -private val EmptyPlayerControlsState = PlayerControlsState() -private val IgnorePlayerControlsAction: (PlayerControlsAction) -> Boolean = { false } -private val IgnorePlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false } -private val IgnorePlayerControlsScrub: (Long) -> Boolean = { false } @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs - val seasonNumber = activeSeasonNumber - val episodeNumber = activeEpisodeNumber - val episodeTitle = activeEpisodeTitle - val isEpisode = seasonNumber != null && episodeNumber != null + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback val isP2pPlaybackActive = activeTorrentInfoHash != null val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming @@ -107,233 +81,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { (bufferedSeconds / 10f).coerceIn(0f, 1f) } } - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - val openingOverlayWanted = playerSettingsUiState.showLoadingOverlay && - !initialLoadCompleted && - errorMessage == null - val playerControlsState = if (isDesktop) { - val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { - stringResource( - Res.string.compose_player_episode_title_format, - seasonNumber, - episodeNumber, - episodeTitle.orEmpty(), - ) - } else { - "" - } - val allFilterLabel = stringResource(Res.string.collections_tab_all) - val playingLabel = stringResource(Res.string.compose_player_playing) - val sourceFilters = buildPlayerControlFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val sourceItems = buildPlayerControlSourceItems() - val episodeItems = buildPlayerControlEpisodeItems() - val episodeSeasons = buildPlayerControlSeasonItems(episodeItems) - val episodeStreamFilters = buildPlayerControlEpisodeStreamFilters( - allLabel = allFilterLabel, - selectedFilter = null, - ) - val episodeStreamItems = buildPlayerControlEpisodeStreamItems() - val playerControlAddonSubtitles = buildPlayerControlAddonSubtitleItems() - val playerControlAutoSyncCues = buildPlayerControlSubtitleCueItems() - val themeColors = MaterialTheme.nuvio.colors - val selectedEpisodeLabel = episodeStreamsPanelState.selectedEpisode?.let { selected -> - val selectedCode = selected.playerControlsEpisodeCode() - buildString { - append(selectedCode) - if (selected.title.isNotBlank()) { - if (isNotEmpty()) append(" • ") - append(selected.title) - } - } - }.orEmpty() - val nativeSkipInterval = activeSkipInterval.takeIf { initialLoadCompleted && !pausedOverlayVisible } - val nextEpisodeForControls = nextEpisodeInfo.takeIf { isSeries && showNextEpisodeCard } - val nextEpisodeStatus = when { - nextEpisodeForControls == null -> "" - !nextEpisodeForControls.hasAired && !nextEpisodeForControls.unairedMessage.isNullOrBlank() -> - nextEpisodeForControls.unairedMessage.orEmpty() - nextEpisodeAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) - !nextEpisodeAutoPlaySourceName.isNullOrBlank() && nextEpisodeAutoPlayCountdown != null -> - stringResource( - Res.string.player_next_episode_playing_via_countdown, - nextEpisodeAutoPlaySourceName.orEmpty(), - nextEpisodeAutoPlayCountdown ?: 0, - ) - else -> "" - } - PlayerControlsState( - title = title, - episodeText = episodeText, - streamTitle = activeStreamTitle, - providerName = activeProviderName, - pauseOverlayWatchingLabel = stringResource(Res.string.compose_player_youre_watching), - pauseOverlayLogo = logo, - pauseOverlayEpisodeInfo = if (seasonNumber != null && episodeNumber != null) { - stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) - } else { - activeProviderName - }, - pauseOverlayEpisodeTitle = activeEpisodeTitle.orEmpty(), - pauseOverlayDescription = (pauseDescription ?: activeStreamSubtitle).orEmpty(), - resizeModeLabel = stringResource(resizeMode.labelRes), - playbackSpeedLabel = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), - subtitlesLabel = stringResource(Res.string.compose_player_subs), - audioLabel = stringResource(Res.string.compose_player_audio), - sourcesLabel = stringResource(Res.string.compose_player_sources), - episodesLabel = stringResource(Res.string.compose_player_episodes), - externalPlayerLabel = stringResource(Res.string.streams_open_external_player), - playLabel = stringResource(Res.string.detail_btn_play), - pauseLabel = stringResource(Res.string.compose_action_pause), - closeLabel = stringResource(Res.string.compose_player_close), - lockLabel = stringResource(Res.string.compose_player_lock_controls), - unlockLabel = stringResource(Res.string.compose_player_unlock_controls), - submitIntroLabel = stringResource(Res.string.submit_intro_action), - videoSettingsLabel = stringResource(Res.string.player_action_video_settings), - tapToUnlockLabel = stringResource(Res.string.compose_player_tap_to_unlock), - playbackErrorTitle = stringResource(Res.string.compose_player_playback_error), - playbackErrorMessage = errorMessage.orEmpty(), - playbackErrorActionLabel = stringResource(Res.string.compose_player_go_back), - sourcesPanelTitle = stringResource(Res.string.compose_player_panel_sources), - episodesPanelTitle = stringResource(Res.string.compose_player_panel_episodes), - streamsPanelTitle = stringResource(Res.string.compose_player_panel_streams), - allFilterLabel = allFilterLabel, - reloadLabel = stringResource(Res.string.compose_action_reload), - backLabel = stringResource(Res.string.action_back), - panelCloseLabel = stringResource(Res.string.action_close), - cancelLabel = stringResource(Res.string.action_cancel), - playingLabel = playingLabel, - noStreamsLabel = stringResource(Res.string.compose_player_no_streams_found), - noEpisodesLabel = stringResource(Res.string.compose_player_no_episodes_available), - submitIntroPanelTitle = stringResource(Res.string.submit_intro_title), - submitIntroSegmentTypeLabel = stringResource(Res.string.submit_intro_segment_type_label), - submitIntroSegmentIntroLabel = stringResource(Res.string.submit_intro_segment_intro), - submitIntroSegmentRecapLabel = stringResource(Res.string.submit_intro_segment_recap), - submitIntroSegmentOutroLabel = stringResource(Res.string.submit_intro_segment_outro), - submitIntroStartTimeLabel = stringResource(Res.string.submit_intro_start_time_label), - submitIntroEndTimeLabel = stringResource(Res.string.submit_intro_end_time_label), - submitIntroCaptureLabel = stringResource(Res.string.submit_intro_capture_button), - submitIntroSubmitLabel = stringResource(Res.string.submit_intro_button_submit), - p2pConsentTitle = stringResource(Res.string.p2p_consent_title), - p2pConsentBody = stringResource(Res.string.p2p_consent_body), - p2pConsentEnableLabel = stringResource(Res.string.p2p_consent_enable), - p2pConsentCancelLabel = stringResource(Res.string.p2p_consent_cancel), - subtitlesPanelTitle = stringResource(Res.string.compose_player_subtitles), - subtitleBuiltInTabLabel = stringResource(Res.string.compose_player_built_in), - subtitleAddonsTabLabel = stringResource(Res.string.addon_title), - subtitleStyleTabLabel = stringResource(Res.string.compose_player_style), - noneLabel = stringResource(Res.string.compose_player_none), - fetchSubtitlesLabel = stringResource(Res.string.compose_player_fetch_subtitles), - subtitleDelayLabel = stringResource(Res.string.compose_player_subtitle_delay), - resetLabel = stringResource(Res.string.compose_player_reset), - autoSyncLabel = stringResource(Res.string.compose_player_auto_sync), - reloadSmallLabel = stringResource(Res.string.compose_player_reload), - captureLineLabel = stringResource(Res.string.compose_player_capture_line), - selectAddonSubtitleFirstLabel = stringResource(Res.string.compose_player_select_addon_subtitle_first), - loadingSubtitleLinesLabel = stringResource(Res.string.compose_player_loading_lines), - fontSizeLabel = stringResource(Res.string.compose_player_font_size), - outlineLabel = stringResource(Res.string.compose_player_outline), - boldLabel = stringResource(Res.string.compose_player_bold), - bottomOffsetLabel = stringResource(Res.string.compose_player_bottom_offset), - colorLabel = stringResource(Res.string.compose_player_color), - textOpacityLabel = stringResource(Res.string.compose_player_text_opacity), - outlineColorLabel = stringResource(Res.string.compose_player_outline_color), - resetDefaultsLabel = stringResource(Res.string.compose_player_reset_defaults), - onLabel = stringResource(Res.string.compose_action_on), - offLabel = stringResource(Res.string.compose_action_off), - themeAccentColor = themeColors.accent.toCssColorString(), - themeAccentStrongColor = themeColors.accentStrong.toCssColorString(), - themeOnAccentColor = themeColors.onAccent.toCssColorString(), - themeFocusColor = themeColors.focusRing.toCssColorString(), - themeSelectedSurfaceColor = themeColors.accent.copy(alpha = 0.24f).toCssColorString(), - themeSelectedSurfaceHoverColor = themeColors.accent.copy(alpha = 0.34f).toCssColorString(), - themeSelectedRingColor = themeColors.accent.copy(alpha = 0.35f).toCssColorString(), - themeTimelineFillColor = themeColors.playerTimelineFill.toCssColorString(), - themeTimelineTrackColor = themeColors.playerTimelineTrack.toCssColorString(), - themeBufferingColor = themeColors.playerBuffering.toCssColorString(), - themeBufferingTrackColor = themeColors.playerBuffering.copy(alpha = 0.28f).toCssColorString(), - themeControlForegroundColor = themeColors.playerControlsForeground.toCssColorString(), - isPlaying = playbackSnapshot.isPlaying, - isLoading = playbackSnapshot.isLoading, - isLocked = playerControlsLocked, - lockedOverlayVisible = lockedOverlayVisible, - controlsVisible = controlsVisible && !playerControlsLocked, - parentalWarnings = parentalWarnings, - showParentalGuide = showParentalGuide, - showSubmitIntro = isSeries && - playerSettingsUiState.introSubmitEnabled && - playerSettingsUiState.introDbApiKey.isNotBlank() && - !activeSubmitIntroImdbId().isNullOrBlank(), - showVideoSettings = isIos, - showSources = activeVideoId != null, - showEpisodes = isSeries, - showExternalPlayer = args.onOpenInExternalPlayer != null, - durationMs = playbackSnapshot.durationMs, - positionMs = displayedPositionMs, - sourceIsLoading = sourceStreamsState.isAnyLoading, - sourceFilters = sourceFilters, - sourceItems = sourceItems, - episodeItems = episodeItems, - episodeSeasons = episodeSeasons, - episodeStreamsVisible = episodeStreamsPanelState.showStreams, - episodeStreamsIsLoading = episodeStreamsRepoState.isAnyLoading, - selectedEpisodeLabel = selectedEpisodeLabel, - episodeStreamFilters = episodeStreamFilters, - episodeStreamItems = episodeStreamItems, - submitIntroSegmentType = submitIntroSegmentType, - submitIntroStartTime = submitIntroStartTimeStr, - submitIntroEndTime = submitIntroEndTimeStr, - isSubmitIntroSubmitting = isSubmitIntroSubmitting, - submitIntroStatusMessage = submitIntroStatusMessage.orEmpty(), - showP2pConsent = playerControlsPendingP2pSwitch != null, - subtitleActiveTab = activeSubtitleTab.name, - addonSubtitleItems = playerControlAddonSubtitles, - isLoadingAddonSubtitles = isLoadingAddonSubtitles, - selectedAddonSubtitleId = selectedAddonSubtitleId.orEmpty(), - useCustomSubtitles = useCustomSubtitles, - subtitleStyle = subtitleStyle, - subtitleDelayMs = subtitleDelayMs, - hasSelectedAddonSubtitle = selectedAddonSubtitle != null, - subtitleAutoSyncCapturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: -1L, - subtitleAutoSyncCues = playerControlAutoSyncCues, - subtitleAutoSyncIsLoading = subtitleAutoSyncState.isLoading, - subtitleAutoSyncErrorMessage = subtitleAutoSyncState.errorMessage.orEmpty(), - closeModalsToken = playerControlsCloseModalsToken, - showOpeningOverlay = openingOverlayWanted, - openingArtwork = background ?: poster, - openingLogo = logo, - openingTitle = title, - openingMessage = p2pInitialLoadingMessage, - openingProgress = p2pInitialLoadingProgress, - skipPromptVisible = nativeSkipInterval != null && !playerControlsLocked, - skipPromptLabel = skipPromptLabel(nativeSkipInterval?.type), - skipPromptStartMs = ((nativeSkipInterval?.startTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptEndMs = ((nativeSkipInterval?.endTime ?: 0.0) * 1000).toLong().coerceAtLeast(0L), - skipPromptDismissed = skipIntervalDismissed, - nextEpisodeVisible = nextEpisodeForControls != null && !playerControlsLocked, - nextEpisodeHeaderLabel = stringResource(Res.string.player_next_episode), - nextEpisodeTitle = nextEpisodeForControls?.let { - stringResource( - Res.string.compose_player_episode_title_format, - it.season, - it.episode, - it.title, - ) - }.orEmpty(), - nextEpisodeThumbnail = nextEpisodeForControls?.thumbnail.orEmpty(), - nextEpisodeStatus = nextEpisodeStatus, - nextEpisodeActionLabel = if (nextEpisodeForControls?.hasAired == true) { - stringResource(Res.string.detail_btn_play) - } else { - stringResource(Res.string.player_next_episode_unaired) - }, - nextEpisodePlayable = nextEpisodeForControls?.hasAired == true, - ) - } else { - EmptyPlayerControlsState - } val gestureCallbacks = rememberSurfaceGestureCallbacks() Box( @@ -355,6 +102,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { layoutSize = layoutSize, sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx, playerControlsLockedState = gestureCallbacks.playerControlsLocked, + touchGesturesEnabledState = gestureCallbacks.touchGesturesEnabled, isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive, currentPositionMsState = gestureCallbacks.currentPositionMs, currentDurationMsState = gestureCallbacks.currentDurationMs, @@ -367,43 +115,17 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, ), ) { + val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl if (playerSurfaceSourceUrl != null) { PlatformPlayerSurface( sourceUrl = playerSurfaceSourceUrl, sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, + streamType = activeStreamType, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, - initialPositionMs = activeInitialPositionMs.takeIf { isDesktop } ?: 0L, - playerControlsState = playerControlsState, - onPlayerControlsAction = if (isDesktop) { - { action -> handlePlayerControlsAction(action) } - } else { - IgnorePlayerControlsAction - }, - onPlayerControlsEvent = if (isDesktop) { - { type, value -> handlePlayerControlsEvent(type, value) } - } else { - IgnorePlayerControlsEvent - }, - onPlayerControlsScrubChange = if (isDesktop) { - { positionMs -> - handlePlayerControlsScrubChange(positionMs) - true - } - } else { - IgnorePlayerControlsScrub - }, - onPlayerControlsScrubFinished = if (isDesktop) { - { positionMs -> - handlePlayerControlsScrubFinished(positionMs) - true - } - } else { - IgnorePlayerControlsScrub - }, onControllerReady = { controller -> playerController = controller playerControllerSourceUrl = activeSourceUrl @@ -459,7 +181,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, - suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -572,606 +293,6 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, } } -private fun PlayerScreenRuntime.handlePlayerControlsAction(action: PlayerControlsAction): Boolean { - when (action) { - PlayerControlsAction.ToggleChrome -> { - if (playerControlsLocked) { - revealLockedOverlay() - } else { - controlsVisible = !controlsVisible - } - } - PlayerControlsAction.RevealLockedOverlay -> revealLockedOverlay() - PlayerControlsAction.Back -> { - flushWatchProgress() - args.onBack() - } - PlayerControlsAction.TogglePlayback -> { - prepareTogglePlaybackForNativeFallback() - return false - } - PlayerControlsAction.KeyboardTogglePlayback -> { - prepareTogglePlaybackForNativeFallback(revealControls = false) - return false - } - PlayerControlsAction.SeekBack -> { - prepareSeekByForNativeFallback(-10_000L) - return false - } - PlayerControlsAction.KeyboardSeekBack -> { - prepareSeekByForNativeFallback(-10_000L, revealControls = false) - return false - } - PlayerControlsAction.SeekForward -> { - prepareSeekByForNativeFallback(10_000L) - return false - } - PlayerControlsAction.KeyboardSeekForward -> { - prepareSeekByForNativeFallback(10_000L, revealControls = false) - return false - } - PlayerControlsAction.ResizeMode -> cycleResizeMode() - PlayerControlsAction.Speed -> cyclePlaybackSpeed() - PlayerControlsAction.Subtitles -> { - refreshTracks() - showSubtitleModal = true - } - PlayerControlsAction.Audio -> { - refreshTracks() - showAudioModal = true - } - PlayerControlsAction.Sources -> { - prepareSourcesForPlayerControls() - } - PlayerControlsAction.Episodes -> { - prepareEpisodesForPlayerControls() - } - PlayerControlsAction.OpenExternalPlayer -> openInExternalPlayer() - PlayerControlsAction.SubmitIntro -> { - submitIntroStatusMessage = null - } - PlayerControlsAction.LockToggle -> { - if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls() - } - PlayerControlsAction.VideoSettings -> { - if (isIos) { - showVideoSettingsModal = true - controlsVisible = true - } - } - PlayerControlsAction.DoubleTapSeekBack -> { - prepareDoubleTapSeekForNativeFallback(PlayerSeekDirection.Backward) - return false - } - PlayerControlsAction.DoubleTapSeekForward -> { - prepareDoubleTapSeekForNativeFallback(PlayerSeekDirection.Forward) - return false - } - } - return true -} - -private fun PlayerScreenRuntime.handlePlayerControlsEvent(type: String, value: Double): Boolean { - when (type) { - "hideChrome" -> { - controlsVisible = false - } - "reloadSources" -> { - prepareSourcesForPlayerControls(forceRefresh = true) - } - "selectSource" -> { - val streams = sourceStreamsState.groups.flatMap { it.streams } - val stream = streams.getOrNull(value.toInt()) ?: return true - if (requestP2pConsentForPlayerControls(stream = stream, episode = null)) return true - switchToSource(stream) - playerControlsCloseModalsToken += 1 - } - "selectEpisode" -> { - val episode = playerMetaVideos.getOrNull(value.toInt()) ?: return true - if (selectDownloadedEpisodeForPlayback( - parentMetaId = parentMetaId, - episode = episode, - onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) }, - ) - ) { - playerControlsCloseModalsToken += 1 - } else { - requestEpisodeStreamsForPlayerControls(episode) - } - } - "selectEpisodeStream" -> { - val episode = episodeStreamsPanelState.selectedEpisode ?: return true - val stream = episodeStreamsRepoState.groups.flatMap { it.streams }.getOrNull(value.toInt()) ?: return true - if (requestP2pConsentForPlayerControls(stream = stream, episode = episode)) return true - switchToEpisodeStream(stream, episode) - playerControlsCloseModalsToken += 1 - } - "backToEpisodes" -> { - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - } - "reloadEpisodeStreams" -> { - episodeStreamsPanelState.selectedEpisode?.let { requestEpisodeStreamsForPlayerControls(it, forceRefresh = true) } - } - "submitIntroSegment" -> { - submitIntroSegmentType = when (value.toInt()) { - 1 -> "recap" - 2 -> "outro" - else -> "intro" - } - submitIntroStatusMessage = null - } - "submitIntroStart" -> { - val seconds = value.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 - submitIntroStartTimeSec = seconds - submitIntroStartTimeStr = formatPlayerControlsSeconds(seconds) - submitIntroStatusMessage = null - } - "submitIntroEnd" -> { - val seconds = value.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 - submitIntroEndTimeSec = seconds - submitIntroEndTimeStr = formatPlayerControlsSeconds(seconds) - submitIntroStatusMessage = null - } - "submitIntroCommit" -> submitIntroFromPlayerControls() - "skipInterval" -> { - val interval = activeSkipInterval ?: return true - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - } - "playNextEpisode" -> { - if (nextEpisodeInfo?.hasAired == true) { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - } - } - "enableP2pForPlayerControls" -> enableP2pForPlayerControls() - "cancelP2pForPlayerControls" -> { - playerControlsPendingP2pSwitch = null - } - "subtitleTab" -> { - activeSubtitleTab = when (value.toInt()) { - 1 -> SubtitleTab.Addons - 2 -> SubtitleTab.Style - else -> SubtitleTab.BuiltIn - } - } - "selectBuiltInSubtitleTrack" -> { - val index = value.toInt() - val wasCustom = useCustomSubtitles - selectedSubtitleIndex = index - selectedAddonSubtitleId = null - useCustomSubtitles = false - persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) - if (wasCustom) { - playerController?.clearExternalSubtitleAndSelect(index) - } else { - playerController?.selectSubtitleTrack(index) - } - } - "fetchAddonSubtitles" -> fetchAddonSubtitlesForActiveItem() - "selectAddonSubtitle" -> { - val addon = visibleAddonSubtitles.getOrNull(value.toInt()) ?: return true - selectedAddonSubtitleId = addon.id - selectedSubtitleIndex = -1 - useCustomSubtitles = true - persistAddonSubtitlePreference(addon) - playerController?.setSubtitleUri(addon.url) - } - "subtitleDelayDelta" -> setSubtitleDelay((subtitleDelayMs + value.toInt()).coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)) - "subtitleDelayReset" -> setSubtitleDelay(0) - "subtitleAutoSyncCapture" -> captureSubtitleAutoSyncTime() - "subtitleAutoSyncReload" -> loadSubtitleAutoSyncCues(force = true) - "subtitleAutoSyncCue" -> { - val cue = playerControlsNearestSubtitleCues().getOrNull(value.toInt()) ?: return true - applySubtitleAutoSyncCue(cue) - } - "subtitleFontSizeDelta" -> { - PlayerSettingsRepository.setSubtitleStyle( - subtitleStyle.copy(fontSizeSp = (subtitleStyle.fontSizeSp + value.toInt()).coerceIn(12, 40)), - ) - } - "subtitleOutlineToggle" -> { - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = !subtitleStyle.outlineEnabled)) - } - "subtitleBoldToggle" -> { - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = !subtitleStyle.bold)) - } - "subtitleBottomOffsetDelta" -> { - PlayerSettingsRepository.setSubtitleStyle( - subtitleStyle.copy(bottomOffset = (subtitleStyle.bottomOffset + value.toInt()).coerceIn(0, 200)), - ) - } - "subtitleTextColor" -> { - SubtitleColorSwatches.getOrNull(value.toInt())?.let { color -> - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(textColor = color.copy(alpha = subtitleStyle.textColor.alpha))) - } - } - "subtitleOutlineColor" -> { - SubtitleColorSwatches.getOrNull(value.toInt())?.let { color -> - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineColor = color.copy(alpha = subtitleStyle.outlineColor.alpha))) - } - } - "subtitleTextOpacity" -> { - val alpha = (value.toFloat() / 100f).coerceIn(0f, 1f) - PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(textColor = subtitleStyle.textColor.copy(alpha = alpha))) - } - "subtitleStyleReset" -> PlayerSettingsRepository.setSubtitleStyle(SubtitleStyleState.DEFAULT) - "parentalGuideComplete" -> { - showParentalGuide = false - } - else -> return false - } - return true -} - -private fun PlayerScreenRuntime.requestP2pConsentForPlayerControls( - stream: StreamItem, - episode: MetaVideo?, -): Boolean { - if (!isP2pStream(stream)) return false - if (!P2pSettingsRepository.isVisible) return false - if (P2pSettingsRepository.uiState.value.p2pEnabled) return false - playerControlsPendingP2pSwitch = PendingPlayerP2pSwitch( - stream = stream, - episode = episode, - isAutoPlay = false, - ) - return true -} - -private fun PlayerScreenRuntime.enableP2pForPlayerControls() { - val pending = playerControlsPendingP2pSwitch ?: return - playerControlsPendingP2pSwitch = null - P2pSettingsRepository.setP2pEnabled(true) - val episode = pending.episode - if (episode != null) { - switchToP2pEpisodeStream(pending.stream, episode, pending.isAutoPlay) - } else { - switchToP2pSourceStream(pending.stream) - } - playerControlsCloseModalsToken += 1 -} - -private fun PlayerScreenRuntime.prepareSourcesForPlayerControls(forceRefresh: Boolean = false) { - val vid = activeVideoId - if (vid == null) { - return - } - val requestType = contentType ?: parentMetaType - PlayerStreamsRepository.loadSources( - type = requestType, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = forceRefresh, - ) -} - -private fun Color.toCssColorString(): String { - val redInt = (red * 255f).roundToInt().coerceIn(0, 255) - val greenInt = (green * 255f).roundToInt().coerceIn(0, 255) - val blueInt = (blue * 255f).roundToInt().coerceIn(0, 255) - val alphaValue = alpha.coerceIn(0f, 1f) - return "rgba($redInt, $greenInt, $blueInt, ${alphaValue.toCssAlphaString()})" -} - -private fun Float.toCssAlphaString(): String { - val rounded = (this * 1000f).roundToInt() / 1000f - return rounded.toString().trimEnd('0').trimEnd('.').ifEmpty { "0" } -} - -private fun PlayerScreenRuntime.prepareEpisodesForPlayerControls() { - if (!isSeries) return - if (playerMetaVideos.isEmpty()) { - scope.launch { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } -} - -private fun PlayerScreenRuntime.requestEpisodeStreamsForPlayerControls( - episode: MetaVideo, - forceRefresh: Boolean = false, -) { - PlayerStreamsRepository.loadEpisodeStreams( - type = contentType ?: parentMetaType, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = forceRefresh, - ) - episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode) -} - -private fun PlayerScreenRuntime.submitIntroFromPlayerControls() { - if (isSubmitIntroSubmitting) return - val imdbId = activeSubmitIntroImdbId() - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val start = submitIntroStartTimeSec - val end = submitIntroEndTimeSec - if (imdbId.isNullOrBlank() || season == null || episode == null || start == null || end == null || end <= start) { - submitIntroStatusMessage = "Check the start and end times." - return - } - isSubmitIntroSubmitting = true - submitIntroStatusMessage = null - scope.launch { - val result = SkipIntroRepository.submitIntro( - imdbId = imdbId, - season = season, - episode = episode, - startSec = start, - endSec = end, - segmentType = submitIntroSegmentType, - ) - isSubmitIntroSubmitting = false - if (result) { - submitIntroStartTimeSec = 0.0 - submitIntroEndTimeSec = 0.0 - submitIntroStartTimeStr = "00:00" - submitIntroEndTimeStr = "00:00" - submitIntroSegmentType = "intro" - submitIntroStatusMessage = null - playerControlsCloseModalsToken += 1 - } else { - submitIntroStatusMessage = "Unable to submit timestamps." - } - } -} - -private fun PlayerScreenRuntime.activeSubmitIntroImdbId(): String? = - activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } - ?: parentMetaId.takeIf { it.startsWith("tt") } - ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } - -@Composable -private fun skipPromptLabel(type: String?): String = - when (type?.lowercase()) { - "intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro) - "outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro) - "recap" -> stringResource(Res.string.player_skip_recap) - else -> stringResource(Res.string.player_skip) - } - -private fun formatPlayerControlsSeconds(seconds: Double): String { - val totalSeconds = seconds - .takeIf { it.isFinite() && it >= 0.0 } - ?.toLong() - ?: 0L - val minutes = totalSeconds / 60L - val remainder = totalSeconds % 60L - return "${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}" -} - -private fun PlayerScreenRuntime.handlePlayerControlsScrubChange(positionMs: Long) { - isScrubbingTimeline = true - scrubbingPositionMs = positionMs -} - -private fun PlayerScreenRuntime.handlePlayerControlsScrubFinished(positionMs: Long) { - isScrubbingTimeline = false - scrubbingPositionMs = null - playerController?.seekTo(positionMs) - scheduleProgressSyncAfterSeek() -} - -private fun PlayerScreenRuntime.openInExternalPlayer() { - val openExternal = args.onOpenInExternalPlayer ?: return - val loadedSubtitles = addonSubtitles - .takeIf { it.isNotEmpty() } - ?.map { sub -> - SubtitleInput( - url = sub.url, - name = buildString { - if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ") - append(sub.display) - }, - lang = sub.language, - ) - } - openExternal( - ExternalPlayerPlaybackRequest( - sourceUrl = activeSourceUrl, - title = title, - streamTitle = activeStreamTitle, - sourceHeaders = activeSourceHeaders, - resumePositionMs = playbackSnapshot.positionMs, - subtitles = loadedSubtitles, - ), - ) -} - -private fun PlayerScreenRuntime.buildPlayerControlFilters( - groups: List = sourceStreamsState.groups, - allLabel: String, - selectedFilter: String?, -): List { - if (groups.size <= 1) return emptyList() - return buildList { - add(PlayerControlFilterItem(id = "", label = allLabel, isSelected = selectedFilter == null)) - groups.distinctBy { it.addonId }.forEach { group -> - add( - PlayerControlFilterItem( - id = group.addonId, - label = group.addonName, - isSelected = selectedFilter == group.addonId, - isLoading = group.isLoading, - hasError = group.error != null, - ), - ) - } - } -} - -private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamFilters( - allLabel: String, - selectedFilter: String?, -): List = - buildPlayerControlFilters( - groups = episodeStreamsRepoState.groups, - allLabel = allLabel, - selectedFilter = selectedFilter, - ) - -private fun PlayerScreenRuntime.buildPlayerControlSourceItems(): List { - val canResolveDebrid = DebridSettingsRepository.uiState.value.canResolvePlayableLinks - return sourceStreamsState.groups.flatMap { group -> - group.streams.map { stream -> group.addonId to stream } - }.mapIndexed { index, (filterId, stream) -> - PlayerControlSourceItem( - index = index, - filterId = filterId, - label = stream.streamLabel, - subtitle = stream.streamSubtitle.orEmpty(), - addonName = stream.addonName, - isCurrent = isCurrentPlayerControlStream(stream), - isEnabled = stream.isSelectableForPlayback(canResolveDebrid), - ) - } -} - -private fun PlayerScreenRuntime.buildPlayerControlEpisodeStreamItems(): List { - val canResolveDebrid = DebridSettingsRepository.uiState.value.canResolvePlayableLinks - return episodeStreamsRepoState.groups.flatMap { group -> - group.streams.map { stream -> group.addonId to stream } - }.mapIndexed { index, (filterId, stream) -> - PlayerControlSourceItem( - index = index, - filterId = filterId, - label = stream.streamLabel, - subtitle = stream.streamSubtitle.orEmpty(), - addonName = stream.addonName, - isCurrent = false, - isEnabled = stream.isSelectableForPlayback(canResolveDebrid), - ) - } -} - -private fun PlayerScreenRuntime.isCurrentPlayerControlStream(stream: StreamItem): Boolean { - val activeKey = activeSourceIdentityKey - val streamKey = stream.playerSourceIdentityKey() - if (activeKey != null) { - return streamKey == activeKey - } - val directUrl = stream.playableDirectUrl - if (directUrl != null && directUrl == activeSourceUrl) return true - val infoHash = stream.p2pInfoHash - if (infoHash != null && infoHash == activeTorrentInfoHash) return true - return false -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlAddonSubtitleItems(): List = - visibleAddonSubtitles.mapIndexed { index, subtitle -> - PlayerControlAddonSubtitleItem( - index = index, - id = subtitle.id, - display = subtitle.display, - languageLabel = languageLabelForCode(subtitle.language), - addonName = subtitle.addonName.orEmpty(), - isSelected = subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId, - ) - } - -private fun PlayerScreenRuntime.buildPlayerControlSubtitleCueItems(): List = - playerControlsNearestSubtitleCues().mapIndexed { index, cue -> - PlayerControlSubtitleCueItem( - index = index, - timeMs = cue.startTimeMs, - timeLabel = formatPlayerControlsCueTimestamp(cue.startTimeMs), - text = cue.text, - ) - } - -private fun PlayerScreenRuntime.playerControlsNearestSubtitleCues(): List { - val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return emptyList() - return subtitleAutoSyncState.cues - .sortedBy { abs(it.startTimeMs - capturedPositionMs) } - .take(5) -} - -private fun formatPlayerControlsCueTimestamp(timeMs: Long): String { - val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L) - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L - return "${minutes}:${seconds.toString().padStart(2, '0')}" -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlEpisodeItems(): List { - val items = mutableListOf() - for ((index, video) in playerMetaVideos.withIndex()) { - if (video.season == null && video.episode == null) continue - val episodeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = video.season, - episodeNumber = video.episode, - fallbackVideoId = video.id, - ) - val isWatched = watchProgressUiState.byVideoId[episodeVideoId]?.isEffectivelyCompleted == true || - WatchingState.isEpisodeWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = parentMetaType, - metaId = parentMetaId, - episode = video, - ) - items.add( - PlayerControlEpisodeItem( - index = index, - id = video.id, - title = video.title, - code = video.playerControlsEpisodeCode(), - overview = video.overview.orEmpty(), - thumbnail = video.thumbnail.orEmpty(), - season = video.season?.coerceAtLeast(0) ?: 0, - episode = video.episode ?: 0, - isCurrent = video.season == activeSeasonNumber && video.episode == activeEpisodeNumber, - isWatched = isWatched, - ), - ) - } - return items -} - -@Composable -private fun PlayerScreenRuntime.buildPlayerControlSeasonItems( - episodes: List, -): List { - val availableSeasons = episodes - .map { it.season } - .distinct() - .let { seasons -> - seasons.filter { it > 0 }.sorted() + seasons.filter { it == 0 } - } - val items = mutableListOf() - for (season in availableSeasons) { - val label = if (season == 0) { - stringResource(Res.string.episodes_specials) - } else { - stringResource(Res.string.episodes_season, season) - } - items.add( - PlayerControlSeasonItem( - season = season, - label = label, - isSelected = activeSeasonNumber == season, - ), - ) - } - return items -} - -@Composable -private fun MetaVideo.playerControlsEpisodeCode(): String = - when { - season != null && episode != null -> stringResource(Res.string.compose_player_episode_code_full, season, episode) - episode != null -> stringResource(Res.string.compose_player_episode_code_episode_only, episode) - else -> "" - } - @Composable private fun BoxScope.RenderPlaybackOverlays( runtime: PlayerScreenRuntime, @@ -1182,66 +303,62 @@ private fun BoxScope.RenderPlaybackOverlays( showP2pRebufferStats: Boolean, p2pRebufferMessage: String?, p2pRebufferProgress: Float?, - suppressOpeningOverlay: Boolean, ) { runtime.run { PlayerPlaybackOverlays( playerControlsLocked = playerControlsLocked, lockedOverlayVisible = lockedOverlayVisible, playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - onUnlock = { unlockPlayerControls() }, - showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && - !initialLoadCompleted && - errorMessage == null && - !suppressOpeningOverlay, - backdropArtwork = background ?: poster, - logo = logo, - title = title, - onBackWithProgress = { - flushWatchProgress() - args.onBack() - }, - p2pInitialLoadingMessage = p2pInitialLoadingMessage, - p2pInitialLoadingProgress = p2pInitialLoadingProgress, - showP2pRebufferStats = showP2pRebufferStats, - p2pRebufferMessage = p2pRebufferMessage, - p2pRebufferProgress = p2pRebufferProgress, - currentGestureFeedback = currentGestureFeedback, - renderedGestureFeedback = renderedGestureFeedback, - initialLoadCompleted = initialLoadCompleted, - pausedOverlayVisible = pausedOverlayVisible, - activeSkipInterval = activeSkipInterval.takeUnless { isDesktop }, - skipIntervalDismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkipInterval = { interval -> - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - }, - onDismissSkipInterval = { skipIntervalDismissed = true }, - sliderEdgePadding = sliderEdgePadding, - overlayBottomPadding = overlayBottomPadding, - isSeries = isSeries, - nextEpisodeInfo = nextEpisodeInfo, - showNextEpisodeCard = showNextEpisodeCard && !isDesktop, - nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, - nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, - nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, - onPlayNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - }, - onDismissNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - showNextEpisodeCard = false - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - }, - errorMessage = errorMessage, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = { unlockPlayerControls() }, + showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, + backdropArtwork = background ?: poster, + logo = logo, + title = title, + onBackWithProgress = { + flushWatchProgress() + args.onBack() + }, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + currentGestureFeedback = currentGestureFeedback, + renderedGestureFeedback = renderedGestureFeedback, + initialLoadCompleted = initialLoadCompleted, + pausedOverlayVisible = pausedOverlayVisible, + activeSkipInterval = activeSkipInterval, + skipIntervalDismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkipInterval = { interval -> + playerController?.seekTo((interval.endTime * 1000).toLong()) + scheduleProgressSyncAfterSeek() + skipIntervalDismissed = true + }, + onDismissSkipInterval = { skipIntervalDismissed = true }, + sliderEdgePadding = sliderEdgePadding, + overlayBottomPadding = overlayBottomPadding, + isSeries = isSeries, + nextEpisodeInfo = nextEpisodeInfo, + showNextEpisodeCard = showNextEpisodeCard, + nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, + nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, + nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, + onPlayNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + playNextEpisode() + }, + onDismissNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + showNextEpisodeCard = false + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + }, + errorMessage = errorMessage, onDismissError = { flushWatchProgress() args.onBack() @@ -1406,9 +523,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it }, onSubmitIntroDismissed = { showSubmitIntroModal = false }, onSubmitIntroSuccess = { - submitIntroStartTimeSec = 0.0 - submitIntroEndTimeSec = 0.0 - submitIntroStatusMessage = null submitIntroStartTimeStr = "00:00" submitIntroEndTimeStr = "00:00" submitIntroSegmentType = "intro" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index a4c94820c..d81608626 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 0196d338b..b2b0f2982 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -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? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index ec1fbe891..b370fb3f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -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, - 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index c78e27da9..86ea300c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -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() + val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } + val completions = Channel(capacity = Channel.BUFFERED) val debridAvailabilityJobs = mutableListOf() - fun emptyStateReason(groups: List, 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(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), - ) - }, - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt index 019da7cee..aae7d4ec3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt @@ -44,6 +44,7 @@ internal fun Modifier.playerSurfaceDragGestures( layoutSize: IntSize, sideGestureSystemEdgeExclusionPx: Float, playerControlsLockedState: State, + touchGesturesEnabledState: State, isHoldToSpeedGestureActiveState: State, currentPositionMsState: State, currentDurationMsState: State, @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index cc2d18e80..2acec2182 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -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) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index dcb5b496e..2de182764 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt index 9dd949ae6..54d606751 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index b6a8524be..5f00697d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index ccca24b30..32f0dc00a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index c2033993f..7644ec63d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -38,9 +38,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -65,7 +62,6 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val profileSwitchMutex = Mutex() private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) @@ -145,15 +141,7 @@ object ProfileRepository { } } - suspend fun switchToProfile(profileIndex: Int) { - profileSwitchMutex.withLock { - withContext(Dispatchers.Default) { - selectProfile(profileIndex) - } - } - } - - private fun selectProfile(profileIndex: Int) { + fun selectProfile(profileIndex: Int) { activeProfileIndex = profileIndex val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex } _state.value = _state.value.copy( @@ -163,6 +151,7 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) TraktSettingsRepository.onProfileChanged() + TraktAuthRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) @@ -178,10 +167,10 @@ object ProfileRepository { HomeRepository.clear() MetaScreenSettingsRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged() + com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache.onProfileChanged() EpisodeReleaseNotificationsRepository.onProfileChanged() TmdbSettingsRepository.onProfileChanged() MdbListSettingsRepository.onProfileChanged() - TraktAuthRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged() CollectionRepository.onProfileChanged() CollectionMobileSettingsRepository.onProfileChanged() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index da6c96b97..195ba6748 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import 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 kotlinx.coroutines.delay @@ -176,6 +176,7 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -216,6 +217,7 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -280,6 +282,7 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null + ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 81545f14b..5e812b28e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -66,7 +66,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import 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.isIos @@ -85,7 +85,6 @@ fun ProfileSwitcherTab( onProfileSelected: (NuvioProfile) -> Unit, onAddProfileRequested: () -> Unit, triggerContent: (@Composable (selected: Boolean) -> Unit)? = null, - openPopupOnClick: Boolean = false, modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio @@ -202,13 +201,7 @@ fun ProfileSwitcherTab( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { - if (openPopupOnClick && profiles.isNotEmpty()) { - showPopup = true - } else { - onClick() - } - }, + onClick = onClick, ) .pointerInput(profiles) { detectDragGesturesAfterLongPress( @@ -343,358 +336,6 @@ fun ProfileSwitcherTab( } } -@Composable -fun SidebarProfileSwitcherStack( - onProfileSelected: (NuvioProfile) -> Unit, - onAddProfileRequested: () -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val activeProfile = profileState.activeProfile - val profiles = profileState.profiles - val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() - var pinProfile by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - AvatarRepository.fetchAvatars() - AvatarRepository.refreshAvatars() - } - - fun chooseProfile(profile: NuvioProfile) { - if (profile.profileIndex == activeProfile?.profileIndex) { - onDismissRequest() - return - } - if (profile.pinEnabled) { - pinProfile = profile - } else { - onProfileSelected(profile) - onDismissRequest() - } - } - - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - profiles.forEach { profile -> - SidebarProfileSwitcherRow( - profile = profile, - avatars = avatars, - isActive = profile.profileIndex == activeProfile?.profileIndex, - onClick = { chooseProfile(profile) }, - ) - } - - if (profiles.size < 4) { - SidebarAddProfileRow( - onClick = { - onDismissRequest() - onAddProfileRequested() - }, - ) - } - - AnimatedVisibility( - visible = pinProfile != null, - enter = expandVertically() + fadeIn(tween(160)), - exit = shrinkVertically(tween(120)) + fadeOut(tween(90)), - ) { - pinProfile?.let { profile -> - SidebarCompactPinEntry( - profileName = profile.name, - onVerified = { - onProfileSelected(profile) - pinProfile = null - onDismissRequest() - }, - onCancel = { pinProfile = null }, - verifyPin = { pin -> - ProfileRepository.verifyPin(profile.profileIndex, pin) - }, - ) - } - } - } -} - -@Composable -private fun SidebarProfileSwitcherRow( - profile: NuvioProfile, - avatars: List, - isActive: Boolean, - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .clip(tokens.shapes.compactCard) - .background( - if (isActive) { - tokens.colors.overlaySelected - } else { - tokens.colors.surface.copy(alpha = 0f) - }, - ) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(30.dp), - contentAlignment = Alignment.Center, - ) { - ActiveProfileMiniAvatar( - profile = profile, - avatars = avatars, - selected = isActive, - size = 26, - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = profile.name.ifBlank { - stringResource(Res.string.profile_label_number, profile.profileIndex) - }, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) tokens.colors.textPrimary else tokens.colors.textMuted, - fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (profile.pinEnabled) { - Icon( - imageVector = Icons.Rounded.Lock, - contentDescription = null, - tint = tokens.colors.textMuted, - modifier = Modifier.size(12.dp), - ) - } - } -} - -@Composable -private fun SidebarAddProfileRow( - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - Row( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .clip(tokens.shapes.compactCard) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surfaceCard), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(Res.string.compose_profile_add_profile), - tint = tokens.colors.textMuted, - modifier = Modifier.size(16.dp), - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(Res.string.compose_profile_add_profile), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium, - color = tokens.colors.textMuted, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun SidebarCompactPinEntry( - profileName: String, - onVerified: () -> Unit, - onCancel: () -> Unit, - verifyPin: suspend (String) -> PinVerifyResult, -) { - val tokens = MaterialTheme.nuvio - var pin by remember(profileName) { mutableStateOf("") } - var error by remember(profileName) { mutableStateOf(null) } - var isVerifying by remember(profileName) { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val haptic = LocalHapticFeedback.current - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(tokens.shapes.compactCard) - .background(tokens.colors.surfaceCard.copy(alpha = 0.72f)) - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(Res.string.pin_enter_for, profileName), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - repeat(4) { index -> - val filled = index < pin.length - Box( - modifier = Modifier - .size(8.dp) - .clip(tokens.shapes.avatar) - .then( - if (filled) { - Modifier.background(tokens.colors.accent) - } else { - Modifier.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.avatar) - }, - ), - ) - } - } - - AnimatedVisibility( - visible = error != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Text( - text = error.orEmpty(), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.danger, - textAlign = TextAlign.Center, - maxLines = 2, - modifier = Modifier.padding(top = 6.dp), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - SidebarCompactPinKeypad( - onDigit = { digit -> - if (pin.length < 4 && !isVerifying) { - error = null - pin += digit - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - if (pin.length == 4) { - isVerifying = true - scope.launch { - val result = verifyPin(pin) - if (result.unlocked) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onVerified() - } else { - error = if (result.retryAfterSeconds > 0) { - getString(Res.string.pin_locked_try_again, result.retryAfterSeconds) - } else { - getString(Res.string.pin_incorrect) - } - pin = "" - } - isVerifying = false - } - } - } - }, - onBackspace = { - if (pin.isNotEmpty() && !isVerifying) { - pin = pin.dropLast(1) - error = null - } - }, - ) - - Text( - text = stringResource(Res.string.pin_cancel), - style = MaterialTheme.typography.labelSmall, - color = tokens.colors.accent, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .clip(tokens.shapes.compactCard) - .clickable(onClick = onCancel) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - } -} - -@Composable -private fun SidebarCompactPinKeypad( - onDigit: (String) -> Unit, - onBackspace: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - val rows = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("", "0", "⌫"), - ) - - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - rows.forEach { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - row.forEach { key -> - when (key) { - "" -> Spacer(modifier = Modifier.size(30.dp)) - "⌫" -> { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surface) - .clickable(onClick = onBackspace), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Backspace, - contentDescription = stringResource(Res.string.pin_backspace), - tint = tokens.colors.textPrimary, - modifier = Modifier.size(14.dp), - ) - } - } - else -> { - Box( - modifier = Modifier - .size(30.dp) - .clip(tokens.shapes.avatar) - .background(tokens.colors.surface) - .clickable { onDigit(key) }, - contentAlignment = Alignment.Center, - ) { - Text( - text = key, - style = MaterialTheme.typography.labelLarge, - color = tokens.colors.textPrimary, - fontWeight = FontWeight.Medium, - ) - } - } - } - } - } - } - } -} - @Composable private fun PopupAddProfileBubble( delayMs: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 2df202489..2cf53cba2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt index 43a243b19..4468d8cfc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt @@ -45,6 +45,7 @@ data class DiscoverUiState( val items: List = emptyList(), val isLoading: Boolean = false, val nextSkip: Int? = null, + val consecutiveDuplicatePages: Int = 0, val emptyStateReason: DiscoverEmptyStateReason? = null, val errorMessage: String? = null, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 6ea474da3..4383a637f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -6,10 +6,13 @@ import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.enabledAddons +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.buildCatalogUrl 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.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection @@ -378,12 +381,15 @@ object SearchRepository { title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()), subtitle = addon.displayTitle, addonName = addon.displayTitle, - type = type, - manifestUrl = manifest.transportUrl, - catalogId = catalogId, + target = CatalogTarget.Addon( + manifestUrl = manifest.transportUrl, + contentType = type, + catalogId = catalogId, + supportsPagination = supportsPagination, + ), items = items, availableItemCount = page.rawItemCount, - supportsPagination = supportsPagination, + hasMore = supportsPagination && page.nextSkip != null, ) } @@ -411,6 +417,7 @@ object SearchRepository { isLoading = true, items = if (reset) emptyList() else current.items, nextSkip = if (reset) null else current.nextSkip, + consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages, emptyStateReason = null, errorMessage = null, ) @@ -435,6 +442,15 @@ object SearchRepository { } else { mergeCatalogItems(latest.items, page.items) } + val supportsPagination = selectedCatalog.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE + val loadedNewItems = reset || mergedItems.size > latest.items.size + val paginationState = nextCatalogPaginationState( + supportsPagination = supportsPagination, + requestedSkip = requestedSkip, + page = page, + loadedNewItems = loadedNewItems, + consecutiveDuplicatePages = if (reset) 0 else latest.consecutiveDuplicatePages, + ) log.d { "Discover response catalogKey=${selectedCatalog.key} returned=${page.items.size} " + "merged=${mergedItems.size} rawItemCount=${page.rawItemCount} nextSkip=${page.nextSkip} " + @@ -443,7 +459,8 @@ object SearchRepository { _discoverUiState.value = latest.copy( items = mergedItems, isLoading = false, - nextSkip = if (selectedCatalog.supportsPagination) page.nextSkip else null, + nextSkip = paginationState.nextSkip, + consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages, emptyStateReason = if (mergedItems.isEmpty()) DiscoverEmptyStateReason.NoResults else null, errorMessage = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 7b8f781a2..c26031269 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -83,7 +83,6 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SearchScreen( modifier: Modifier = Modifier, - topChromePadding: Dp? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, searchFocusRequestCount: Int = 0, @@ -239,7 +238,6 @@ fun SearchScreen( NuvioScreen( horizontalPadding = 0.dp, - topPadding = if (topChromePadding != null) 0.dp else null, listState = listState, modifier = Modifier.fillMaxSize(), ) { @@ -257,7 +255,6 @@ fun SearchScreen( NuvioScreenHeader( title = headerTitle, modifier = Modifier.padding(horizontal = 16.dp), - topPadding = topChromePadding, ) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index e80c08225..205140e42 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -15,12 +15,17 @@ 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.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.network.SyncBackendRepository import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard @@ -36,6 +41,7 @@ import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_ti import nuvio.composeapp.generated.resources.settings_account_status import nuvio.composeapp.generated.resources.settings_account_status_anonymous import nuvio.composeapp.generated.resources.settings_account_status_signed_in +import nuvio.composeapp.generated.resources.settings_account_sync_backend import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.accountSettingsContent( @@ -51,8 +57,10 @@ private fun AccountSettingsBody( isTablet: Boolean, ) { val authState by AuthRepository.state.collectAsStateWithLifecycle() + val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSignOutConfirm by remember { mutableStateOf(false) } + val syncBackendLabel = syncBackendState.selectedBackend.displayName Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { NuvioSurfaceCard { @@ -66,44 +74,21 @@ private fun AccountSettingsBody( when (val state = authState) { is AuthState.Authenticated -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(Res.string.settings_account_status), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = if (state.isAnonymous) { - stringResource(Res.string.settings_account_status_anonymous) - } else { - stringResource(Res.string.settings_account_status_signed_in) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - if (!state.isAnonymous && state.email != null) { + AccountInfoRow( + label = stringResource(Res.string.settings_account_status), + value = if (state.isAnonymous) { + stringResource(Res.string.settings_account_status_anonymous) + } else { + stringResource(Res.string.settings_account_status_signed_in) + }, + valueColor = MaterialTheme.colorScheme.primary, + ) + state.email?.takeUnless { state.isAnonymous }?.let { email -> Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(Res.string.settings_account_email), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = state.email, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - } + AccountInfoRow( + label = stringResource(Res.string.settings_account_email), + value = email, + ) } } else -> { @@ -114,6 +99,12 @@ private fun AccountSettingsBody( ) } } + + Spacer(modifier = Modifier.height(8.dp)) + AccountInfoRow( + label = stringResource(Res.string.settings_account_sync_backend), + value = syncBackendLabel, + ) } NuvioPrimaryButton( @@ -135,3 +126,32 @@ private fun AccountSettingsBody( onDismiss = { showSignOutConfirm = false }, ) } + +@Composable +private fun AccountInfoRow( + label: String, + value: String, + valueColor: Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + modifier = Modifier.weight(1f), + text = value, + style = MaterialTheme.typography.bodyLarge, + color = valueColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt index fc9f77cc1..e5967b23a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt @@ -3,10 +3,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done @@ -43,6 +46,7 @@ internal fun LazyListScope.advancedSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + val scope = rememberCoroutineScope() var cleared by rememberSaveable { mutableStateOf(false) } SettingsNavigationRow( title = stringResource(Res.string.settings_advanced_clear_cw_cache), @@ -54,8 +58,13 @@ internal fun LazyListScope.advancedSettingsContent( isTablet = isTablet, onClick = { if (!cleared) { - ContinueWatchingEnrichmentCache.clearAll() + ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) cleared = true + scope.launch { + WatchProgressRepository.forceSnapshotRefreshFromServer( + ProfileRepository.activeProfileId, + ) + } } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 07bb324b9..157bed8ea 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -40,8 +40,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -59,8 +57,6 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she import nuvio.composeapp.generated.resources.settings_appearance_amoled_black import nuvio.composeapp.generated.resources.settings_appearance_amoled_description import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description @@ -139,11 +135,6 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } - var showDesktopNavigationSheet by remember { mutableStateOf(false) } - val desktopNavigationLayout by remember { - ThemeSettingsRepository.ensureLoaded() - ThemeSettingsRepository.desktopNavigationLayout - }.collectAsStateWithLifecycle() SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -166,16 +157,6 @@ internal fun LazyListScope.appearanceSettingsContent( onCheckedChange = onLiquidGlassNativeTabBarToggle, ) } - if (isDesktop) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.settings_appearance_desktop_navigation), - description = stringResource(desktopNavigationLayout.labelRes), - icon = Icons.Rounded.Style, - isTablet = isTablet, - onClick = { showDesktopNavigationSheet = true }, - ) - } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_appearance_app_language), @@ -187,17 +168,6 @@ internal fun LazyListScope.appearanceSettingsContent( } } - if (showDesktopNavigationSheet) { - DesktopNavigationLayoutBottomSheet( - selectedLayout = desktopNavigationLayout, - onLayoutSelected = { - ThemeSettingsRepository.setDesktopNavigationLayout(it) - showDesktopNavigationSheet = false - }, - onDismiss = { showDesktopNavigationSheet = false }, - ) - } - if (showLanguageSheet) { AppearanceLanguageBottomSheet( selectedLanguage = selectedAppLanguage, @@ -236,66 +206,6 @@ internal fun LazyListScope.appearanceSettingsContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DesktopNavigationLayoutBottomSheet( - selectedLayout: DesktopNavigationLayout, - onLayoutSelected: (DesktopNavigationLayout) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val coroutineScope = rememberCoroutineScope() - - NuvioModalBottomSheet( - onDismissRequest = { - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - sheetState = sheetState, - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) { - item { - Text( - text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - ) - } - - itemsIndexed(DesktopNavigationLayout.entries) { index, layout -> - if (index > 0) { - NuvioBottomSheetDivider() - } - NuvioBottomSheetActionRow( - title = stringResource(layout.labelRes), - onClick = { - onLayoutSelected(layout) - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - trailingContent = { - if (layout == selectedLayout) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(Res.string.cd_selected), - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - ) - } - } - } -} - private data class AppLanguageSheetOption( val language: AppLanguage, val labelRes: StringResource, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt deleted file mode 100644 index 507a8ea58..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.settings - -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar -import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar -import org.jetbrains.compose.resources.StringResource - -enum class DesktopNavigationLayout( - val labelRes: StringResource, -) { - Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar), - TopBar(Res.string.settings_appearance_desktop_navigation_top_bar), - ; - - companion object { - val Default = Sidebar - - fun fromName(name: String?): DesktopNavigationLayout = - entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index e58a91f72..d86eb0a67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.cloud.PremiumizeCloudLibraryPosterUrl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index ed5ce9da9..54718c44f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -91,6 +91,7 @@ internal fun LazyListScope.playbackSettingsContent( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -109,6 +110,7 @@ internal fun LazyListScope.playbackSettingsContent( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -243,6 +245,7 @@ private fun PlaybackSettingsSection( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -351,6 +354,15 @@ private fun PlaybackSettingsSection( ) } SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_touch_gestures), + description = stringResource(Res.string.settings_playback_touch_gestures_description), + checked = touchGesturesEnabled, + enabled = !autoPlayPlayerSettings.externalPlayerEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setTouchGesturesEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_playback_hold_to_speed), description = stringResource(Res.string.settings_playback_hold_to_speed_description), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index c882944e5..f557f762b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -21,9 +21,8 @@ import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.nuvio.app.core.build.AppVersionPolicy +import com.nuvio.app.core.build.AppVersionConfig import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.compose_about_based_on_version_format import nuvio.composeapp.generated.resources.compose_about_made_with import nuvio.composeapp.generated.resources.compose_about_version_format import nuvio.composeapp.generated.resources.compose_settings_page_account @@ -76,8 +75,6 @@ internal fun LazyListScope.settingsRootContent( onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, - showDownloadsEntry: Boolean = true, - showNotificationsEntry: Boolean = true, showAccountSection: Boolean = true, showGeneralSection: Boolean = true, showAboutSection: Boolean = true, @@ -141,16 +138,14 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onContentDiscoveryClick, ) - if (showDownloadsEntry) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_root_downloads_title), - description = stringResource(Res.string.compose_settings_root_downloads_description), - icon = Icons.Rounded.CloudDownload, - isTablet = isTablet, - onClick = onDownloadsClick, - ) - } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_root_downloads_title), + description = stringResource(Res.string.compose_settings_root_downloads_description), + icon = Icons.Rounded.CloudDownload, + isTablet = isTablet, + onClick = onDownloadsClick, + ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_playback), @@ -175,16 +170,14 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onIntegrationsClick, ) - if (showNotificationsEntry) { - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_notifications), - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - isTablet = isTablet, - onClick = onNotificationsClick, - ) - } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_notifications), + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + isTablet = isTablet, + onClick = onNotificationsClick, + ) } } } @@ -259,26 +252,14 @@ internal fun LazyListScope.settingsRootContent( Text( text = stringResource( Res.string.compose_about_version_format, - AppVersionPolicy.displayVersionName, - AppVersionPolicy.displayVersionCode, + AppVersionConfig.VERSION_NAME, + AppVersionConfig.VERSION_CODE, ), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - AppVersionPolicy.basedOnVersionName?.let { basedOnVersionName -> - Text( - text = stringResource( - Res.string.compose_about_based_on_version_format, - basedOnVersionName, - ), - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 35566fe48..dd1551b07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -217,12 +217,6 @@ fun SettingsScreen( val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() - LaunchedEffect(page) { - if (!page.isEnabledByFeaturePolicy()) { - currentPage = SettingsPage.Root.name - } - } - LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { rootActionRequests.collect { if (!rootActionsEnabled) return@collect @@ -240,9 +234,7 @@ fun SettingsScreen( ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } ?: return@LaunchedEffect if (!rootActionsEnabled) return@LaunchedEffect - if (targetPage.isEnabledByFeaturePolicy()) { - currentPage = targetPage.name - } + currentPage = targetPage.name onRequestedPageConsumed() } @@ -259,6 +251,7 @@ fun SettingsScreen( showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, + touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -309,6 +302,7 @@ fun SettingsScreen( showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, + touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -369,6 +363,7 @@ private fun MobileSettingsScreen( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -440,8 +435,6 @@ private fun MobileSettingsScreen( } val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, - downloadsEnabled = AppFeaturePolicy.downloadsEnabled, - notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -464,11 +457,7 @@ private fun MobileSettingsScreen( SettingsPage.MetaScreen -> onMetaScreenClick() else -> onPageChange(target.page) } - SettingsSearchTarget.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - onDownloadsClick() - } - } + SettingsSearchTarget.Downloads -> onDownloadsClick() SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -528,8 +517,6 @@ private fun MobileSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, - showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, - showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, ) } } @@ -547,6 +534,7 @@ private fun MobileSettingsScreen( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -580,12 +568,10 @@ private fun MobileSettingsScreen( isTablet = false, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { - notificationsSettingsContent( - isTablet = false, - uiState = episodeReleaseNotificationsUiState, - ) - } + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = false, + uiState = episodeReleaseNotificationsUiState, + ) SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = false, isVisible = continueWatchingPreferencesUiState.isVisible, @@ -653,13 +639,6 @@ private fun MobileSettingsScreen( } } -private fun SettingsPage.isEnabledByFeaturePolicy(): Boolean = - when (this) { - SettingsPage.Notifications -> AppFeaturePolicy.notificationsEnabled - SettingsPage.Plugins -> AppFeaturePolicy.pluginsEnabled - else -> true - } - @Composable private fun rememberSettingsRootSearchRevealConnection( page: SettingsPage, @@ -710,6 +689,7 @@ private fun TabletSettingsScreen( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -820,8 +800,6 @@ private fun TabletSettingsScreen( val hapticScope = rememberCoroutineScope() val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, - downloadsEnabled = AppFeaturePolicy.downloadsEnabled, - notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -829,16 +807,8 @@ private fun TabletSettingsScreen( fun openSearchTarget(target: SettingsSearchTarget) { when (target) { - is SettingsSearchTarget.Page -> { - if (target.page.isEnabledByFeaturePolicy()) { - openInlinePage(target.page) - } - } - SettingsSearchTarget.Downloads -> { - if (AppFeaturePolicy.downloadsEnabled) { - onDownloadsClick() - } - } + is SettingsSearchTarget.Page -> openInlinePage(target.page) + SettingsSearchTarget.Downloads -> onDownloadsClick() SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -928,8 +898,6 @@ private fun TabletSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, - showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, - showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, showAccountSection = activeCategory == SettingsCategory.Account, showGeneralSection = activeCategory == SettingsCategory.General, showAboutSection = activeCategory == SettingsCategory.About, @@ -951,6 +919,7 @@ private fun TabletSettingsScreen( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -984,12 +953,10 @@ private fun TabletSettingsScreen( isTablet = true, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { - notificationsSettingsContent( - isTablet = true, - uiState = episodeReleaseNotificationsUiState, - ) - } + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = true, + uiState = episodeReleaseNotificationsUiState, + ) SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = true, isVisible = continueWatchingPreferencesUiState.isVisible, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index b500ad0df..248d63977 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -79,8 +79,6 @@ internal data class SettingsSearchEntry( @Composable internal fun settingsSearchEntries( pluginsEnabled: Boolean, - downloadsEnabled: Boolean, - notificationsEnabled: Boolean, liquidGlassNativeTabBarSupported: Boolean, switchProfileAvailable: Boolean, checkForUpdatesAvailable: Boolean, @@ -227,16 +225,14 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_content_discovery_description), icon = Icons.Rounded.Extension, ) - if (downloadsEnabled) { - add( - key = "downloads", - title = downloadsPage, - description = stringResource(Res.string.compose_settings_root_downloads_description), - category = generalCategory, - icon = Icons.Rounded.CloudDownload, - target = SettingsSearchTarget.Downloads, - ) - } + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) addPage( page = SettingsPage.Playback, key = "playback", @@ -258,15 +254,13 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_integrations_description), icon = Icons.Rounded.Link, ) - if (notificationsEnabled) { - addPage( - page = SettingsPage.Notifications, - key = "notifications", - title = notificationsPage, - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - ) - } + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) addPage( page = SettingsPage.SupportersContributors, key = "supporters", @@ -530,6 +524,11 @@ internal fun settingsSearchEntries( stringResource(Res.string.settings_playback_hold_to_speed), stringResource(Res.string.settings_playback_hold_to_speed_description), ), + PlaybackSearchRow( + "touch-gestures", + stringResource(Res.string.settings_playback_touch_gestures), + stringResource(Res.string.settings_playback_touch_gestures_description), + ), PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)), ), ) @@ -800,26 +799,24 @@ internal fun settingsSearchEntries( ) } - if (notificationsEnabled) { - val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) - addRow( - page = SettingsPage.Notifications, - key = "episode-release-alerts", - title = stringResource(Res.string.settings_notifications_episode_release_alerts), - description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), - pageLabel = notificationsPage, - section = notificationsAlerts, - icon = Icons.Rounded.Notifications, - ) - addRow( - page = SettingsPage.Notifications, - key = "notification-test", - title = stringResource(Res.string.settings_notifications_test_title), - pageLabel = notificationsPage, - section = stringResource(Res.string.settings_notifications_section_test), - icon = Icons.Rounded.Notifications, - ) - } + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) addRow( page = SettingsPage.TraktAuthentication, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 664cfd36f..2299e120a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -49,7 +50,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSurfaceCard @@ -73,6 +74,7 @@ private data class CommunityUiState( val hasLoadedContributors: Boolean = false, val contributors: List = emptyList(), val contributorsErrorMessage: String? = null, + val donationProgress: DonationProgress? = null, val isSupportersLoading: Boolean = false, val hasLoadedSupporters: Boolean = false, val supporters: List = emptyList(), @@ -94,13 +96,23 @@ private data class ContributionDto( @Serializable private data class DonationsResponseDto( + val currency: String? = null, + val monthlyGoal: DonationMonthlyGoalDto? = null, val donations: List = emptyList(), ) +@Serializable +private data class DonationMonthlyGoalDto( + val progressPercent: Double? = null, + val monthLabel: String? = null, +) + @Serializable private data class DonationDto( + val id: String? = null, val name: String? = null, val date: String? = null, + val createdAt: String? = null, val message: String? = null, ) @@ -119,6 +131,15 @@ internal data class SupporterDonation( val sortTimestamp: Long, ) +internal data class DonationProgress( + val progressPercent: Int, +) + +internal data class SupportersResult( + val supporters: List, + val progress: DonationProgress?, +) + private object SupportersContributorsRepository { private val json = Json { ignoreUnknownKeys = true; isLenient = true } @@ -147,7 +168,7 @@ private object SupportersContributorsRepository { ) } - suspend fun getSupporters(limit: Int = 200): Result> = runCatching { + suspend fun getSupporters(): Result = runCatching { val baseUrl = CommunityConfig.DONATIONS_BASE_URL.trim().removeSuffix("/") check(baseUrl.isNotBlank()) { getString(Res.string.community_supporters_not_configured) @@ -155,7 +176,7 @@ private object SupportersContributorsRepository { val response = httpRequestRaw( method = "GET", - url = "$baseUrl/api/donations?limit=$limit", + url = "$baseUrl/api/donations?view=recent", headers = emptyMap(), body = "", ) @@ -163,15 +184,17 @@ private object SupportersContributorsRepository { error(getString(Res.string.community_error_supporters_request_failed)) } - json.decodeFromString(response.body) - .donations + val donationsResponse = json.decodeFromString(response.body) + val supporters = donationsResponse.donations .mapNotNull { donation -> val name = donation.name?.trim().orEmpty() - val date = donation.date?.trim().orEmpty() + val date = donation.date?.trim() + ?: donation.createdAt?.trim() + ?: "" if (name.isBlank() || date.isBlank()) return@mapNotNull null SupporterDonation( - key = "${name.lowercase()}-$date", + key = donation.id?.trim()?.takeIf { it.isNotBlank() } ?: "${name.lowercase()}-$date", name = name, date = date, message = donation.message?.trim()?.takeIf { it.isNotBlank() }, @@ -182,6 +205,17 @@ private object SupportersContributorsRepository { .mapIndexed { index, donation -> donation.copy(key = "${donation.key}#$index") } + + val progress = donationsResponse.monthlyGoal + ?.progressPercent + ?.toInt() + ?.coerceIn(0, 100) + ?.let { percent -> DonationProgress(progressPercent = percent) } + + SupportersResult( + supporters = supporters, + progress = progress, + ) } private fun normalizeContributor(dto: ContributionDto): CommunityContributor? { @@ -286,11 +320,12 @@ private fun SupportersContributorsBody( supportersErrorMessage = null, ) SupportersContributorsRepository.getSupporters() - .onSuccess { supporters -> + .onSuccess { result -> uiState = uiState.copy( isSupportersLoading = false, hasLoadedSupporters = true, - supporters = supporters, + supporters = result.supporters, + donationProgress = result.progress, supportersErrorMessage = null, ) } @@ -299,6 +334,7 @@ private fun SupportersContributorsBody( isSupportersLoading = false, hasLoadedSupporters = false, supporters = emptyList(), + donationProgress = null, supportersErrorMessage = error.message ?: supportersErrorFallback, ) } @@ -307,6 +343,7 @@ private fun SupportersContributorsBody( LaunchedEffect(Unit) { loadContributors(force = false) + loadSupporters(force = false) } LaunchedEffect(uiState.selectedTab) { @@ -331,6 +368,14 @@ private fun SupportersContributorsBody( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (donationsConfigured) { + Spacer(modifier = Modifier.height(16.dp)) + DonationProgressSection( + progress = uiState.donationProgress, + isLoading = uiState.isSupportersLoading, + errorMessage = uiState.supportersErrorMessage, + ) + } Spacer(modifier = Modifier.height(16.dp)) Button( onClick = { if (donateConfigured) uriHandler.openUri(donateUrl) }, @@ -572,6 +617,61 @@ private fun SupportersCard( } } +@Composable +private fun DonationProgressSection( + progress: DonationProgress?, + isLoading: Boolean, + errorMessage: String?, +) { + val percent = progress?.progressPercent ?: 0 + val progressFraction = (percent / 100f).coerceIn(0f, 1f) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.community_donation_progress_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + ) + + if (isLoading && progress == null) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } else { + LinearProgressIndicator( + progress = { progressFraction }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } + + Text( + text = when { + errorMessage != null -> errorMessage + isLoading && progress == null -> stringResource(Res.string.community_loading_donation_progress) + percent >= 100 -> stringResource(Res.string.community_donation_progress_complete) + else -> stringResource(Res.string.community_donation_progress_remaining) + }, + style = MaterialTheme.typography.bodySmall, + color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + @Composable private fun ContributorRow( contributor: CommunityContributor, @@ -855,6 +955,8 @@ private fun contributorContributionSummary(contributor: CommunityContributor): S private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" + "whitegiso" -> "https://ko-fi.com/whitegiso" + "edoedac0" -> "https://ko-fi.com/edoedac" "crisszollo", "xrissozollo" -> "https://ko-fi.com/crisszollo" else -> null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 956dc2171..431b9d20e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -17,9 +17,6 @@ object ThemeSettingsRepository { private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() - private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default) - val desktopNavigationLayout: StateFlow = _desktopNavigationLayout.asStateFlow() - private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() @@ -39,7 +36,6 @@ object ThemeSettingsRepository { _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false _liquidGlassNativeTabBarEnabled.value = false - _desktopNavigationLayout.value = DesktopNavigationLayout.Default NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH @@ -63,9 +59,6 @@ object ThemeSettingsRepository { val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled) - _desktopNavigationLayout.value = DesktopNavigationLayout.fromName( - ThemeSettingsStorage.loadDesktopNavigationLayout(), - ) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage @@ -94,13 +87,6 @@ object ThemeSettingsRepository { NativeTabBridge.publishLiquidGlassEnabled(enabled) } - fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) { - ensureLoaded() - if (_desktopNavigationLayout.value == layout) return - _desktopNavigationLayout.value = layout - ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name) - } - fun setAppLanguage(language: AppLanguage) { ensureLoaded() if (_selectedAppLanguage.value == language) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 5bb6a2840..2a788baf1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -9,8 +9,6 @@ internal expect object ThemeSettingsStorage { fun saveAmoledEnabled(enabled: Boolean) fun loadLiquidGlassNativeTabBarEnabled(): Boolean? fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) - fun loadDesktopNavigationLayout(): String? - fun saveDesktopNavigationLayout(layoutName: String) fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt deleted file mode 100644 index 6d9682e61..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt +++ /dev/null @@ -1,318 +0,0 @@ -package com.nuvio.app.features.streams - -import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.AddonManifest -import com.nuvio.app.features.addons.AddonRepository -import com.nuvio.app.features.addons.ManagedAddon -import com.nuvio.app.features.addons.buildAddonResourceUrl -import com.nuvio.app.features.addons.enabledAddons -import com.nuvio.app.features.addons.httpGetText -import com.nuvio.app.features.debrid.DebridSettings -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.debrid.DebridStreamPresentation -import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.LocalDebridAvailabilityService -import com.nuvio.app.features.player.PlayerSettingsRepository -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L - -object AddonStreamWarmupRepository { - private val log = Logger.withTag("AddonStreamWarmup") - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val mutex = Mutex() - private val cache = mutableMapOf() - private val inFlight = mutableMapOf>>() - - fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { - val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return - scope.launch { - runCatching { fetchWarmup(key) } - .onFailure { error -> - if (error is CancellationException) throw error - log.d(error) { "Addon stream warmup failed" } - } - } - } - - fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List? { - val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null - if (!mutex.tryLock()) return null - return try { - cachedGroupsLocked(key) - } finally { - mutex.unlock() - } - } - - private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List { - cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it } - - var ownsFetch = false - val newFetch = scope.async(start = CoroutineStart.LAZY) { - fetchWarmupUncached(key) - } - val activeFetch = mutex.withLock { - cachedGroupsLocked(key)?.let { cached -> - return@withLock null to cached - } - val existing = inFlight[key] - if (existing != null) { - existing to null - } else { - inFlight[key] = newFetch - ownsFetch = true - newFetch to null - } - } - activeFetch.second?.let { - newFetch.cancel() - return it - } - val deferred = activeFetch.first ?: return emptyList() - if (!ownsFetch) newFetch.cancel() - if (ownsFetch) deferred.start() - - return try { - val result = deferred.await() - val cacheableGroups = result.filter { it.streams.isNotEmpty() } - if (ownsFetch && cacheableGroups.isNotEmpty()) { - mutex.withLock { - cache[key] = CachedAddonStreamWarmup( - groups = cacheableGroups, - createdAtMs = epochMs(), - ) - } - } - result - } finally { - if (ownsFetch) { - mutex.withLock { - if (inFlight[key] === deferred) { - inFlight.remove(key) - } - } - } - } - } - - private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List { - val targets = key.addonTargets - if (targets.isEmpty()) return emptyList() - - val addonIds = targets.map { it.addonId }.toSet() - val orderedGroups = coroutineScope { - targets.map { target -> - async { - val group = fetchAddonStreams( - target = target, - type = key.type, - videoId = key.videoId, - ) - val eligibleGroupIds = setOf(group.addonId) - val checkingGroup = LocalDebridAvailabilityService.markChecking( - groups = listOf(group), - eligibleGroupIds = eligibleGroupIds, - ).firstOrNull() ?: group - val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( - groups = listOf(checkingGroup), - eligibleGroupIds = eligibleGroupIds, - ).firstOrNull() ?: checkingGroup - val badgeGroup = StreamBadgePresentation.apply( - groups = listOf(availabilityGroup), - rules = key.streamBadgeRules, - ).firstOrNull() ?: availabilityGroup - DebridStreamPresentation.apply( - groups = listOf(badgeGroup), - settings = key.settings, - ).firstOrNull() ?: badgeGroup - } - }.awaitAll() - }.let { groups -> - StreamAutoPlaySelector.orderAddonStreams( - groups = groups, - installedOrder = targets.map { it.addonName }, - ) - } - - var preparedGroups = orderedGroups - - PlayerSettingsRepository.ensureLoaded() - DirectDebridStreamPreparer.prepare( - streams = preparedGroups.flatMap { it.streams }, - season = key.season, - episode = key.episode, - playerSettings = PlayerSettingsRepository.uiState.value, - installedAddonNames = targets.map { it.addonName }.toSet(), - ) { original, prepared -> - preparedGroups = DirectDebridStreamPreparer.replacePreparedStream( - groups = preparedGroups, - original = original, - prepared = prepared, - eligibleGroupIds = addonIds, - ) - } - - return preparedGroups - } - - private suspend fun fetchAddonStreams( - target: AddonStreamWarmupTarget, - type: String, - videoId: String, - ): AddonStreamGroup { - val url = buildAddonResourceUrl( - manifestUrl = target.manifest.transportUrl, - resource = "stream", - type = type, - id = videoId, - ) - return runCatchingUnlessCancelled { - val payload = httpGetText(url) - StreamParser.parse( - payload = payload, - addonName = target.addonName, - addonId = target.addonId, - ) - }.fold( - onSuccess = { streams -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = streams, - isLoading = false, - ) - }, - onFailure = { error -> - log.d(error) { "Failed to warm addon stream target ${target.addonName}" } - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = false, - error = error.message, - ) - }, - ) - } - - private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? { - val normalizedType = type.trim().lowercase() - val normalizedVideoId = videoId.trim() - if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null - - DebridSettingsRepository.ensureLoaded() - val settings = DebridSettingsRepository.snapshot() - if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null - val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() - - AddonRepository.initialize() - val addonTargets = AddonRepository.uiState.value.addons - .enabledAddons() - .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } - if (addonTargets.isEmpty()) return null - - return AddonStreamWarmupKey( - type = normalizedType, - videoId = normalizedVideoId, - season = season, - episode = episode, - addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, - settingsFingerprint = settings.warmupFingerprint(), - streamBadgeRulesFingerprint = streamBadgeRules.toString(), - settings = settings, - streamBadgeRules = streamBadgeRules, - addonTargets = addonTargets, - ) - } - - private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List? { - val cached = cache[key] ?: return null - val age = epochMs() - cached.createdAtMs - return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) { - cached.groups - } else { - cache.remove(key) - null - } - } -} - -private data class AddonStreamWarmupKey( - val type: String, - val videoId: String, - val season: Int?, - val episode: Int?, - val addonFingerprint: String, - val settingsFingerprint: String, - val streamBadgeRulesFingerprint: String, - val settings: DebridSettings, - val streamBadgeRules: StreamBadgeRules, - val addonTargets: List, -) - -private data class AddonStreamWarmupTarget( - val addonName: String, - val addonId: String, - val manifest: AddonManifest, - val fingerprint: String, -) - -private data class CachedAddonStreamWarmup( - val groups: List, - val createdAtMs: Long, -) - -private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? { - val manifest = manifest ?: return null - val supportsRequestedStream = manifest.resources.any { resource -> - resource.name == "stream" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) }) - } - if (!supportsRequestedStream) return null - - val addonName = displayTitle.ifBlank { manifest.name } - return AddonStreamWarmupTarget( - addonName = addonName, - addonId = "addon:${manifest.id}:$manifestUrl", - manifest = manifest, - fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName", - ) -} - -private fun DebridSettings.warmupFingerprint(): String = - listOf( - enabled, - torboxApiKey, - instantPlaybackPreparationLimit, - streamMaxResults, - streamSortMode, - streamMinimumQuality, - streamDolbyVisionFilter, - streamHdrFilter, - streamCodecFilter, - streamPreferences, - streamNameTemplate, - streamDescriptionTemplate, - ).joinToString("|") - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt index 08241f190..f0e9ebd2a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt index 397d26adf..febd2b9b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.streams +import com.nuvio.app.core.i18n.localizedBadgeEnterUrl +import com.nuvio.app.core.i18n.localizedBadgeImportFailed +import com.nuvio.app.core.i18n.localizedBadgeImportLimit +import com.nuvio.app.core.i18n.localizedBadgeUrlSchemeInvalid import com.nuvio.app.features.addons.httpGetText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -77,12 +81,12 @@ object StreamBadgeSettingsRepository { ensureLoaded() val normalizedUrl = url.trim() if (normalizedUrl.isBlank()) { - return StreamBadgeImportResult.Error("Enter a badge JSON URL.") + return StreamBadgeImportResult.Error(localizedBadgeEnterUrl()) } if (!normalizedUrl.startsWith("https://", ignoreCase = true) && !normalizedUrl.startsWith("http://", ignoreCase = true) ) { - return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") + return StreamBadgeImportResult.Error(localizedBadgeUrlSchemeInvalid()) } return try { @@ -91,7 +95,7 @@ object StreamBadgeSettingsRepository { import.sourceUrl.equals(normalizedUrl, ignoreCase = true) } if (!isExistingImport && currentRules.imports.size >= STREAM_BADGE_IMPORT_LIMIT) { - return StreamBadgeImportResult.Error("You can import up to $STREAM_BADGE_IMPORT_LIMIT badge URLs.") + return StreamBadgeImportResult.Error(localizedBadgeImportLimit(STREAM_BADGE_IMPORT_LIMIT)) } val payload = httpGetText(normalizedUrl) val parsedImport = StreamBadgeRulesParser.parse( @@ -104,7 +108,7 @@ object StreamBadgeSettingsRepository { StreamBadgeImportResult.Success(streamBadgeRules) } catch (error: Exception) { if (error is CancellationException) throw error - StreamBadgeImportResult.Error(error.message ?: "Badge import failed.") + StreamBadgeImportResult.Error(error.message ?: localizedBadgeImportFailed()) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt new file mode 100644 index 000000000..3f15b63b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt @@ -0,0 +1,274 @@ +package com.nuvio.app.features.streams + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Color +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 androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.nuvio.app.features.debrid.DebridProviders + +@Composable +internal fun StreamCard( + stream: StreamItem, + enabled: Boolean, + appendInstantServiceToDefaultName: Boolean, + showFileSizeBadges: Boolean, + showAddonLogo: Boolean, + badgePlacement: StreamBadgePlacement, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isCurrent: Boolean = false, + currentLabel: String? = null, +) { + val cardShape = RoundedCornerShape(12.dp) + val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } + val hasBadges = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null) + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .shadow( + elevation = 2.dp, + shape = cardShape, + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f), + ) + .clip(cardShape) + .background( + if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + Color.White.copy(alpha = 0.05f) + }, + ) + .then( + if (isCurrent) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + shape = cardShape, + ) + } else { + Modifier + }, + ) + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { + StreamCardBadgeRow( + badgeImages = badgeImages, + stream = stream, + showFileSizeBadges = showFileSizeBadges, + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + StreamNameWithInstantService( + stream = stream, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, + ) { + if (isCurrent && !currentLabel.isNullOrBlank()) { + Spacer(modifier = Modifier.width(8.dp)) + CurrentStreamBadge(label = currentLabel) + } + } + + val subtitle = stream.streamSubtitle + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + lineHeight = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (hasBadges && badgePlacement == StreamBadgePlacement.BOTTOM) { + Spacer(modifier = Modifier.height(5.dp)) + StreamCardBadgeRow( + badgeImages = badgeImages, + stream = stream, + showFileSizeBadges = showFileSizeBadges, + ) + } + } + + if (showAddonLogo) { + Spacer(modifier = Modifier.width(12.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!stream.addonLogo.isNullOrBlank()) { + AsyncImage( + model = stream.addonLogo, + contentDescription = stream.addonName, + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Fit, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stream.addonName, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun StreamCardBadgeRow( + badgeImages: List, + stream: StreamItem, + showFileSizeBadges: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + badgeImages.forEach { badge -> + StreamBadgeImage(badge = badge) + } + if (showFileSizeBadges) { + StreamFileSizeBadge(stream = stream) + } + } +} + +@Composable +private fun StreamNameWithInstantService( + stream: StreamItem, + appendInstantServiceToDefaultName: Boolean, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + val nameStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val instantLabel = if (appendInstantServiceToDefaultName) { + stream.instantServiceLabel() + } else { + null + } + val showInstantLabel = instantLabel != null + val visibleState = remember(stream.streamLabel) { + MutableTransitionState(showInstantLabel) + } + visibleState.targetState = showInstantLabel + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stream.streamLabel, + modifier = Modifier.weight(1f, fill = false), + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(durationMillis = 260)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 120), + shrinkTowards = Alignment.Start, + ), + label = "streamNameInstantService", + ) { + Text( + text = " ${instantLabel.orEmpty()}", + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + } + trailingContent() + } +} + +@Composable +private fun CurrentStreamBadge(label: String) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun StreamItem.instantServiceLabel(): String? { + val status = debridCacheStatus ?: return null + if (status.state != StreamDebridCacheState.CACHED) return null + val providerLabel = DebridProviders.shortName(status.providerId) + .ifBlank { status.providerName.trim() } + .ifBlank { DebridProviders.displayName(status.providerId) } + return "- $providerLabel Instant" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt new file mode 100644 index 000000000..84d67e887 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.features.addons.AddonManifest +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.plugins.PluginRepositoryItem +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginScraper +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.streams_plugin_repository_fallback +import org.jetbrains.compose.resources.getString + +internal data class InstalledStreamAddonTarget( + val addonName: String, + val addonId: String, + val manifest: AddonManifest, +) + +internal fun ManagedAddon.streamAddonInstanceId(manifestId: String): String = + "addon:$manifestId:$manifestUrl" + +internal data class PluginProviderGroup( + val addonId: String, + val addonName: String, + val scrapers: List, +) + +internal sealed interface StreamLoadCompletion { + data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion + data class PluginScraper( + val addonId: String, + val streams: List, + val error: String?, + ) : StreamLoadCompletion +} + +internal fun List.toPluginProviderGroups( + repositories: List, + groupByRepository: Boolean, +): List { + if (!groupByRepository) { + return map { scraper -> + PluginProviderGroup( + addonId = "plugin:${scraper.id}", + addonName = scraper.name, + scrapers = listOf(scraper), + ) + } + } + + val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } + return groupBy { it.repositoryUrl } + .map { (repositoryUrl, scrapers) -> + PluginProviderGroup( + addonId = "plugin-repo:${repositoryUrl.lowercase()}", + addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, + scrapers = scrapers.sortedBy { it.name.lowercase() }, + ) + } + .sortedBy { it.addonName.lowercase() } +} + +internal fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { + if (anyLoading || any { it.streams.isNotEmpty() }) { + return null + } + + return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { + StreamsEmptyStateReason.StreamFetchFailed + } else { + StreamsEmptyStateReason.NoStreamsFound + } +} + +internal suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } + +internal fun PluginRuntimeResult.toStreamItem( + scraper: PluginScraper, + addonName: String = scraper.name, + addonId: String = "plugin:${scraper.id}", + includeScraperNameInSubtitle: Boolean = false, +): StreamItem { + val subtitleParts = listOfNotNull( + scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, + 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, + sourceName = scraper.name, + addonName = addonName, + addonId = addonId, + streamType = normalizeStreamType(type), + behaviorHints = if (requestHeaders.isEmpty()) { + StreamBehaviorHints() + } else { + StreamBehaviorHints( + notWebReady = true, + proxyHeaders = StreamProxyHeaders(request = requestHeaders), + ) + }, + ) +} + +internal fun List.sortedForGroupedDisplay(): List = + sortedWith( + compareBy( + { it.sourceName.orEmpty().lowercase() }, + { it.streamLabel.lowercase() }, + { it.streamSubtitle.orEmpty().lowercase() }, + ), + ) + +private fun String.fallbackRepositoryLabel(): String { + val withoutQuery = substringBefore("?") + val withoutManifest = withoutQuery.removeSuffix("/manifest.json") + val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') + return host.ifBlank { + withoutManifest.substringAfterLast('/').ifBlank { + runBlocking { getString(Res.string.streams_plugin_repository_fallback) } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt index bed943076..1a47be1a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.streams data class StreamLaunch( + val profileId: Int, val type: String, val videoId: String, val parentMetaId: String? = null, @@ -40,4 +41,4 @@ object StreamLaunchStore { nextLaunchId = 1L launches.clear() } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index f218ae740..e3a7e8efc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -18,6 +18,7 @@ data class CachedStreamLink( val fileIdx: Int? = null, val sources: List = emptyList(), val bingeGroup: String? = null, + val streamType: String? = null, ) internal expect fun epochMs(): Long @@ -54,6 +55,7 @@ object StreamLinkCacheRepository { fileIdx: Int? = null, sources: List = emptyList(), bingeGroup: String? = null, + streamType: String? = null, ) { if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) { remove(contentKey) @@ -74,6 +76,7 @@ object StreamLinkCacheRepository { fileIdx = fileIdx, sources = sources, bingeGroup = bingeGroup, + streamType = streamType, ) val payload = json.encodeToString(CachedStreamLink.serializer(), entry) StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 4719a89db..d45b51487 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -18,6 +18,7 @@ data class StreamItem( val addonName: String, val addonId: String, val addonLogo: String? = null, + val streamType: String? = null, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, @@ -32,14 +33,23 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + /** + * First URL that can be handed directly to a player or HTTP consumer. + * `magnet:` and `torrent://` URLs are filtered out, falling back to + * [externalUrl] when [url] carries one of those schemes. + */ val playableDirectUrl: String? get() = listOfNotNull(url, externalUrl) - .firstOrNull { !it.isMagnetLink() } + .firstOrNull { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } val torrentMagnetUri: String? get() = listOfNotNull(url, externalUrl) .firstOrNull { it.isMagnetLink() } + val torrentSchemeUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isTorrentSchemeUrl() } + val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true @@ -50,7 +60,9 @@ data class StreamItem( get() = !isDirectDebridStream && ( !infoHash.isNullOrBlank() || url.isMagnetLink() || - externalUrl.isMagnetLink() + externalUrl.isMagnetLink() || + url.isTorrentSchemeUrl() || + externalUrl.isTorrentSchemeUrl() ) val isCachedDebridTorrentStream: Boolean @@ -63,6 +75,10 @@ data class StreamItem( get() = infoHash.normalizedInfoHash() ?: clientResolve?.infoHash.normalizedInfoHash() ?: torrentMagnetUri.extractBtihInfoHash() + ?: torrentSchemeUri.extractTorrentSchemeInfoHash() + + val p2pFileIdx: Int? + get() = fileIdx ?: torrentSchemeUri.extractTorrentSchemeFileIdx() val p2pTrackers: List get() = sources @@ -89,9 +105,38 @@ data class StreamBadge( val borderColor: String = "", ) +fun normalizeStreamType(raw: String?): String? = + raw?.trim()?.lowercase()?.takeIf { it.isNotBlank() } + private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +private fun String?.isTorrentSchemeUrl(): Boolean = + this?.trimStart()?.startsWith("torrent://", ignoreCase = true) == true + +private fun String?.extractTorrentSchemeInfoHash(): String? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + return raw.removeRange(0, "torrent://".length) + .substringBefore('/') + .substringBefore('?') + .trim() + .takeIf { it.isValidInfoHash() } +} + +private fun String?.extractTorrentSchemeFileIdx(): Int? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + val path = raw.removeRange(0, "torrent://".length).substringBefore('?') + if ('/' !in path) return null + return path.substringAfter('/') + .trim() + .takeIf { segment -> segment.isNotEmpty() && segment.all { it.isDigit() } } + ?.toIntOrNull() +} + +private fun String.isValidInfoHash(): Boolean = + (length == 40 && all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }) || + (length == 32 && all { it in '2'..'7' || it.lowercaseChar() in 'a'..'z' }) + private fun String?.normalizedInfoHash(): String? = this ?.trim() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index a27033eda..ab1df9593 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -48,6 +48,7 @@ object StreamParser { addonName = addonName, addonId = addonId, addonLogo = addonLogo, + streamType = normalizeStreamType(obj.string("type")), clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 49fc361a9..d0f5cddf8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -15,11 +15,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginsUiState -import com.nuvio.app.features.plugins.PluginRepositoryItem -import com.nuvio.app.features.plugins.PluginRuntimeResult -import com.nuvio.app.features.plugins.PluginScraper import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -29,7 +25,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import kotlinx.coroutines.launch @@ -210,13 +205,8 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } - val warmedAddonGroups = AddonStreamWarmupRepository - .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) - .orEmpty() - .associateBy { it.addonId } - val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( + AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -242,13 +232,12 @@ object StreamsRepository { ) activeJob = scope.launch { - val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val completions = Channel(capacity = Channel.BUFFERED) val pluginRemainingByAddonId = pluginProviderGroups .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = pendingStreamAddons.size + + val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() @@ -439,7 +428,7 @@ object StreamsRepository { null } - pendingStreamAddons.forEach { addon -> + streamAddons.forEach { addon -> launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -810,138 +799,3 @@ object StreamsRepository { _uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) } } } - -private data class InstalledStreamAddonTarget( - val addonName: String, - val addonId: String, - val manifest: com.nuvio.app.features.addons.AddonManifest, -) - -private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = - "addon:$manifestId:$manifestUrl" - -private data class PluginProviderGroup( - val addonId: String, - val addonName: String, - val scrapers: List, -) - -private sealed interface StreamLoadCompletion { - data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class PluginScraper( - val addonId: String, - val streams: List, - val error: String?, - ) : StreamLoadCompletion -} - -private fun List.toPluginProviderGroups( - repositories: List, - groupByRepository: Boolean, -): List { - if (!groupByRepository) { - return map { scraper -> - PluginProviderGroup( - addonId = "plugin:${scraper.id}", - addonName = scraper.name, - scrapers = listOf(scraper), - ) - } - } - - val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } - return groupBy { it.repositoryUrl } - .map { (repositoryUrl, scrapers) -> - PluginProviderGroup( - addonId = "plugin-repo:${repositoryUrl.lowercase()}", - addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, - scrapers = scrapers.sortedBy { it.name.lowercase() }, - ) - } - .sortedBy { it.addonName.lowercase() } -} - -private fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { - if (anyLoading || any { it.streams.isNotEmpty() }) { - return null - } - - return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { - StreamsEmptyStateReason.StreamFetchFailed - } else { - StreamsEmptyStateReason.NoStreamsFound - } -} - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } - -private fun PluginRuntimeResult.toStreamItem( - scraper: PluginScraper, - addonName: String = scraper.name, - addonId: String = "plugin:${scraper.id}", - includeScraperNameInSubtitle: Boolean = false, -): StreamItem { - val subtitleParts = listOfNotNull( - scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, - 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, - sourceName = scraper.name, - addonName = addonName, - addonId = addonId, - behaviorHints = if (requestHeaders.isEmpty()) { - StreamBehaviorHints() - } else { - StreamBehaviorHints( - notWebReady = true, - proxyHeaders = StreamProxyHeaders(request = requestHeaders), - ) - }, - ) -} - -private fun List.sortedForGroupedDisplay(): List = - sortedWith( - compareBy( - { it.sourceName.orEmpty().lowercase() }, - { it.streamLabel.lowercase() }, - { it.streamSubtitle.orEmpty().lowercase() }, - ), - ) - -private fun String.fallbackRepositoryLabel(): String { - val withoutQuery = substringBefore("?") - val withoutManifest = withoutQuery.removeSuffix("/manifest.json") - val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') - return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { - runBlocking { getString(Res.string.streams_plugin_repository_fallback) } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index f9138cc11..8fdf7e116 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -3,16 +3,12 @@ package com.nuvio.app.features.streams import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween -import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,7 +19,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -63,7 +58,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -77,21 +71,18 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.dismissNuvioBottomSheet -import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.downloads.DownloadsRepository import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding -import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -146,9 +137,7 @@ fun StreamsScreen( WatchProgressRepository.uiState }.collectAsStateWithLifecycle() remember { - if (AppFeaturePolicy.downloadsEnabled) { - DownloadsRepository.ensureLoaded() - } + DownloadsRepository.ensureLoaded() } val isEpisode = seasonNumber != null && episodeNumber != null val clipboardManager = LocalClipboardManager.current @@ -156,6 +145,8 @@ fun StreamsScreen( val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } + var autoPlayOverlayLogoLoadError by remember(logo) { mutableStateOf(false) } + val autoPlayOverlayLogoUrl = logo?.takeIf { it.isNotBlank() } val storedProgress = if (startFromBeginning) { null } else { @@ -320,13 +311,24 @@ fun StreamsScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (!logo.isNullOrBlank()) { + if (autoPlayOverlayLogoUrl != null && !autoPlayOverlayLogoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = autoPlayOverlayLogoUrl, + contentDescription = title, modifier = Modifier .height(48.dp), contentScale = ContentScale.Fit, + onError = { autoPlayOverlayLogoLoadError = true }, + ) + } else if (title.isNotBlank()) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Black), + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), ) } CircularProgressIndicator( @@ -347,7 +349,6 @@ fun StreamsScreen( StreamActionsSheet( stream = streamActionsTarget, externalPlayerEnabled = playerSettings.externalPlayerEnabled, - showDownloadAction = AppFeaturePolicy.downloadsEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> val directUrl = stream.playableDirectUrl @@ -537,6 +538,9 @@ private fun MovieHeroBlock( logo: String?, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } + Box( modifier = modifier .fillMaxWidth() @@ -544,14 +548,15 @@ private fun MovieHeroBlock( .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), contentAlignment = Alignment.Center, ) { - if (logo != null) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = title, modifier = Modifier .height(80.dp) .fillMaxWidth(0.85f), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( @@ -996,193 +1001,11 @@ private fun StreamSourceHeader( ) } -// --------------------------------------------------------------------------- -// Stream Card -// --------------------------------------------------------------------------- - -@Composable -private fun StreamCard( - stream: StreamItem, - enabled: Boolean, - appendInstantServiceToDefaultName: Boolean, - showFileSizeBadges: Boolean, - showAddonLogo: Boolean, - badgePlacement: StreamBadgePlacement, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - val cardShape = RoundedCornerShape(12.dp) - val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } - val hasBadges = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null) - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 68.dp) - .shadow( - elevation = 2.dp, - shape = cardShape, - ambientColor = Color.Black.copy(alpha = 0.04f), - spotColor = Color.Black.copy(alpha = 0.04f), - ) - .clip(cardShape) - .background(Color.White.copy(alpha = 0.05f)) - .combinedClickable( - enabled = enabled, - onClick = onClick, - onLongClick = onLongClick, - ) - .secondaryClick(if (enabled) onLongClick else null) - .padding(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { - StreamCardBadgeRow( - badgeImages = badgeImages, - stream = stream, - showFileSizeBadges = showFileSizeBadges, - ) - Spacer(modifier = Modifier.height(6.dp)) - } - - StreamNameWithInstantService( - stream = stream, - appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, - ) - - val subtitle = stream.streamSubtitle - if (!subtitle.isNullOrBlank()) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, - lineHeight = 18.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (hasBadges && badgePlacement == StreamBadgePlacement.BOTTOM) { - Spacer(modifier = Modifier.height(5.dp)) - StreamCardBadgeRow( - badgeImages = badgeImages, - stream = stream, - showFileSizeBadges = showFileSizeBadges, - ) - } - } - - if (showAddonLogo) { - Spacer(modifier = Modifier.width(12.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!stream.addonLogo.isNullOrBlank()) { - AsyncImage( - model = stream.addonLogo, - contentDescription = stream.addonName, - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(6.dp)), - contentScale = ContentScale.Fit, - ) - } - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stream.addonName, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } - } -} - -@Composable -private fun StreamCardBadgeRow( - badgeImages: List, - stream: StreamItem, - showFileSizeBadges: Boolean, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - badgeImages.forEach { badge -> - StreamBadgeImage(badge = badge) - } - if (showFileSizeBadges) { - StreamFileSizeBadge(stream = stream) - } - } -} - -@Composable -private fun StreamNameWithInstantService( - stream: StreamItem, - appendInstantServiceToDefaultName: Boolean, -) { - val nameStyle = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - val instantLabel = if (appendInstantServiceToDefaultName) { - stream.instantServiceLabel() - } else { - null - } - val showInstantLabel = instantLabel != null - val visibleState = remember(stream.streamLabel) { - MutableTransitionState(showInstantLabel) - } - visibleState.targetState = showInstantLabel - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stream.streamLabel, - modifier = Modifier.weight(1f, fill = false), - style = nameStyle, - color = MaterialTheme.colorScheme.onSurface, - ) - AnimatedVisibility( - visibleState = visibleState, - enter = fadeIn(animationSpec = tween(durationMillis = 260)) + - expandHorizontally( - animationSpec = tween(durationMillis = 260), - expandFrom = Alignment.Start, - ), - exit = fadeOut(animationSpec = tween(durationMillis = 120)) + - shrinkHorizontally( - animationSpec = tween(durationMillis = 120), - shrinkTowards = Alignment.Start, - ), - label = "streamNameInstantService", - ) { - Text( - text = " ${instantLabel.orEmpty()}", - style = nameStyle, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StreamActionsSheet( stream: StreamItem?, externalPlayerEnabled: Boolean, - showDownloadAction: Boolean, onDismiss: () -> Unit, onCopyLink: (StreamItem) -> Unit, onDownload: (StreamItem) -> Unit, @@ -1261,32 +1084,21 @@ private fun StreamActionsSheet( } }, ) - if (showDownloadAction) { - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Rounded.Download, - title = stringResource(Res.string.streams_download_file), - onClick = { - onDownload(stream) - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - ) - } + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Rounded.Download, + title = stringResource(Res.string.streams_download_file), + onClick = { + onDownload(stream) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) } } } -private fun StreamItem.instantServiceLabel(): String? { - val status = debridCacheStatus ?: return null - if (status.state != StreamDebridCacheState.CACHED) return null - val providerLabel = DebridProviders.shortName(status.providerId) - .ifBlank { status.providerName.trim() } - .ifBlank { DebridProviders.displayName(status.providerId) } - return "- $providerLabel Instant" -} - private fun Long.toPlaybackClock(): String { val totalSeconds = (this / 1000L).coerceAtLeast(0L) val hours = totalSeconds / 3600L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index ff5ab11bf..98c225a65 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -40,7 +40,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.sp -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import coil3.compose.AsyncImage import com.nuvio.app.isIos import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -222,19 +222,23 @@ private fun TabletMovieInfoPanel( logo: String?, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } + Column( modifier = modifier.fillMaxWidth(0.8f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (!logo.isNullOrBlank()) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = title, modifier = Modifier .fillMaxWidth() .height(120.dp), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else if (title.isNotBlank()) { Text( @@ -277,6 +281,8 @@ private fun TabletEpisodeInfoPanel( showTitle: String, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } val textShadow = Shadow( color = Color.Black, offset = Offset(0f, 0f), @@ -288,14 +294,15 @@ private fun TabletEpisodeInfoPanel( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (!logo.isNullOrBlank()) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = showTitle, modifier = Modifier .fillMaxWidth() .height(120.dp), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index c8d365abf..39f82e9c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -14,10 +14,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select @@ -612,10 +614,10 @@ object TraktLibraryRepository { followRedirects = true, ) if (response.status !in 200..299) { - throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_request_failed))) } if (response.body.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(localizedString(Res.string.trakt_error_empty_response)) } return response } @@ -636,7 +638,7 @@ object TraktLibraryRepository { followRedirects = true, ) if (response.status !in 200..299) { - throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_request_failed))) } return response } @@ -661,7 +663,7 @@ object TraktLibraryRepository { headers = headers, ) if (!isSuccessfulAddResponse(response.body)) { - throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_add_watchlist_failed))) } } @@ -682,7 +684,7 @@ object TraktLibraryRepository { headers = headers, ) if (!isSuccessfulAddResponse(response.body)) { - throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_add_list_failed))) } } @@ -699,7 +701,7 @@ object TraktLibraryRepository { val type = normalizeType(item.type) val ids = resolveIds(item) if (!ids.hasAnyId()) { - throw IllegalStateException("Missing compatible Trakt IDs") + throw IllegalStateException(localizedString(Res.string.trakt_error_missing_ids)) } val request = if (type == "movie") { @@ -819,12 +821,14 @@ object TraktLibraryRepository { return addCount > 0 || existingCount > 0 } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } + private fun errorMessageForStatus(status: Int, defaultMessage: String): String = when (status) { - 401, 403 -> "Trakt authorization expired" - 404 -> "Trakt list not found" - 420 -> "Trakt list limit reached" - 429 -> "Trakt rate limit reached" + 401, 403 -> localizedString(Res.string.trakt_error_authorization_expired) + 404 -> localizedString(Res.string.trakt_error_list_not_found) + 420 -> localizedString(Res.string.trakt_error_list_limit_reached) + 429 -> localizedString(Res.string.trakt_error_rate_limit_reached) else -> "$defaultMessage ($status)" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index d80d7ee43..ff3d8c8f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -317,12 +317,14 @@ object TraktProgressRepository { }.getOrNull() if (playbackEntries == null) { + if (!isLatestRefreshRequest(requestId)) return _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = getString(Res.string.trakt_progress_load_failed), ) return } + if (!isLatestRefreshRequest(requestId)) return // Merge new playback entries into the existing state rather than replacing it wholesale. // This prevents the CW list from briefly losing "next up" seeds (like One Piece) for the @@ -356,6 +358,7 @@ object TraktProgressRepository { }.getOrNull() if (completedEntries == null) { + if (!isLatestRefreshRequest(requestId)) return _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 5c4613498..102d75906 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.core.build.AppVersionPolicy +import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.CancellationException @@ -62,12 +62,12 @@ internal object TraktScrobbleRepository { private val retryDelayMs = 1_500L private val serverOverloadedRetryDelayMs = 5_000L - suspend fun scrobbleStart(item: TraktScrobbleItem, progressPercent: Float) { - sendScrobble(action = "start", item = item, progressPercent = progressPercent) + suspend fun scrobbleStart(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) { + sendScrobble(profileId = profileId, action = "start", item = item, progressPercent = progressPercent) } - suspend fun scrobbleStop(item: TraktScrobbleItem, progressPercent: Float) { - sendScrobble(action = "stop", item = item, progressPercent = progressPercent) + suspend fun scrobbleStop(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) { + sendScrobble(profileId = profileId, action = "stop", item = item, progressPercent = progressPercent) } suspend fun buildItem( @@ -127,14 +127,16 @@ internal object TraktScrobbleRepository { } private suspend fun sendScrobble( + profileId: Int, action: String, item: TraktScrobbleItem, progressPercent: Float, ) { + if (ProfileRepository.activeProfileId != profileId) return val headers = TraktAuthRepository.authorizedHeaders() ?: return - val activeProfileId = ProfileRepository.activeProfileId + if (ProfileRepository.activeProfileId != profileId) return val clampedProgress = progressPercent.coerceIn(0f, 100f) - if (shouldSkip(activeProfileId, action, item.itemKey, clampedProgress)) return + if (shouldSkip(profileId, action, item.itemKey, clampedProgress)) return val url = "$BASE_URL/scrobble/$action" val requestBody = json.encodeToString(buildRequestBody(item, clampedProgress)) @@ -227,7 +229,7 @@ internal object TraktScrobbleRepository { if (!wasSent) return lastScrobbleStamp = ScrobbleStamp( - profileId = activeProfileId, + profileId = profileId, action = action, itemKey = item.itemKey, progress = clampedProgress, @@ -255,7 +257,7 @@ internal object TraktScrobbleRepository { ids = item.ids.toRequestBodyOrNull(), ), progress = clampedProgress, - appVersion = AppVersionPolicy.displayVersionName, + appVersion = AppVersionConfig.VERSION_NAME, ) is TraktScrobbleItem.Episode -> TraktScrobbleRequest( @@ -270,7 +272,7 @@ internal object TraktScrobbleRepository { number = item.number, ), progress = clampedProgress, - appVersion = AppVersionPolicy.displayVersionName, + appVersion = AppVersionConfig.VERSION_NAME, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 3010a162a..9d1fcdd91 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -59,6 +59,7 @@ object WatchedRepository { private var hasLoaded = false private var currentProfileId: Int = 1 + private var profileGeneration: Long = 0L private var itemsByKey: MutableMap = mutableMapOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L @@ -78,6 +79,7 @@ object WatchedRepository { fun clearLocalState() { hasLoaded = false currentProfileId = 1 + profileGeneration += 1L itemsByKey.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -87,6 +89,7 @@ object WatchedRepository { private fun loadFromDisk(profileId: Int) { currentProfileId = profileId + profileGeneration += 1L hasLoaded = true itemsByKey.clear() @@ -111,10 +114,26 @@ object WatchedRepository { publish() } + 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 + suspend fun pullFromServer(profileId: Int) { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() - currentProfileId = profileId + val operationGeneration = activeOperationGeneration(profileId) ?: run { + log.d { "Skipping watched pull for inactive profile $profileId" } + return + } val pullStartedEpochMs = WatchedClock.nowEpochMs() val localBeforePull = itemsByKey.values .map(WatchedItem::normalizedMarkedAt) @@ -129,6 +148,7 @@ object WatchedRepository { lastPushEpochMs = lastPushEpochMs, pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, + operationGeneration = operationGeneration, ) } else { pullSupabaseDeltaFromServer( @@ -136,6 +156,7 @@ object WatchedRepository { localBeforePull = localBeforePull, lastPushEpochMs = lastPushEpochMs, pullStartedEpochMs = pullStartedEpochMs, + operationGeneration = operationGeneration, ) } }.onFailure { e -> @@ -150,11 +171,13 @@ object WatchedRepository { lastPushEpochMs: Long, pullStartedEpochMs: Long, resetDeltaState: Boolean, + operationGeneration: Long, ) { val serverItems = adapter.pull( profileId = profileId, pageSize = watchedItemsPageSize, ) + if (!isActiveOperation(profileId, operationGeneration)) return itemsByKey = mergeWatchedItemsPreservingUnsynced( serverItems = serverItems, @@ -176,7 +199,9 @@ object WatchedRepository { localBeforePull: List, lastPushEpochMs: Long, pullStartedEpochMs: Long, + operationGeneration: Long, ) { + if (!isActiveOperation(profileId, operationGeneration)) return if (!deltaInitialized) { val cursorBeforeSnapshot = syncAdapter.getDeltaCursor(profileId) ?: return pullFullFromAdapter( @@ -186,7 +211,9 @@ object WatchedRepository { lastPushEpochMs = lastPushEpochMs, pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, + operationGeneration = operationGeneration, ) + if (!isActiveOperation(profileId, operationGeneration)) return deltaCursorEventId = cursorBeforeSnapshot deltaInitialized = true persist() @@ -202,6 +229,7 @@ object WatchedRepository { sinceEventId = cursor, limit = watchedItemsDeltaPageSize, ) + if (!isActiveOperation(profileId, operationGeneration)) return if (events.isEmpty()) break applyWatchedDeltaEvents( @@ -375,10 +403,10 @@ object WatchedRepository { items: Collection, traktHistorySync: WatchedTraktHistorySync, ) { + val profileId = currentProfileId syncScope.launch { runCatching { if (items.isEmpty()) return@runCatching - val profileId = ProfileRepository.activeProfileId val pushed = pushToActiveTargets( profileId = profileId, items = items, @@ -394,10 +422,10 @@ object WatchedRepository { } private fun pushDeleteToServer(items: Collection) { + val profileId = currentProfileId syncScope.launch { runCatching { if (items.isEmpty()) return@runCatching - val profileId = ProfileRepository.activeProfileId deleteFromActiveTargets(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched item delete" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index b1a681b59..13e2ebd55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import co.touchlab.kermit.Logger @Composable fun computeAirDateBadgeText( @@ -66,32 +67,46 @@ class ReleaseAlertState( val isNewSeasonRelease: Boolean, ) -private const val ReleaseAlertWindowMs = 60L * 24 * 60 * 60 * 1000 -private val NoReleaseAlertState = ReleaseAlertState(false, false) - fun calculateReleaseAlertState( seedLastUpdatedEpochMs: Long, seedSeasonNumber: Int?, nextSeasonNumber: Int?, releasedIso: String?, ): ReleaseAlertState { - if (releasedIso.isNullOrBlank()) return NoReleaseAlertState - val releaseEpoch = parseReleaseDateToEpochMs(releasedIso) - ?: return NoReleaseAlertState - val nowMs = WatchProgressClock.nowEpochMs() - if (nowMs < releaseEpoch) return NoReleaseAlertState - if (releaseEpoch <= seedLastUpdatedEpochMs) return NoReleaseAlertState - if (nowMs - releaseEpoch >= ReleaseAlertWindowMs) return NoReleaseAlertState - val isNewSeasonRelease = + val log = Logger.withTag("ReleaseAlert") + log.d { + "calculateReleaseAlertState inputs: releasedIso=$releasedIso, " + + "releaseEpoch=$releaseEpoch, seedLastUpdatedEpochMs=$seedLastUpdatedEpochMs, " + + "seedSeasonNumber=$seedSeasonNumber, nextSeasonNumber=$nextSeasonNumber, nowMs=$nowMs" + } + + if (releaseEpoch == null) { + log.d { "calculateReleaseAlertState failed: releaseEpoch is null" } + return ReleaseAlertState(false, false) + } + + val hasAired = nowMs >= releaseEpoch + val sixtyDaysMs = 60L * 24 * 60 * 60 * 1000 + val isReleaseAlert = hasAired && + releaseEpoch > seedLastUpdatedEpochMs && + (nowMs - releaseEpoch) < sixtyDaysMs + + val isNewSeasonRelease = isReleaseAlert && seedSeasonNumber != null && nextSeasonNumber != null && nextSeasonNumber != seedSeasonNumber + log.d { + "calculateReleaseAlertState result: isReleaseAlert=$isReleaseAlert (hasAired=$hasAired, " + + "epoch>seed=${releaseEpoch > seedLastUpdatedEpochMs}, ageMs=${nowMs - releaseEpoch}), " + + "isNewSeasonRelease=$isNewSeasonRelease" + } + return ReleaseAlertState( - isReleaseAlert = true, + isReleaseAlert = isReleaseAlert, isNewSeasonRelease = isNewSeasonRelease ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 00226f24f..0ba3b0c55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.core.storage.ProfileScopedKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -62,51 +65,62 @@ internal object ContinueWatchingEnrichmentCache { } private const val storageKey = "cw_enrichment_cache" - private var lastPayloadHash: Int? = null + private val lastPayloadHashByProfile = mutableMapOf() + private val _cacheCleared = MutableStateFlow(0) + val cacheCleared: StateFlow = _cacheCleared.asStateFlow() - fun getNextUpSnapshot(): List = - loadPayload()?.nextUp ?: emptyList() + fun getNextUpSnapshot(profileId: Int): List = + loadPayload(profileId)?.nextUp ?: emptyList() - fun getInProgressSnapshot(): List = - loadPayload()?.inProgress ?: emptyList() + fun getInProgressSnapshot(profileId: Int): List = + loadPayload(profileId)?.inProgress ?: emptyList() - fun getSnapshots(): Pair, List> { - val payload = loadPayload() + fun getSnapshots(profileId: Int): Pair, List> { + val payload = loadPayload(profileId) val nextUp = payload?.nextUp ?: emptyList() val inProgress = payload?.inProgress ?: emptyList() return nextUp to inProgress } fun saveSnapshots( + profileId: Int, nextUp: List, inProgress: List, force: Boolean = false, ) { val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress) val payloadHash = payload.hashCode() - if (!force && lastPayloadHash == payloadHash) { + if (!force && lastPayloadHashByProfile[profileId] == payloadHash) { return } val encoded = runCatching { json.encodeToString(payload) }.getOrNull() ?: return - ContinueWatchingEnrichmentStorage.savePayload(ProfileScopedKey.of(storageKey), encoded) - lastPayloadHash = payloadHash + ContinueWatchingEnrichmentStorage.savePayload(profileScopedStorageKey(profileId), encoded) + lastPayloadHashByProfile[profileId] = payloadHash } - fun clearAll() { - ContinueWatchingEnrichmentStorage.removePayload(ProfileScopedKey.of(storageKey)) - lastPayloadHash = null + fun clearAll(profileId: Int) { + ContinueWatchingEnrichmentStorage.removePayload(profileScopedStorageKey(profileId)) + lastPayloadHashByProfile.remove(profileId) + _cacheCleared.value += 1 } - private fun loadPayload(): CachedEnrichmentPayload? { - val raw = ContinueWatchingEnrichmentStorage.loadPayload(ProfileScopedKey.of(storageKey)) + fun onProfileChanged() { + _cacheCleared.value += 1 + } + + private fun loadPayload(profileId: Int): CachedEnrichmentPayload? { + val raw = ContinueWatchingEnrichmentStorage.loadPayload(profileScopedStorageKey(profileId)) ?: return null return runCatching { json.decodeFromString(raw) }.getOrNull()?.also { payload -> - lastPayloadHash = payload.hashCode() + lastPayloadHashByProfile[profileId] = payload.hashCode() } } + + private fun profileScopedStorageKey(profileId: Int): String = + ProfileScopedKey.of(storageKey, profileId) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 29c41fa36..069782373 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -131,6 +131,7 @@ data class WatchProgressUiState( } data class WatchProgressPlaybackSession( + val profileId: Int, val contentType: String, val parentMetaId: String, val parentMetaType: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index bc695a47f..4d510c137 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -83,6 +83,7 @@ object WatchProgressRepository { private var hasLoaded = false private var currentProfileId: Int = 1 + private var profileGeneration: Long = 0L private var entriesByVideoId: MutableMap = mutableMapOf() private var metadataResolutionJob: Job? = null private var isPullingNuvioSyncFromServer = false @@ -168,6 +169,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() hasLoaded = false currentProfileId = 1 + profileGeneration += 1L lastAddonMetadataReadyFingerprint = null entriesByVideoId.clear() lastSuccessfulPushEpochMs = 0L @@ -179,7 +181,9 @@ object WatchProgressRepository { } private fun loadFromDisk(profileId: Int) { + metadataResolutionJob?.cancel() currentProfileId = profileId + profileGeneration += 1L hasLoaded = true lastAddonMetadataReadyFingerprint = null entriesByVideoId.clear() @@ -206,11 +210,27 @@ object WatchProgressRepository { resolveRemoteMetadata() } + 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 + suspend fun pullFromServer(profileId: Int) { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() - currentProfileId = profileId + val operationGeneration = activeOperationGeneration(profileId) ?: run { + log.d { "Skipping watch progress pull for inactive profile $profileId" } + return + } val useTraktProgress = shouldUseTraktProgress() @@ -230,7 +250,9 @@ object WatchProgressRepository { if (e is CancellationException) throw e log.e(e) { "Failed to pull Trakt progress" } } - publish() + if (isActiveOperation(profileId, operationGeneration)) { + publish() + } return } @@ -239,6 +261,7 @@ object WatchProgressRepository { pullSupabaseDeltaFromServer( profileId = profileId, pullStartedEpochMs = WatchProgressClock.nowEpochMs(), + operationGeneration = operationGeneration, ) }.onFailure { e -> if (e is CancellationException) throw e @@ -251,10 +274,41 @@ object WatchProgressRepository { } } + suspend fun forceSnapshotRefreshFromServer(profileId: Int) { + ensureLoaded() + if (currentProfileId != profileId) { + loadFromDisk(profileId) + } + + if (shouldUseTraktProgress()) { + log.d { "Force refreshing Trakt watch progress for profile $profileId" } + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to force refresh Trakt progress" } + } + publish() + return + } + + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + log.d { "Skipping force watch progress refresh because Nuvio Sync is not authenticated" } + return + } + + deltaCursorEventId = 0L + deltaInitialized = false + persist() + pullFromServer(profileId) + } + private suspend fun pullSupabaseDeltaFromServer( profileId: Int, pullStartedEpochMs: Long, + operationGeneration: Long, ) { + if (!isActiveOperation(profileId, operationGeneration)) return log.d { "Watch progress delta sync start: profile=$profileId entries=${entriesByVideoId.size} " + "deltaInitialized=$deltaInitialized cursor=$deltaCursorEventId lastPush=$lastSuccessfulPushEpochMs" @@ -275,6 +329,7 @@ object WatchProgressRepository { profileId = profileId, pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, + operationGeneration = operationGeneration, ) return } @@ -284,7 +339,9 @@ object WatchProgressRepository { profileId = profileId, pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, + operationGeneration = operationGeneration, ) + if (!isActiveOperation(profileId, operationGeneration)) return deltaCursorEventId = cursorBeforeSnapshot deltaInitialized = true persist() @@ -318,9 +375,11 @@ object WatchProgressRepository { profileId = profileId, pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, + operationGeneration = operationGeneration, ) return } + if (!isActiveOperation(profileId, operationGeneration)) return if (events.isEmpty()) { log.d { "Watch progress delta page $page returned no events for profile $profileId at cursor $cursor" } break @@ -373,8 +432,10 @@ object WatchProgressRepository { profileId: Int, pullStartedEpochMs: Long, resetDeltaState: Boolean, + operationGeneration: Long, ) { val serverEntries = syncAdapter.pull(profileId = profileId) + if (!isActiveOperation(profileId, operationGeneration)) return log.d { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + "resetDeltaState=$resetDeltaState" @@ -538,6 +599,8 @@ object WatchProgressRepository { } private fun resolveRemoteMetadata() { + val targetProfileId = currentProfileId + val targetGeneration = profileGeneration val missingMetadataEntries = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } val entriesToResolve = missingMetadataEntries.continueWatchingEntries( @@ -551,6 +614,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch { val providerReadiness = awaitReadyMetadataProviders() ?: return@launch + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint val supportedNeedsResolution = needsResolution.filter { (key, _) -> @@ -575,6 +639,7 @@ object WatchProgressRepository { for (result in resolutionResults) { ensureActive() + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch val meta = result.meta if (meta == null) { continue @@ -609,8 +674,10 @@ object WatchProgressRepository { resolvedEntries += appliedEntries } if (resolvedEntries > 0) { - publish() - persist() + if (isActiveOperation(targetProfileId, targetGeneration)) { + publish() + persist() + } } } } @@ -797,6 +864,7 @@ object WatchProgressRepository { persist: Boolean, syncRemote: Boolean, ) { + val targetProfileId = session.profileId val positionMs = snapshot.positionMs.coerceAtLeast(0L) val durationMs = snapshot.durationMs.coerceAtLeast(0L) val isCompleted = isWatchProgressComplete( @@ -844,6 +912,16 @@ object WatchProgressRepository { isCompleted = isCompleted, ).normalizedCompletion() + if (targetProfileId != currentProfileId || ProfileRepository.activeProfileId != targetProfileId) { + if (persist) { + upsertStoredProfileProgress(profileId = targetProfileId, entry = entry) + } + if (syncRemote) { + pushScrobbleToServer(entry = entry, profileId = targetProfileId) + } + return + } + if (entry.parentMetaType.equals("series", ignoreCase = true)) { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } @@ -858,17 +936,36 @@ object WatchProgressRepository { resolveRemoteMetadata() } if (syncRemote) { - pushScrobbleToServer(entry) + pushScrobbleToServer(entry = entry, profileId = targetProfileId) } if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) { WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote) } } - private fun pushScrobbleToServer(entry: WatchProgressEntry) { + private fun upsertStoredProfileProgress(profileId: Int, entry: WatchProgressEntry) { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + val storedPayload = if (payload.isNotEmpty()) { + WatchProgressCodec.decodePayload(payload) + } else { + StoredWatchProgressPayload() + } + val updatedEntries = storedPayload.entries + .filterNot { it.videoId == entry.videoId } + entry + WatchProgressStorage.savePayload( + profileId, + WatchProgressCodec.encodePayload( + entries = updatedEntries, + lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs, + deltaCursorEventId = storedPayload.deltaCursorEventId, + deltaInitialized = storedPayload.deltaInitialized, + ), + ) + } + + private fun pushScrobbleToServer(entry: WatchProgressEntry, profileId: Int) { syncScope.launch { runCatching { - val profileId = ProfileRepository.activeProfileId syncAdapter.push(profileId = profileId, entries = listOf(entry)) recordSuccessfulPush(profileId = profileId, entries = listOf(entry)) }.onFailure { e -> @@ -879,10 +976,10 @@ object WatchProgressRepository { private fun pushDeleteToServer(entries: Collection) { if (shouldUseTraktProgress()) return + val profileId = currentProfileId syncScope.launch { runCatching { if (entries.isEmpty()) return@runCatching - val profileId = ProfileRepository.activeProfileId syncAdapter.delete(profileId = profileId, entries = entries) }.onFailure { e -> log.e(e) { "Failed to push watch progress delete" } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt new file mode 100644 index 000000000..7d25a08df --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt @@ -0,0 +1,81 @@ +package com.nuvio.app.features.catalog + +import com.nuvio.app.features.addons.AddonCatalog +import com.nuvio.app.features.addons.AddonExtraProperty +import com.nuvio.app.features.home.MetaPreview +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CatalogPaginationStateTest { + @Test + fun `pagination advances by returned raw item count`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 11, + page = page(rawItemCount = 17, nextSkip = 28), + loadedNewItems = true, + consecutiveDuplicatePages = 0, + ) + + assertEquals(28, state.nextSkip) + assertEquals(0, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination advances through duplicate page with returned next skip`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 45, + page = page(rawItemCount = 18, nextSkip = 63), + loadedNewItems = false, + consecutiveDuplicatePages = 0, + ) + + assertEquals(63, state.nextSkip) + assertEquals(1, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination stops after duplicate page limit`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 81, + page = page(rawItemCount = 18, nextSkip = 99), + loadedNewItems = false, + consecutiveDuplicatePages = 2, + ) + + assertNull(state.nextSkip) + assertEquals(3, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination support matches skip extra case insensitively`() { + val catalog = AddonCatalog( + type = "movie", + id = "popular", + name = "Popular", + extra = listOf(AddonExtraProperty(name = "Skip")), + ) + + assertTrue(catalog.supportsPagination()) + } + + private fun page( + rawItemCount: Int, + nextSkip: Int?, + ): CatalogPage = + CatalogPage( + items = listOf( + MetaPreview( + id = "tt$rawItemCount", + type = "movie", + name = "Movie $rawItemCount", + ), + ), + rawItemCount = rawItemCount, + nextSkip = nextSkip, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt new file mode 100644 index 000000000..b772f791a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DebridMagnetBuilderTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + + @Test + fun `builds well-formed magnet from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `builds magnet from dedicated infoHash field`() { + val stream = stream(infoHash = hexHash) + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `passes existing magnet url through unchanged`() { + val magnet = "magnet:?xt=urn:btih:$hexHash&dn=Test" + val stream = stream(url = magnet) + assertEquals(magnet, DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for torrent-null sentinel url`() { + val stream = stream(url = "torrent://null") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for plain http stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + private fun stream( + url: String? = null, + infoHash: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + addonName = "TestAddon", + addonId = "test.addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index a70a26274..faff7f847 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -37,7 +37,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(1, action.seasonNumber) assertEquals(3, action.episodeNumber) @@ -86,7 +86,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) } @@ -124,7 +124,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next • S4E15", action.label) + assertEquals("Next Up • S4E15", action.label) assertEquals("tmdb:98765:4:15", action.videoId) assertEquals(4, action.seasonNumber) assertEquals(15, action.episodeNumber) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt new file mode 100644 index 000000000..03fa958fd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.catalog.CatalogTarget +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HomeCatalogSectionTest { + @Test + fun `can open catalog when page has more below preview limit`() { + val section = section( + availableItemCount = 11, + hasMore = true, + ) + + assertTrue(section.canOpenCatalog(previewLimit = 18)) + } + + @Test + fun `can open catalog when non paginated section exceeds preview limit`() { + val section = section( + availableItemCount = 19, + hasMore = false, + ) + + assertTrue(section.canOpenCatalog(previewLimit = 18)) + } + + @Test + fun `cannot open catalog when section fits preview and has no more pages`() { + val section = section( + availableItemCount = 11, + hasMore = false, + ) + + assertFalse(section.canOpenCatalog(previewLimit = 18)) + } + + private fun section( + availableItemCount: Int, + hasMore: Boolean, + ): HomeCatalogSection = + HomeCatalogSection( + key = "addon:movie:popular", + title = "Popular", + subtitle = "Addon", + addonName = "Addon", + target = CatalogTarget.Addon( + manifestUrl = "https://example.com/manifest.json", + contentType = "movie", + catalogId = "popular", + supportsPagination = hasMore, + ), + items = listOf( + MetaPreview( + id = "tt1", + type = "movie", + name = "Movie", + ), + ), + availableItemCount = availableItemCount, + hasMore = hasMore, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 2ebf8447c..1f189673c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -23,6 +24,18 @@ class HomeScreenTest { assertEquals(32, HomeNextUpInitialResolutionLimit) } + @Test + fun `home next up resolution keeps candidates beyond initial limit for background resolution`() { + val candidates = (1..35).map { index -> completedSeriesCandidate(index) } + + val plan = planHomeNextUpResolutionCandidates(candidates) + + assertEquals(HomeNextUpInitialResolutionLimit, plan.initialCandidates.size) + assertEquals(3, plan.deferredCandidates.size) + assertEquals("show-1", plan.initialCandidates.first().content.id) + assertEquals("show-33", plan.deferredCandidates.first().content.id) + } + @Test fun `build home continue watching items removes duplicate video ids`() { val inProgress = progressEntry( @@ -33,7 +46,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "tt0944947:1:4", - subtitle = "Up Next • S1E4 • Cripples, Bastards, and Broken Things", + subtitle = "Next Up • S1E4 • Cripples, Bastards, and Broken Things", ) val movie = progressEntry( videoId = "movie-1", @@ -64,7 +77,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "show:1:5", - subtitle = "Up Next • S1E5 • The Wolf and the Lion", + subtitle = "Next Up • S1E5 • The Wolf and the Lion", ) val result = buildHomeContinueWatchingItems( @@ -76,6 +89,33 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `build home continue watching items keeps deferred next up items with metadata`() { + val nextUpItems = (1..35).associate { index -> + val id = "show-$index" + val item = continueWatchingItem( + videoId = "$id:1:$index", + subtitle = "Next Up • S1E$index", + imageUrl = "https://example.test/$id.jpg", + logo = "https://example.test/$id-logo.png", + episodeThumbnail = "https://example.test/$id-thumb.jpg", + ) + + id to ((10_000L - index) to item) + } + + val result = buildHomeContinueWatchingItems( + visibleEntries = emptyList(), + nextUpItemsBySeries = nextUpItems, + ) + val deferredItem = result.first { item -> item.parentMetaId == "show-33" } + + assertEquals(35, result.size) + assertEquals("https://example.test/show-33.jpg", deferredItem.imageUrl) + assertEquals("https://example.test/show-33-logo.png", deferredItem.logo) + assertEquals("https://example.test/show-33-thumb.jpg", deferredItem.episodeThumbnail) + } + @Test fun `build home continue watching items suppresses next up when series has in progress resume`() { val inProgress = progressEntry( @@ -87,7 +127,7 @@ class HomeScreenTest { ) val nextUp = continueWatchingItem( videoId = "show:1:5", - subtitle = "Up Next • S1E5 • Next", + subtitle = "Next Up • S1E5 • Next", ) val result = buildHomeContinueWatchingItems( @@ -138,6 +178,45 @@ class HomeScreenTest { assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) } + @Test + fun `build home continue watching items preserves cached in progress artwork fallback`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ) + val cached = ContinueWatchingItem( + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:4", + title = "Cached Show", + subtitle = "S1E4", + imageUrl = "https://example.test/cached.jpg", + logo = "https://example.test/logo.png", + poster = "https://example.test/poster.jpg", + background = "https://example.test/backdrop.jpg", + seasonNumber = 1, + episodeNumber = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + isNextUp = false, + resumePositionMs = 120_000L, + durationMs = 1_000_000L, + progressFraction = 0.12f, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + cachedInProgressByVideoId = mapOf(progress.videoId to cached), + nextUpItemsBySeries = emptyMap(), + ) + + assertEquals("https://example.test/cached.jpg", result.single().imageUrl) + assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( @@ -264,7 +343,7 @@ class HomeScreenTest { fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( videoId = "show:4:11", - subtitle = "Up Next • S4E11", + subtitle = "Next Up • S4E11", seedSeasonNumber = 4, seedEpisodeNumber = 10, ) @@ -310,6 +389,9 @@ class HomeScreenTest { episodeNumber: Int? = 4, seedSeasonNumber: Int? = seasonNumber, seedEpisodeNumber: Int? = episodeNumber, + imageUrl: String? = null, + logo: String? = null, + episodeThumbnail: String? = null, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -317,10 +399,12 @@ class HomeScreenTest { videoId = videoId, title = "Show", subtitle = subtitle, - imageUrl = null, + imageUrl = imageUrl, + logo = logo, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), + episodeThumbnail = episodeThumbnail, isNextUp = true, nextUpSeedSeasonNumber = seedSeasonNumber, nextUpSeedEpisodeNumber = seedEpisodeNumber, @@ -344,6 +428,14 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = + CompletedSeriesCandidate( + content = WatchingContentRef(type = "series", id = "show-$index"), + seasonNumber = 1, + episodeNumber = index, + markedAtEpochMs = 10_000L - index, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt index dc00ef0b2..349a57e97 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.home +import com.nuvio.app.features.catalog.CatalogTarget import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -42,9 +43,11 @@ class ReleaseInfoUtilsTest { title = "Popular", subtitle = "Addon", addonName = "Addon", - type = "movie", - manifestUrl = "https://example.com/manifest.json", - catalogId = "popular", + target = CatalogTarget.Addon( + manifestUrl = "https://example.com/manifest.json", + contentType = "movie", + catalogId = "popular", + ), items = listOf( preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"), preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt index 4128f45cd..8784d5cc1 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt @@ -9,6 +9,7 @@ class PlayerLaunchStoreTest { @Test fun storesAndRemovesLaunchesById() { val launch = PlayerLaunch( + profileId = 1, title = "Title", sourceUrl = "https://example.com/video.m3u8?token=a/b:c", streamTitle = "Source", @@ -25,4 +26,4 @@ class PlayerLaunchStoreTest { assertNull(PlayerLaunchStore.get(launchId)) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt new file mode 100644 index 000000000..d0241a33a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt @@ -0,0 +1,289 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StreamModelsTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + private val base32Hash = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + // ----------------------------------------------------------------------- + // isTorrentStream + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url in url field is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with fileIdx path is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash/0") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url is detected case-insensitively`() { + val stream = stream(url = "TORRENT://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url in externalUrl is detected as torrent stream`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = " torrent://$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = "\nmagnet:?xt=urn:btih:$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent url sentinel torrent-null-string is still detected as torrent scheme`() { + val stream = stream(url = "torrent://null") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `plain http url is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertFalse(stream.isTorrentStream) + } + + @Test + fun `plain http url with torrent in path is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/torrent/download.mp4") + assertFalse(stream.isTorrentStream) + } + + // ----------------------------------------------------------------------- + // playableDirectUrl — torrent:// and magnet: are never surfaced + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url yields null playableDirectUrl`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent scheme url in externalUrl yields null playableDirectUrl`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url yields null playableDirectUrl`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `plain http url is surfaced as playableDirectUrl`() { + val url = "https://cdn.example.com/video.mp4" + val stream = stream(url = url) + assertEquals(url, stream.playableDirectUrl) + } + + @Test + fun `torrent url falls through to http externalUrl`() { + val httpUrl = "https://cdn.example.com/video.mp4" + val stream = stream( + url = "torrent://$hexHash", + externalUrl = httpUrl, + ) + assertEquals(httpUrl, stream.playableDirectUrl) + } + + // ----------------------------------------------------------------------- + // p2pInfoHash extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pInfoHash extracts hex hash from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from torrent scheme url`() { + val stream = stream(url = "torrent://$base32Hash") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts uppercase hex hash from uppercase scheme`() { + val upperHash = hexHash.uppercase() + val stream = stream(url = "TORRENT://$upperHash") + assertEquals(upperHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at fileIdx path segment`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at query separator`() { + val stream = stream(url = "torrent://$hexHash?index=2") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts hex hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash&dn=Test") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$base32Hash&dn=Test") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `dedicated infoHash field wins over different hash in torrent url`() { + val dedicated = "fedcba9876543210fedcba9876543210fedcba98" + val stream = stream( + url = "torrent://$hexHash", + infoHash = dedicated, + ) + assertEquals(dedicated, stream.p2pInfoHash) + } + + @Test + fun `torrent-null sentinel yields null p2pInfoHash`() { + val stream = stream(url = "torrent://null") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `torrent url with invalid hash length yields null p2pInfoHash`() { + val stream = stream(url = "torrent://abcdef123456") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `plain http url yields null p2pInfoHash`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // p2pFileIdx extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pFileIdx extracts trailing index segment from torrent url`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(3, stream.p2pFileIdx) + } + + @Test + fun `dedicated fileIdx field wins over torrent url segment`() { + val stream = stream(url = "torrent://$hexHash/3", fileIdx = 7) + assertEquals(7, stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null without a path segment`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null for non-numeric path segment`() { + val stream = stream(url = "torrent://$hexHash/name.mkv") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx ignores query parameters`() { + val stream = stream(url = "torrent://$hexHash/2?foo=bar") + assertEquals(2, stream.p2pFileIdx) + } + + // ----------------------------------------------------------------------- + // Parser integration — torrent:// in JSON url field + // ----------------------------------------------------------------------- + + @Test + fun `parser preserves torrent scheme url and stream is correctly classified`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "torrent://$hexHash", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `parser keeps normal http stream playable and not torrent`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "https://cdn.example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertFalse(stream.isTorrentStream) + assertEquals("https://cdn.example.com/video.mp4", stream.playableDirectUrl) + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private fun stream( + url: String? = null, + infoHash: String? = null, + fileIdx: Int? = null, + externalUrl: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + fileIdx = fileIdx, + externalUrl = externalUrl, + addonName = "TestAddon", + addonId = "test.addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 41519dac4..d6ddf28de 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -171,4 +171,78 @@ class StreamParserTest { assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) } + + @Test + fun `parse keeps addon-declared streamType`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://cdn.example.com/playlist?token=abc", + "name": "1080p", + "type": "hls" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals("hls", streams.single().streamType) + } + + @Test + fun `parse normalizes streamType casing and whitespace`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://cdn.example.com/playlist?token=abc", + "name": "1080p", + "type": " HLS " + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals("hls", streams.single().streamType) + } + + @Test + fun `normalizeStreamType trims lowercases and blanks to null`() { + assertEquals("hls", normalizeStreamType(" Hls ")) + assertEquals("dash", normalizeStreamType("DASH")) + assertEquals(null, normalizeStreamType(" ")) + assertEquals(null, normalizeStreamType("")) + assertEquals(null, normalizeStreamType(null)) + } + + @Test + fun `parse leaves streamType null when addon omits it`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals(null, streams.single().streamType) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index 5aab3131e..eda075e4e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -39,7 +39,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next • S1E3", action.label) + assertEquals("Next Up • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(3, action.episodeNumber) } @@ -166,7 +166,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next • S2E2", action.label) + assertEquals("Next Up • S2E2", action.label) assertEquals("show:2:2", action.videoId) } } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt deleted file mode 100644 index 04cded013..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.nuvio.app - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import androidx.compose.ui.unit.dp -import com.nuvio.app.features.player.PlatformPlayerSurface -import com.nuvio.app.features.player.desktop.applyNativeDesktopWindowChrome -import com.nuvio.app.features.player.desktop.installDesktopAppFullscreenShortcuts -import com.nuvio.app.features.player.desktop.preloadNativePlayerBridgeAsync -import com.nuvio.app.features.player.desktop.registerDesktopAppFullscreenToggle -import java.awt.Color as AwtColor -import javax.swing.JComponent - -private val NuvioDesktopNativeBackground = AwtColor(0x0D, 0x0D, 0x0D) -private const val NuvioDesktopIconPath = "icons/nuvio-app-icon.png" -private const val MacosDarkAquaAppearance = "NSAppearanceNameDarkAqua" - -fun main() { - configureDesktopChrome() - preloadNativePlayerBridgeAsync() - - application { - val smokePlayerUrl = ( - System.getProperty("nuvio.desktop.smokePlayerUrl") - ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") - ) - ?.takeIf { it.isNotBlank() } - val windowState = rememberWindowState(width = 1280.dp, height = 820.dp) - val restoreWindowPlacement = remember { mutableStateOf(WindowPlacement.Floating) } - - Window( - onCloseRequest = ::exitApplication, - title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke", - state = windowState, - icon = painterResource(NuvioDesktopIconPath), - ) { - SideEffect { - window.background = NuvioDesktopNativeBackground - window.rootPane.background = NuvioDesktopNativeBackground - window.contentPane.background = NuvioDesktopNativeBackground - (window.contentPane as? JComponent)?.isOpaque = true - } - LaunchedEffect(window) { - applyNativeDesktopWindowChrome(window) - } - DisposableEffect(window, windowState) { - val unregisterFullscreenToggle = registerDesktopAppFullscreenToggle { targetWindow -> - if (targetWindow != null && targetWindow !== window) return@registerDesktopAppFullscreenToggle - if (windowState.placement == WindowPlacement.Fullscreen) { - windowState.placement = restoreWindowPlacement.value - } else { - restoreWindowPlacement.value = windowState.placement - .takeUnless { it == WindowPlacement.Fullscreen } - ?: WindowPlacement.Floating - windowState.placement = WindowPlacement.Fullscreen - } - } - val uninstallFullscreenShortcuts = installDesktopAppFullscreenShortcuts(window) - onDispose { - uninstallFullscreenShortcuts() - unregisterFullscreenToggle() - } - } - - if (smokePlayerUrl == null) { - App() - } else { - PlatformPlayerSurface( - sourceUrl = smokePlayerUrl, - modifier = Modifier.fillMaxSize(), - onControllerReady = {}, - onSnapshot = {}, - onError = {}, - ) - } - } - } -} - -private fun configureDesktopChrome() { - if (System.getProperty("os.name").contains("mac", ignoreCase = true)) { - System.setProperty("apple.awt.application.appearance", MacosDarkAquaAppearance) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt deleted file mode 100644 index 79fd9ee71..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.nuvio.app - -class DesktopPlatform : Platform { - override val name: String = "Desktop ${System.getProperty("os.name").orEmpty()}".trim() -} - -actual fun getPlatform(): Platform = DesktopPlatform() - -internal actual val isIos: Boolean = false -internal actual val isDesktop: Boolean = true diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt deleted file mode 100644 index c0dc1da44..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.core.auth - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object AuthStorage { - private val store = DesktopStorage.store("nuvio_auth") - - actual fun loadAnonymousUserId(): String? = - store.getString("anonymous_user_id") - - actual fun saveAnonymousUserId(userId: String) { - store.putString("anonymous_user_id", userId) - } - - actual fun clearAnonymousUserId() { - store.remove("anonymous_user_id") - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index a989c77db..2dda9682e 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -1,9 +1,7 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { - actual val pluginsEnabled: Boolean = true - actual val downloadsEnabled: Boolean = false - actual val notificationsEnabled: Boolean = false + actual val pluginsEnabled: Boolean = false actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt deleted file mode 100644 index 88ada6335..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.build - -actual object AppVersionPolicy { - actual val displayVersionName: String = AppVersionConfig.DESKTOP_VERSION_NAME - actual val displayVersionCode: Int = AppVersionConfig.DESKTOP_VERSION_CODE - actual val basedOnVersionName: String? = AppVersionConfig.VERSION_NAME.takeIf { it != displayVersionName } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt deleted file mode 100644 index ea0a4eebe..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.nuvio.app.core.storage - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.Comparator -import java.util.Locale -import java.util.Properties -import kotlin.io.path.exists - -internal object DesktopStorage { - private val json = Json { ignoreUnknownKeys = true } - private val stores = mutableMapOf() - - val rootDir: Path by lazy { - resolveAppDataDir().also { Files.createDirectories(it) } - } - - fun store(name: String): Store = synchronized(stores) { - stores.getOrPut(name) { Store(rootDir.resolve("$name.properties")) } - } - - fun wipe() { - synchronized(stores) { - stores.values.forEach(Store::clearInMemory) - stores.clear() - } - if (!rootDir.exists()) return - Files.walk(rootDir).use { stream -> - stream - .sorted(Comparator.reverseOrder()) - .filter { it != rootDir } - .forEach { path -> runCatching { Files.deleteIfExists(path) } } - } - } - - private fun resolveAppDataDir(): Path { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - val userHome = Paths.get(System.getProperty("user.home").orEmpty()) - return when { - osName.contains("mac") -> userHome.resolve("Library/Application Support/Nuvio") - osName.contains("win") -> { - val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } - (appData?.let(Paths::get) ?: userHome.resolve("AppData/Roaming")).resolve("Nuvio") - } - else -> { - val xdgConfig = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() } - (xdgConfig?.let(Paths::get) ?: userHome.resolve(".config")).resolve("nuvio") - } - } - } - - internal class Store( - private val file: Path, - ) { - private val lock = Any() - private val properties = Properties() - private var loaded = false - - fun contains(key: String): Boolean = synchronized(lock) { - ensureLoaded() - properties.containsKey(key) - } - - fun getString(key: String): String? = synchronized(lock) { - ensureLoaded() - properties.getProperty(key) - } - - fun putString(key: String, value: String?) = synchronized(lock) { - ensureLoaded() - if (value == null) { - properties.remove(key) - } else { - properties.setProperty(key, value) - } - persist() - } - - fun getBoolean(key: String): Boolean? = - getString(key)?.toBooleanStrictOrNull() - - fun putBoolean(key: String, value: Boolean) { - putString(key, value.toString()) - } - - fun getInt(key: String): Int? = - getString(key)?.toIntOrNull() - - fun putInt(key: String, value: Int) { - putString(key, value.toString()) - } - - fun getFloat(key: String): Float? = - getString(key)?.toFloatOrNull() - - fun putFloat(key: String, value: Float) { - putString(key, value.toString()) - } - - fun getStringSet(key: String): Set? = - getString(key)?.let { payload -> - runCatching { json.decodeFromString>(payload).toSet() }.getOrNull() - } - - fun putStringSet(key: String, values: Set) { - putString(key, json.encodeToString(values.toList())) - } - - fun remove(key: String) = synchronized(lock) { - ensureLoaded() - properties.remove(key) - persist() - } - - fun removeAll(keys: Iterable) = synchronized(lock) { - ensureLoaded() - keys.forEach(properties::remove) - persist() - } - - fun clearInMemory() = synchronized(lock) { - properties.clear() - loaded = false - } - - private fun ensureLoaded() { - if (loaded) return - loaded = true - properties.clear() - if (!file.exists()) return - runCatching { - Files.newInputStream(file).use { input -> - properties.load(input) - } - } - } - - private fun persist() { - Files.createDirectories(file.parent) - Files.newOutputStream(file).use { output -> - properties.store(output, "Nuvio desktop preferences") - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt deleted file mode 100644 index b7b0305d7..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.storage - -internal actual object PlatformLocalAccountDataCleaner { - actual fun wipe() { - DesktopStorage.wipe() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt deleted file mode 100644 index e0c469fc6..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.nuvio.app.core.sync - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow - -internal actual object AppForegroundMonitor { - actual fun events(): Flow = emptyFlow() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt deleted file mode 100644 index 86ad57cdf..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.ic_player_aspect_ratio -import nuvio.composeapp.generated.resources.ic_player_audio_filled -import nuvio.composeapp.generated.resources.ic_player_pause -import nuvio.composeapp.generated.resources.ic_player_play -import nuvio.composeapp.generated.resources.ic_player_subtitles -import nuvio.composeapp.generated.resources.library_add_plus -import org.jetbrains.compose.resources.painterResource - -@Composable -actual fun appIconPainter(icon: AppIconResource): Painter = - painterResource( - when (icon) { - AppIconResource.PlayerPlay -> Res.drawable.ic_player_play - AppIconResource.PlayerPause -> Res.drawable.ic_player_pause - AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio - AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles - AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled - AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt deleted file mode 100644 index c7c556c50..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.core.ui - -internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false - -internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit - -internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit - -internal actual fun publishNativeSelectedTab(tabName: String) = Unit - -internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit - -internal actual fun publishNativeProfileTabIcon( - name: String?, - avatarColorHex: String?, - avatarImageUrl: String?, - avatarBackgroundColorHex: String?, -) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt deleted file mode 100644 index 24d3d73da..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt +++ /dev/null @@ -1,281 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.DefaultAlpha -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.graphics.asSkiaBitmap -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import coil3.BitmapImage -import coil3.Image -import coil3.PlatformContext -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.NullRequestDataException -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.FilterMipmap -import org.jetbrains.skia.FilterMode -import org.jetbrains.skia.Image as SkiaImage -import org.jetbrains.skia.MipmapMode -import kotlin.math.max -import kotlin.math.roundToInt - -private const val MinCustomDownscaleRatio = 1.08f -private const val MaxDesktopSourceSizePx = 1536 -private const val MaxScaledBitmapPixels = 1_250_000L - -private val IsWindowsDesktop: Boolean = - System.getProperty("os.name") - ?.startsWith("Windows", ignoreCase = true) - ?: false - -@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, -) { - val context = LocalPlatformContext.current - val effectiveDesktopImageScaling = remember(desktopImageScaling) { - if (IsWindowsDesktop) desktopImageScaling else NuvioDesktopImageScaling.Disabled - } - val requestModel = remember(context, model, effectiveDesktopImageScaling) { - if (effectiveDesktopImageScaling == NuvioDesktopImageScaling.Disabled) { - model - } else { - model.withDesktopHighQualitySize(context) - } - } - val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember( - placeholder, - error, - fallback, - effectiveDesktopImageScaling, - ) { - { state -> - when (state) { - is AsyncImagePainter.State.Loading -> { - placeholder?.let { state.copy(painter = it) } ?: state - } - is AsyncImagePainter.State.Success -> { - state.result.image.toScaledBitmapPainter(effectiveDesktopImageScaling) - ?.let { state.copy(painter = it) } - ?: state - } - is AsyncImagePainter.State.Error -> { - val fallbackPainter = if (state.result.throwable is NullRequestDataException) { - fallback ?: error - } else { - error - } - fallbackPainter?.let { state.copy(painter = it) } ?: state - } - AsyncImagePainter.State.Empty -> state - } - } - } - val onState: ((AsyncImagePainter.State) -> Unit)? = remember(onLoading, onSuccess, onError) { - if (onLoading == null && onSuccess == null && onError == null) { - null - } else { - { state -> - when (state) { - is AsyncImagePainter.State.Loading -> onLoading?.invoke(state) - is AsyncImagePainter.State.Success -> onSuccess?.invoke(state) - is AsyncImagePainter.State.Error -> onError?.invoke(state) - AsyncImagePainter.State.Empty -> Unit - } - } - } - } - - AsyncImage( - model = requestModel, - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality ?: FilterQuality.High, - clipToBounds = clipToBounds, - ) -} - -private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? { - if (this == null) return null - - return if (this is ImageRequest) { - newBuilder() - .size(MaxDesktopSourceSizePx) - .build() - } else { - ImageRequest.Builder(context) - .data(this) - .size(MaxDesktopSourceSizePx) - .build() - } -} - -private fun Image.toScaledBitmapPainter(desktopImageScaling: NuvioDesktopImageScaling): Painter? { - if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) return null - - return (this as? BitmapImage) - ?.bitmap - ?.asComposeImageBitmap() - ?.let { imageBitmap -> ScaledBitmapPainter(imageBitmap) } -} - -private class ScaledBitmapPainter( - private val image: ImageBitmap, -) : Painter() { - private var cachedSize: IntSize? = null - private var cachedBitmap: ImageBitmap? = null - private var alpha: Float = DefaultAlpha - private var colorFilter: ColorFilter? = null - - override val intrinsicSize: Size = - Size(image.width.toFloat(), image.height.toFloat()) - - override fun DrawScope.onDraw() { - val drawSize = IntSize( - width = size.width.roundToInt().coerceAtLeast(1), - height = size.height.roundToInt().coerceAtLeast(1), - ) - if (!shouldUseScaledBitmap(drawSize)) { - drawSource(drawSize) - return - } - - val cacheSize = drawSize.cacheSize() - if (cacheSize.pixelCount() > MaxScaledBitmapPixels) { - drawSource(drawSize) - return - } - - val bitmap = scaledBitmap(cacheSize) - - drawImage( - image = bitmap, - srcOffset = IntOffset.Zero, - srcSize = cacheSize, - dstOffset = IntOffset.Zero, - dstSize = drawSize, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium, - ) - } - - override fun applyAlpha(alpha: Float): Boolean { - this.alpha = alpha - return true - } - - override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { - this.colorFilter = colorFilter - return true - } - - private fun scaledBitmap(size: IntSize): ImageBitmap { - cachedBitmap?.let { bitmap -> - if (cachedSize == size) return bitmap - } - return image.scale(size.width, size.height).also { bitmap -> - cachedSize = size - cachedBitmap = bitmap - } - } - - private fun DrawScope.drawSource(drawSize: IntSize) { - drawImage( - image = image, - srcOffset = IntOffset.Zero, - srcSize = IntSize(image.width, image.height), - dstOffset = IntOffset.Zero, - dstSize = drawSize, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = FilterQuality.High, - ) - } - - private fun shouldUseScaledBitmap(drawSize: IntSize): Boolean { - if (drawSize.pixelCount() > MaxScaledBitmapPixels) return false - - val widthScale = image.width.toFloat() / drawSize.width.toFloat() - val heightScale = image.height.toFloat() / drawSize.height.toFloat() - return max(widthScale, heightScale) >= MinCustomDownscaleRatio - } - - private fun IntSize.cacheSize(): IntSize { - val quantum = cacheQuantum() - return IntSize( - width = width.roundUpTo(quantum), - height = height.roundUpTo(quantum), - ) - } - - private fun IntSize.cacheQuantum(): Int { - val longestSide = max(width, height) - return when { - longestSide >= 1200 -> 16 - longestSide >= 600 -> 8 - longestSide >= 200 -> 4 - else -> 2 - } - } - - private fun Int.roundUpTo(quantum: Int): Int = - ((this + quantum - 1) / quantum) * quantum - - private fun IntSize.pixelCount(): Long = - width.toLong() * height.toLong() - - private fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap { - val image = SkiaImage.makeFromBitmap(asSkiaBitmap()) - return try { - image.scale(width, height) - } finally { - image.close() - } - } - - private fun SkiaImage.scale(width: Int, height: Int): ImageBitmap { - val bitmap = Bitmap() - bitmap.allocN32Pixels(width, height) - scalePixels( - bitmap.peekPixels()!!, - FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR), - false, - ) - return bitmap.asComposeImageBitmap() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt deleted file mode 100644 index 5fdd59604..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp -internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp -internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp - -@Composable -internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0.dp) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt deleted file mode 100644 index 6434bd49e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.runtime.Composable - -@Composable -actual fun PlatformBackHandler( - enabled: Boolean, - onBack: () -> Unit, -) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt deleted file mode 100644 index d96114b5a..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.core.ui - -import kotlin.system.exitProcess - -actual fun platformExitApp() { - exitProcess(0) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt deleted file mode 100644 index 6734e5fd1..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import coil3.ImageLoader - -internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt deleted file mode 100644 index 687ee7986..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.core.ui - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object PosterCardStyleStorage { - private val store = DesktopStorage.store("nuvio_poster_card_style") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("poster_card_style")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("poster_card_style"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt deleted file mode 100644 index 4b20c3068..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput - -@OptIn(ExperimentalComposeUiApi::class) -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier { - if (onClick == null) return this - - return pointerInput(onClick) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) { - event.changes.forEach { change -> change.consume() } - onClick() - } - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt deleted file mode 100644 index bff3bf30f..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.nuvio.app.features.addons - -import com.nuvio.app.core.storage.DesktopStorage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration - -internal actual object AddonStorage { - private val store = DesktopStorage.store("nuvio_addons") - private val json = Json { ignoreUnknownKeys = true } - - actual fun loadInstalledAddonUrls(profileId: Int): List = - store.getString("installed_addon_urls_$profileId") - ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } - ?: emptyList() - - actual fun saveInstalledAddonUrls(profileId: Int, urls: List) { - store.putString("installed_addon_urls_$profileId", json.encodeToString(urls)) - } - - actual fun loadAddonEnabledStates(profileId: Int): Map = - store.getString("addon_enabled_states_$profileId") - ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } - ?: emptyMap() - - actual fun saveAddonEnabledStates(profileId: Int, states: Map) { - store.putString("addon_enabled_states_$profileId", json.encodeToString(states)) - } -} - -private val desktopHttpClient: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - -actual suspend fun httpGetText(url: String): String = - httpGetTextWithHeaders(url, emptyMap()) - -actual suspend fun httpPostJson(url: String, body: String): String = - httpPostJsonWithHeaders(url, body, emptyMap()) - -actual suspend fun httpGetTextWithHeaders( - url: String, - headers: Map, -): String = - httpRequestRaw("GET", url, headers, body = "").body - -actual suspend fun httpPostJsonWithHeaders( - url: String, - body: String, - headers: Map, -): String = - httpRequestRaw( - method = "POST", - url = url, - headers = mapOf("Content-Type" to "application/json") + headers, - body = body, - ).body - -actual suspend fun httpRequestRaw( - method: String, - url: String, - headers: Map, - body: String, - followRedirects: Boolean, -): RawHttpResponse = withContext(Dispatchers.IO) { - val client = if (followRedirects) { - desktopHttpClient - } else { - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.NEVER) - .build() - } - val normalizedMethod = method.trim().uppercase().ifBlank { "GET" } - val requestBuilder = HttpRequest.newBuilder() - .uri(URI(url)) - .timeout(Duration.ofSeconds(60)) - .method( - normalizedMethod, - if (normalizedMethod == "GET" || normalizedMethod == "HEAD") { - HttpRequest.BodyPublishers.noBody() - } else { - HttpRequest.BodyPublishers.ofString(body) - }, - ) - - headers.forEach { (key, value) -> - if (key.isNotBlank() && value.isNotBlank()) { - requestBuilder.header(key, value) - } - } - - val response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - RawHttpResponse( - status = response.statusCode(), - statusText = "HTTP ${response.statusCode()}", - url = response.uri().toString(), - body = response.body(), - headers = response.headers().map().mapValues { (_, values) -> values.joinToString(",") }, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt deleted file mode 100644 index 8598fa626..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.collection - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object CollectionMobileSettingsStorage { - private val store = DesktopStorage.store("nuvio_collection_mobile_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("collection_mobile_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("collection_mobile_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt deleted file mode 100644 index 3bd8bb6ce..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.collection - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object CollectionStorage { - private val store = DesktopStorage.store("nuvio_collections") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("collections")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("collections"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt deleted file mode 100644 index d47c69b42..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncInt -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncInt -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object DebridSettingsStorage { - private const val enabledKey = "debrid_enabled" - private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled" - private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" - private const val torboxApiKeyKey = "debrid_torbox_api_key" - private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" - private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" - private const val streamMaxResultsKey = "debrid_stream_max_results" - private const val streamSortModeKey = "debrid_stream_sort_mode" - private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" - private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" - private const val streamHdrFilterKey = "debrid_stream_hdr_filter" - private const val streamCodecFilterKey = "debrid_stream_codec_filter" - private const val streamPreferencesKey = "debrid_stream_preferences" - private const val streamNameTemplateKey = "debrid_stream_name_template" - private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private val store = DesktopStorage.store("nuvio_debrid_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) - actual fun saveCloudLibraryEnabled(enabled: Boolean) = saveBoolean(cloudLibraryEnabledKey, enabled) - actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) - actual fun savePreferredResolverProviderId(providerId: String) = saveString(preferredResolverProviderIdKey, providerId) - actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) - actual fun saveProviderApiKey(providerId: String, apiKey: String) = saveString(providerApiKeyKey(providerId), apiKey) - actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) - actual fun saveTorboxApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) - actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) - actual fun saveRealDebridApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey) - actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) - actual fun saveInstantPlaybackPreparationLimit(limit: Int) = saveInt(instantPlaybackPreparationLimitKey, limit) - actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) - actual fun saveStreamMaxResults(maxResults: Int) = saveInt(streamMaxResultsKey, maxResults) - actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) - actual fun saveStreamSortMode(mode: String) = saveString(streamSortModeKey, mode) - actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) - actual fun saveStreamMinimumQuality(quality: String) = saveString(streamMinimumQualityKey, quality) - actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) - actual fun saveStreamDolbyVisionFilter(filter: String) = saveString(streamDolbyVisionFilterKey, filter) - actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) - actual fun saveStreamHdrFilter(filter: String) = saveString(streamHdrFilterKey, filter) - actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) - actual fun saveStreamCodecFilter(filter: String) = saveString(streamCodecFilterKey, filter) - actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) - actual fun saveStreamPreferences(preferences: String) = saveString(streamPreferencesKey, preferences) - actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) - actual fun saveStreamNameTemplate(template: String) = saveString(streamNameTemplateKey, template) - actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) - actual fun saveStreamDescriptionTemplate(template: String) = saveString(streamDescriptionTemplateKey, template) - - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - private fun loadInt(key: String): Int? = store.getInt(ProfileScopedKey.of(key)) - private fun saveInt(key: String, value: Int) = store.putInt(ProfileScopedKey.of(key), value) - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } - loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } - DebridProviders.all().forEach { provider -> - loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) } - } - loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } - loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } - loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } - loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } - loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } - loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } - loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } - loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } - loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } - loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys().map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) - payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) - DebridProviders.all().forEach { provider -> - payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { saveProviderApiKey(provider.id, it) } - } - payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) - payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) - payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) - payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) - payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) - payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) - payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) - payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) - payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) - payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) - } - - private fun syncKeys(): List = - listOf( - enabledKey, - cloudLibraryEnabledKey, - preferredResolverProviderIdKey, - instantPlaybackPreparationLimitKey, - streamMaxResultsKey, - streamSortModeKey, - streamMinimumQualityKey, - streamDolbyVisionFilterKey, - streamHdrFilterKey, - streamCodecFilterKey, - streamPreferencesKey, - streamNameTemplateKey, - streamDescriptionTemplateKey, - ) + DebridProviders.all().map { providerApiKeyKey(it.id) } - - private fun providerApiKeyKey(providerId: String): String { - val normalized = DebridProviders.byId(providerId)?.id - ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") - return when (normalized) { - DebridProviders.TORBOX_ID -> torboxApiKeyKey - DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey - else -> "debrid_${normalized}_api_key" - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt deleted file mode 100644 index 5112956ab..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.details - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object MetaScreenSettingsStorage { - private val store = DesktopStorage.store("nuvio_meta_screen_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("meta_screen_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("meta_screen_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt deleted file mode 100644 index 2c961b328..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.details - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object SeasonViewModeStorage { - private val store = DesktopStorage.store("nuvio_season_view_mode") - - actual fun load(): SeasonViewMode? = - SeasonViewMode.parse(store.getString(ProfileScopedKey.of("season_view_mode"))) - - actual fun save(mode: SeasonViewMode) { - store.putString(ProfileScopedKey.of("season_view_mode"), SeasonViewMode.persist(mode)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt deleted file mode 100644 index 85aeb5016..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.details.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier - -@Composable -actual fun HeroTrailerPlayerSurface( - sourceUrl: String, - sourceAudioUrl: String?, - playWhenReady: Boolean, - muted: Boolean, - modifier: Modifier, - onReady: () -> Unit, - onEnded: () -> Unit, - onError: () -> Unit, -) { - LaunchedEffect(sourceUrl) { - onError() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt deleted file mode 100644 index 2a9a1c8e4..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.downloads - -internal actual object DownloadsClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt deleted file mode 100644 index 5e0e33f5a..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.downloads - -internal actual object DownloadsLiveStatusPlatform { - actual fun onItemsChanged(items: List) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt deleted file mode 100644 index 12097a653..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.nuvio.app.features.downloads - -import com.nuvio.app.core.storage.DesktopStorage -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import java.io.File -import java.io.FileOutputStream -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import java.time.Duration -import kotlin.io.path.createDirectories - -private val desktopDownloadHttpClient: HttpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(60)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - -internal actual object DownloadsPlatformDownloader { - private val downloadsDir: File - get() = File(DesktopStorage.rootDir.resolve("downloads").also { it.createDirectories() }.toUri()) - - actual fun start( - request: DownloadPlatformRequest, - onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, - onFailure: (message: String) -> Unit, - ): DownloadsTaskHandle { - val job = SupervisorJob() - val scope = CoroutineScope(job + Dispatchers.IO) - - scope.launch { - val destination = File(downloadsDir, request.destinationFileName) - val tempFile = File(downloadsDir, "${request.destinationFileName}.part") - - try { - var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L - var attemptedRangeRequest = resumeFromBytes > 0L - var response = sendDownloadRequest(request, if (attemptedRangeRequest) resumeFromBytes else null) - - if (attemptedRangeRequest && response.statusCode() == 416) { - tempFile.delete() - resumeFromBytes = 0L - attemptedRangeRequest = false - response = sendDownloadRequest(request, null) - } - - if (response.statusCode() !in 200..299) { - error("Download failed with HTTP ${response.statusCode()}") - } - - val isPartialResume = attemptedRangeRequest && response.statusCode() == 206 && resumeFromBytes > 0L - val appendToTemp = isPartialResume - val startingBytes = if (appendToTemp) resumeFromBytes else 0L - if (!appendToTemp && tempFile.exists()) { - tempFile.delete() - } - - val totalBytes = resolveTotalBytes( - startingBytes = startingBytes, - isPartialResume = isPartialResume, - contentRangeHeader = response.headers().firstValue("Content-Range").orElse(null), - contentLength = response.headers().firstValue("Content-Length").orElse(null)?.toLongOrNull(), - ) - var downloadedBytes = startingBytes - onProgress(downloadedBytes, totalBytes) - - response.body().use { input -> - FileOutputStream(tempFile, appendToTemp).use { output -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - ensureActive() - val read = input.read(buffer) - if (read <= 0) break - output.write(buffer, 0, read) - downloadedBytes += read.toLong() - onProgress(downloadedBytes, totalBytes) - } - output.flush() - } - } - - if (destination.exists()) { - destination.delete() - } - if (!tempFile.renameTo(destination)) { - tempFile.copyTo(destination, overwrite = true) - tempFile.delete() - } - - val finalSize = destination.length() - onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - onFailure(error.message ?: "Download failed") - } - } - - return DesktopDownloadsTaskHandle(job) - } - - actual fun removeFile(localFileUri: String?): Boolean { - if (localFileUri.isNullOrBlank()) return false - val file = localFileUri.toLocalFileOrNull() ?: return false - return runCatching { file.delete() }.getOrDefault(false) - } - - actual fun removePartialFile(destinationFileName: String): Boolean { - val tempFile = File(downloadsDir, "$destinationFileName.part") - if (!tempFile.exists()) return true - return runCatching { tempFile.delete() }.getOrDefault(false) - } - - actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { - localFileUri - ?.toLocalFileOrNull() - ?.takeIf { it.exists() } - ?.let { return it.toURI().toString() } - - val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } - ?: localFileUri?.toLocalFileOrNull()?.name?.takeIf { it.isNotBlank() } - ?: return null - return File(downloadsDir, fileName).takeIf { it.exists() }?.toURI()?.toString() - } - - private fun sendDownloadRequest( - request: DownloadPlatformRequest, - rangeStart: Long?, - ): HttpResponse { - val builder = HttpRequest.newBuilder() - .uri(URI(request.sourceUrl)) - .timeout(Duration.ofSeconds(60)) - .GET() - request.sourceHeaders.forEach { (key, value) -> - if (key.isNotBlank() && value.isNotBlank()) { - builder.header(key, value) - } - } - if (rangeStart != null && rangeStart > 0L) { - builder.header("Range", "bytes=$rangeStart-") - } - return desktopDownloadHttpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()) - } -} - -private class DesktopDownloadsTaskHandle( - private val job: Job, -) : DownloadsTaskHandle { - override fun cancel() { - job.cancel() - } -} - -private fun String.toLocalFileOrNull(): File? = - runCatching { - if (startsWith("file:")) { - File(URI(this)) - } else { - File(this) - } - }.getOrNull() - -private fun resolveTotalBytes( - startingBytes: Long, - isPartialResume: Boolean, - contentRangeHeader: String?, - contentLength: Long?, -): Long? { - parseContentRangeTotal(contentRangeHeader)?.let { return it } - val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null - return if (isPartialResume && startingBytes > 0L) { - startingBytes + normalizedLength - } else { - normalizedLength - } -} - -private fun parseContentRangeTotal(headerValue: String?): Long? { - val value = headerValue?.trim().orEmpty() - if (value.isBlank()) return null - val slashIndex = value.lastIndexOf('/') - if (slashIndex == -1 || slashIndex == value.lastIndex) return null - val totalPart = value.substring(slashIndex + 1).trim() - if (totalPart == "*") return null - return totalPart.toLongOrNull()?.takeIf { it > 0L } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt deleted file mode 100644 index 04ac0d9e8..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.downloads - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object DownloadsStorage { - private val store = DesktopStorage.store("nuvio_downloads") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("downloads")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("downloads"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt deleted file mode 100644 index 2972496e5..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.home - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object HomeCatalogSettingsStorage { - private val store = DesktopStorage.store("nuvio_home_catalog_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("home_catalog_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("home_catalog_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt deleted file mode 100644 index 4b2b4083f..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nuvio.app.features.home.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest - -@Composable -internal actual fun CollectionCardRemoteImage( - imageUrl: String, - contentDescription: String, - modifier: Modifier, - contentScale: ContentScale, - animateIfPossible: Boolean, -) { - val context = LocalPlatformContext.current - val request = remember(context, imageUrl) { - ImageRequest.Builder(context) - .data(imageUrl) - .memoryCacheKey("home-collection:$imageUrl") - .diskCacheKey(imageUrl) - .build() - } - - AsyncImage( - model = request, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt deleted file mode 100644 index 4869d713b..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.library - -internal actual object LibraryClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt deleted file mode 100644 index 659d58156..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.library - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object LibraryStorage { - private val store = DesktopStorage.store("nuvio_library") - - actual fun loadPayload(profileId: Int): String? = - store.getString("library_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("library_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt deleted file mode 100644 index 0d95cfeb4..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.nuvio.app.features.mdblist - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object MdbListSettingsStorage { - private const val enabledKey = "mdblist_enabled" - private const val apiKey = "mdblist_api_key" - private const val useImdbKey = "mdblist_use_imdb" - private const val useTmdbKey = "mdblist_use_tmdb" - private const val useTomatoesKey = "mdblist_use_tomatoes" - private const val useMetacriticKey = "mdblist_use_metacritic" - private const val useTraktKey = "mdblist_use_trakt" - private const val useLetterboxdKey = "mdblist_use_letterboxd" - private const val useAudienceKey = "mdblist_use_audience" - private val syncKeys = listOf( - enabledKey, - apiKey, - useImdbKey, - useTmdbKey, - useTomatoesKey, - useMetacriticKey, - useTraktKey, - useLetterboxdKey, - useAudienceKey, - ) - private val store = DesktopStorage.store("nuvio_mdblist_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadApiKey(): String? = loadString(apiKey) - actual fun saveApiKey(apiKey: String) = saveString(this.apiKey, apiKey) - actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) - actual fun saveUseImdb(enabled: Boolean) = saveBoolean(useImdbKey, enabled) - actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) - actual fun saveUseTmdb(enabled: Boolean) = saveBoolean(useTmdbKey, enabled) - actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) - actual fun saveUseTomatoes(enabled: Boolean) = saveBoolean(useTomatoesKey, enabled) - actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) - actual fun saveUseMetacritic(enabled: Boolean) = saveBoolean(useMetacriticKey, enabled) - actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) - actual fun saveUseTrakt(enabled: Boolean) = saveBoolean(useTraktKey, enabled) - actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) - actual fun saveUseLetterboxd(enabled: Boolean) = saveBoolean(useLetterboxdKey, enabled) - actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) - actual fun saveUseAudience(enabled: Boolean) = saveBoolean(useAudienceKey, enabled) - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadApiKey()?.let { put(apiKey, encodeSyncString(it)) } - loadUseImdb()?.let { put(useImdbKey, encodeSyncBoolean(it)) } - loadUseTmdb()?.let { put(useTmdbKey, encodeSyncBoolean(it)) } - loadUseTomatoes()?.let { put(useTomatoesKey, encodeSyncBoolean(it)) } - loadUseMetacritic()?.let { put(useMetacriticKey, encodeSyncBoolean(it)) } - loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } - loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } - loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(apiKey)?.let(::saveApiKey) - payload.decodeSyncBoolean(useImdbKey)?.let(::saveUseImdb) - payload.decodeSyncBoolean(useTmdbKey)?.let(::saveUseTmdb) - payload.decodeSyncBoolean(useTomatoesKey)?.let(::saveUseTomatoes) - payload.decodeSyncBoolean(useMetacriticKey)?.let(::saveUseMetacritic) - payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) - payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) - payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt deleted file mode 100644 index 6d4647381..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -internal actual object EpisodeReleaseNotificationPlatform { - actual suspend fun notificationsAuthorized(): Boolean = false - - actual suspend fun requestAuthorization(): Boolean = false - - actual suspend fun scheduleEpisodeReleaseNotifications( - requests: List, - ) = Unit - - actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit - - actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt deleted file mode 100644 index 3ee8ee44b..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -internal actual object EpisodeReleaseNotificationsClock { - private val formatter = DateTimeFormatter.ISO_LOCAL_DATE - - actual fun isoDateFromEpochMs(epochMs: Long): String = - Instant.ofEpochMilli(epochMs) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(formatter) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt deleted file mode 100644 index 473aee283..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.notifications - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object EpisodeReleaseNotificationsStorage { - private val store = DesktopStorage.store("nuvio_episode_release_notifications") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("episode_release_notifications")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("episode_release_notifications"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt deleted file mode 100644 index e19e8912f..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.p2p - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object P2pSettingsStorage { - private const val p2pEnabledKey = "p2p_enabled" - private const val enableUploadKey = "enable_upload" - private const val hideTorrentStatsKey = "hide_torrent_stats" - private val store = DesktopStorage.store("torrent_settings") - - actual fun loadP2pEnabled(): Boolean? = loadBoolean(p2pEnabledKey) - actual fun saveP2pEnabled(enabled: Boolean) = saveBoolean(p2pEnabledKey, enabled) - actual fun loadEnableUpload(): Boolean? = loadBoolean(enableUploadKey) - actual fun saveEnableUpload(enabled: Boolean) = saveBoolean(enableUploadKey, enabled) - actual fun loadHideTorrentStats(): Boolean? = loadBoolean(hideTorrentStatsKey) - actual fun saveHideTorrentStats(enabled: Boolean) = saveBoolean(hideTorrentStatsKey, enabled) - - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt deleted file mode 100644 index 5eebed0ce..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.nuvio.app.features.p2p - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -actual object P2pStreamingEngine { - private val _state = MutableStateFlow(P2pStreamingState.Idle) - - actual val state: StateFlow = _state.asStateFlow() - - actual suspend fun startStream(request: P2pStreamRequest): String { - _state.value = P2pStreamingState.Error("P2P streaming is not available on desktop yet.") - throw P2pStreamingException("P2P streaming is not available on desktop yet.") - } - - actual fun stopStream() { - _state.value = P2pStreamingState.Idle - } - - actual fun shutdown() { - _state.value = P2pStreamingState.Idle - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt deleted file mode 100644 index 280256c7f..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.runtime.Composable - -@Composable -actual fun rememberExternalPlayerLauncher( - onResult: (ExternalPlaybackResult?) -> Unit, -): (ExternalPlayerIntentResult.Success) -> Boolean = - { intentResult -> - val launched = ExternalPlayerPlatform.launch(intentResult.intent) - if (launched) { - onResult(null) - } - launched - } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt deleted file mode 100644 index 6a9a820f7..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.nuvio.app.features.player - -import java.awt.Desktop -import java.io.File -import java.net.URI - -private data class DesktopExternalPlayerIntent( - val request: ExternalPlayerPlaybackRequest, - val playerId: String?, -) - -internal actual object ExternalPlayerPlatform { - private const val systemPlayerId = "system" - - actual fun defaultPlayerId(): String? = systemPlayerId - - actual fun availablePlayers(): List = - listOf(ExternalPlayerApp(systemPlayerId, "System default")) - - actual fun open( - request: ExternalPlayerPlaybackRequest, - playerId: String?, - ): ExternalPlayerOpenResult = - if (openUri(request.sourceUrl)) { - ExternalPlayerOpenResult.Opened - } else { - ExternalPlayerOpenResult.Failed - } - - actual fun buildIntent( - request: ExternalPlayerPlaybackRequest, - playerId: String?, - ): ExternalPlayerIntentResult = - ExternalPlayerIntentResult.Success(DesktopExternalPlayerIntent(request, playerId)) - - internal fun launch(intent: Any): Boolean { - val desktopIntent = intent as? DesktopExternalPlayerIntent ?: return false - return open(desktopIntent.request, desktopIntent.playerId) == ExternalPlayerOpenResult.Opened - } - - private fun openUri(rawUri: String): Boolean { - val uri = runCatching { URI(rawUri) }.getOrNull() ?: return false - val desktop = runCatching { Desktop.getDesktop() }.getOrNull() - - if (desktop != null && Desktop.isDesktopSupported()) { - val opened = runCatching { - if (uri.scheme.equals("file", ignoreCase = true)) { - desktop.open(File(uri)) - } else { - desktop.browse(uri) - } - }.isSuccess - if (opened) return true - } - - return openWithPlatformCommand(rawUri) - } - - private fun openWithPlatformCommand(rawUri: String): Boolean { - val osName = System.getProperty("os.name").orEmpty().lowercase() - val command = when { - osName.contains("mac") -> listOf("open", rawUri) - osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", rawUri) - else -> listOf("xdg-open", rawUri) - } - return runCatching { ProcessBuilder(command).start() }.isSuccess - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt deleted file mode 100644 index b1c20ca2e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ /dev/null @@ -1,243 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.nuvio.app.features.player.desktop.DesktopHostOs -import com.nuvio.app.features.player.desktop.DesktopPlayerLaunchShield -import com.nuvio.app.features.player.desktop.NativePlayerController -import com.nuvio.app.features.player.desktop.NativePlayerHost -import kotlinx.coroutines.delay - -@Composable -actual fun PlatformPlayerSurface( - sourceUrl: String, - sourceAudioUrl: String?, - sourceHeaders: Map, - sourceResponseHeaders: Map, - 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, -) { - if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { - NativePlayerSurface( - sourceUrl = sourceUrl, - sourceHeaders = sourceHeaders, - modifier = modifier, - playWhenReady = playWhenReady, - resizeMode = resizeMode, - initialPositionMs = initialPositionMs, - playerControlsState = playerControlsState, - onPlayerControlsAction = onPlayerControlsAction, - onPlayerControlsEvent = onPlayerControlsEvent, - onPlayerControlsScrubChange = onPlayerControlsScrubChange, - onPlayerControlsScrubFinished = onPlayerControlsScrubFinished, - onControllerReady = onControllerReady, - onSnapshot = onSnapshot, - onError = onError, - ) - return - } - - DesktopStubPlayerSurface( - modifier = modifier, - onControllerReady = onControllerReady, - onSnapshot = onSnapshot, - ) -} - -@Composable -private fun NativePlayerSurface( - sourceUrl: String, - sourceHeaders: Map, - modifier: Modifier, - playWhenReady: Boolean, - resizeMode: PlayerResizeMode, - initialPositionMs: Long, - playerControlsState: PlayerControlsState, - onPlayerControlsAction: (PlayerControlsAction) -> Boolean, - onPlayerControlsEvent: (String, Double) -> Boolean, - onPlayerControlsScrubChange: (Long) -> Boolean, - onPlayerControlsScrubFinished: (Long) -> Boolean, - onControllerReady: (PlayerEngineController) -> Unit, - onSnapshot: (PlayerPlaybackSnapshot) -> Unit, - onError: (String?) -> Unit, -) { - val host = remember { NativePlayerHost() } - val controller = remember(host) { NativePlayerController(host) } - val hostFirstPaintComplete = remember { mutableStateOf(false) } - val hostFirstFullSizePaintComplete = remember { mutableStateOf(false) } - LaunchedEffect(sourceUrl) { - DesktopPlayerLaunchShield.showForActiveWindow() - } - val playbackHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) } - val latestOnPlayerControlsAction = rememberUpdatedState(onPlayerControlsAction) - val latestOnPlayerControlsEvent = rememberUpdatedState(onPlayerControlsEvent) - val latestOnPlayerControlsScrubChange = rememberUpdatedState(onPlayerControlsScrubChange) - val latestOnPlayerControlsScrubFinished = rememberUpdatedState(onPlayerControlsScrubFinished) - val latestOnError = rememberUpdatedState(onError) - - LaunchedEffect(controller) { - onControllerReady(controller) - } - - DisposableEffect(host) { - host.onDisplayableChanged = { displayable -> - if (!displayable) { - hostFirstPaintComplete.value = false - hostFirstFullSizePaintComplete.value = false - } - } - host.onFirstPaint = { - hostFirstPaintComplete.value = true - } - host.onFirstFullSizePaint = { - hostFirstFullSizePaintComplete.value = true - DesktopPlayerLaunchShield.hideAfter() - } - onDispose { - host.onDisplayableChanged = null - host.onFirstPaint = null - host.onFirstFullSizePaint = null - DesktopPlayerLaunchShield.hide() - } - } - - LaunchedEffect(controller) { - controller.setControlCallbacks( - onAction = { action -> latestOnPlayerControlsAction.value(action) }, - onEvent = { type, value -> latestOnPlayerControlsEvent.value(type, value) }, - onScrubChange = { positionMs -> latestOnPlayerControlsScrubChange.value(positionMs) }, - onScrubFinished = { positionMs -> latestOnPlayerControlsScrubFinished.value(positionMs) }, - ) - } - - DisposableEffect(controller, sourceUrl, playbackHeaders) { - onDispose { controller.dispose() } - } - - LaunchedEffect(controller, sourceUrl, playbackHeaders, hostFirstFullSizePaintComplete.value) { - if (!hostFirstFullSizePaintComplete.value) { - return@LaunchedEffect - } - delay(16L) - controller.attach( - sourceUrl = sourceUrl, - sourceHeaders = playbackHeaders, - playWhenReady = playWhenReady, - initialPositionMs = initialPositionMs, - onError = { message -> latestOnError.value(message) }, - ) - } - - LaunchedEffect(controller, playWhenReady) { - if (playWhenReady) { - controller.play() - } else { - controller.pause() - } - } - - LaunchedEffect(controller, resizeMode) { - controller.setResizeMode(resizeMode) - } - - LaunchedEffect(controller, playerControlsState) { - controller.updateControls(playerControlsState) - } - - LaunchedEffect(controller) { - while (true) { - onSnapshot(controller.snapshot()) - delay(500L) - } - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - ) { - SwingPanel( - factory = { - host - }, - modifier = if (hostFirstPaintComplete.value) { - Modifier.fillMaxSize() - } else { - Modifier - .align(Alignment.BottomEnd) - .requiredSize(1.dp) - }, - background = Color.Black, - ) - } -} - -@Composable -private fun DesktopStubPlayerSurface( - modifier: Modifier, - onControllerReady: (PlayerEngineController) -> Unit, - onSnapshot: (PlayerPlaybackSnapshot) -> Unit, -) { - val controller = remember { DesktopStubPlayerController() } - - LaunchedEffect(controller) { - onControllerReady(controller) - onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Desktop in-app playback is not available yet.", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - ) - } -} - -private class DesktopStubPlayerController : PlayerEngineController { - override fun play() = Unit - override fun pause() = Unit - override fun seekTo(positionMs: Long) = Unit - override fun seekBy(offsetMs: Long) = Unit - override fun retry() = Unit - override fun setPlaybackSpeed(speed: Float) = Unit - override fun getAudioTracks(): List = emptyList() - override fun getSubtitleTracks(): List = emptyList() - override fun selectAudioTrack(index: Int) = Unit - override fun selectSubtitleTrack(index: Int) = Unit - override fun setSubtitleUri(url: String) = Unit - override fun clearExternalSubtitle() = Unit - override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt deleted file mode 100644 index 3dd1535b9..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nuvio.app.features.player - -import java.util.Locale - -internal actual object DeviceLanguagePreferences { - actual fun preferredLanguageCodes(): List = - Locale.getDefault() - .toLanguageTag() - .takeIf { it.isNotBlank() } - ?.let(::listOf) - ?: emptyList() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt deleted file mode 100644 index 91ad9cef4..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.player - -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.IntSize - -@Composable -actual fun LockPlayerToLandscape() = Unit - -@Composable -actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) = Unit - -@Composable -actual fun ManagePlayerPictureInPicture( - isPlaying: Boolean, - playerSize: IntSize, -) = Unit - -@Composable -actual fun rememberPlayerGestureController(): PlayerGestureController? = null diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt deleted file mode 100644 index 1498a9592..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt +++ /dev/null @@ -1,406 +0,0 @@ -package com.nuvio.app.features.player - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncFloat -import com.nuvio.app.core.sync.decodeSyncInt -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.decodeSyncStringSet -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncFloat -import com.nuvio.app.core.sync.encodeSyncInt -import com.nuvio.app.core.sync.encodeSyncString -import com.nuvio.app.core.sync.encodeSyncStringSet -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object PlayerSettingsStorage { - private const val showLoadingOverlayKey = "show_loading_overlay" - 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 externalPlayerEnabledKey = "external_player_enabled" - private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" - private const val externalPlayerIdKey = "external_player_id" - private const val preferredAudioLanguageKey = "preferred_audio_language" - private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" - private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" - private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" - private const val subtitleTextColorKey = "subtitle_text_color" - private const val subtitleBackgroundColorKey = "subtitle_background_color" - private const val subtitleOutlineColorKey = "subtitle_outline_color" - private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled" - private const val subtitleOutlineWidthKey = "subtitle_outline_width" - private const val subtitleBoldKey = "subtitle_bold" - private const val subtitleFontSizeSpKey = "subtitle_font_size_sp" - private const val subtitleBottomOffsetKey = "subtitle_bottom_offset" - private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles" - private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages" - private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode" - private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" - private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" - private const val decoderPriorityKey = "decoder_priority" - private const val mapDV7ToHevcKey = "map_dv7_to_hevc" - private const val tunnelingEnabledKey = "tunneling_enabled" - private const val streamAutoPlayModeKey = "stream_auto_play_mode" - private const val streamAutoPlaySourceKey = "stream_auto_play_source" - private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" - private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" - private const val streamAutoPlayRegexKey = "stream_auto_play_regex" - private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" - private const val skipIntroEnabledKey = "skip_intro_enabled" - private const val animeSkipEnabledKey = "animeskip_enabled" - private const val animeSkipClientIdKey = "animeskip_client_id" - private const val introDbApiKeyKey = "introdb_api_key" - private const val introSubmitEnabledKey = "intro_submit_enabled" - private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" - private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" - private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" - private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" - private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" - private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" - private const val useLibassKey = "use_libass" - private const val libassRenderTypeKey = "libass_render_type" - private const val iosVideoOutputPresetKey = "ios_video_output_preset" - private const val iosToneMappingModeKey = "ios_tone_mapping_mode" - private const val iosTargetPrimariesKey = "ios_target_primaries" - private const val iosTargetTransferKey = "ios_target_transfer" - private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" - private const val iosAudioOutputModeKey = "ios_audio_output_mode" - private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" - private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" - private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" - private const val iosDebandEnabledKey = "ios_deband_enabled" - private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" - private const val iosBrightnessKey = "ios_brightness" - private const val iosContrastKey = "ios_contrast" - private const val iosSaturationKey = "ios_saturation" - private const val iosGammaKey = "ios_gamma" - private val syncKeys = listOf( - showLoadingOverlayKey, - resizeModeKey, - holdToSpeedEnabledKey, - holdToSpeedValueKey, - externalPlayerEnabledKey, - externalPlayerForwardSubtitlesKey, - externalPlayerIdKey, - preferredAudioLanguageKey, - secondaryPreferredAudioLanguageKey, - preferredSubtitleLanguageKey, - secondaryPreferredSubtitleLanguageKey, - subtitleTextColorKey, - subtitleBackgroundColorKey, - subtitleOutlineColorKey, - subtitleOutlineEnabledKey, - subtitleOutlineWidthKey, - subtitleBoldKey, - subtitleFontSizeSpKey, - subtitleBottomOffsetKey, - subtitleUseForcedSubtitlesKey, - subtitleShowOnlyPreferredLanguagesKey, - addonSubtitleStartupModeKey, - streamReuseLastLinkEnabledKey, - streamReuseLastLinkCacheHoursKey, - decoderPriorityKey, - mapDV7ToHevcKey, - tunnelingEnabledKey, - streamAutoPlayModeKey, - streamAutoPlaySourceKey, - streamAutoPlaySelectedAddonsKey, - streamAutoPlaySelectedPluginsKey, - streamAutoPlayRegexKey, - streamAutoPlayTimeoutSecondsKey, - skipIntroEnabledKey, - animeSkipEnabledKey, - animeSkipClientIdKey, - streamAutoPlayNextEpisodeEnabledKey, - streamAutoPlayPreferBingeGroupKey, - streamAutoPlayReuseBingeGroupKey, - nextEpisodeThresholdModeKey, - nextEpisodeThresholdPercentKey, - nextEpisodeThresholdMinutesBeforeEndKey, - useLibassKey, - libassRenderTypeKey, - iosVideoOutputPresetKey, - iosToneMappingModeKey, - iosTargetPrimariesKey, - iosTargetTransferKey, - iosHardwareDecoderModeKey, - iosAudioOutputModeKey, - iosExtendedDynamicRangeEnabledKey, - iosTargetColorspaceHintEnabledKey, - iosHdrComputePeakEnabledKey, - iosDebandEnabledKey, - iosInterpolationEnabledKey, - iosBrightnessKey, - iosContrastKey, - iosSaturationKey, - iosGammaKey, - ) - private val store = DesktopStorage.store("nuvio_player_settings") - - actual fun loadShowLoadingOverlay(): Boolean? = loadBoolean(showLoadingOverlayKey) - actual fun saveShowLoadingOverlay(enabled: Boolean) = saveBoolean(showLoadingOverlayKey, enabled) - actual fun loadResizeMode(): String? = loadString(resizeModeKey) - actual fun saveResizeMode(mode: String) = saveString(resizeModeKey, mode) - actual fun loadHoldToSpeedEnabled(): Boolean? = loadBoolean(holdToSpeedEnabledKey) - actual fun saveHoldToSpeedEnabled(enabled: Boolean) = saveBoolean(holdToSpeedEnabledKey, enabled) - actual fun loadHoldToSpeedValue(): Float? = loadFloat(holdToSpeedValueKey) - actual fun saveHoldToSpeedValue(speed: Float) = saveFloat(holdToSpeedValueKey, speed) - actual fun loadExternalPlayerEnabled(): Boolean? = loadBoolean(externalPlayerEnabledKey) - actual fun saveExternalPlayerEnabled(enabled: Boolean) = saveBoolean(externalPlayerEnabledKey, enabled) - actual fun loadExternalPlayerForwardSubtitles(): Boolean? = loadBoolean(externalPlayerForwardSubtitlesKey) - actual fun saveExternalPlayerForwardSubtitles(enabled: Boolean) = saveBoolean(externalPlayerForwardSubtitlesKey, enabled) - actual fun loadExternalPlayerId(): String? = loadString(externalPlayerIdKey) - actual fun saveExternalPlayerId(playerId: String?) = saveOptionalString(externalPlayerIdKey, playerId) - actual fun loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey) - actual fun savePreferredAudioLanguage(language: String) = saveString(preferredAudioLanguageKey, language) - actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey) - actual fun saveSecondaryPreferredAudioLanguage(language: String?) = saveOptionalString(secondaryPreferredAudioLanguageKey, language) - actual fun loadPreferredSubtitleLanguage(): String? = loadString(preferredSubtitleLanguageKey) - actual fun savePreferredSubtitleLanguage(language: String) = saveString(preferredSubtitleLanguageKey, language) - actual fun loadSecondaryPreferredSubtitleLanguage(): String? = loadString(secondaryPreferredSubtitleLanguageKey) - actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) = saveOptionalString(secondaryPreferredSubtitleLanguageKey, language) - actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey) - actual fun saveSubtitleTextColor(colorHex: String) = saveString(subtitleTextColorKey, colorHex) - actual fun loadSubtitleBackgroundColor(): String? = loadString(subtitleBackgroundColorKey) - actual fun saveSubtitleBackgroundColor(colorHex: String) = saveString(subtitleBackgroundColorKey, colorHex) - actual fun loadSubtitleOutlineColor(): String? = loadString(subtitleOutlineColorKey) - actual fun saveSubtitleOutlineColor(colorHex: String) = saveString(subtitleOutlineColorKey, colorHex) - actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey) - actual fun saveSubtitleOutlineEnabled(enabled: Boolean) = saveBoolean(subtitleOutlineEnabledKey, enabled) - actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey) - actual fun saveSubtitleOutlineWidth(width: Int) = saveInt(subtitleOutlineWidthKey, width) - actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey) - actual fun saveSubtitleBold(enabled: Boolean) = saveBoolean(subtitleBoldKey, enabled) - actual fun loadSubtitleFontSizeSp(): Int? = loadInt(subtitleFontSizeSpKey) - actual fun saveSubtitleFontSizeSp(fontSizeSp: Int) = saveInt(subtitleFontSizeSpKey, fontSizeSp) - actual fun loadSubtitleBottomOffset(): Int? = loadInt(subtitleBottomOffsetKey) - actual fun saveSubtitleBottomOffset(bottomOffset: Int) = saveInt(subtitleBottomOffsetKey, bottomOffset) - actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey) - actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) = saveBoolean(subtitleUseForcedSubtitlesKey, enabled) - actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey) - actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) = saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled) - actual fun loadAddonSubtitleStartupMode(): String? = loadString(addonSubtitleStartupModeKey) - actual fun saveAddonSubtitleStartupMode(mode: String) = saveString(addonSubtitleStartupModeKey, mode) - actual fun loadStreamReuseLastLinkEnabled(): Boolean? = loadBoolean(streamReuseLastLinkEnabledKey) - actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) = saveBoolean(streamReuseLastLinkEnabledKey, enabled) - actual fun loadStreamReuseLastLinkCacheHours(): Int? = loadInt(streamReuseLastLinkCacheHoursKey) - actual fun saveStreamReuseLastLinkCacheHours(hours: Int) = saveInt(streamReuseLastLinkCacheHoursKey, hours) - actual fun loadDecoderPriority(): Int? = loadInt(decoderPriorityKey) - actual fun saveDecoderPriority(priority: Int) = saveInt(decoderPriorityKey, priority) - actual fun loadMapDV7ToHevc(): Boolean? = loadBoolean(mapDV7ToHevcKey) - actual fun saveMapDV7ToHevc(enabled: Boolean) = saveBoolean(mapDV7ToHevcKey, enabled) - actual fun loadTunnelingEnabled(): Boolean? = loadBoolean(tunnelingEnabledKey) - actual fun saveTunnelingEnabled(enabled: Boolean) = saveBoolean(tunnelingEnabledKey, enabled) - actual fun loadStreamAutoPlayMode(): String? = loadString(streamAutoPlayModeKey) - actual fun saveStreamAutoPlayMode(mode: String) = saveString(streamAutoPlayModeKey, mode) - actual fun loadStreamAutoPlaySource(): String? = loadString(streamAutoPlaySourceKey) - actual fun saveStreamAutoPlaySource(source: String) = saveString(streamAutoPlaySourceKey, source) - actual fun loadStreamAutoPlaySelectedAddons(): Set? = loadStringSet(streamAutoPlaySelectedAddonsKey) - actual fun saveStreamAutoPlaySelectedAddons(addons: Set) = saveStringSet(streamAutoPlaySelectedAddonsKey, addons) - actual fun loadStreamAutoPlaySelectedPlugins(): Set? = loadStringSet(streamAutoPlaySelectedPluginsKey) - actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) = saveStringSet(streamAutoPlaySelectedPluginsKey, plugins) - actual fun loadStreamAutoPlayRegex(): String? = loadString(streamAutoPlayRegexKey) - actual fun saveStreamAutoPlayRegex(regex: String) = saveString(streamAutoPlayRegexKey, regex) - actual fun loadStreamAutoPlayTimeoutSeconds(): Int? = loadInt(streamAutoPlayTimeoutSecondsKey) - actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) = saveInt(streamAutoPlayTimeoutSecondsKey, seconds) - actual fun loadSkipIntroEnabled(): Boolean? = loadBoolean(skipIntroEnabledKey) - actual fun saveSkipIntroEnabled(enabled: Boolean) = saveBoolean(skipIntroEnabledKey, enabled) - actual fun loadAnimeSkipEnabled(): Boolean? = loadBoolean(animeSkipEnabledKey) - actual fun saveAnimeSkipEnabled(enabled: Boolean) = saveBoolean(animeSkipEnabledKey, enabled) - actual fun loadAnimeSkipClientId(): String? = loadString(animeSkipClientIdKey) - actual fun saveAnimeSkipClientId(clientId: String) = saveString(animeSkipClientIdKey, clientId) - actual fun loadIntroDbApiKey(): String? = loadString(introDbApiKeyKey) - actual fun saveIntroDbApiKey(apiKey: String) = saveString(introDbApiKeyKey, apiKey) - actual fun loadIntroSubmitEnabled(): Boolean? = loadBoolean(introSubmitEnabledKey) - actual fun saveIntroSubmitEnabled(enabled: Boolean) = saveBoolean(introSubmitEnabledKey, enabled) - actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? = loadBoolean(streamAutoPlayNextEpisodeEnabledKey) - actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) = saveBoolean(streamAutoPlayNextEpisodeEnabledKey, enabled) - actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? = loadBoolean(streamAutoPlayPreferBingeGroupKey) - actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayPreferBingeGroupKey, enabled) - actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = loadBoolean(streamAutoPlayReuseBingeGroupKey) - actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayReuseBingeGroupKey, enabled) - actual fun loadNextEpisodeThresholdMode(): String? = loadString(nextEpisodeThresholdModeKey) - actual fun saveNextEpisodeThresholdMode(mode: String) = saveString(nextEpisodeThresholdModeKey, mode) - actual fun loadNextEpisodeThresholdPercent(): Float? = loadFloat(nextEpisodeThresholdPercentKey) - actual fun saveNextEpisodeThresholdPercent(percent: Float) = saveFloat(nextEpisodeThresholdPercentKey, percent) - actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? = loadFloat(nextEpisodeThresholdMinutesBeforeEndKey) - actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) = saveFloat(nextEpisodeThresholdMinutesBeforeEndKey, minutes) - actual fun loadUseLibass(): Boolean? = loadBoolean(useLibassKey) - actual fun saveUseLibass(enabled: Boolean) = saveBoolean(useLibassKey, enabled) - actual fun loadLibassRenderType(): String? = loadString(libassRenderTypeKey) - actual fun saveLibassRenderType(renderType: String) = saveString(libassRenderTypeKey, renderType) - actual fun loadIosVideoOutputPreset(): String? = loadString(iosVideoOutputPresetKey) - actual fun saveIosVideoOutputPreset(preset: String) = saveString(iosVideoOutputPresetKey, preset) - actual fun loadIosToneMappingMode(): String? = loadString(iosToneMappingModeKey) - actual fun saveIosToneMappingMode(mode: String) = saveString(iosToneMappingModeKey, mode) - actual fun loadIosTargetPrimaries(): String? = loadString(iosTargetPrimariesKey) - actual fun saveIosTargetPrimaries(primaries: String) = saveString(iosTargetPrimariesKey, primaries) - actual fun loadIosTargetTransfer(): String? = loadString(iosTargetTransferKey) - actual fun saveIosTargetTransfer(transfer: String) = saveString(iosTargetTransferKey, transfer) - actual fun loadIosHardwareDecoderMode(): String? = loadString(iosHardwareDecoderModeKey) - actual fun saveIosHardwareDecoderMode(mode: String) = saveString(iosHardwareDecoderModeKey, mode) - actual fun loadIosAudioOutputMode(): String? = loadString(iosAudioOutputModeKey) - actual fun saveIosAudioOutputMode(mode: String) = saveString(iosAudioOutputModeKey, mode) - actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = loadBoolean(iosExtendedDynamicRangeEnabledKey) - actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) = saveBoolean(iosExtendedDynamicRangeEnabledKey, enabled) - actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = loadBoolean(iosTargetColorspaceHintEnabledKey) - actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) = saveBoolean(iosTargetColorspaceHintEnabledKey, enabled) - actual fun loadIosHdrComputePeakEnabled(): Boolean? = loadBoolean(iosHdrComputePeakEnabledKey) - actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) = saveBoolean(iosHdrComputePeakEnabledKey, enabled) - actual fun loadIosDebandEnabled(): Boolean? = loadBoolean(iosDebandEnabledKey) - actual fun saveIosDebandEnabled(enabled: Boolean) = saveBoolean(iosDebandEnabledKey, enabled) - actual fun loadIosInterpolationEnabled(): Boolean? = loadBoolean(iosInterpolationEnabledKey) - actual fun saveIosInterpolationEnabled(enabled: Boolean) = saveBoolean(iosInterpolationEnabledKey, enabled) - actual fun loadIosBrightness(): Int? = loadInt(iosBrightnessKey) - actual fun saveIosBrightness(value: Int) = saveInt(iosBrightnessKey, value) - actual fun loadIosContrast(): Int? = loadInt(iosContrastKey) - actual fun saveIosContrast(value: Int) = saveInt(iosContrastKey, value) - actual fun loadIosSaturation(): Int? = loadInt(iosSaturationKey) - actual fun saveIosSaturation(value: Int) = saveInt(iosSaturationKey, value) - actual fun loadIosGamma(): Int? = loadInt(iosGammaKey) - actual fun saveIosGamma(value: Int) = saveInt(iosGammaKey, value) - - private fun scoped(key: String): String = ProfileScopedKey.of(key) - private fun loadString(key: String): String? = store.getString(scoped(key)) - private fun saveString(key: String, value: String) = store.putString(scoped(key), value) - private fun saveOptionalString(key: String, value: String?) = store.putString(scoped(key), value?.takeIf { it.isNotBlank() }) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(scoped(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(scoped(key), value) - private fun loadInt(key: String): Int? = store.getInt(scoped(key)) - private fun saveInt(key: String, value: Int) = store.putInt(scoped(key), value) - private fun loadFloat(key: String): Float? = store.getFloat(scoped(key)) - private fun saveFloat(key: String, value: Float) = store.putFloat(scoped(key), value) - private fun loadStringSet(key: String): Set? = store.getStringSet(scoped(key)) - private fun saveStringSet(key: String, values: Set) = store.putStringSet(scoped(key), values) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } - loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } - loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } - loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } - loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } - loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } - loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } - loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } - loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } - loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } - loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) } - loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) } - loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) } - loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) } - loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) } - loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) } - loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) } - loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) } - loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) } - loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) } - loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) } - loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) } - loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } - loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } - loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } - loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } - loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayMode()?.let { put(streamAutoPlayModeKey, encodeSyncString(it)) } - loadStreamAutoPlaySource()?.let { put(streamAutoPlaySourceKey, encodeSyncString(it)) } - loadStreamAutoPlaySelectedAddons()?.let { put(streamAutoPlaySelectedAddonsKey, encodeSyncStringSet(it)) } - loadStreamAutoPlaySelectedPlugins()?.let { put(streamAutoPlaySelectedPluginsKey, encodeSyncStringSet(it)) } - loadStreamAutoPlayRegex()?.let { put(streamAutoPlayRegexKey, encodeSyncString(it)) } - loadStreamAutoPlayTimeoutSeconds()?.let { put(streamAutoPlayTimeoutSecondsKey, encodeSyncInt(it)) } - loadSkipIntroEnabled()?.let { put(skipIntroEnabledKey, encodeSyncBoolean(it)) } - loadAnimeSkipEnabled()?.let { put(animeSkipEnabledKey, encodeSyncBoolean(it)) } - loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } - loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } - loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } - loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } - loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } - loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } - loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } - loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } - loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } - loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } - loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } - loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } - loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } - loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) } - loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } - loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } - loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } - loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } - loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } - loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } - loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } - loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } - loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(::scoped)) - payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) - payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) - payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) - payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) - payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) - payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) - payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) - payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) - payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) - payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) - payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage) - payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor) - payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor) - payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor) - payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled) - payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth) - payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold) - payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp) - payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset) - payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles) - payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages) - payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode) - payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) - payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) - payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) - payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) - payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) - payload.decodeSyncString(streamAutoPlayModeKey)?.let(::saveStreamAutoPlayMode) - payload.decodeSyncString(streamAutoPlaySourceKey)?.let(::saveStreamAutoPlaySource) - payload.decodeSyncStringSet(streamAutoPlaySelectedAddonsKey)?.let(::saveStreamAutoPlaySelectedAddons) - payload.decodeSyncStringSet(streamAutoPlaySelectedPluginsKey)?.let(::saveStreamAutoPlaySelectedPlugins) - payload.decodeSyncString(streamAutoPlayRegexKey)?.let(::saveStreamAutoPlayRegex) - payload.decodeSyncInt(streamAutoPlayTimeoutSecondsKey)?.let(::saveStreamAutoPlayTimeoutSeconds) - payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) - payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) - payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) - payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) - payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) - payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) - payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) - payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) - payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) - payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) - payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) - payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) - payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) - payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) - payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) - payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) - payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) - payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) - payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode) - payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) - payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) - payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) - payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) - payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) - payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) - payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) - payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) - payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt deleted file mode 100644 index d35d9894c..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.nuvio.app.features.player - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object PlayerTrackPreferenceStorage { - private const val subtitleTypeKey = "subtitle_type" - private const val subtitleLanguageKey = "subtitle_language" - private const val subtitleNameKey = "subtitle_name" - private const val subtitleTrackIdKey = "subtitle_track_id" - private const val addonSubtitleIdKey = "addon_subtitle_id" - private const val addonSubtitleUrlKey = "addon_subtitle_url" - private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name" - private const val audioLanguageKey = "audio_language" - private const val audioNameKey = "audio_name" - private const val audioTrackIdKey = "audio_track_id" - private const val subtitleDelayMsKey = "subtitle_delay_ms" - private val store = DesktopStorage.store("nuvio_player_track_preferences") - - actual fun load(contentId: String): PersistedPlayerTrackPreference? { - val id = contentId.normalizedStorageId() ?: return null - val preference = PersistedPlayerTrackPreference( - subtitleType = loadString(subtitleTypeKey, id), - subtitleLanguage = loadString(subtitleLanguageKey, id), - subtitleName = loadString(subtitleNameKey, id), - subtitleTrackId = loadString(subtitleTrackIdKey, id), - addonSubtitleId = loadString(addonSubtitleIdKey, id), - addonSubtitleUrl = loadString(addonSubtitleUrlKey, id), - addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id), - audioLanguage = loadString(audioLanguageKey, id), - audioName = loadString(audioNameKey, id), - audioTrackId = loadString(audioTrackIdKey, id), - ) - return preference.takeIf { - listOf( - it.subtitleType, - it.subtitleLanguage, - it.subtitleName, - it.subtitleTrackId, - it.addonSubtitleId, - it.addonSubtitleUrl, - it.addonSubtitleAddonName, - it.audioLanguage, - it.audioName, - it.audioTrackId, - ).any { value -> !value.isNullOrBlank() } - } - } - - actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) { - val id = contentId.normalizedStorageId() ?: return - putOptionalString(subtitleTypeKey, id, preference.subtitleType) - putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage) - putOptionalString(subtitleNameKey, id, preference.subtitleName) - putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId) - putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId) - putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl) - putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName) - putOptionalString(audioLanguageKey, id, preference.audioLanguage) - putOptionalString(audioNameKey, id, preference.audioName) - putOptionalString(audioTrackIdKey, id, preference.audioTrackId) - } - - actual fun loadSubtitleDelayMs(videoId: String): Int? { - val id = videoId.normalizedStorageId() ?: return null - return store.getInt(scopedKey(subtitleDelayMsKey, id)) - } - - actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) { - val id = videoId.normalizedStorageId() ?: return - store.putInt( - scopedKey(subtitleDelayMsKey, id), - delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS), - ) - } - - private fun loadString(field: String, contentId: String): String? = - store.getString(scopedKey(field, contentId))?.takeIf { it.isNotBlank() } - - private fun putOptionalString(field: String, contentId: String, value: String?) { - val key = scopedKey(field, contentId) - if (value.isNullOrBlank()) { - store.remove(key) - } else { - store.putString(key, value) - } - } - - private fun scopedKey(field: String, contentId: String): String = - ProfileScopedKey.of("$field|$contentId") - - private fun String.normalizedStorageId(): String? = - trim().takeIf { it.isNotBlank() } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt deleted file mode 100644 index a47ecaaf0..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.KeyEventDispatcher -import java.awt.KeyboardFocusManager -import java.awt.Window -import java.awt.event.KeyEvent -import javax.swing.SwingUtilities - -private object DesktopAppFullscreen { - private var toggleHandler: ((Window?) -> Unit)? = null - - fun setToggleHandler(handler: ((Window?) -> Unit)?): () -> Unit { - toggleHandler = handler - return { - if (toggleHandler === handler) { - toggleHandler = null - } - } - } - - fun toggle(window: Window? = null) { - val handler = toggleHandler ?: return - if (SwingUtilities.isEventDispatchThread()) { - handler(window) - } else { - SwingUtilities.invokeLater { handler(window) } - } - } -} - -internal fun registerDesktopAppFullscreenToggle(handler: (Window?) -> Unit): () -> Unit = - DesktopAppFullscreen.setToggleHandler(handler) - -internal fun toggleDesktopAppFullscreen(window: Window? = null) { - DesktopAppFullscreen.toggle(window) -} - -internal fun installDesktopAppFullscreenShortcuts(window: Window): () -> Unit { - val dispatcher = KeyEventDispatcher { event -> - if (!event.isDesktopAppFullscreenShortcut()) return@KeyEventDispatcher false - toggleDesktopAppFullscreen(window) - true - } - KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(dispatcher) - return { - KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(dispatcher) - } -} - -private fun KeyEvent.isDesktopAppFullscreenShortcut(): Boolean { - if (id != KeyEvent.KEY_PRESSED) return false - if (keyCode == KeyEvent.VK_F11) return true - if (keyCode != KeyEvent.VK_F) return false - val modifiers = modifiersEx - val hasMacFullscreenModifiers = - modifiers and KeyEvent.META_DOWN_MASK != 0 && - modifiers and KeyEvent.CTRL_DOWN_MASK != 0 && - modifiers and KeyEvent.ALT_DOWN_MASK == 0 - return hasMacFullscreenModifiers -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt deleted file mode 100644 index fb63eae77..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.util.Locale - -internal enum class DesktopHostOs { - MACOS, - WINDOWS, - LINUX, - UNKNOWN; - - companion object { - val current: DesktopHostOs by lazy { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - when { - osName.contains("mac") -> MACOS - osName.contains("win") -> WINDOWS - osName.contains("linux") -> LINUX - else -> UNKNOWN - } - } - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt deleted file mode 100644 index 1da376998..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Color -import java.awt.KeyboardFocusManager -import java.awt.Rectangle -import java.awt.Window -import javax.swing.JWindow -import javax.swing.RootPaneContainer -import javax.swing.SwingUtilities -import javax.swing.Timer - -internal object DesktopPlayerLaunchShield { - private var shieldWindow: JWindow? = null - private var hideTimer: Timer? = null - - fun showForActiveWindow() { - if (DesktopHostOs.current != DesktopHostOs.WINDOWS) return - SwingUtilities.invokeLater { - val owner = activeOwnerWindow() ?: return@invokeLater - val bounds = ownerContentBounds(owner) ?: return@invokeLater - hideTimer?.stop() - hideTimer = null - - val shield = shieldWindow?.takeIf { it.owner === owner } ?: JWindow(owner).also { window -> - window.background = Color.BLACK - window.contentPane.background = Color.BLACK - window.focusableWindowState = false - window.setType(Window.Type.POPUP) - shieldWindow = window - } - shield.bounds = bounds - if (!shield.isVisible) { - shield.isVisible = true - } - shield.toFront() - hideAfter(3_000) - } - } - - fun hideAfter(delayMs: Int = 48) { - SwingUtilities.invokeLater { - hideTimer?.stop() - hideTimer = Timer(delayMs) { - hideNow() - }.apply { - isRepeats = false - start() - } - } - } - - fun hide() { - SwingUtilities.invokeLater { - hideTimer?.stop() - hideTimer = null - hideNow() - } - } - - private fun hideNow() { - val shield = shieldWindow ?: return - if (shield.isVisible) { - shield.isVisible = false - } - } - - private fun activeOwnerWindow(): Window? { - val active = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow - if (active?.isShowing == true) return active - return Window.getWindows() - .filter { it.isShowing && it !is JWindow } - .firstOrNull() - } - - private fun ownerContentBounds(owner: Window): Rectangle? { - val content = (owner as? RootPaneContainer)?.contentPane - return runCatching { - if (content != null && content.isShowing) { - val location = content.locationOnScreen - Rectangle(location.x, location.y, content.width, content.height) - } else { - val location = owner.locationOnScreen - Rectangle(location.x, location.y, owner.width, owner.height) - } - }.getOrNull() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt deleted file mode 100644 index 342962590..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Window - -private const val NuvioWindowBackgroundRgb = 0x0D0D0D -private const val NuvioWindowTextRgb = 0xF5F7F8 - -internal fun applyNativeDesktopWindowChrome(window: Window) { - if (DesktopHostOs.current != DesktopHostOs.WINDOWS || !window.isDisplayable) return - - runCatching { - val hwnd = AwtNativeViewResolver.resolveNativeViewPointer(window) - NativePlayerBridge.applyWindowChrome( - windowHwnd = hwnd, - darkMode = true, - captionColorRgb = NuvioWindowBackgroundRgb, - borderColorRgb = NuvioWindowBackgroundRgb, - textColorRgb = NuvioWindowTextRgb, - ) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt deleted file mode 100644 index aea9d4b7e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Component -import java.lang.reflect.Field -import java.lang.reflect.Method - -internal object AwtNativeViewResolver { - fun resolveNativeViewPointer(component: Component): Long = - when (DesktopHostOs.current) { - DesktopHostOs.MACOS -> MacosAwtViewResolver.resolveNativeViewPointer(component) - DesktopHostOs.WINDOWS -> WindowsAwtViewResolver.resolveNativeViewPointer(component) - else -> error("Native desktop playback is not implemented for ${DesktopHostOs.current}.") - } -} - -private object MacosAwtViewResolver { - private val componentPeerField: Field by lazy { - Component::class.java.getDeclaredField("peer").apply { isAccessible = true } - } - - fun resolveNativeViewPointer(component: Component): Long { - val peer = componentPeerField.get(component) - ?: error("AWT component peer is not ready for native playback.") - - val platformWindow = invokeObject(peer, "getPlatformWindow") - val contentView = invokeObject(platformWindow, "getContentView") - val pointer = invokeLong(contentView, "getAWTView") - if (pointer == 0L) { - error("macOS AWT view pointer was zero.") - } - return pointer - } - - private fun findMethod(type: Class<*>, name: String): Method { - var current: Class<*>? = type - while (current != null) { - runCatching { - return current.getDeclaredMethod(name).apply { isAccessible = true } - } - current = current.superclass - } - error("Method $name was not found on ${type.name}.") - } - - private fun invokeObject(target: Any, methodName: String): Any = - findMethod(target.javaClass, methodName).invoke(target) - ?: error("$methodName returned null.") - - private fun invokeLong(target: Any, methodName: String): Long = - (findMethod(target.javaClass, methodName).invoke(target) as Number).toLong() -} - -private object WindowsAwtViewResolver { - private val componentPeerField: Field by lazy { - Component::class.java.getDeclaredField("peer").apply { isAccessible = true } - } - - fun resolveNativeViewPointer(component: Component): Long { - val peer = componentPeerField.get(component) - ?: error("AWT component peer is not ready for native playback.") - - val pointer = invokeLong(peer, "getHWnd") - if (pointer == 0L) { - error("Windows AWT HWND pointer was zero.") - } - return pointer - } - - private fun findMethod(type: Class<*>, name: String): Method { - var current: Class<*>? = type - while (current != null) { - runCatching { - return current.getDeclaredMethod(name).apply { isAccessible = true } - } - current = current.superclass - } - error("Method $name was not found on ${type.name}.") - } - - private fun invokeLong(target: Any, methodName: String): Long = - (findMethod(target.javaClass, methodName).invoke(target) as Number).toLong() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt deleted file mode 100644 index 149078b77..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ /dev/null @@ -1,286 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.io.File -import java.nio.file.Files -import java.util.concurrent.atomic.AtomicBoolean - -internal fun interface NativePlayerEventSink { - fun onPlayerEvent(type: String, value: Double) -} - -internal object NativePlayerBridge { - private val preloadStarted = AtomicBoolean(false) - - init { - loadNativeLibrary() - } - - external fun create( - hostViewPtr: Long, - sourceUrl: String, - headerLines: Array, - playWhenReady: Boolean, - initialPositionMs: Long, - controlsPageUrl: String, - eventSink: NativePlayerEventSink, - ): Long - - external fun dispose(handle: Long) - external fun updateControls(handle: Long, controlsJson: String) - external fun setPaused(handle: Long, paused: Boolean) - external fun seekTo(handle: Long, positionMs: Long) - external fun seekBy(handle: Long, offsetMs: Long) - external fun setSpeed(handle: Long, speed: Float) - external fun setResizeMode(handle: Long, mode: Int) - external fun durationMs(handle: Long): Long - external fun positionMs(handle: Long): Long - external fun bufferedPositionMs(handle: Long): Long - external fun isLoading(handle: Long): Boolean - external fun isEnded(handle: Long): Boolean - external fun isPaused(handle: Long): Boolean - external fun speed(handle: Long): Float - external fun audioTracksJson(handle: Long): String - external fun subtitleTracksJson(handle: Long): String - external fun selectAudioTrack(handle: Long, trackId: Int) - external fun selectSubtitleTrack(handle: Long, trackId: Int) - external fun addSubtitleUrl(handle: Long, url: String) - external fun clearExternalSubtitles(handle: Long) - external fun clearExternalSubtitlesAndSelect(handle: Long, trackId: Int) - external fun applyWindowChrome( - windowHwnd: Long, - darkMode: Boolean, - captionColorRgb: Int, - borderColorRgb: Int, - textColorRgb: Int, - ) - - external fun setSubtitleDelayMs(handle: Long, delayMs: Int) - external fun applySubtitleStyle( - handle: Long, - textColor: String, - backgroundColor: String, - outlineColor: String, - outlineSize: Float, - bold: Boolean, - fontSize: Float, - subPos: Int, - ) - external fun warmupWebView2(controlsPageUrl: String): Boolean - external fun shutdownWebView2Warmup() - - val controlsPageUrl: String by lazy { controlsPageAssets.url } - private val controlsPageAssets: ControlsPageAssets by lazy { exportControlsPageAssets() } - - fun preloadAsync() { - if (!preloadStarted.compareAndSet(false, true)) return - Thread { - val controlsPage = runCatching { controlsPageAssets } - .getOrNull() - ?: return@Thread - if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { - runCatching { warmupWebView2(controlsPage.url) } - } - }.apply { - name = "nuvio-native-player-preload" - isDaemon = true - start() - } - if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { - Runtime.getRuntime().addShutdownHook( - Thread { - runCatching { shutdownWebView2Warmup() } - }.apply { - name = "nuvio-webview2-warmup-shutdown" - } - ) - } - } - - private fun loadNativeLibrary() { - val platform = DesktopHostOs.current - require(platform == DesktopHostOs.MACOS || platform == DesktopHostOs.WINDOWS) { - "Native desktop playback is not implemented for $platform yet." - } - - val libraryName = nativeLibraryName(platform) - val platformDir = nativeDirectoryName(platform) - findLocalBuildLibrary(platformDir, libraryName)?.let { localLibrary -> - copyLocalRuntimeResources(platformDir, localLibrary.parentFile) - System.load(localLibrary.absolutePath) - return - } - - val resource = "/native/$platformDir/$libraryName" - val input = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?: error("Missing bundled native player bridge: $resource") - val dir = File(System.getProperty("java.io.tmpdir"), "native-player-bridge").apply { mkdirs() } - val suffix = libraryName.substringAfter("player_bridge", ".dylib") - val file = Files.createTempFile(dir.toPath(), "player-bridge-", suffix).toFile() - file.deleteOnExit() - extractBundledRuntimeResources(platformDir, dir) - input.use { source -> - file.outputStream().use { target -> source.copyTo(target) } - } - System.load(file.absolutePath) - } - - private fun extractBundledRuntimeResources(platformDir: String, dir: File) { - val runtimeNames = bundledRuntimeResourceNames(platformDir) - runtimeNames.forEach { name -> - val resource = "/native/$platformDir/$name" - val input = NativePlayerBridge::class.java.getResourceAsStream(resource) ?: return@forEach - val target = dir.resolve(name) - input.use { source -> - target.outputStream().use { output -> source.copyTo(output) } - } - target.deleteOnExit() - } - } - - private fun bundledRuntimeResourceNames(platformDir: String): List { - val indexResource = "/native/$platformDir/runtime-files.txt" - val indexed = NativePlayerBridge::class.java.getResourceAsStream(indexResource) - ?.bufferedReader() - ?.useLines { lines -> - lines.map(String::trim) - .filter { it.isNotEmpty() && !it.startsWith("#") } - .toList() - } - .orEmpty() - if (indexed.isNotEmpty()) return indexed - - return when (platformDir) { - "windows" -> listOf("libmpv-2.dll") - else -> emptyList() - } - } - - private fun findLocalBuildLibrary(platformDir: String, libraryName: String): File? { - val candidates = listOf( - File("composeApp/build/native/$platformDir/$libraryName"), - File("build/native/$platformDir/$libraryName"), - ) - return candidates.firstOrNull { it.exists() } - } - - private fun copyLocalRuntimeResources(platformDir: String, targetDir: File) { - val runtimeDirs = listOf( - File("composeApp/build/native/$platformDir-runtime"), - File("build/native/$platformDir-runtime"), - ) - runtimeDirs.firstOrNull(File::isDirectory) - ?.listFiles { file -> file.isFile } - ?.forEach { runtimeFile -> - val target = targetDir.resolve(runtimeFile.name) - if (runtimeFile.absolutePath != target.absolutePath) { - runCatching { runtimeFile.copyTo(target, overwrite = true) } - } - } - } - - private fun nativeDirectoryName(platform: DesktopHostOs): String = - when (platform) { - DesktopHostOs.MACOS -> "macos" - DesktopHostOs.WINDOWS -> "windows" - DesktopHostOs.LINUX -> "linux" - DesktopHostOs.UNKNOWN -> "unknown" - } - - private fun nativeLibraryName(platform: DesktopHostOs): String = - when (platform) { - DesktopHostOs.MACOS -> "libplayer_bridge.dylib" - DesktopHostOs.WINDOWS -> "player_bridge.dll" - DesktopHostOs.LINUX -> "libplayer_bridge.so" - DesktopHostOs.UNKNOWN -> "player_bridge" - } - - private fun exportControlsPageAssets(): ControlsPageAssets { - val root = File(System.getProperty("java.io.tmpdir"), "nuvio-player-ui").apply { mkdirs() } - val fontsDir = root.resolve("fonts").apply { mkdirs() } - val htmlFile = root.resolve("controls.html") - writeTextIfChanged( - target = htmlFile, - text = readTextResource("/player-ui/controls.html"), - ) - writeTextIfChanged( - target = root.resolve("controls.css"), - text = readTextResource("/player-ui/controls.css") - .replace("/* __NUVIO_PLAYER_FONT_FACES__ */", nativePlayerFontFaces()), - ) - copyResourceIfChanged( - resource = "/player-ui/controls.js", - target = root.resolve("controls.js"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_regular.ttf", - target = fontsDir.resolve("jetbrains_sans_regular.ttf"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_semibold.ttf", - target = fontsDir.resolve("jetbrains_sans_semibold.ttf"), - ) - copyResourceIfChanged( - resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_bold.ttf", - target = fontsDir.resolve("jetbrains_sans_bold.ttf"), - ) - return ControlsPageAssets( - url = htmlFile.toURI().toASCIIString(), - ) - } - - private fun nativePlayerFontFaces(): String = - """ - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_regular.ttf") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: block; - } - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_semibold.ttf") format("truetype"); - font-weight: 600; - font-style: normal; - font-display: block; - } - @font-face { - font-family: "Nuvio JetBrains Sans"; - src: url("fonts/jetbrains_sans_bold.ttf") format("truetype"); - font-weight: 700 900; - font-style: normal; - font-display: block; - } - """.trimIndent() - - private fun readTextResource(resource: String): String = - NativePlayerBridge::class.java.getResourceAsStream(resource) - ?.bufferedReader(Charsets.UTF_8) - ?.use { it.readText() } - ?: error("Missing native player controls resource: $resource") - - private fun writeTextIfChanged(target: File, text: String) { - val bytes = text.toByteArray(Charsets.UTF_8) - if (target.exists() && target.readBytes().contentEquals(bytes)) return - target.writeBytes(bytes) - } - - private fun copyResourceIfChanged(resource: String, target: File) { - val bytes = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?.use { it.readBytes() } - ?: error("Missing native player controls resource: $resource") - if (target.exists() && target.readBytes().contentEquals(bytes)) return - Files.createDirectories(target.parentFile.toPath()) - target.writeBytes(bytes) - } - - private data class ControlsPageAssets( - val url: String, - ) -} - -internal fun preloadNativePlayerBridgeAsync() { - if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { - NativePlayerBridge.preloadAsync() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt deleted file mode 100644 index 9bf4f3cc8..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ /dev/null @@ -1,953 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import androidx.compose.ui.graphics.Color -import com.nuvio.app.features.player.PlayerControlAddonSubtitleItem -import com.nuvio.app.features.player.PlayerControlEpisodeItem -import com.nuvio.app.features.player.PlayerControlFilterItem -import com.nuvio.app.features.player.PlayerControlSeasonItem -import com.nuvio.app.features.player.PlayerControlSourceItem -import com.nuvio.app.features.player.PlayerControlSubtitleCueItem -import com.nuvio.app.features.player.AudioTrack -import com.nuvio.app.features.player.ParentalWarning -import com.nuvio.app.features.player.PlayerControlsAction -import com.nuvio.app.features.player.PlayerControlsState -import com.nuvio.app.features.player.PlayerEngineController -import com.nuvio.app.features.player.PlayerPlaybackSnapshot -import com.nuvio.app.features.player.PlayerResizeMode -import com.nuvio.app.features.player.SUBTITLE_DELAY_MAX_MS -import com.nuvio.app.features.player.SUBTITLE_DELAY_MIN_MS -import com.nuvio.app.features.player.SubtitleColorSwatches -import com.nuvio.app.features.player.SubtitleStyleState -import com.nuvio.app.features.player.SubtitleTrack -import com.nuvio.app.features.player.inferForcedSubtitleTrack -import com.nuvio.app.features.player.toStorageHexString -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import javax.swing.SwingUtilities -import kotlin.concurrent.Volatile - -internal class NativePlayerController( - private val host: NativePlayerHost, -) : PlayerEngineController { - private companion object { - val json = Json { ignoreUnknownKeys = true } - } - - @Volatile - private var handle: Long = 0L - private var pendingSource: PendingSource? = null - private var controlsState = PlayerControlsState() - private var lastSentControlsStructureKey: PlayerControlsState? = null - private var onAction: (PlayerControlsAction) -> Boolean = { false } - private var onEvent: (String, Double) -> Boolean = { _, _ -> false } - private var onScrubChange: (Long) -> Boolean = { false } - private var onScrubFinished: (Long) -> Boolean = { false } - private val eventSink = NativePlayerEventSink { type, value -> - SwingUtilities.invokeLater { - handlePlayerEvent(type, value) - } - } - - fun attach( - sourceUrl: String, - sourceHeaders: Map, - playWhenReady: Boolean, - initialPositionMs: Long, - onError: (String?) -> Unit, - ) { - val pending = PendingSource( - sourceUrl = sourceUrl, - headerLines = sourceHeaders.toHeaderLines(), - playWhenReady = playWhenReady, - initialPositionMs = initialPositionMs.coerceAtLeast(0L), - onError = onError, - ) - pendingSource = pending - host.onPeerReady = { attachPending() } - if (host.isDisplayable) { - attachPending() - } - } - - private fun attachPending() { - val pending = pendingSource ?: return - SwingUtilities.invokeLater { - if (!host.isDisplayable) { - return@invokeLater - } - disposePlayerHandle() - runCatching { - val hostViewPtr = AwtNativeViewResolver.resolveNativeViewPointer(host) - handle = NativePlayerBridge.create( - hostViewPtr = hostViewPtr, - sourceUrl = pending.sourceUrl, - headerLines = pending.headerLines.toTypedArray(), - playWhenReady = pending.playWhenReady, - initialPositionMs = pending.initialPositionMs, - controlsPageUrl = NativePlayerBridge.controlsPageUrl, - eventSink = eventSink, - ) - if (handle == 0L) error("Native player did not return a handle.") - updateControls(controlsState) - }.onFailure { error -> - pending.onError(error.message) - } - } - } - - fun setControlCallbacks( - onAction: (PlayerControlsAction) -> Boolean, - onEvent: (String, Double) -> Boolean, - onScrubChange: (Long) -> Boolean, - onScrubFinished: (Long) -> Boolean, - ) { - this.onAction = onAction - this.onEvent = onEvent - this.onScrubChange = onScrubChange - this.onScrubFinished = onScrubFinished - } - - fun updateControls(state: PlayerControlsState) { - controlsState = state - val currentHandle = handle - val structureKey = state.nativeControlsStructureKey() - val current = currentHandle.takeIf { it != 0L } ?: return - if (structureKey == lastSentControlsStructureKey) return - lastSentControlsStructureKey = structureKey - NativePlayerBridge.updateControls(current, state.toControlsJson()) - } - - fun setResizeMode(mode: PlayerResizeMode) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.setResizeMode( - handle = current, - mode = when (mode) { - PlayerResizeMode.Fit -> 0 - PlayerResizeMode.Fill -> 1 - PlayerResizeMode.Zoom -> 2 - }, - ) - } - } - - private fun handlePlayerEvent(type: String, value: Double) { - when (type) { - "scrubChange" -> { - if (!onScrubChange(value.toLong())) { - updateLocalProgress(value.toLong()) - } - } - "scrubFinish" -> { - val scrubHandled = onScrubFinished(value.toLong()) - if (!scrubHandled) { - seekTo(value.toLong()) - } - } - "toggleFullscreen" -> toggleDesktopAppFullscreen(SwingUtilities.getWindowAncestor(host)) - else -> { - val eventHandled = onEvent(type, value) - if (eventHandled) return - val action = type.toPlayerControlsAction() - if (action == null) return - val actionHandled = onAction(action) - if (!actionHandled) { - handleFallbackAction(action) - } - } - } - } - - private fun updateLocalProgress(positionMs: Long) { - controlsState = controlsState.copy(positionMs = positionMs) - updateControls(controlsState) - } - - private fun handleFallbackAction(action: PlayerControlsAction) { - when (action) { - PlayerControlsAction.TogglePlayback, - PlayerControlsAction.KeyboardTogglePlayback -> { - val current = handle - if (current == 0L) return - val isEnded = NativePlayerBridge.isEnded(current) - val isPaused = NativePlayerBridge.isPaused(current) - if (isEnded) { - NativePlayerBridge.seekTo(current, 0L) - NativePlayerBridge.setPaused(current, false) - } else { - NativePlayerBridge.setPaused(current, !isPaused) - } - } - PlayerControlsAction.SeekBack, - PlayerControlsAction.KeyboardSeekBack -> fallbackSeekBy(-10_000L) - PlayerControlsAction.SeekForward, - PlayerControlsAction.KeyboardSeekForward -> fallbackSeekBy(10_000L) - PlayerControlsAction.Speed -> cycleFallbackSpeed() - else -> Unit - } - } - - private fun fallbackSeekBy(offsetMs: Long) { - val current = handle - if (current != 0L) { - NativePlayerBridge.seekBy(current, offsetMs) - } - } - - private fun cycleFallbackSpeed() { - val current = handle - if (current == 0L) return - val speeds = listOf(1f, 1.25f, 1.5f, 2f) - val currentSpeed = NativePlayerBridge.speed(current) - val next = speeds.firstOrNull { it > currentSpeed + 0.01f } ?: speeds.first() - NativePlayerBridge.setSpeed(current, next) - } - - fun snapshot(): PlayerPlaybackSnapshot { - val current = handle - if (current == 0L) return PlayerPlaybackSnapshot(isLoading = true) - return runCatching { - val isLoading = NativePlayerBridge.isLoading(current) - val isEnded = NativePlayerBridge.isEnded(current) - PlayerPlaybackSnapshot( - isLoading = isLoading, - isPlaying = !NativePlayerBridge.isPaused(current) && !isLoading && !isEnded, - isEnded = isEnded, - durationMs = NativePlayerBridge.durationMs(current), - positionMs = NativePlayerBridge.positionMs(current), - bufferedPositionMs = NativePlayerBridge.bufferedPositionMs(current), - playbackSpeed = NativePlayerBridge.speed(current), - ) - }.getOrDefault(PlayerPlaybackSnapshot(isLoading = true)) - } - - fun dispose() { - disposePlayerHandle() - } - - private fun disposePlayerHandle() { - val current = handle - handle = 0L - lastSentControlsStructureKey = null - if (current != 0L) { - runCatching { NativePlayerBridge.dispose(current) } - } - } - - override fun play() { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, false) } - } - - override fun pause() { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setPaused(it, true) } - } - - override fun seekTo(positionMs: Long) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekTo(it, positionMs) } - } - - override fun seekBy(offsetMs: Long) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.seekBy(it, offsetMs) } - } - - override fun retry() { - val pending = pendingSource ?: return - attach( - sourceUrl = pending.sourceUrl, - sourceHeaders = pending.headerLines.toHeaderMap(), - playWhenReady = pending.playWhenReady, - initialPositionMs = pending.initialPositionMs, - onError = pending.onError, - ) - } - - override fun setPlaybackSpeed(speed: Float) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.setSpeed(it, speed) } - } - - override fun getAudioTracks(): List = - decodeTracks { NativePlayerBridge.audioTracksJson(it) }.map { track -> - AudioTrack( - index = track.index, - id = track.id, - label = track.label, - language = track.language.takeUnless(String::isBlank), - isSelected = track.selected, - ) - } - - override fun getSubtitleTracks(): List = - decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }.map { track -> - SubtitleTrack( - index = track.index, - id = track.id, - label = track.label, - language = track.language.takeUnless(String::isBlank), - isSelected = track.selected, - isForced = track.forced || inferForcedSubtitleTrack( - label = track.label, - language = track.language, - trackId = track.id, - ), - ) - } - - override fun selectAudioTrack(index: Int) { - val current = handle.takeIf { it != 0L } ?: return - val trackId = resolveTrackId(index, decodeTracks { NativePlayerBridge.audioTracksJson(it) }) ?: return - NativePlayerBridge.selectAudioTrack(current, trackId) - } - - override fun selectSubtitleTrack(index: Int) { - val current = handle.takeIf { it != 0L } ?: return - if (index < 0) { - NativePlayerBridge.selectSubtitleTrack(current, -1) - return - } - val trackId = resolveTrackId(index, decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }) ?: return - NativePlayerBridge.selectSubtitleTrack(current, trackId) - } - - override fun setSubtitleUri(url: String) { - handle.takeIf { it != 0L }?.let { NativePlayerBridge.addSubtitleUrl(it, url) } - } - - override fun clearExternalSubtitle() { - handle.takeIf { it != 0L }?.let(NativePlayerBridge::clearExternalSubtitles) - } - - override fun clearExternalSubtitleAndSelect(trackIndex: Int) { - val current = handle.takeIf { it != 0L } ?: return - val trackId = if (trackIndex < 0) { - -1 - } else { - resolveTrackId(trackIndex, decodeTracks { NativePlayerBridge.subtitleTracksJson(it) }) ?: return - } - NativePlayerBridge.clearExternalSubtitlesAndSelect(current, trackId) - } - - override fun setSubtitleDelayMs(delayMs: Int) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.setSubtitleDelayMs( - current, - delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS), - ) - } - } - - override fun applySubtitleStyle(style: SubtitleStyleState) { - handle.takeIf { it != 0L }?.let { current -> - NativePlayerBridge.applySubtitleStyle( - handle = current, - textColor = style.textColor.toMpvColorString(), - backgroundColor = style.backgroundColor.toMpvColorString(), - outlineColor = style.outlineColor.toMpvColorString(), - outlineSize = if (style.outlineEnabled) style.outlineWidth.toFloat() else 0f, - bold = style.bold, - fontSize = style.toMpvSubtitleFontSize(), - subPos = style.toMpvSubtitlePosition(), - ) - } - } - - private fun decodeTracks(readJson: (Long) -> String): List { - val current = handle.takeIf { it != 0L } ?: return emptyList() - return runCatching { - json.decodeFromString>(readJson(current)) - }.getOrDefault(emptyList()) - } -} - -@Serializable -private data class NativeMpvTrack( - val index: Int = 0, - val id: String = "", - val label: String = "", - val language: String = "", - val selected: Boolean = false, - val forced: Boolean = false, -) - -private fun resolveTrackId(index: Int, tracks: List): Int? = - tracks.firstNotNullOfOrNull { track -> - if (track.index == index) { - track.id.toIntOrNull() - } else { - null - } - } ?: tracks.getOrNull(index)?.id?.toIntOrNull() - -private fun Color.toMpvColorString(): String { - val alphaInt = (alpha * 255f).toInt().coerceIn(0, 255) - val redInt = (red * 255f).toInt().coerceIn(0, 255) - val greenInt = (green * 255f).toInt().coerceIn(0, 255) - val blueInt = (blue * 255f).toInt().coerceIn(0, 255) - return buildString { - append('#') - append(alphaInt.toHexByte()) - append(redInt.toHexByte()) - append(greenInt.toHexByte()) - append(blueInt.toHexByte()) - } -} - -private fun SubtitleStyleState.toMpvSubtitlePosition(): Int = - (100 - (bottomOffset / 2)).coerceIn(0, 150) - -private fun SubtitleStyleState.toMpvSubtitleFontSize(): Float = - (fontSizeSp * 3f).coerceIn(24f, 96f) - -private fun Int.toHexByte(): String { - val digits = "0123456789ABCDEF" - val value = coerceIn(0, 255) - return buildString { - append(digits[value / 16]) - append(digits[value % 16]) - } -} - -private data class PendingSource( - val sourceUrl: String, - val headerLines: List, - val playWhenReady: Boolean, - val initialPositionMs: Long, - val onError: (String?) -> Unit, -) - -private fun Map.toHeaderLines(): List = - entries.mapNotNull { (key, value) -> - val cleanKey = key.trim() - val cleanValue = value.trim() - if (cleanKey.isBlank() || cleanValue.isBlank()) { - null - } else { - "$cleanKey: $cleanValue" - } - } - -private fun List.toHeaderMap(): Map = - mapNotNull { line -> - val separator = line.indexOf(':') - if (separator <= 0) return@mapNotNull null - line.substring(0, separator).trim() to line.substring(separator + 1).trim() - }.toMap() - -private fun String.toPlayerControlsAction(): PlayerControlsAction? = - when (this) { - "toggleChrome" -> PlayerControlsAction.ToggleChrome - "revealLockedOverlay" -> PlayerControlsAction.RevealLockedOverlay - "back" -> PlayerControlsAction.Back - "toggle" -> PlayerControlsAction.TogglePlayback - "keyboardToggle" -> PlayerControlsAction.KeyboardTogglePlayback - "seekBack" -> PlayerControlsAction.SeekBack - "keyboardSeekBack" -> PlayerControlsAction.KeyboardSeekBack - "seekForward" -> PlayerControlsAction.SeekForward - "keyboardSeekForward" -> PlayerControlsAction.KeyboardSeekForward - "resize" -> PlayerControlsAction.ResizeMode - "speed" -> PlayerControlsAction.Speed - "subtitles" -> PlayerControlsAction.Subtitles - "audio" -> PlayerControlsAction.Audio - "sources" -> PlayerControlsAction.Sources - "episodes" -> PlayerControlsAction.Episodes - "external" -> PlayerControlsAction.OpenExternalPlayer - "submitIntro" -> PlayerControlsAction.SubmitIntro - "lock" -> PlayerControlsAction.LockToggle - "videoSettings" -> PlayerControlsAction.VideoSettings - else -> null - } - -private fun PlayerControlsState.toControlsJson(): String = - buildString { - append('{') - appendJsonField("title", title) - append(',') - appendJsonField("episodeText", episodeText) - append(',') - appendJsonField("streamTitle", streamTitle) - append(',') - appendJsonField("providerName", providerName) - append(',') - appendJsonField("pauseOverlayWatchingLabel", pauseOverlayWatchingLabel) - append(',') - appendJsonField("pauseOverlayLogo", pauseOverlayLogo.orEmpty()) - append(',') - appendJsonField("pauseOverlayEpisodeInfo", pauseOverlayEpisodeInfo) - append(',') - appendJsonField("pauseOverlayEpisodeTitle", pauseOverlayEpisodeTitle) - append(',') - appendJsonField("pauseOverlayDescription", pauseOverlayDescription) - append(',') - appendJsonField("resizeModeLabel", resizeModeLabel) - append(',') - appendJsonField("playbackSpeedLabel", playbackSpeedLabel) - append(',') - appendJsonField("subtitlesLabel", subtitlesLabel) - append(',') - appendJsonField("audioLabel", audioLabel) - append(',') - appendJsonField("sourcesLabel", sourcesLabel) - append(',') - appendJsonField("episodesLabel", episodesLabel) - append(',') - appendJsonField("externalPlayerLabel", externalPlayerLabel) - append(',') - appendJsonField("playLabel", playLabel) - append(',') - appendJsonField("pauseLabel", pauseLabel) - append(',') - appendJsonField("closeLabel", closeLabel) - append(',') - appendJsonField("lockLabel", lockLabel) - append(',') - appendJsonField("unlockLabel", unlockLabel) - append(',') - appendJsonField("submitIntroLabel", submitIntroLabel) - append(',') - appendJsonField("videoSettingsLabel", videoSettingsLabel) - append(',') - appendJsonField("tapToUnlockLabel", tapToUnlockLabel) - append(',') - appendJsonField("playbackErrorTitle", playbackErrorTitle) - append(',') - appendJsonField("playbackErrorMessage", playbackErrorMessage) - append(',') - appendJsonField("playbackErrorActionLabel", playbackErrorActionLabel) - append(',') - appendJsonField("sourcesPanelTitle", sourcesPanelTitle) - append(',') - appendJsonField("episodesPanelTitle", episodesPanelTitle) - append(',') - appendJsonField("streamsPanelTitle", streamsPanelTitle) - append(',') - appendJsonField("allFilterLabel", allFilterLabel) - append(',') - appendJsonField("reloadLabel", reloadLabel) - append(',') - appendJsonField("backLabel", backLabel) - append(',') - appendJsonField("panelCloseLabel", panelCloseLabel) - append(',') - appendJsonField("cancelLabel", cancelLabel) - append(',') - appendJsonField("playingLabel", playingLabel) - append(',') - appendJsonField("noStreamsLabel", noStreamsLabel) - append(',') - appendJsonField("noEpisodesLabel", noEpisodesLabel) - append(',') - appendJsonField("submitIntroPanelTitle", submitIntroPanelTitle) - append(',') - appendJsonField("submitIntroSegmentTypeLabel", submitIntroSegmentTypeLabel) - append(',') - appendJsonField("submitIntroSegmentIntroLabel", submitIntroSegmentIntroLabel) - append(',') - appendJsonField("submitIntroSegmentRecapLabel", submitIntroSegmentRecapLabel) - append(',') - appendJsonField("submitIntroSegmentOutroLabel", submitIntroSegmentOutroLabel) - append(',') - appendJsonField("submitIntroStartTimeLabel", submitIntroStartTimeLabel) - append(',') - appendJsonField("submitIntroEndTimeLabel", submitIntroEndTimeLabel) - append(',') - appendJsonField("submitIntroCaptureLabel", submitIntroCaptureLabel) - append(',') - appendJsonField("submitIntroSubmitLabel", submitIntroSubmitLabel) - append(',') - appendJsonField("p2pConsentTitle", p2pConsentTitle) - append(',') - appendJsonField("p2pConsentBody", p2pConsentBody) - append(',') - appendJsonField("p2pConsentEnableLabel", p2pConsentEnableLabel) - append(',') - appendJsonField("p2pConsentCancelLabel", p2pConsentCancelLabel) - append(',') - appendJsonField("subtitlesPanelTitle", subtitlesPanelTitle) - append(',') - appendJsonField("subtitleBuiltInTabLabel", subtitleBuiltInTabLabel) - append(',') - appendJsonField("subtitleAddonsTabLabel", subtitleAddonsTabLabel) - append(',') - appendJsonField("subtitleStyleTabLabel", subtitleStyleTabLabel) - append(',') - appendJsonField("noneLabel", noneLabel) - append(',') - appendJsonField("fetchSubtitlesLabel", fetchSubtitlesLabel) - append(',') - appendJsonField("subtitleDelayLabel", subtitleDelayLabel) - append(',') - appendJsonField("resetLabel", resetLabel) - append(',') - appendJsonField("autoSyncLabel", autoSyncLabel) - append(',') - appendJsonField("reloadSmallLabel", reloadSmallLabel) - append(',') - appendJsonField("captureLineLabel", captureLineLabel) - append(',') - appendJsonField("selectAddonSubtitleFirstLabel", selectAddonSubtitleFirstLabel) - append(',') - appendJsonField("loadingSubtitleLinesLabel", loadingSubtitleLinesLabel) - append(',') - appendJsonField("fontSizeLabel", fontSizeLabel) - append(',') - appendJsonField("outlineLabel", outlineLabel) - append(',') - appendJsonField("boldLabel", boldLabel) - append(',') - appendJsonField("bottomOffsetLabel", bottomOffsetLabel) - append(',') - appendJsonField("colorLabel", colorLabel) - append(',') - appendJsonField("textOpacityLabel", textOpacityLabel) - append(',') - appendJsonField("outlineColorLabel", outlineColorLabel) - append(',') - appendJsonField("resetDefaultsLabel", resetDefaultsLabel) - append(',') - appendJsonField("onLabel", onLabel) - append(',') - appendJsonField("offLabel", offLabel) - append(',') - appendJsonField("themeAccentColor", themeAccentColor) - append(',') - appendJsonField("themeAccentStrongColor", themeAccentStrongColor) - append(',') - appendJsonField("themeOnAccentColor", themeOnAccentColor) - append(',') - appendJsonField("themeFocusColor", themeFocusColor) - append(',') - appendJsonField("themeSelectedSurfaceColor", themeSelectedSurfaceColor) - append(',') - appendJsonField("themeSelectedSurfaceHoverColor", themeSelectedSurfaceHoverColor) - append(',') - appendJsonField("themeSelectedRingColor", themeSelectedRingColor) - append(',') - appendJsonField("themeTimelineFillColor", themeTimelineFillColor) - append(',') - appendJsonField("themeTimelineTrackColor", themeTimelineTrackColor) - append(',') - appendJsonField("themeBufferingColor", themeBufferingColor) - append(',') - appendJsonField("themeBufferingTrackColor", themeBufferingTrackColor) - append(',') - appendJsonField("themeControlForegroundColor", themeControlForegroundColor) - append(',') - appendJsonField("isPlaying", isPlaying) - append(',') - appendJsonField("isLoading", isLoading) - append(',') - appendJsonField("isLocked", isLocked) - append(',') - appendJsonField("lockedOverlayVisible", lockedOverlayVisible) - append(',') - appendJsonField("controlsVisible", controlsVisible) - append(',') - appendJsonArrayField("parentalWarnings", parentalWarnings) { appendParentalWarningJson(it) } - append(',') - appendJsonField("showParentalGuide", showParentalGuide) - append(',') - appendJsonField("showOpeningOverlay", showOpeningOverlay) - append(',') - appendJsonField("openingArtwork", openingArtwork.orEmpty()) - append(',') - appendJsonField("openingLogo", openingLogo.orEmpty()) - append(',') - appendJsonField("openingTitle", openingTitle) - append(',') - appendJsonField("openingMessage", openingMessage.orEmpty()) - append(',') - appendJsonField("openingProgress", openingProgress) - append(',') - appendJsonField("skipPromptVisible", skipPromptVisible) - append(',') - appendJsonField("skipPromptLabel", skipPromptLabel) - append(',') - appendJsonField("skipPromptStartMs", skipPromptStartMs) - append(',') - appendJsonField("skipPromptEndMs", skipPromptEndMs) - append(',') - appendJsonField("skipPromptDismissed", skipPromptDismissed) - append(',') - appendJsonField("nextEpisodeVisible", nextEpisodeVisible) - append(',') - appendJsonField("nextEpisodeHeaderLabel", nextEpisodeHeaderLabel) - append(',') - appendJsonField("nextEpisodeTitle", nextEpisodeTitle) - append(',') - appendJsonField("nextEpisodeThumbnail", nextEpisodeThumbnail) - append(',') - appendJsonField("nextEpisodeStatus", nextEpisodeStatus) - append(',') - appendJsonField("nextEpisodeActionLabel", nextEpisodeActionLabel) - append(',') - appendJsonField("nextEpisodePlayable", nextEpisodePlayable) - append(',') - appendJsonField("showSubmitIntro", showSubmitIntro) - append(',') - appendJsonField("showVideoSettings", showVideoSettings) - append(',') - appendJsonField("showSources", showSources) - append(',') - appendJsonField("showEpisodes", showEpisodes) - append(',') - appendJsonField("showExternalPlayer", showExternalPlayer) - append(',') - appendJsonField("durationMs", durationMs) - append(',') - appendJsonField("positionMs", positionMs) - append(',') - appendJsonField("sourceIsLoading", sourceIsLoading) - append(',') - appendJsonArrayField("sourceFilters", sourceFilters) { appendFilterItemJson(it) } - append(',') - appendJsonArrayField("sourceItems", sourceItems) { appendSourceItemJson(it) } - append(',') - appendJsonArrayField("episodeItems", episodeItems) { appendEpisodeItemJson(it) } - append(',') - appendJsonArrayField("episodeSeasons", episodeSeasons) { appendSeasonItemJson(it) } - append(',') - appendJsonField("episodeStreamsVisible", episodeStreamsVisible) - append(',') - appendJsonField("episodeStreamsIsLoading", episodeStreamsIsLoading) - append(',') - appendJsonField("selectedEpisodeLabel", selectedEpisodeLabel) - append(',') - appendJsonArrayField("episodeStreamFilters", episodeStreamFilters) { appendFilterItemJson(it) } - append(',') - appendJsonArrayField("episodeStreamItems", episodeStreamItems) { appendSourceItemJson(it) } - append(',') - appendJsonField("submitIntroSegmentType", submitIntroSegmentType) - append(',') - appendJsonField("submitIntroStartTime", submitIntroStartTime) - append(',') - appendJsonField("submitIntroEndTime", submitIntroEndTime) - append(',') - appendJsonField("isSubmitIntroSubmitting", isSubmitIntroSubmitting) - append(',') - appendJsonField("submitIntroStatusMessage", submitIntroStatusMessage) - append(',') - appendJsonField("showP2pConsent", showP2pConsent) - append(',') - appendJsonField("subtitleActiveTab", subtitleActiveTab) - append(',') - appendJsonArrayField("addonSubtitleItems", addonSubtitleItems) { appendAddonSubtitleItemJson(it) } - append(',') - appendJsonField("isLoadingAddonSubtitles", isLoadingAddonSubtitles) - append(',') - appendJsonField("selectedAddonSubtitleId", selectedAddonSubtitleId) - append(',') - appendJsonField("useCustomSubtitles", useCustomSubtitles) - append(',') - appendJsonField("subtitleDelayMs", subtitleDelayMs) - append(',') - appendJsonField("hasSelectedAddonSubtitle", hasSelectedAddonSubtitle) - append(',') - appendJsonField("subtitleAutoSyncCapturedPositionMs", subtitleAutoSyncCapturedPositionMs) - append(',') - appendJsonArrayField("subtitleAutoSyncCues", subtitleAutoSyncCues) { appendSubtitleCueItemJson(it) } - append(',') - appendJsonField("subtitleAutoSyncIsLoading", subtitleAutoSyncIsLoading) - append(',') - appendJsonField("subtitleAutoSyncErrorMessage", subtitleAutoSyncErrorMessage) - append(',') - appendJsonField("subtitleStyle", subtitleStyle) - append(',') - appendJsonArrayField("subtitleColorSwatches", SubtitleColorSwatches.map { it.toStorageHexString() }) { append(it.toJsonString()) } - append(',') - appendJsonField("closeModalsToken", closeModalsToken) - append('}') - } - -private fun PlayerControlsState.nativeControlsStructureKey(): PlayerControlsState = - copy( - isPlaying = false, - isLoading = false, - durationMs = 0L, - positionMs = 0L, - ) - -private fun StringBuilder.appendJsonField(name: String, value: String) { - append('"').append(name).append("\":") - append(value.toJsonString()) -} - -private fun StringBuilder.appendJsonField(name: String, value: Boolean) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: Long) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: Float?) { - append('"').append(name).append("\":") - if (value == null || value.isNaN() || value.isInfinite()) { - append("null") - } else { - append(value.coerceIn(0f, 1f)) - } -} - -private fun StringBuilder.appendJsonField(name: String, value: Int) { - append('"').append(name).append("\":").append(value) -} - -private fun StringBuilder.appendJsonField(name: String, value: SubtitleStyleState) { - append('"').append(name).append("\":") - appendSubtitleStyleJson(value) -} - -private inline fun StringBuilder.appendJsonArrayField( - name: String, - values: List, - appendValue: StringBuilder.(T) -> Unit, -) { - append('"').append(name).append("\":[") - values.forEachIndexed { index, value -> - if (index > 0) append(',') - appendValue(value) - } - append(']') -} - -private fun StringBuilder.appendFilterItemJson(item: PlayerControlFilterItem) { - append('{') - appendJsonField("id", item.id) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("isSelected", item.isSelected) - append(',') - appendJsonField("isLoading", item.isLoading) - append(',') - appendJsonField("hasError", item.hasError) - append('}') -} - -private fun StringBuilder.appendSeasonItemJson(item: PlayerControlSeasonItem) { - append('{') - appendJsonField("season", item.season) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("isSelected", item.isSelected) - append('}') -} - -private fun StringBuilder.appendSourceItemJson(item: PlayerControlSourceItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("filterId", item.filterId) - append(',') - appendJsonField("label", item.label) - append(',') - appendJsonField("subtitle", item.subtitle) - append(',') - appendJsonField("addonName", item.addonName) - append(',') - appendJsonField("isCurrent", item.isCurrent) - append(',') - appendJsonField("isEnabled", item.isEnabled) - append('}') -} - -private fun StringBuilder.appendEpisodeItemJson(item: PlayerControlEpisodeItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("id", item.id) - append(',') - appendJsonField("title", item.title) - append(',') - appendJsonField("code", item.code) - append(',') - appendJsonField("overview", item.overview) - append(',') - appendJsonField("thumbnail", item.thumbnail) - append(',') - appendJsonField("season", item.season) - append(',') - appendJsonField("episode", item.episode) - append(',') - appendJsonField("isCurrent", item.isCurrent) - append(',') - appendJsonField("isWatched", item.isWatched) - append('}') -} - -private fun StringBuilder.appendAddonSubtitleItemJson(item: PlayerControlAddonSubtitleItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("id", item.id) - append(',') - appendJsonField("display", item.display) - append(',') - appendJsonField("languageLabel", item.languageLabel) - append(',') - appendJsonField("addonName", item.addonName) - append(',') - appendJsonField("isSelected", item.isSelected) - append('}') -} - -private fun StringBuilder.appendSubtitleCueItemJson(item: PlayerControlSubtitleCueItem) { - append('{') - appendJsonField("index", item.index) - append(',') - appendJsonField("timeMs", item.timeMs) - append(',') - appendJsonField("timeLabel", item.timeLabel) - append(',') - appendJsonField("text", item.text) - append('}') -} - -private fun StringBuilder.appendParentalWarningJson(item: ParentalWarning) { - append('{') - appendJsonField("label", item.label) - append(',') - appendJsonField("severity", item.severity) - append('}') -} - -private fun StringBuilder.appendSubtitleStyleJson(style: SubtitleStyleState) { - append('{') - appendJsonField("textColor", style.textColor.toStorageHexString()) - append(',') - appendJsonField("outlineColor", style.outlineColor.toStorageHexString()) - append(',') - appendJsonField("outlineEnabled", style.outlineEnabled) - append(',') - appendJsonField("bold", style.bold) - append(',') - appendJsonField("fontSizeSp", style.fontSizeSp) - append(',') - appendJsonField("bottomOffset", style.bottomOffset) - append('}') -} - -private fun String.toJsonString(): String = - buildString(length + 2) { - append('"') - for (char in this@toJsonString) { - when (char) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> { - if (char.code < 0x20) { - append("\\u") - append(char.code.toString(16).padStart(4, '0')) - } else { - append(char) - } - } - } - } - append('"') - } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt deleted file mode 100644 index a1675948a..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.nuvio.app.features.player.desktop - -import java.awt.Canvas -import java.awt.Color -import java.awt.Graphics - -internal class NativePlayerHost : Canvas() { - var onPeerReady: (() -> Unit)? = null - var onDisplayableChanged: ((Boolean) -> Unit)? = null - var onFirstPaint: (() -> Unit)? = null - var onFirstFullSizePaint: (() -> Unit)? = null - private var firstPaintNotified = false - private var firstFullSizePaintNotified = false - - init { - background = Color.BLACK - ignoreRepaint = false - } - - override fun update(graphics: Graphics) { - paint(graphics) - } - - override fun paint(graphics: Graphics) { - graphics.color = Color.BLACK - graphics.fillRect(0, 0, width, height) - if (!firstPaintNotified) { - firstPaintNotified = true - onFirstPaint?.invoke() - } - if (!firstFullSizePaintNotified && width > 1 && height > 1) { - firstFullSizePaintNotified = true - onFirstFullSizePaint?.invoke() - } - } - - override fun addNotify() { - super.addNotify() - onDisplayableChanged?.invoke(true) - repaint() - onPeerReady?.invoke() - } - - override fun removeNotify() { - onDisplayableChanged?.invoke(false) - firstPaintNotified = false - firstFullSizePaintNotified = false - onPeerReady = null - onFirstPaint = null - onFirstFullSizePaint = null - super.removeNotify() - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt deleted file mode 100644 index 443eebab8..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nuvio.app.features.player.skip - -import java.time.LocalDate - -internal actual fun currentDateComponents(): DateComponents { - val today = LocalDate.now() - return DateComponents( - year = today.year, - month = today.monthValue, - day = today.dayOfMonth, - ) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt deleted file mode 100644 index 0b73ade08..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.nuvio.app.features.plugins - -import java.security.MessageDigest -import java.util.Base64 -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -internal fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} - -internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = when (algorithm.uppercase()) { - "SHA1" -> "HmacSHA1" - "SHA256" -> "HmacSHA256" - "SHA512" -> "HmacSHA512" - "MD5" -> "HmacMD5" - else -> error("Unsupported HMAC algorithm: $algorithm") - } - val mac = Mac.getInstance(normalized) - mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) - val digest = mac.doFinal(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} - -internal fun pluginBase64Encode(data: String): String = - Base64.getEncoder().encodeToString(data.encodeToByteArray()) - -internal fun pluginBase64Decode(data: String): String { - val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") - val decoded = Base64.getDecoder().decode(normalized) - return decoded.decodeToString() -} - -internal fun pluginUtf8ToHex(value: String): String = - value.encodeToByteArray().joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } - -internal fun pluginHexToUtf8(hex: String): String { - val normalized = hex.trim().lowercase() - .replace(" ", "") - .removePrefix("0x") - if (normalized.isEmpty()) return "" - - val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" - val out = ByteArray(evenHex.length / 2) - for (index in out.indices) { - val part = evenHex.substring(index * 2, index * 2 + 2) - out[index] = part.toInt(16).toByte() - } - return out.decodeToString() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt deleted file mode 100644 index 1217b4485..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.nuvio.app.features.plugins - -import com.nuvio.app.core.storage.DesktopStorage -import java.util.Locale - -internal object PluginStorage { - private const val pluginsStateKey = "plugins_state" - private val store = DesktopStorage.store("nuvio_plugins") - - fun loadState(profileId: Int): String? = - store.getString("${pluginsStateKey}_$profileId") - - fun saveState(profileId: Int, payload: String) { - store.putString("${pluginsStateKey}_$profileId", payload) - } -} - -internal fun currentPluginPlatform(): String = "desktop" - -internal fun currentPluginPlatformTags(): Set { - val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) - val osTag = when { - osName.contains("mac") || osName.contains("darwin") -> "macos" - osName.contains("win") -> "windows" - osName.contains("linux") -> "linux" - else -> null - } - return buildSet { - add(currentPluginPlatform()) - add("jvm") - osTag?.let(::add) - } -} - -internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt deleted file mode 100644 index 8c00aedad..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object AvatarStorage { - private val store = DesktopStorage.store("nuvio_avatars") - - actual fun loadPayload(): String? = store.getString("avatars") - - actual fun savePayload(payload: String) { - store.putString("avatars", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt deleted file mode 100644 index 486f14779..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.features.profiles - -internal actual object ProfileHoverHapticFeedback { - actual fun prepare() = Unit - actual fun perform() = Unit - actual fun release() = Unit -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt deleted file mode 100644 index bcca6616d..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ProfilePinCacheStorage { - private val store = DesktopStorage.store("nuvio_profile_pin_cache") - - actual fun loadPayload(profileIndex: Int): String? = - store.getString(key(profileIndex)) - - actual fun savePayload(profileIndex: Int, payload: String) { - store.putString(key(profileIndex), payload) - } - - actual fun removePayload(profileIndex: Int) { - store.remove(key(profileIndex)) - } - - private fun key(profileIndex: Int): String = "profile_pin_$profileIndex" -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt deleted file mode 100644 index 70cd338b8..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.nuvio.app.features.profiles - -import java.security.MessageDigest - -internal actual object ProfilePinCrypto { - actual fun sha256Hex(value: String): String = - MessageDigest - .getInstance("SHA-256") - .digest(value.toByteArray(Charsets.UTF_8)) - .joinToString("") { byte -> "%02x".format(byte) } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt deleted file mode 100644 index 38b7776dc..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.nuvio.app.features.profiles - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ProfileStorage { - private val store = DesktopStorage.store("nuvio_profiles") - - actual fun loadPayload(): String? = store.getString("profiles") - - actual fun savePayload(payload: String) { - store.putString("profiles", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt deleted file mode 100644 index 639c2b8ec..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.search - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object SearchHistoryStorage { - private val store = DesktopStorage.store("nuvio_search_history") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("search_history")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("search_history"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt deleted file mode 100644 index e3f4a5f0e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.settings - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.introdb_favicon -import nuvio.composeapp.generated.resources.mdblist_logo -import nuvio.composeapp.generated.resources.rating_tmdb -import nuvio.composeapp.generated.resources.trakt_tv_favicon -import org.jetbrains.compose.resources.painterResource - -@Composable -internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = - painterResource( - when (logo) { - IntegrationLogo.Tmdb -> Res.drawable.rating_tmdb - IntegrationLogo.Trakt -> Res.drawable.trakt_tv_favicon - IntegrationLogo.MdbList -> Res.drawable.mdblist_logo - IntegrationLogo.IntroDb -> Res.drawable.introdb_favicon - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt deleted file mode 100644 index 31fed517e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.settings - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.Modifier -import com.nuvio.app.features.plugins.PluginsSettingsPageContent - -internal actual fun LazyListScope.pluginsSettingsContent() { - item { - PluginsSettingsPageContent( - modifier = Modifier.fillMaxWidth(), - ) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt deleted file mode 100644 index 0ca7369ea..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.nuvio.app.features.settings - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import java.util.Locale - -internal 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 val store = DesktopStorage.store("nuvio_theme_settings") - - actual fun loadSelectedTheme(): String? = - store.getString(ProfileScopedKey.of(selectedThemeKey)) - - actual fun saveSelectedTheme(themeName: String) { - store.putString(ProfileScopedKey.of(selectedThemeKey), themeName) - } - - actual fun loadAmoledEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(amoledEnabledKey)) - - actual fun saveAmoledEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(amoledEnabledKey), enabled) - } - - actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)) - - actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled) - } - - actual fun loadDesktopNavigationLayout(): String? = - store.getString(ProfileScopedKey.of(desktopNavigationLayoutKey)) - - actual fun saveDesktopNavigationLayout(layoutName: String) { - store.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName) - } - - actual fun loadSelectedAppLanguage(): String? = - store.getString(selectedAppLanguageKey) - ?: Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } - - actual fun saveSelectedAppLanguage(languageCode: String) { - store.putString(selectedAppLanguageKey, languageCode) - } - - actual fun applySelectedAppLanguage(languageCode: String) { - Locale.setDefault(Locale.forLanguageTag(languageCode)) - } - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - 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) { - store.removeAll(profileScopedSyncKeys.map(ProfileScopedKey::of)) - 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) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt deleted file mode 100644 index 3f83db890..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object BingeGroupCacheStorage { - private val store = DesktopStorage.store("nuvio_binge_group_cache") - - actual fun load(hashedKey: String): String? = - store.getString(ProfileScopedKey.of(hashedKey)) - - actual fun save(hashedKey: String, value: String) { - store.putString(ProfileScopedKey.of(hashedKey), value) - } - - actual fun remove(hashedKey: String) { - store.remove(ProfileScopedKey.of(hashedKey)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt deleted file mode 100644 index 1cc7d4699..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.nuvio.app.features.streams - -internal actual fun epochMs(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt deleted file mode 100644 index f801c3322..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object StreamBadgeSettingsStorage { - private const val streamBadgeRulesKey = "stream_badge_rules" - private const val showFileSizeBadgesKey = "show_file_size_badges" - private const val streamBadgePlacementKey = "stream_badge_placement" - private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" - private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) - private val store = DesktopStorage.store("nuvio_stream_badge_settings") - private val legacyDebridStore = DesktopStorage.store("nuvio_debrid_settings") - - actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) - actual fun saveStreamBadgeRules(rules: String) = saveString(streamBadgeRulesKey, rules) - actual fun loadShowFileSizeBadges(): Boolean? = loadBoolean(showFileSizeBadgesKey) - actual fun saveShowFileSizeBadges(enabled: Boolean) = saveBoolean(showFileSizeBadgesKey, enabled) - actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) - actual fun saveStreamBadgePlacement(placement: String) = saveString(streamBadgePlacementKey, placement) - - actual fun loadLegacyDebridStreamBadgeRules(): String? = - legacyDebridStore.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) - - actual fun clearLegacyDebridStreamBadgeRules() { - legacyDebridStore.remove(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) - } - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } - loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) } - loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) - payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges) - payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt deleted file mode 100644 index cf5b689c9..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.nuvio.app.features.streams - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object StreamLinkCacheStorage { - private val store = DesktopStorage.store("nuvio_stream_link_cache") - - actual fun loadEntry(hashedKey: String): String? = - store.getString(ProfileScopedKey.of(hashedKey)) - - actual fun saveEntry(hashedKey: String, payload: String) { - store.putString(ProfileScopedKey.of(hashedKey), payload) - } - - actual fun removeEntry(hashedKey: String) { - store.remove(ProfileScopedKey.of(hashedKey)) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt deleted file mode 100644 index 349f3c6ab..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.nuvio.app.features.tmdb - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.decodeSyncString -import com.nuvio.app.core.sync.encodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncString -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object TmdbSettingsStorage { - private const val enabledKey = "tmdb_enabled" - private const val apiKeyKey = "tmdb_api_key" - private const val languageKey = "tmdb_language" - private const val useTrailersKey = "tmdb_use_trailers" - private const val useArtworkKey = "tmdb_use_artwork" - private const val useBasicInfoKey = "tmdb_use_basic_info" - private const val useDetailsKey = "tmdb_use_details" - private const val useCreditsKey = "tmdb_use_credits" - private const val useProductionsKey = "tmdb_use_productions" - private const val useNetworksKey = "tmdb_use_networks" - private const val useEpisodesKey = "tmdb_use_episodes" - private const val useSeasonPostersKey = "tmdb_use_season_posters" - private const val useMoreLikeThisKey = "tmdb_use_more_like_this" - private const val useCollectionsKey = "tmdb_use_collections" - private val syncKeys = listOf( - enabledKey, - apiKeyKey, - languageKey, - useTrailersKey, - useArtworkKey, - useBasicInfoKey, - useDetailsKey, - useCreditsKey, - useProductionsKey, - useNetworksKey, - useEpisodesKey, - useSeasonPostersKey, - useMoreLikeThisKey, - useCollectionsKey, - ) - private val store = DesktopStorage.store("nuvio_tmdb_settings") - - actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) - actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) - actual fun loadApiKey(): String? = loadString(apiKeyKey) - actual fun saveApiKey(apiKey: String) = saveString(apiKeyKey, apiKey) - actual fun loadLanguage(): String? = loadString(languageKey) - actual fun saveLanguage(language: String) = saveString(languageKey, language) - actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey) - actual fun saveUseTrailers(enabled: Boolean) = saveBoolean(useTrailersKey, enabled) - actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey) - actual fun saveUseArtwork(enabled: Boolean) = saveBoolean(useArtworkKey, enabled) - actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey) - actual fun saveUseBasicInfo(enabled: Boolean) = saveBoolean(useBasicInfoKey, enabled) - actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey) - actual fun saveUseDetails(enabled: Boolean) = saveBoolean(useDetailsKey, enabled) - actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) - actual fun saveUseCredits(enabled: Boolean) = saveBoolean(useCreditsKey, enabled) - actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey) - actual fun saveUseProductions(enabled: Boolean) = saveBoolean(useProductionsKey, enabled) - actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey) - actual fun saveUseNetworks(enabled: Boolean) = saveBoolean(useNetworksKey, enabled) - actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey) - actual fun saveUseEpisodes(enabled: Boolean) = saveBoolean(useEpisodesKey, enabled) - actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey) - actual fun saveUseSeasonPosters(enabled: Boolean) = saveBoolean(useSeasonPostersKey, enabled) - actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey) - actual fun saveUseMoreLikeThis(enabled: Boolean) = saveBoolean(useMoreLikeThisKey, enabled) - actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey) - actual fun saveUseCollections(enabled: Boolean) = saveBoolean(useCollectionsKey, enabled) - - private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) - private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) - private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) - private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadApiKey()?.let { put(apiKeyKey, encodeSyncString(it)) } - loadLanguage()?.let { put(languageKey, encodeSyncString(it)) } - loadUseTrailers()?.let { put(useTrailersKey, encodeSyncBoolean(it)) } - loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } - loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } - loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } - loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } - loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } - loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } - loadUseEpisodes()?.let { put(useEpisodesKey, encodeSyncBoolean(it)) } - loadUseSeasonPosters()?.let { put(useSeasonPostersKey, encodeSyncBoolean(it)) } - loadUseMoreLikeThis()?.let { put(useMoreLikeThisKey, encodeSyncBoolean(it)) } - loadUseCollections()?.let { put(useCollectionsKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.removeAll(syncKeys.map(ProfileScopedKey::of)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(apiKeyKey)?.let(::saveApiKey) - payload.decodeSyncString(languageKey)?.let(::saveLanguage) - payload.decodeSyncBoolean(useTrailersKey)?.let(::saveUseTrailers) - payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) - payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) - payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) - payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) - payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) - payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) - payload.decodeSyncBoolean(useEpisodesKey)?.let(::saveUseEpisodes) - payload.decodeSyncBoolean(useSeasonPostersKey)?.let(::saveUseSeasonPosters) - payload.decodeSyncBoolean(useMoreLikeThisKey)?.let(::saveUseMoreLikeThis) - payload.decodeSyncBoolean(useCollectionsKey)?.let(::saveUseCollections) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt deleted file mode 100644 index 3c05dff11..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.trailer - -actual object TrailerPlaybackResolver { - actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? = null -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt deleted file mode 100644 index b768fef1e..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.nuvio.app.features.trakt - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.trakt_logo_wordmark -import nuvio.composeapp.generated.resources.trakt_tv_favicon -import org.jetbrains.compose.resources.painterResource - -@Composable -actual fun traktBrandPainter(asset: TraktBrandAsset): Painter = - painterResource( - when (asset) { - TraktBrandAsset.Glyph -> Res.drawable.trakt_tv_favicon - TraktBrandAsset.Wordmark -> Res.drawable.trakt_logo_wordmark - }, - ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt deleted file mode 100644 index bca3536f2..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.core.sync.decodeSyncBoolean -import com.nuvio.app.core.sync.encodeSyncBoolean -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -internal actual object TraktCommentsStorage { - private const val enabledKey = "trakt_comments_enabled" - private val store = DesktopStorage.store("nuvio_trakt_comments") - - actual fun loadEnabled(): Boolean? = - store.getBoolean(ProfileScopedKey.of(enabledKey)) - - actual fun saveEnabled(enabled: Boolean) { - store.putBoolean(ProfileScopedKey.of(enabledKey), enabled) - } - - actual fun exportToSyncPayload(): JsonObject = buildJsonObject { - loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - } - - actual fun replaceFromSyncPayload(payload: JsonObject) { - store.remove(ProfileScopedKey.of(enabledKey)) - payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt deleted file mode 100644 index 6da1187c7..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.nuvio.app.features.trakt - -import java.time.Instant -import java.time.format.DateTimeParseException - -internal actual object TraktPlatformClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() - - actual fun parseIsoDateTimeToEpochMs(value: String): Long? = - try { - Instant.parse(value).toEpochMilli() - } catch (_: DateTimeParseException) { - null - } - - actual fun availableProcessors(): Int = - Runtime.getRuntime().availableProcessors().coerceAtLeast(1) -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt deleted file mode 100644 index b4433d6d0..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object TraktAuthStorage { - private val store = DesktopStorage.store("nuvio_trakt_auth") - - actual fun loadPayload(): String? = store.getString("trakt_auth") - - actual fun savePayload(payload: String) { - store.putString("trakt_auth", payload) - } -} - -internal actual object TraktLibraryStorage { - private val store = DesktopStorage.store("nuvio_trakt_library") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("trakt_library")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("trakt_library"), payload) - } -} - -internal actual object TraktSettingsStorage { - private val store = DesktopStorage.store("nuvio_trakt_settings") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("trakt_settings")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("trakt_settings"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt deleted file mode 100644 index f94a38b20..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.watched - -actual object WatchedClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt deleted file mode 100644 index 44da853a8..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.watched - -import com.nuvio.app.core.storage.DesktopStorage - -actual object WatchedStorage { - private val store = DesktopStorage.store("nuvio_watched") - - actual fun loadPayload(profileId: Int): String? = - store.getString("watched_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("watched_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt deleted file mode 100644 index ce44c1c17..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object ContinueWatchingEnrichmentStorage { - private val store = DesktopStorage.store("nuvio_continue_watching_enrichment") - - actual fun loadPayload(key: String): String? = - store.getString(ProfileScopedKey.of(key.scopedKey())) - - actual fun savePayload(key: String, payload: String) { - store.putString(ProfileScopedKey.of(key.scopedKey()), payload) - } - - actual fun removePayload(key: String) { - store.remove(ProfileScopedKey.of(key.scopedKey())) - } - - private fun String.scopedKey(): String = "continue_watching_enrichment_$this" -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt deleted file mode 100644 index d3eb3d1b1..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage -import com.nuvio.app.core.storage.ProfileScopedKey - -internal actual object ContinueWatchingPreferencesStorage { - private val store = DesktopStorage.store("nuvio_continue_watching_preferences") - - actual fun loadPayload(): String? = - store.getString(ProfileScopedKey.of("continue_watching_preferences")) - - actual fun savePayload(payload: String) { - store.putString(ProfileScopedKey.of("continue_watching_preferences"), payload) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt deleted file mode 100644 index ccae7137c..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import java.time.LocalDate - -actual object CurrentDateProvider { - actual fun todayIsoDate(): String = LocalDate.now().toString() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt deleted file mode 100644 index 82b82d1a9..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object ResumePromptStorage { - private val store = DesktopStorage.store("nuvio_resume_prompt") - - actual fun loadWasInPlayer(): Boolean = - store.getBoolean("was_in_player") ?: false - - actual fun saveWasInPlayer(value: Boolean) { - store.putBoolean("was_in_player", value) - } - - actual fun loadLastPlayerVideoId(): String? = - store.getString("last_player_video_id") - - actual fun saveLastPlayerVideoId(videoId: String?) { - store.putString("last_player_video_id", videoId?.takeIf { it.isNotBlank() }) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt deleted file mode 100644 index faead0065..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.watchprogress - -internal actual object WatchProgressClock { - actual fun nowEpochMs(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt deleted file mode 100644 index cc922a391..000000000 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.nuvio.app.features.watchprogress - -import com.nuvio.app.core.storage.DesktopStorage - -internal actual object WatchProgressStorage { - private val store = DesktopStorage.store("nuvio_watch_progress") - - actual fun loadPayload(profileId: Int): String? = - store.getString("watch_progress_$profileId") - - actual fun savePayload(profileId: Int, payload: String) { - store.putString("watch_progress_$profileId", payload) - } -} diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm deleted file mode 100644 index 389f383ab..000000000 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ /dev/null @@ -1,2663 +0,0 @@ -#import -#import -#define GL_SILENCE_DEPRECATION -#import -#import -#import -#import - -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -@class PlayerMetalView; -@class MpvWebPlayer; -@class NuvioPlayerOpenGLLayer; - -@interface PlayerMetalView : NSView -- (double)edrMax; -- (double)hdrTargetPeakNits; -- (CGSize)drawableSize; -- (void)updateMetalLayerLayout; -- (void)refreshMetalLayerEdrState; -- (void)configureExtendedDynamicRange:(BOOL)enabled primaries:(NSString *)primaries targetPeakNits:(double)targetPeakNits; -- (BOOL)createMpvRenderContext:(mpv_handle *)mpv error:(NSString **)error; -- (void)destroyMpvRenderContext; -- (void)scheduleRenderUpdate; -- (void)setFullscreenTransitionActive:(BOOL)active; -@end - -@interface NuvioMainThreadPriorityLock : NSObject -- (void)beforeLocking; -- (void)afterLocked; -@end - -@interface NuvioPlayerOpenGLLayer : CAOpenGLLayer -@property(nonatomic, assign, getter=isNuvioLiveResize) BOOL nuvioLiveResize; -@property(nonatomic, assign, readonly) GLint bufferDepth; -- (instancetype)initWithOwner:(PlayerMetalView *)owner; -- (void)setMpvRenderContext:(mpv_render_context *)renderContext; -- (void)clearMpvRenderContext; -- (void)requestRender; -- (void)requestRenderForced:(BOOL)force; -- (void)lockAndSetContext; -- (void)unlockContext; -@end - -@interface PlayerScriptHandler : NSObject -@property(nonatomic, weak) MpvWebPlayer *player; -@end - -@interface MpvWebPlayer : NSObject -- (instancetype)initWithHostView:(NSView *)hostView - sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsUrl:(NSString *)controlsUrl - javaVm:(JavaVM *)javaVm - eventSink:(jobject)eventSink - eventMethod:(jmethodID)eventMethod; -- (void)shutdown; -- (void)updateControlsJson:(NSString *)controlsJson; -- (void)setPaused:(BOOL)paused; -- (BOOL)isPaused; -- (void)seekToMilliseconds:(long long)positionMs; -- (void)seekByMilliseconds:(long long)offsetMs; -- (void)setSpeed:(double)speed; -- (double)speed; -- (void)setResizeMode:(int)mode; -- (long long)durationMs; -- (long long)positionMs; -- (long long)bufferedPositionMs; -- (BOOL)isLoading; -- (BOOL)isEnded; -- (NSString *)audioTracksJson; -- (NSString *)subtitleTracksJson; -- (void)selectAudioTrackId:(int)trackId; -- (void)selectSubtitleTrackId:(int)trackId; -- (void)addSubtitleUrl:(NSString *)url; -- (void)removeExternalSubtitles; -- (void)removeExternalSubtitlesAndSelect:(int)trackId; -- (void)setSubtitleDelayMs:(int)delayMs; -- (void)applySubtitleStyleWithTextColor:(NSString *)textColor - backgroundColor:(NSString *)backgroundColor - outlineColor:(NSString *)outlineColor - outlineSize:(double)outlineSize - bold:(BOOL)bold - fontSize:(double)fontSize - subPos:(int)subPos; -- (void)handleScriptMessage:(NSDictionary *)message; -- (void)focusControlsWebViewIfNeeded; -- (void)layoutNativeSubviews; -- (void)layoutControlsWebViewToBounds:(NSRect)bounds immediate:(BOOL)immediate; -- (void)hostViewBoundsDidChange:(NSNotification *)notification; -- (void)hostViewFrameDidChange:(NSNotification *)notification; -- (void)windowWillEnterFullScreen:(NSNotification *)notification; -- (void)windowDidEnterFullScreen:(NSNotification *)notification; -- (void)windowWillExitFullScreen:(NSNotification *)notification; -- (void)windowDidExitFullScreen:(NSNotification *)notification; -- (void)activateFullscreenTransitionWithReason:(NSString *)reason; -- (void)beginFullscreenTransitionWithReason:(NSString *)reason; -- (void)finishFullscreenTransitionWithReason:(NSString *)reason; -- (void)handleFullscreenTransitionTimer:(NSTimer *)timer; -- (void)schedulePostResizeRefreshWithReason:(NSString *)reason; -- (void)handleResizeSettleTimer:(NSTimer *)timer; -- (void)configureHdrForCurrentScreenWithReason:(NSString *)reason force:(BOOL)force; -- (void)applyHdrForPolledGamma:(NSString *)gamma primaries:(NSString *)primaries reason:(NSString *)reason force:(BOOL)force; -@end - -typedef CFDictionaryRef (*CoreDisplayCreateInfoDictionaryFn)(CGDirectDisplayID displayId); - -static void setBoolWithSelector(id target, NSString *selectorName, BOOL value) { - SEL selector = NSSelectorFromString(selectorName); - if (!target || !selector || ![target respondsToSelector:selector]) { - return; - } - typedef void (*Setter)(id, SEL, BOOL); - Setter setter = (Setter)[target methodForSelector:selector]; - setter(target, selector, value); -} - -static double doubleWithSelector(id target, NSString *selectorName, double fallback) { - SEL selector = NSSelectorFromString(selectorName); - if (!target || !selector || ![target respondsToSelector:selector]) { - return fallback; - } - typedef double (*Getter)(id, SEL); - Getter getter = (Getter)[target methodForSelector:selector]; - return getter(target, selector); -} - -static BOOL setLayerColorSpace(CALayer *layer, CGColorSpaceRef colorSpace) { - SEL selector = NSSelectorFromString(@"setColorspace:"); - if (!layer || !colorSpace || ![layer respondsToSelector:selector]) { - return NO; - } - typedef void (*Setter)(id, SEL, CGColorSpaceRef); - Setter setter = (Setter)[layer methodForSelector:selector]; - setter(layer, selector, colorSpace); - return YES; -} - -static BOOL setLayerEdrMetadata(CALayer *layer, double minNits, double maxNits, double opticalOutputScale) { - if (!layer || maxNits <= 0.0) { - return NO; - } - - Class edrMetadataClass = NSClassFromString(@"CAEDRMetadata"); - SEL selector = NSSelectorFromString(@"HDR10MetadataWithMinLuminance:maxLuminance:opticalOutputScale:"); - if (!edrMetadataClass || ![edrMetadataClass respondsToSelector:selector]) { - return NO; - } - - typedef id (*Factory)(id, SEL, float, float, float); - Factory factory = (Factory)[edrMetadataClass methodForSelector:selector]; - id metadata = factory(edrMetadataClass, selector, (float)minNits, (float)maxNits, (float)opticalOutputScale); - if (!metadata) { - return NO; - } - - SEL setterSelector = NSSelectorFromString(@"setEDRMetadata:"); - if (![layer respondsToSelector:setterSelector]) { - return NO; - } - typedef void (*Setter)(id, SEL, id); - Setter setter = (Setter)[layer methodForSelector:setterSelector]; - setter(layer, setterSelector, metadata); - return YES; -} - -static void clearLayerEdrMetadata(CALayer *layer) { - SEL selector = NSSelectorFromString(@"setEDRMetadata:"); - if (!layer || ![layer respondsToSelector:selector]) { - return; - } - typedef void (*Setter)(id, SEL, id); - Setter setter = (Setter)[layer methodForSelector:selector]; - setter(layer, selector, nil); -} - -static CGColorSpaceRef copyPqColorSpaceForPrimaries(NSString *primaries) { - NSString *normalized = [[primaries ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - CFStringRef name = [normalized isEqualToString:@"display-p3"] - ? kCGColorSpaceDisplayP3_PQ - : kCGColorSpaceITUR_2100_PQ; - return CGColorSpaceCreateWithName(name); -} - -static NSString *diagnosticSize(CGSize size) { - return [NSString stringWithFormat:@"%0.0fx%0.0f", size.width, size.height]; -} - -static NSString *diagnosticRect(NSRect rect) { - return [NSString stringWithFormat: - @"%0.0fx%0.0f@%0.0f,%0.0f", - rect.size.width, - rect.size.height, - rect.origin.x, - rect.origin.y - ]; -} - -static CGDirectDisplayID displayIdForScreen(NSScreen *screen) { - NSNumber *screenNumber = screen.deviceDescription[@"NSScreenNumber"]; - return screenNumber ? (CGDirectDisplayID)screenNumber.unsignedIntValue : CGMainDisplayID(); -} - -static double edrMaxForScreen(NSScreen *screen) { - NSScreen *targetScreen = screen ?: NSScreen.mainScreen; - double maxValue = doubleWithSelector( - targetScreen, - @"maximumPotentialExtendedDynamicRangeColorComponentValue", - 1.0 - ); - if (!std::isfinite(maxValue) || maxValue <= 0.0) { - return 1.0; - } - return maxValue; -} - -static CoreDisplayCreateInfoDictionaryFn coreDisplayInfoDictionaryFn(void) { - static BOOL didLookup = NO; - static CoreDisplayCreateInfoDictionaryFn fn = nullptr; - if (!didLookup) { - didLookup = YES; - void *handle = dlopen( - "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", - RTLD_LAZY | RTLD_LOCAL - ); - fn = (CoreDisplayCreateInfoDictionaryFn)dlsym( - handle ?: RTLD_DEFAULT, - "CoreDisplay_DisplayCreateInfoDictionary" - ); - } - return fn; -} - -static double doubleFromCoreDisplayValue(CFTypeRef value) { - if (!value) { - return 0.0; - } - CFTypeID typeId = CFGetTypeID(value); - if (typeId == CFNumberGetTypeID()) { - double result = 0.0; - if (CFNumberGetValue((CFNumberRef)value, kCFNumberDoubleType, &result)) { - return result; - } - return 0.0; - } - if (typeId == CFDictionaryGetTypeID()) { - CFDictionaryRef dictionary = (CFDictionaryRef)value; - double directPeak = doubleFromCoreDisplayValue( - CFDictionaryGetValue(dictionary, CFSTR("NonReferencePeakHDRLuminance")) - ); - if (directPeak > 0.0) { - return directPeak; - } - return doubleFromCoreDisplayValue( - CFDictionaryGetValue(dictionary, CFSTR("DisplayBacklight")) - ); - } - return 0.0; -} - -static double coreDisplayPeakNitsForScreen(NSScreen *screen) { - CoreDisplayCreateInfoDictionaryFn fn = coreDisplayInfoDictionaryFn(); - if (!fn) { - return 0.0; - } - CFDictionaryRef info = fn(displayIdForScreen(screen ?: NSScreen.mainScreen)); - if (!info) { - return 0.0; - } - double peak = doubleFromCoreDisplayValue(info); - CFRelease(info); - if (!std::isfinite(peak) || peak < 100.0 || peak > 10000.0) { - return 0.0; - } - return peak; -} - -static double fixedPointNitsFromRegistryValue(CFTypeRef value) { - if (!value || CFGetTypeID(value) != CFNumberGetTypeID()) { - return 0.0; - } - double raw = 0.0; - if (!CFNumberGetValue((CFNumberRef)value, kCFNumberDoubleType, &raw) || raw <= 0.0) { - return 0.0; - } - double nits = raw / 65536.0; - if (!std::isfinite(nits) || nits < 100.0 || nits > 10000.0) { - return 0.0; - } - return nits; -} - -static double ioRegistryPeakNits(void) { - CFMutableDictionaryRef matching = IOServiceMatching("IOMobileFramebufferShim"); - if (!matching) { - return 0.0; - } - - io_iterator_t iterator = IO_OBJECT_NULL; - kern_return_t result = IOServiceGetMatchingServices(MACH_PORT_NULL, matching, &iterator); - if (result != KERN_SUCCESS || iterator == IO_OBJECT_NULL) { - return 0.0; - } - - double peak = 0.0; - io_object_t service = IO_OBJECT_NULL; - while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { - CFTypeRef indicatorCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("IOMFBIndicatorNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(indicatorCap)); - if (indicatorCap) { - CFRelease(indicatorCap); - } - - CFTypeRef rtplcCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("RTPLCBLNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(rtplcCap)); - if (rtplcCap) { - CFRelease(rtplcCap); - } - - CFTypeRef backlightCap = IORegistryEntryCreateCFProperty( - service, - CFSTR("BLNitsCap"), - kCFAllocatorDefault, - 0 - ); - peak = fmax(peak, fixedPointNitsFromRegistryValue(backlightCap)); - if (backlightCap) { - CFRelease(backlightCap); - } - - IOObjectRelease(service); - } - IOObjectRelease(iterator); - return peak; -} - -static double hdrTargetPeakNitsForScreen(NSScreen *screen) { - double coreDisplayPeak = coreDisplayPeakNitsForScreen(screen); - if (coreDisplayPeak > 0.0) { - return coreDisplayPeak; - } - double registryPeak = ioRegistryPeakNits(); - if (registryPeak > 0.0) { - return registryPeak; - } - double edrMax = edrMaxForScreen(screen); - if (edrMax > 1.0) { - return fmin(fmax(edrMax * 100.0, 100.0), 10000.0); - } - return 100.0; -} - -static double hdrMetadataMaxNits(void) { - return 1000.0; -} - -static double normalizedHdrMetadataMaxNits(double targetPeakNits) { - if (std::isfinite(targetPeakNits) && targetPeakNits >= 100.0 && targetPeakNits <= 10000.0) { - return targetPeakNits; - } - return hdrMetadataMaxNits(); -} - -static CGLPixelFormatObj createPlayerOpenGLPixelFormat(GLint *bufferDepth) { - CGLPixelFormatObj pixelFormat = nullptr; - GLint pixelCount = 0; - CGLPixelFormatAttribute hdrAttributes[] = { - kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, - kCGLPFAAccelerated, - kCGLPFADoubleBuffer, - kCGLPFAColorSize, (CGLPixelFormatAttribute)64, - kCGLPFAColorFloat, - kCGLPFANoRecovery, - kCGLPFAAllowOfflineRenderers, - (CGLPixelFormatAttribute)0 - }; - CGLError error = CGLChoosePixelFormat(hdrAttributes, &pixelFormat, &pixelCount); - if (error == kCGLNoError && pixelFormat) { - if (bufferDepth) { - *bufferDepth = 16; - } - return pixelFormat; - } - - CGLPixelFormatAttribute fallbackAttributes[] = { - kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core, - kCGLPFAAccelerated, - kCGLPFADoubleBuffer, - kCGLPFAColorSize, (CGLPixelFormatAttribute)32, - kCGLPFANoRecovery, - kCGLPFAAllowOfflineRenderers, - (CGLPixelFormatAttribute)0 - }; - pixelFormat = nullptr; - pixelCount = 0; - error = CGLChoosePixelFormat(fallbackAttributes, &pixelFormat, &pixelCount); - if (error == kCGLNoError && pixelFormat) { - if (bufferDepth) { - *bufferDepth = 8; - } - return pixelFormat; - } - return nullptr; -} - -static CGLContextObj createPlayerOpenGLContext(CGLPixelFormatObj pixelFormat) { - CGLContextObj context = nullptr; - if (!pixelFormat || CGLCreateContext(pixelFormat, nullptr, &context) != kCGLNoError || !context) { - return nullptr; - } - GLint swapInterval = 1; - CGLSetParameter(context, kCGLCPSwapInterval, &swapInterval); - CGLEnable(context, kCGLCEMPEngine); - return context; -} - -static void *mpvOpenGLGetProcAddress(void *ctx, const char *name) { - if (!name) { - return nullptr; - } - CFBundleRef framework = CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); - if (!framework) { - return nullptr; - } - CFStringRef symbol = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingASCII); - if (!symbol) { - return nullptr; - } - void *address = CFBundleGetFunctionPointerForName(framework, symbol); - CFRelease(symbol); - return address; -} - -@implementation NuvioMainThreadPriorityLock { - NSCondition *_condition; - BOOL _mainThreadNeedsLock; -} - -- (instancetype)init { - self = [super init]; - if (!self) { - return nil; - } - _condition = [NSCondition new]; - return self; -} - -- (void)beforeLocking { - [_condition lock]; - if ([NSThread isMainThread]) { - _mainThreadNeedsLock = YES; - } else { - while (_mainThreadNeedsLock) { - [_condition wait]; - } - } - [_condition unlock]; -} - -- (void)afterLocked { - if (![NSThread isMainThread]) { - return; - } - [_condition lock]; - _mainThreadNeedsLock = NO; - [_condition broadcast]; - [_condition unlock]; -} - -@end - -@implementation NuvioPlayerOpenGLLayer { - __weak PlayerMetalView *_owner; - CGLPixelFormatObj _pixelFormat; - CGLContextObj _glContext; - dispatch_queue_t _renderQueue; - NSRecursiveLock *_displayLock; - NuvioMainThreadPriorityLock *_mainThreadPriorityLock; - NSRecursiveLock *_renderLock; - mpv_render_context *_renderContext; - std::atomic_bool _needsFlip; - std::atomic_bool _forceDraw; - std::atomic_bool _renderQueued; -} - -@synthesize nuvioLiveResize = _nuvioLiveResize; -@synthesize bufferDepth = _bufferDepth; - -- (instancetype)initWithOwner:(PlayerMetalView *)owner { - self = [super init]; - if (!self) { - return nil; - } - _owner = owner; - _renderQueue = dispatch_queue_create("com.nuvio.player.mpvgl", DISPATCH_QUEUE_SERIAL); - dispatch_set_target_queue(_renderQueue, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0)); - _displayLock = [NSRecursiveLock new]; - _mainThreadPriorityLock = [NuvioMainThreadPriorityLock new]; - _renderLock = [NSRecursiveLock new]; - _needsFlip.store(false); - _forceDraw.store(false); - _renderQueued.store(false); - _pixelFormat = createPlayerOpenGLPixelFormat(&_bufferDepth); - _glContext = createPlayerOpenGLContext(_pixelFormat); - self.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; - self.backgroundColor = NSColor.blackColor.CGColor; - self.opaque = YES; - self.asynchronous = NO; - self.needsDisplayOnBoundsChange = YES; - if (_bufferDepth > 8) { - self.contentsFormat = kCAContentsFormatRGBA16Float; - } - return self; -} - -- (void)dealloc { - if (_glContext) { - CGLReleaseContext(_glContext); - _glContext = nullptr; - } - if (_pixelFormat) { - CGLReleasePixelFormat(_pixelFormat); - _pixelFormat = nullptr; - } -} - -- (void)setNuvioLiveResize:(BOOL)nuvioLiveResize { - if (_nuvioLiveResize == nuvioLiveResize) { - return; - } - _nuvioLiveResize = nuvioLiveResize; - if (nuvioLiveResize) { - self.asynchronous = YES; - [self requestRenderForced:YES]; - } else { - self.asynchronous = NO; - } -} - -- (CGLPixelFormatObj)copyCGLPixelFormatForDisplayMask:(uint32_t)mask { - return _pixelFormat ? CGLRetainPixelFormat(_pixelFormat) : nullptr; -} - -- (CGLContextObj)copyCGLContextForPixelFormat:(CGLPixelFormatObj)pixelFormat { - return _glContext ? CGLRetainContext(_glContext) : nullptr; -} - -- (BOOL)canDrawInCGLContext:(CGLContextObj)ctx - pixelFormat:(CGLPixelFormatObj)pf - forLayerTime:(CFTimeInterval)t - displayTime:(const CVTimeStamp *)ts { - if (_nuvioLiveResize && [NSThread isMainThread]) { - return NO; - } - if (!_nuvioLiveResize) { - self.asynchronous = NO; - } - - return _forceDraw.load() || _needsFlip.load(); -} - -- (void)drawInCGLContext:(CGLContextObj)ctx - pixelFormat:(CGLPixelFormatObj)pf - forLayerTime:(CFTimeInterval)t - displayTime:(const CVTimeStamp *)ts { - [_renderLock lock]; - mpv_render_context *renderContext = _renderContext; - _needsFlip.store(false); - _forceDraw.store(false); - if (!renderContext) { - glClearColor(0.0, 0.0, 0.0, 1.0); - glClear(GL_COLOR_BUFFER_BIT); - glFlush(); - [_renderLock unlock]; - return; - } - - GLint framebuffer = 0; - glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &framebuffer); - CGFloat scale = self.contentsScale > 0.0 ? self.contentsScale : 1.0; - CGSize drawableSize = CGSizeMake( - llround(self.bounds.size.width * scale), - llround(self.bounds.size.height * scale) - ); - if (drawableSize.width <= 1.0 || drawableSize.height <= 1.0) { - glClearColor(0.0, 0.0, 0.0, 1.0); - glClear(GL_COLOR_BUFFER_BIT); - glFlush(); - [_renderLock unlock]; - return; - } - glViewport(0, 0, (GLsizei)drawableSize.width, (GLsizei)drawableSize.height); - mpv_opengl_fbo fbo = { - (int)framebuffer, - (int)drawableSize.width, - (int)drawableSize.height, - 0 - }; - int flipY = 1; - int depth = (int)_bufferDepth; - mpv_render_param params[] = { - { MPV_RENDER_PARAM_OPENGL_FBO, &fbo }, - { MPV_RENDER_PARAM_FLIP_Y, &flipY }, - { MPV_RENDER_PARAM_DEPTH, &depth }, - { MPV_RENDER_PARAM_INVALID, nullptr } - }; - mpv_render_context_render(renderContext, params); - mpv_render_context_report_swap(renderContext); - glFlush(); - [_renderLock unlock]; -} - -- (void)display { - [_mainThreadPriorityLock beforeLocking]; - [_displayLock lock]; - [_mainThreadPriorityLock afterLocked]; - if ([NSThread isMainThread]) { - [super display]; - } else { - [CATransaction begin]; - [super display]; - [CATransaction commit]; - } - [CATransaction flush]; - [_displayLock unlock]; -} - -- (void)setMpvRenderContext:(mpv_render_context *)renderContext { - [_renderLock lock]; - _renderContext = renderContext; - _forceDraw.store(true); - [_renderLock unlock]; - [self requestRenderForced:YES]; -} - -- (void)clearMpvRenderContext { - [_renderLock lock]; - _renderContext = nullptr; - _needsFlip.store(false); - _forceDraw.store(true); - [_renderLock unlock]; -} - -- (void)requestRender { - [self requestRenderForced:NO]; -} - -- (void)requestRenderForced:(BOOL)force { - if (force) { - _forceDraw.store(true); - } - _needsFlip.store(true); - bool expected = false; - if (!_renderQueued.compare_exchange_strong(expected, true)) { - return; - } - - dispatch_async(_renderQueue, ^{ - self->_renderQueued.store(false); - [self display]; - }); -} - -- (void)lockAndSetContext { - if (!_glContext) { - return; - } - CGLLockContext(_glContext); - CGLSetCurrentContext(_glContext); -} - -- (void)unlockContext { - if (!_glContext) { - return; - } - CGLUnlockContext(_glContext); -} - -@end - -static void mpvRenderUpdateCallback(void *callbackContext) { - PlayerMetalView *view = (__bridge PlayerMetalView *)callbackContext; - [view scheduleRenderUpdate]; -} - -@implementation PlayerMetalView { - NuvioPlayerOpenGLLayer *_openGLLayer; - mpv_render_context *_mpvRenderContext; - BOOL _hasConfiguredEdr; - BOOL _edrEnabled; - BOOL _fullscreenTransitionActive; - NSString *_edrPrimaries; - NSString *_lastAppliedEdrLayerKey; - double _edrMetadataMaxNits; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - self = [super initWithFrame:frameRect]; - if (!self) { - return nil; - } - - self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - self.wantsLayer = YES; - _openGLLayer = [[NuvioPlayerOpenGLLayer alloc] initWithOwner:self]; - _openGLLayer.frame = self.bounds; - _openGLLayer.contentsGravity = kCAGravityResize; - self.layer = _openGLLayer; - [self updateMetalLayerLayout]; - return self; -} - -- (BOOL)isOpaque { - return YES; -} - -- (CALayer *)makeBackingLayer { - return _openGLLayer ?: [[NuvioPlayerOpenGLLayer alloc] initWithOwner:self]; -} - -- (void)layout { - [super layout]; - [self updateMetalLayerLayout]; -} - -- (void)setFrameSize:(NSSize)newSize { - [super setFrameSize:newSize]; - [self updateMetalLayerLayout]; -} - -- (void)setBoundsSize:(NSSize)newSize { - [super setBoundsSize:newSize]; - [self updateMetalLayerLayout]; -} - -- (void)viewDidMoveToWindow { - [super viewDidMoveToWindow]; - [self updateMetalLayerLayout]; - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); -} - -- (void)viewWillStartLiveResize { - [super viewWillStartLiveResize]; - _openGLLayer.nuvioLiveResize = YES; -} - -- (void)viewDidEndLiveResize { - [super viewDidEndLiveResize]; - // The bridge clears resize mode on its settle timer, after AppKit has - // finished the release/fullscreen layout burst. -} - -- (void)setFullscreenTransitionActive:(BOOL)active { - _fullscreenTransitionActive = active; - BOOL transitionLike = active || self.inLiveResize || self.window.inLiveResize; - _openGLLayer.nuvioLiveResize = transitionLike; - if (!transitionLike) { - [self updateMetalLayerLayout]; - [_openGLLayer requestRenderForced:YES]; - } -} - -- (void)updateMetalLayerLayout { - if (!_openGLLayer) { - return; - } - - NSSize boundsSize = self.bounds.size; - if (boundsSize.width <= 1.0 || boundsSize.height <= 1.0) { - return; - } - - CGFloat scale = self.window.backingScaleFactor > 0.0 - ? self.window.backingScaleFactor - : NSScreen.mainScreen.backingScaleFactor; - BOOL liveResize = self.inLiveResize || self.window.inLiveResize || _fullscreenTransitionActive; - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - _openGLLayer.nuvioLiveResize = liveResize; - _openGLLayer.contentsScale = scale; - _openGLLayer.frame = self.bounds; - if (!liveResize) { - [self refreshMetalLayerEdrState]; - [_openGLLayer requestRenderForced:NO]; - } - [CATransaction commit]; -} - -- (void)refreshMetalLayerEdrState { - if (!_hasConfiguredEdr || !_openGLLayer) { - return; - } - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%0.0f", - _edrEnabled ? @"hdr" : @"sdr", - _edrPrimaries ?: @"auto", - normalizedHdrMetadataMaxNits(_edrMetadataMaxNits) - ]; - if (_lastAppliedEdrLayerKey && [_lastAppliedEdrLayerKey isEqualToString:stateKey]) { - return; - } - - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); - - CGColorSpaceRef colorSpace = _edrEnabled - ? copyPqColorSpaceForPrimaries(_edrPrimaries) - : CGColorSpaceCreateWithName(kCGColorSpaceSRGB); - if (colorSpace) { - setLayerColorSpace(_openGLLayer, colorSpace); - CGColorSpaceRelease(colorSpace); - } - - if (_edrEnabled) { - setLayerEdrMetadata(_openGLLayer, 0.0, normalizedHdrMetadataMaxNits(_edrMetadataMaxNits), 1.0); - } else { - clearLayerEdrMetadata(_openGLLayer); - } - _lastAppliedEdrLayerKey = [stateKey copy]; - [_openGLLayer requestRender]; -} - -- (void)configureExtendedDynamicRange:(BOOL)enabled primaries:(NSString *)primaries targetPeakNits:(double)targetPeakNits { - _hasConfiguredEdr = YES; - _edrEnabled = enabled; - _edrPrimaries = [primaries copy]; - _edrMetadataMaxNits = enabled ? normalizedHdrMetadataMaxNits(targetPeakNits) : hdrMetadataMaxNits(); - _lastAppliedEdrLayerKey = nil; - - setBoolWithSelector(self, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(self.window.contentView, @"setWantsExtendedDynamicRangeContent:", YES); - setBoolWithSelector(_openGLLayer, @"setWantsExtendedDynamicRangeContent:", YES); - - CGColorSpaceRef colorSpace = enabled - ? copyPqColorSpaceForPrimaries(primaries) - : CGColorSpaceCreateWithName(kCGColorSpaceSRGB); - if (colorSpace) { - setLayerColorSpace(_openGLLayer, colorSpace); - } - if (colorSpace) { - CGColorSpaceRelease(colorSpace); - } - - if (enabled) { - setLayerEdrMetadata(_openGLLayer, 0.0, _edrMetadataMaxNits, 1.0); - } else { - clearLayerEdrMetadata(_openGLLayer); - } - _lastAppliedEdrLayerKey = [[NSString stringWithFormat: - @"%@:%@:%0.0f", - enabled ? @"hdr" : @"sdr", - primaries ?: @"auto", - _edrMetadataMaxNits - ] copy]; - [_openGLLayer requestRender]; -} - -- (BOOL)createMpvRenderContext:(mpv_handle *)mpv error:(NSString **)error { - if (_mpvRenderContext) { - return YES; - } - if (!_openGLLayer) { - if (error) { - *error = @"OpenGL layer unavailable"; - } - return NO; - } - - [_openGLLayer lockAndSetContext]; - const char *apiType = MPV_RENDER_API_TYPE_OPENGL; - mpv_opengl_init_params initParams = { - mpvOpenGLGetProcAddress, - nullptr - }; - mpv_render_param params[] = { - { MPV_RENDER_PARAM_API_TYPE, (void *)apiType }, - { MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &initParams }, - { MPV_RENDER_PARAM_INVALID, nullptr } - }; - int result = mpv_render_context_create(&_mpvRenderContext, mpv, params); - [_openGLLayer unlockContext]; - if (result < 0) { - if (error) { - *error = [NSString stringWithFormat:@"mpv_render_context_create failed: %s", mpv_error_string(result)]; - } - return NO; - } - - [_openGLLayer setMpvRenderContext:_mpvRenderContext]; - mpv_render_context_set_update_callback( - _mpvRenderContext, - mpvRenderUpdateCallback, - (__bridge void *)self - ); - return YES; -} - -- (void)destroyMpvRenderContext { - if (!_mpvRenderContext) { - return; - } - mpv_render_context *renderContext = _mpvRenderContext; - _mpvRenderContext = nullptr; - mpv_render_context_set_update_callback(renderContext, nullptr, nullptr); - [_openGLLayer clearMpvRenderContext]; - [_openGLLayer lockAndSetContext]; - mpv_render_context_free(renderContext); - [_openGLLayer unlockContext]; -} - -- (void)scheduleRenderUpdate { - [_openGLLayer requestRender]; -} - -- (double)edrMax { - return edrMaxForScreen(self.window.screen ?: NSScreen.mainScreen); -} - -- (double)hdrTargetPeakNits { - return hdrTargetPeakNitsForScreen(self.window.screen ?: NSScreen.mainScreen); -} - -- (CGSize)drawableSize { - if (!_openGLLayer) { - return CGSizeZero; - } - CGFloat scale = _openGLLayer.contentsScale > 0.0 ? _openGLLayer.contentsScale : 1.0; - return CGSizeMake( - llround(_openGLLayer.bounds.size.width * scale), - llround(_openGLLayer.bounds.size.height * scale) - ); -} - -@end - -@implementation PlayerScriptHandler -- (void)userContentController:(WKUserContentController *)userContentController - didReceiveScriptMessage:(WKScriptMessage *)message { - if ([message.body isKindOfClass:[NSDictionary class]]) { - [self.player handleScriptMessage:(NSDictionary *)message.body]; - } -} -@end - -static NSString *javaScriptStringLiteral(NSString *value) { - NSArray *array = @[value ?: @""]; - NSError *error = nil; - NSData *data = [NSJSONSerialization dataWithJSONObject:array options:0 error:&error]; - if (!data) { - return @"\"\""; - } - NSString *jsonArray = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (jsonArray.length < 2) { - return @"\"\""; - } - return [jsonArray substringWithRange:NSMakeRange(1, jsonArray.length - 2)]; -} - -static void setMpvOptionString(mpv_handle *mpv, const char *name, const char *value) { - mpv_set_option_string(mpv, name, value); -} - -@implementation MpvWebPlayer { - NSView *_hostView; - PlayerMetalView *_videoView; - WKWebView *_webView; - PlayerScriptHandler *_scriptHandler; - mpv_handle *_mpv; - NSTimer *_timer; - NSTimer *_resizeSettleTimer; - NSTimer *_fullscreenTransitionTimer; - JavaVM *_javaVm; - jobject _eventSink; - jmethodID _eventMethod; - NSString *_lastConfiguredHdrKey; - NSString *_lastResizeRefreshKey; - dispatch_queue_t _mpvEventQueue; - BOOL _didFocusControlsWebView; - BOOL _controlsWebReady; - BOOL _fullscreenTransitionActive; - NSString *_pendingControlsJson; - double _initialStartSeconds; - BOOL _controlsSyncInFlight; - NSRect _lastAppliedNativeLayoutBounds; - BOOL _lastAppliedNativeLayoutWasLiveResize; - NSTimeInterval _lightweightResizeSettleUntil; - NSSize _lastControlsViewportNudgeSize; - NSTimeInterval _lastControlsViewportNudgeAt; - std::atomic _cachedDurationSeconds; - std::atomic _cachedPositionSeconds; - std::atomic _cachedCacheAheadSeconds; - std::atomic _cachedSpeed; - std::atomic_bool _cachedPaused; - std::atomic_bool _cachedLoading; - std::atomic_bool _cachedEnded; -} - -- (instancetype)initWithHostView:(NSView *)hostView - sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsUrl:(NSString *)controlsUrl - javaVm:(JavaVM *)javaVm - eventSink:(jobject)eventSink - eventMethod:(jmethodID)eventMethod { - self = [super init]; - if (!self) { - return nil; - } - - _cachedDurationSeconds.store(0.0); - _cachedPositionSeconds.store(initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0); - _cachedCacheAheadSeconds.store(0.0); - _cachedSpeed.store(1.0); - _cachedPaused.store(!playWhenReady); - _cachedLoading.store(true); - _cachedEnded.store(false); - _mpvEventQueue = dispatch_queue_create("com.nuvio.desktop.mpv-events", DISPATCH_QUEUE_SERIAL); - _javaVm = javaVm; - _eventSink = eventSink; - _eventMethod = eventMethod; - - _hostView = hostView; - _hostView.wantsLayer = YES; - _hostView.layer.backgroundColor = NSColor.blackColor.CGColor; - [_hostView setPostsFrameChangedNotifications:YES]; - [_hostView setPostsBoundsChangedNotifications:YES]; - - _videoView = [[PlayerMetalView alloc] initWithFrame:_hostView.bounds]; - [_hostView addSubview:_videoView]; - - _scriptHandler = [PlayerScriptHandler new]; - _scriptHandler.player = self; - WKUserContentController *contentController = [WKUserContentController new]; - [contentController addScriptMessageHandler:_scriptHandler name:@"player"]; - - WKWebViewConfiguration *configuration = [WKWebViewConfiguration new]; - configuration.userContentController = contentController; - _webView = [[WKWebView alloc] initWithFrame:_hostView.bounds configuration:configuration]; - _webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - _webView.wantsLayer = YES; - [_webView setValue:@NO forKey:@"drawsBackground"]; - [_hostView addSubview:_webView positioned:NSWindowAbove relativeTo:_videoView]; - NSURL *controlsURL = [NSURL URLWithString:controlsUrl ?: @""]; - if (!controlsURL) { - @throw [NSException exceptionWithName:@"PlayerBridgeError" - reason:@"Invalid native player controls URL." - userInfo:nil]; - } - if (controlsURL.isFileURL) { - [_webView loadFileURL:controlsURL allowingReadAccessToURL:[controlsURL URLByDeletingLastPathComponent]]; - } else { - [_webView loadRequest:[NSURLRequest requestWithURL:controlsURL]]; - } - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(hostViewFrameDidChange:) - name:NSViewFrameDidChangeNotification - object:_hostView]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(hostViewBoundsDidChange:) - name:NSViewBoundsDidChangeNotification - object:_hostView]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowWillEnterFullScreen:) - name:NSWindowWillEnterFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowDidEnterFullScreen:) - name:NSWindowDidEnterFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowWillExitFullScreen:) - name:NSWindowWillExitFullScreenNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowDidExitFullScreen:) - name:NSWindowDidExitFullScreenNotification - object:nil]; - [self layoutNativeSubviews]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self focusControlsWebViewIfNeeded]; - }); - - [self startMpvWithSource:sourceUrl - headerLines:headerLines - playWhenReady:playWhenReady - initialPositionMs:initialPositionMs]; - _timer = [NSTimer scheduledTimerWithTimeInterval:0.5 - target:self - selector:@selector(syncControls) - userInfo:nil - repeats:YES]; - return self; -} - -- (void)focusControlsWebViewIfNeeded { - if (_didFocusControlsWebView || !_webView || !_webView.window) { - return; - } - _didFocusControlsWebView = YES; - [_webView.window makeFirstResponder:_webView]; -} - -- (void)layoutControlsWebViewToBounds:(NSRect)bounds immediate:(BOOL)immediate { - if (!_webView) { - return; - } - - if (!NSEqualRects(_webView.frame, bounds)) { - _webView.frame = bounds; - } - if (!immediate) { - return; - } - - [_webView setNeedsLayout:YES]; - [_webView layoutSubtreeIfNeeded]; - [_webView setNeedsDisplay:YES]; - - NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; - BOOL sizeChanged = !NSEqualSizes(_lastControlsViewportNudgeSize, bounds.size); - if (!_controlsWebReady || !sizeChanged || now - _lastControlsViewportNudgeAt < 0.05) { - return; - } - - _lastControlsViewportNudgeSize = bounds.size; - _lastControlsViewportNudgeAt = now; - NSString *script = @"window.nuvioNativeViewportChanged ? window.nuvioNativeViewportChanged() : window.dispatchEvent(new Event('resize'));"; - [_webView evaluateJavaScript:script completionHandler:nil]; -} - -- (void)layoutNativeSubviews { - if (!_hostView) { - return; - } - NSRect bounds = _hostView.bounds; - if (bounds.size.width <= 0.0 || bounds.size.height <= 0.0) { - return; - } - - NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; - BOOL nativeLiveResize = _hostView.inLiveResize || _hostView.window.inLiveResize; - BOOL wasResizeLikeLayout = _lastAppliedNativeLayoutWasLiveResize - || _fullscreenTransitionActive - || (_lightweightResizeSettleUntil > now); - BOOL hasPreviousBounds = _lastAppliedNativeLayoutBounds.size.width > 1.0 - && _lastAppliedNativeLayoutBounds.size.height > 1.0; - if (!_fullscreenTransitionActive && !nativeLiveResize && hasPreviousBounds && !NSEqualRects(bounds, _lastAppliedNativeLayoutBounds)) { - double previousArea = _lastAppliedNativeLayoutBounds.size.width * _lastAppliedNativeLayoutBounds.size.height; - double currentArea = bounds.size.width * bounds.size.height; - double areaRatio = previousArea > 1.0 ? currentArea / previousArea : 1.0; - CGFloat widthDelta = fabs(bounds.size.width - _lastAppliedNativeLayoutBounds.size.width); - CGFloat heightDelta = fabs(bounds.size.height - _lastAppliedNativeLayoutBounds.size.height); - if (areaRatio > 1.75 || areaRatio < 0.60 || widthDelta > 480.0 || heightDelta > 320.0) { - [self activateFullscreenTransitionWithReason:@"layout-jump"]; - } - } - - BOOL liveResize = nativeLiveResize || _fullscreenTransitionActive; - BOOL settlingFromResize = !liveResize && wasResizeLikeLayout; - if (liveResize - && _lastAppliedNativeLayoutWasLiveResize - && NSEqualRects(bounds, _lastAppliedNativeLayoutBounds)) { - return; - } - if (liveResize || settlingFromResize) { - _lightweightResizeSettleUntil = now + 0.45; - } - _lastAppliedNativeLayoutBounds = bounds; - _lastAppliedNativeLayoutWasLiveResize = liveResize; - - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - if (_videoView) { - if (liveResize || settlingFromResize) { - [_videoView setFullscreenTransitionActive:YES]; - } - if (!NSEqualRects(_videoView.frame, bounds)) { - _videoView.frame = bounds; - } - if (!liveResize && !settlingFromResize) { - [_videoView setNeedsLayout:YES]; - [_videoView layoutSubtreeIfNeeded]; - [_videoView updateMetalLayerLayout]; - } - } - if (_webView) { - BOOL immediateControlsLayout = _fullscreenTransitionActive || settlingFromResize || (!liveResize && !settlingFromResize); - [self layoutControlsWebViewToBounds:bounds immediate:immediateControlsLayout]; - } - [CATransaction commit]; - - if (liveResize || settlingFromResize) { - if (_mpv) { - [self schedulePostResizeRefreshWithReason:liveResize ? @"live-layout" : @"settle-layout"]; - } - return; - } - - CGSize currentDrawableSize = _videoView ? [_videoView drawableSize] : CGSizeZero; - NSString *resizeKey = [NSString stringWithFormat: - @"%@:%@:%@:%@", - diagnosticRect(bounds), - _videoView ? diagnosticRect(_videoView.frame) : @"none", - _webView ? diagnosticRect(_webView.frame) : @"none", - diagnosticSize(currentDrawableSize) - ]; - if (!_lastResizeRefreshKey || ![_lastResizeRefreshKey isEqualToString:resizeKey]) { - _lastResizeRefreshKey = resizeKey; - if (_mpv) { - [self schedulePostResizeRefreshWithReason:@"layout"]; - } - } -} - -- (void)hostViewFrameDidChange:(NSNotification *)notification { - [self layoutNativeSubviews]; -} - -- (void)hostViewBoundsDidChange:(NSNotification *)notification { - [self layoutNativeSubviews]; -} - -- (BOOL)notificationBelongsToHostWindow:(NSNotification *)notification { - NSWindow *window = [notification.object isKindOfClass:[NSWindow class]] ? (NSWindow *)notification.object : nil; - return window && _hostView.window && window == _hostView.window; -} - -- (void)windowWillEnterFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self beginFullscreenTransitionWithReason:@"will-enter-fullscreen"]; -} - -- (void)windowDidEnterFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self finishFullscreenTransitionWithReason:@"did-enter-fullscreen"]; -} - -- (void)windowWillExitFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self beginFullscreenTransitionWithReason:@"will-exit-fullscreen"]; -} - -- (void)windowDidExitFullScreen:(NSNotification *)notification { - if (![self notificationBelongsToHostWindow:notification]) { - return; - } - [self finishFullscreenTransitionWithReason:@"did-exit-fullscreen"]; -} - -- (void)activateFullscreenTransitionWithReason:(NSString *)reason { - _fullscreenTransitionActive = YES; - [_fullscreenTransitionTimer invalidate]; - [_videoView setFullscreenTransitionActive:YES]; - _fullscreenTransitionTimer = [NSTimer scheduledTimerWithTimeInterval:1.25 - target:self - selector:@selector(handleFullscreenTransitionTimer:) - userInfo:reason ?: @"unknown" - repeats:NO]; -} - -- (void)beginFullscreenTransitionWithReason:(NSString *)reason { - [self activateFullscreenTransitionWithReason:reason]; - [self layoutNativeSubviews]; -} - -- (void)finishFullscreenTransitionWithReason:(NSString *)reason { - if (!_fullscreenTransitionActive && !_fullscreenTransitionTimer) { - return; - } - [_fullscreenTransitionTimer invalidate]; - _fullscreenTransitionTimer = nil; - _fullscreenTransitionActive = NO; - [self layoutNativeSubviews]; - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = nil; - if (_mpv) { - NSString *refreshReason = [NSString stringWithFormat:@"fullscreen/%@", reason ?: @"unknown"]; - [self schedulePostResizeRefreshWithReason:refreshReason]; - } -} - -- (void)handleFullscreenTransitionTimer:(NSTimer *)timer { - [self finishFullscreenTransitionWithReason:[NSString stringWithFormat:@"timer/%@", timer.userInfo ?: @"unknown"]]; -} - -- (void)schedulePostResizeRefreshWithReason:(NSString *)reason { - if (!_mpv || !_videoView) { - return; - } - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = [NSTimer scheduledTimerWithTimeInterval:0.35 - target:self - selector:@selector(handleResizeSettleTimer:) - userInfo:reason ?: @"unknown" - repeats:NO]; -} - -- (void)handleResizeSettleTimer:(NSTimer *)timer { - _resizeSettleTimer = nil; - if (!_mpv || !_videoView) { - return; - } - if (_hostView.inLiveResize || _hostView.window.inLiveResize || _fullscreenTransitionActive) { - [self schedulePostResizeRefreshWithReason:timer.userInfo ?: @"unknown"]; - return; - } - _lightweightResizeSettleUntil = 0.0; - [self layoutControlsWebViewToBounds:_hostView.bounds immediate:YES]; - [_videoView setFullscreenTransitionActive:NO]; - [_videoView updateMetalLayerLayout]; - NSString *reason = [NSString stringWithFormat:@"resize-settled/%@", timer.userInfo ?: @"unknown"]; - [self configureHdrForCurrentScreenWithReason:reason force:NO]; -} - -- (void)startMpvWithSource:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs { - _mpv = mpv_create(); - if (!_mpv) { - @throw [NSException exceptionWithName:@"PlayerBridgeError" - reason:@"mpv_create failed" - userInfo:nil]; - } - _initialStartSeconds = initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0; - - setMpvOptionString(_mpv, "config", "no"); - setMpvOptionString(_mpv, "osc", "no"); - setMpvOptionString(_mpv, "input-default-bindings", "yes"); - setMpvOptionString(_mpv, "input-vo-keyboard", "no"); - setMpvOptionString(_mpv, "keep-open", "yes"); - setMpvOptionString(_mpv, "vo", "libmpv"); - setMpvOptionString(_mpv, "hwdec", "auto"); - setMpvOptionString(_mpv, "gpu-hwdec-interop", "auto"); - setMpvOptionString(_mpv, "hwdec-codecs", "all"); - setMpvOptionString(_mpv, "vd-lavc-software-fallback", "yes"); - setMpvOptionString(_mpv, "vd-lavc-threads", "4"); - setMpvOptionString(_mpv, "target-colorspace-hint", "yes"); - setMpvOptionString(_mpv, "target-colorspace-hint-mode", "source"); - setMpvOptionString(_mpv, "target-colorspace-hint-strict", "no"); - setMpvOptionString(_mpv, "tone-mapping", "auto"); - setMpvOptionString(_mpv, "hdr-compute-peak", "no"); - setMpvOptionString(_mpv, "dither-depth", "auto"); - setMpvOptionString(_mpv, "deband", "yes"); - setMpvOptionString(_mpv, "scale", "spline36"); - setMpvOptionString(_mpv, "cscale", "spline36"); - setMpvOptionString(_mpv, "demuxer-max-bytes", "64MiB"); - setMpvOptionString(_mpv, "demuxer-max-back-bytes", "16MiB"); - setMpvOptionString(_mpv, "demuxer-seekable-cache", "no"); - setMpvOptionString(_mpv, "cache-secs", "30"); - setMpvOptionString(_mpv, "hr-seek", "no"); - - if (headerLines.count > 0) { - NSString *headers = [headerLines componentsJoinedByString:@","]; - setMpvOptionString(_mpv, "http-header-fields", headers.UTF8String); - } - - int initResult = mpv_initialize(_mpv); - if (initResult < 0) { - NSString *reason = [NSString stringWithFormat:@"mpv_initialize failed: %s", mpv_error_string(initResult)]; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - NSString *renderError = nil; - if (![_videoView createMpvRenderContext:_mpv error:&renderError]) { - NSString *reason = renderError ?: @"mpv render context failed"; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - std::vector command = {"loadfile", sourceUrl.UTF8String}; - std::string loadOptions; - if (initialPositionMs > 0) { - char startBuffer[64]; - snprintf(startBuffer, sizeof(startBuffer), "start=%.3f", (double)initialPositionMs / 1000.0); - loadOptions = startBuffer; - command.push_back("replace"); - command.push_back("-1"); - command.push_back(loadOptions.c_str()); - } - command.push_back(NULL); - int commandResult = mpv_command(_mpv, command.data()); - if (commandResult < 0) { - NSString *reason = [NSString stringWithFormat:@"mpv loadfile failed: %s", mpv_error_string(commandResult)]; - @throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil]; - } - - [self setPaused:!playWhenReady]; -} - -- (void)syncControls { - if (!_webView || !_mpv) { - return; - } - [self layoutNativeSubviews]; - [self focusControlsWebViewIfNeeded]; - if (_controlsSyncInFlight || !_mpvEventQueue) { - return; - } - _controlsSyncInFlight = YES; - - mpv_handle *mpv = _mpv; - dispatch_async(_mpvEventQueue, ^{ - @autoreleasepool { - if (self->_mpv != mpv) { - dispatch_async(dispatch_get_main_queue(), ^{ - self->_controlsSyncInFlight = NO; - }); - return; - } - - double duration = [self doubleProperty:"duration" fallback:0.0]; - double position = [self doubleProperty:"time-pos" fallback:0.0]; - double cacheAhead = [self cacheAheadSecondsForPosition:position]; - BOOL paused = [self rawIsPaused]; - BOOL ended = [self rawIsEnded]; - BOOL loading = [self rawLoadingWithPaused:paused ended:ended duration:duration]; - double speed = [self rawSpeed]; - NSString *audioTracks = [self audioTracksJson] ?: @"[]"; - NSString *subtitleTracks = [self subtitleTracksJson] ?: @"[]"; - NSString *gamma = [[self stringProperty:"video-params/gamma" fallback:@""] lowercaseString]; - NSString *primaries = [[self stringProperty:"video-params/primaries" fallback:@""] lowercaseString]; - [self updateCachedDuration:duration - position:position - cacheAhead:cacheAhead - paused:paused - loading:loading - ended:ended - speed:speed]; - - dispatch_async(dispatch_get_main_queue(), ^{ - self->_controlsSyncInFlight = NO; - if (!self->_webView || self->_mpv != mpv) { - return; - } - [self applyHdrForPolledGamma:gamma primaries:primaries reason:@"sync" force:NO]; - NSString *script = [NSString stringWithFormat: - @"window.playerUpdate({duration:%0.3f,position:%0.3f,paused:%@,loading:%@,audioTracks:%@,subtitleTracks:%@})", - duration, - position, - paused ? @"true" : @"false", - loading ? @"true" : @"false", - audioTracks, - subtitleTracks]; - [self->_webView evaluateJavaScript:script completionHandler:nil]; - }); - } - }); -} - -- (void)configureHdrForCurrentScreenIfNeeded { - [self configureHdrForCurrentScreenWithReason:@"legacy" force:NO]; -} - -- (void)configureHdrForCurrentScreenWithReason:(NSString *)reason force:(BOOL)force { - if (!_mpv || !_videoView) { - return; - } - NSString *gamma = [[self stringProperty:"video-params/gamma" fallback:@""] lowercaseString]; - NSString *primaries = [[self stringProperty:"video-params/primaries" fallback:@""] lowercaseString]; - if (gamma.length == 0 && primaries.length == 0) { - return; - } - - BOOL isPq = [gamma containsString:@"pq"] - || [gamma containsString:@"2084"] - || [gamma containsString:@"st2084"]; - BOOL isHlg = [gamma containsString:@"hlg"]; - double edrMax = [_videoView edrMax]; - double screenPeak = [_videoView hdrTargetPeakNits]; - BOOL hdrDetected = (isPq || isHlg) && edrMax > 1.0; - NSString *targetPrimaries = hdrDetected && primaries.length > 0 ? primaries : @"auto"; - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%@:%0.2f:%0.0f", - hdrDetected ? @"native-opengl-edr" : @"sdr", - gamma ?: @"", - targetPrimaries, - edrMax, - screenPeak - ]; - if (!force && _lastConfiguredHdrKey && [_lastConfiguredHdrKey isEqualToString:stateKey]) { - return; - } - _lastConfiguredHdrKey = stateKey; - - double targetPeakNits = normalizedHdrMetadataMaxNits(screenPeak); - [_videoView configureExtendedDynamicRange:hdrDetected primaries:targetPrimaries targetPeakNits:targetPeakNits]; - NSString *targetTransfer = hdrDetected ? @"pq" : @"auto"; - NSString *toneMapping = hdrDetected ? @"" : @"auto"; - NSString *targetPeak = hdrDetected ? [NSString stringWithFormat:@"%.0f", targetPeakNits] : @"auto"; - [self setStringProperty:"target-colorspace-hint" value:@"yes"]; - [self setStringProperty:"target-colorspace-hint-mode" value:hdrDetected ? @"source" : @"target"]; - [self setStringProperty:"target-colorspace-hint-strict" value:@"no"]; - [self setStringProperty:"tone-mapping" value:toneMapping]; - [self setStringProperty:"hdr-compute-peak" value:@"no"]; - [self setStringProperty:"target-prim" value:targetPrimaries]; - [self setStringProperty:"target-trc" value:targetTransfer]; - [self setStringProperty:"target-peak" value:targetPeak]; -} - -- (void)applyHdrForPolledGamma:(NSString *)gamma primaries:(NSString *)primaries reason:(NSString *)reason force:(BOOL)force { - if (!_mpv || !_videoView) { - return; - } - NSString *normalizedGamma = [[gamma ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - NSString *normalizedPrimaries = [[primaries ?: @"" lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - if (normalizedGamma.length == 0 && normalizedPrimaries.length == 0) { - return; - } - - BOOL isPq = [normalizedGamma containsString:@"pq"] - || [normalizedGamma containsString:@"2084"] - || [normalizedGamma containsString:@"st2084"]; - BOOL isHlg = [normalizedGamma containsString:@"hlg"]; - double edrMax = [_videoView edrMax]; - double screenPeak = [_videoView hdrTargetPeakNits]; - BOOL hdrDetected = (isPq || isHlg) && edrMax > 1.0; - NSString *targetPrimaries = hdrDetected && normalizedPrimaries.length > 0 ? normalizedPrimaries : @"auto"; - NSString *stateKey = [NSString stringWithFormat: - @"%@:%@:%@:%0.2f:%0.0f", - hdrDetected ? @"native-opengl-edr" : @"sdr", - normalizedGamma ?: @"", - targetPrimaries, - edrMax, - screenPeak - ]; - if (!force && _lastConfiguredHdrKey && [_lastConfiguredHdrKey isEqualToString:stateKey]) { - return; - } - _lastConfiguredHdrKey = stateKey; - - double targetPeakNits = normalizedHdrMetadataMaxNits(screenPeak); - [_videoView configureExtendedDynamicRange:hdrDetected primaries:targetPrimaries targetPeakNits:targetPeakNits]; - - NSString *targetTransfer = hdrDetected ? @"pq" : @"auto"; - NSString *toneMapping = hdrDetected ? @"" : @"auto"; - NSString *targetPeak = hdrDetected ? [NSString stringWithFormat:@"%.0f", targetPeakNits] : @"auto"; - mpv_handle *mpv = _mpv; - dispatch_queue_t queue = _mpvEventQueue; - if (queue) { - dispatch_async(queue, ^{ - if (self->_mpv != mpv) { - return; - } - mpv_set_property_string(mpv, "target-colorspace-hint", "yes"); - mpv_set_property_string(mpv, "target-colorspace-hint-mode", hdrDetected ? "source" : "target"); - mpv_set_property_string(mpv, "target-colorspace-hint-strict", "no"); - mpv_set_property_string(mpv, "tone-mapping", toneMapping.UTF8String); - mpv_set_property_string(mpv, "hdr-compute-peak", "no"); - mpv_set_property_string(mpv, "target-prim", targetPrimaries.UTF8String); - mpv_set_property_string(mpv, "target-trc", targetTransfer.UTF8String); - mpv_set_property_string(mpv, "target-peak", targetPeak.UTF8String); - }); - } -} - -- (JNIEnv *)jniEnvDidAttach:(BOOL *)didAttach { - if (didAttach) { - *didAttach = NO; - } - if (!_javaVm) { - return nullptr; - } - - JNIEnv *env = nullptr; - jint status = _javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); - if (status == JNI_OK) { - return env; - } - if (status != JNI_EDETACHED) { - return nullptr; - } - if (_javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { - return nullptr; - } - if (didAttach) { - *didAttach = YES; - } - return env; -} - -- (void)sendPlayerEvent:(NSString *)type value:(double)value { - if (!_eventSink || !_eventMethod) { - return; - } - - BOOL didAttach = NO; - JNIEnv *env = [self jniEnvDidAttach:&didAttach]; - if (!env) { - return; - } - - jstring eventType = env->NewStringUTF(type.UTF8String); - env->CallVoidMethod(_eventSink, _eventMethod, eventType, (jdouble)value); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } - if (eventType) { - env->DeleteLocalRef(eventType); - } - if (didAttach) { - _javaVm->DetachCurrentThread(); - } -} - -- (void)updateControlsJson:(NSString *)controlsJson { - if (!_webView) { - return; - } - if (!controlsJson) { - return; - } - _pendingControlsJson = [controlsJson copy]; - [self flushPendingControlsJsonIfReady]; -} - -- (void)flushPendingControlsJsonIfReady { - if (!_webView || !_pendingControlsJson) { - return; - } - if (!_controlsWebReady) { - return; - } - NSString *controlsJson = [_pendingControlsJson copy]; - NSString *jsonString = javaScriptStringLiteral(controlsJson); - NSString *script = [NSString stringWithFormat: - @"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(%@));return 'applied';})()", - jsonString]; - [_webView evaluateJavaScript:script completionHandler:^(id _Nullable jsResult, NSError * _Nullable error) { - if (error) { - return; - } - NSString *resultText = [jsResult isKindOfClass:[NSString class]] ? (NSString *)jsResult : @"unknown"; - if ([resultText isEqualToString:@"missing"]) { - _controlsWebReady = NO; - return; - } - if (_pendingControlsJson && [_pendingControlsJson isEqualToString:controlsJson]) { - _pendingControlsJson = nil; - } - }]; -} - -- (void)shutdown { - [[NSNotificationCenter defaultCenter] removeObserver:self]; - [_timer invalidate]; - _timer = nil; - [_resizeSettleTimer invalidate]; - _resizeSettleTimer = nil; - [_fullscreenTransitionTimer invalidate]; - _fullscreenTransitionTimer = nil; - _controlsWebReady = NO; - _pendingControlsJson = nil; - if (_mpvEventQueue) { - dispatch_sync(_mpvEventQueue, ^{}); - } - [_videoView destroyMpvRenderContext]; - if (_mpv) { - mpv_terminate_destroy(_mpv); - _mpv = NULL; - } - [_webView.configuration.userContentController removeScriptMessageHandlerForName:@"player"]; - [_webView removeFromSuperview]; - [_videoView removeFromSuperview]; - _webView = nil; - _videoView = nil; - _scriptHandler = nil; - if (_eventSink) { - BOOL didAttach = NO; - JNIEnv *env = [self jniEnvDidAttach:&didAttach]; - if (env) { - env->DeleteGlobalRef(_eventSink); - } - if (didAttach) { - _javaVm->DetachCurrentThread(); - } - _eventSink = nullptr; - } - _eventMethod = nullptr; - _javaVm = nullptr; -} - -- (void)setPaused:(BOOL)paused { - if (!_mpv) return; - int flag = paused ? 1 : 0; - mpv_set_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag); - _cachedPaused.store(paused); -} - -- (BOOL)isPaused { - return _cachedPaused.load(); -} - -- (BOOL)rawIsPaused { - if (!_mpv) return _cachedPaused.load(); - int flag = 1; - mpv_get_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag); - return flag != 0; -} - -- (void)seekToMilliseconds:(long long)positionMs { - if (!_mpv) return; - std::string seconds = std::to_string((double)positionMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "absolute+keyframes", NULL}; - mpv_command(_mpv, command); - _cachedPositionSeconds.store(fmax((double)positionMs / 1000.0, 0.0)); -} - -- (void)seekByMilliseconds:(long long)offsetMs { - if (!_mpv) return; - std::string seconds = std::to_string((double)offsetMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "relative+keyframes", NULL}; - mpv_command(_mpv, command); - double nextPosition = fmax(_cachedPositionSeconds.load() + ((double)offsetMs / 1000.0), 0.0); - _cachedPositionSeconds.store(nextPosition); -} - -- (void)setSpeed:(double)speed { - if (!_mpv) return; - double clamped = fmax(0.25, fmin(4.0, speed)); - mpv_set_property(_mpv, "speed", MPV_FORMAT_DOUBLE, &clamped); - _cachedSpeed.store(clamped); -} - -- (double)speed { - return _cachedSpeed.load(); -} - -- (double)rawSpeed { - return [self doubleProperty:"speed" fallback:_cachedSpeed.load()]; -} - -- (void)setResizeMode:(int)mode { - if (!_mpv) return; - NSString *panscan = @"0.0"; - switch (mode) { - case 1: - case 2: - panscan = @"1.0"; - break; - default: - break; - } - - dispatch_queue_t queue = _mpvEventQueue; - if (!queue) { - [self setStringProperty:"panscan" value:panscan]; - [self setStringProperty:"video-unscaled" value:@"no"]; - return; - } - - dispatch_async(queue, ^{ - mpv_handle *mpv = self->_mpv; - if (!mpv) { - return; - } - mpv_set_property_string(mpv, "panscan", panscan.UTF8String); - mpv_set_property_string(mpv, "video-unscaled", "no"); - }); -} - -- (long long)durationMs { - return (long long)llround(fmax(_cachedDurationSeconds.load(), 0.0) * 1000.0); -} - -- (long long)positionMs { - return (long long)llround(fmax(_cachedPositionSeconds.load(), 0.0) * 1000.0); -} - -- (double)rawPositionSeconds { - double position = [self doubleProperty:"time-pos" fallback:0.0]; - return std::isfinite(position) ? fmax(position, 0.0) : 0.0; -} - -- (double)effectiveCachePositionSeconds { - double position = [self rawPositionSeconds]; - if (_initialStartSeconds > 0.0 && position + 5.0 < _initialStartSeconds) { - return _initialStartSeconds; - } - return position; -} - -- (double)cacheAheadSeconds { - return [self cacheAheadSecondsForPosition:[self rawPositionSeconds]]; -} - -- (double)cacheAheadSecondsForPosition:(double)position { - double safePosition = std::isfinite(position) ? fmax(position, 0.0) : 0.0; - double effectivePosition = safePosition; - if (_initialStartSeconds > 0.0 && safePosition + 5.0 < _initialStartSeconds) { - effectivePosition = _initialStartSeconds; - } - double cacheTime = [self doubleProperty:"demuxer-cache-time" fallback:0.0]; - if (std::isfinite(cacheTime) && cacheTime > 0.0) { - if (cacheTime >= effectivePosition - 5.0) { - return fmax(cacheTime - effectivePosition, 0.0); - } - return cacheTime; - } - - double cacheDuration = [self doubleProperty:"demuxer-cache-duration" fallback:0.0]; - if (std::isfinite(cacheDuration) && cacheDuration > 0.0) { - return cacheDuration; - } - - return 0.0; -} - -- (long long)bufferedPositionMs { - double bufferedPosition = _cachedPositionSeconds.load() + _cachedCacheAheadSeconds.load(); - return (long long)llround(fmax(bufferedPosition, 0.0) * 1000.0); -} - -- (BOOL)isLoading { - return _cachedLoading.load(); -} - -- (BOOL)rawLoadingWithPaused:(BOOL)paused ended:(BOOL)eofReached duration:(double)duration { - BOOL idle = [self flagProperty:"core-idle" fallback:YES]; - BOOL seeking = [self flagProperty:"seeking" fallback:NO]; - BOOL bufferingCache = [self flagProperty:"paused-for-cache" fallback:NO]; - BOOL fileReady = duration > 0.0 - || [self int64Property:"track-list/count" fallback:0] > 0; - return !fileReady || (idle && !paused && !eofReached) || seeking || bufferingCache; -} - -- (BOOL)isEnded { - return _cachedEnded.load(); -} - -- (BOOL)rawIsEnded { - return [self flagProperty:"eof-reached" fallback:_cachedEnded.load()]; -} - -- (void)updateCachedDuration:(double)duration - position:(double)position - cacheAhead:(double)cacheAhead - paused:(BOOL)paused - loading:(BOOL)loading - ended:(BOOL)ended - speed:(double)speed { - _cachedDurationSeconds.store(std::isfinite(duration) ? fmax(duration, 0.0) : 0.0); - _cachedPositionSeconds.store(std::isfinite(position) ? fmax(position, 0.0) : 0.0); - _cachedCacheAheadSeconds.store(std::isfinite(cacheAhead) ? fmax(cacheAhead, 0.0) : 0.0); - _cachedSpeed.store(std::isfinite(speed) ? fmax(0.25, fmin(4.0, speed)) : 1.0); - _cachedPaused.store(paused); - _cachedLoading.store(loading); - _cachedEnded.store(ended); -} - -- (NSString *)audioTracksJson { - return [self tracksJsonForType:@"audio"]; -} - -- (NSString *)subtitleTracksJson { - return [self tracksJsonForType:@"sub"]; -} - -- (void)selectAudioTrackId:(int)trackId { - if (!_mpv) return; - int64_t id = trackId; - mpv_set_property(_mpv, "aid", MPV_FORMAT_INT64, &id); -} - -- (void)selectSubtitleTrackId:(int)trackId { - if (!_mpv) return; - if (trackId < 0) { - [self setStringProperty:"sid" value:@"no"]; - return; - } - int64_t id = trackId; - mpv_set_property(_mpv, "sid", MPV_FORMAT_INT64, &id); -} - -- (void)addSubtitleUrl:(NSString *)url { - if (!_mpv || url.length == 0) return; - [self command:@[@"sub-add", url, @"select"]]; -} - -- (void)removeExternalSubtitles { - if (!_mpv) return; - [self removeExternalSubtitleTracks]; - [self setStringProperty:"sid" value:@"no"]; -} - -- (void)removeExternalSubtitlesAndSelect:(int)trackId { - if (!_mpv) return; - [self removeExternalSubtitleTracks]; - if (trackId >= 0) { - [self selectSubtitleTrackId:trackId]; - } else { - [self setStringProperty:"sid" value:@"no"]; - } -} - -- (void)setSubtitleDelayMs:(int)delayMs { - if (!_mpv) return; - int clamped = MAX(-60000, MIN(60000, delayMs)); - double delaySeconds = (double)clamped / 1000.0; - mpv_set_property(_mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds); -} - -- (void)applySubtitleStyleWithTextColor:(NSString *)textColor - backgroundColor:(NSString *)backgroundColor - outlineColor:(NSString *)outlineColor - outlineSize:(double)outlineSize - bold:(BOOL)bold - fontSize:(double)fontSize - subPos:(int)subPos { - if (!_mpv) return; - [self setStringProperty:"sub-ass-override" value:@"force"]; - [self setStringProperty:"sub-color" value:textColor ?: @"#FFFFFFFF"]; - [self setStringProperty:"sub-back-color" value:backgroundColor ?: @"#00000000"]; - [self setStringProperty:"sub-outline-color" value:outlineColor ?: @"#FF000000"]; - [self setStringProperty:"sub-border-style" - value:[(backgroundColor ?: @"") hasPrefix:@"#00"] ? @"outline-and-shadow" : @"opaque-box"]; - [self setStringProperty:"sub-bold" value:bold ? @"yes" : @"no"]; - - double outline = MAX(0.0, MIN(8.0, outlineSize)); - mpv_set_property(_mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline); - - double size = MAX(24.0, MIN(96.0, fontSize)); - mpv_set_property(_mpv, "sub-font-size", MPV_FORMAT_DOUBLE, &size); - - int64_t position = MAX(0, MIN(150, subPos)); - mpv_set_property(_mpv, "sub-pos", MPV_FORMAT_INT64, &position); -} - -- (double)doubleProperty:(const char *)name fallback:(double)fallback { - if (!_mpv) return fallback; - double value = fallback; - if (mpv_get_property(_mpv, name, MPV_FORMAT_DOUBLE, &value) < 0) { - return fallback; - } - return value; -} - -- (long long)int64Property:(const char *)name fallback:(long long)fallback { - if (!_mpv) return fallback; - int64_t value = fallback; - if (mpv_get_property(_mpv, name, MPV_FORMAT_INT64, &value) < 0) { - return fallback; - } - return value; -} - -- (BOOL)flagProperty:(const char *)name fallback:(BOOL)fallback { - if (!_mpv) return fallback; - int flag = fallback ? 1 : 0; - if (mpv_get_property(_mpv, name, MPV_FORMAT_FLAG, &flag) < 0) { - return fallback; - } - return flag != 0; -} - -- (NSString *)stringProperty:(const char *)name fallback:(NSString *)fallback { - if (!_mpv) return fallback ?: @""; - char *value = nullptr; - if (mpv_get_property(_mpv, name, MPV_FORMAT_STRING, &value) < 0 || !value) { - return fallback ?: @""; - } - NSString *result = [NSString stringWithUTF8String:value] ?: (fallback ?: @""); - mpv_free(value); - return result; -} - -- (void)setStringProperty:(const char *)name value:(NSString *)value { - if (!_mpv) return; - mpv_set_property_string(_mpv, name, (value ?: @"").UTF8String); -} - -- (void)command:(NSArray *)args { - if (!_mpv || args.count == 0) return; - std::vector cargs; - cargs.reserve(args.count + 1); - for (NSString *arg in args) { - cargs.push_back((arg ?: @"").UTF8String); - } - cargs.push_back(nullptr); - mpv_command(_mpv, cargs.data()); -} - -- (void)removeExternalSubtitleTracks { - long long count = [self int64Property:"track-list/count" fallback:0]; - if (count <= 0) return; - for (long long index = count - 1; index >= 0; index--) { - NSString *typeKey = [NSString stringWithFormat:@"track-list/%lld/type", index]; - NSString *externalKey = [NSString stringWithFormat:@"track-list/%lld/external", index]; - NSString *idKey = [NSString stringWithFormat:@"track-list/%lld/id", index]; - NSString *type = [self stringProperty:typeKey.UTF8String fallback:@""]; - BOOL external = [self flagProperty:externalKey.UTF8String fallback:NO]; - if ([type isEqualToString:@"sub"] && external) { - long long trackId = [self int64Property:idKey.UTF8String fallback:-1]; - if (trackId >= 0) { - [self command:@[@"sub-remove", [NSString stringWithFormat:@"%lld", trackId]]]; - } - } - } -} - -- (NSString *)tracksJsonForType:(NSString *)wantedType { - if (!_mpv) return @"[]"; - NSMutableArray *tracks = [NSMutableArray array]; - long long count = [self int64Property:"track-list/count" fallback:0]; - int logicalIndex = 0; - - for (long long index = 0; index < count; index++) { - NSString *prefix = [NSString stringWithFormat:@"track-list/%lld", index]; - NSString *type = [self stringProperty:[[prefix stringByAppendingString:@"/type"] UTF8String] fallback:@""]; - if (![type isEqualToString:wantedType]) { - continue; - } - - long long trackId = [self int64Property:[[prefix stringByAppendingString:@"/id"] UTF8String] fallback:logicalIndex + 1]; - NSString *title = [self trackStringAtIndex:index field:@"title"]; - NSString *language = [self trackStringAtIndex:index field:@"lang"]; - NSString *codec = [self trackStringAtIndex:index field:@"codec"]; - NSString *decoderDescription = [self trackStringAtIndex:index field:@"decoder-desc"]; - NSString *channels = [self trackStringAtIndex:index field:@"demux-channels"]; - long long channelCount = [self int64Property:[[prefix stringByAppendingString:@"/demux-channel-count"] UTF8String] fallback:0]; - BOOL selected = [self flagProperty:[[prefix stringByAppendingString:@"/selected"] UTF8String] fallback:NO]; - BOOL forced = [self flagProperty:[[prefix stringByAppendingString:@"/forced"] UTF8String] fallback:NO]; - NSString *label = [self formatTrackTitleWithType:type - index:logicalIndex - title:title - language:language - codec:codec - decoderDescription:decoderDescription - channels:channels - channelCount:(int)channelCount]; - [tracks addObject:@{ - @"index": @(logicalIndex), - @"id": [NSString stringWithFormat:@"%lld", trackId], - @"label": label ?: @"", - @"language": language ?: @"", - @"selected": @(selected), - @"forced": @(forced), - }]; - logicalIndex += 1; - } - - NSData *data = [NSJSONSerialization dataWithJSONObject:tracks options:0 error:nil]; - if (!data) return @"[]"; - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"[]"; -} - -- (NSString *)trackStringAtIndex:(long long)index field:(NSString *)field { - NSString *key = [NSString stringWithFormat:@"track-list/%lld/%@", index, field]; - return [[self stringProperty:key.UTF8String fallback:@""] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; -} - -- (NSString *)formatTrackTitleWithType:(NSString *)type - index:(int)index - title:(NSString *)title - language:(NSString *)language - codec:(NSString *)codec - decoderDescription:(NSString *)decoderDescription - channels:(NSString *)channels - channelCount:(int)channelCount { - NSString *base = [self ifNotBlank:title] - ?: [self localizedLanguageName:language] - ?: ([type isEqualToString:@"sub"] - ? [NSString stringWithFormat:@"Subtitle %d", index + 1] - : [NSString stringWithFormat:@"Track %d", index + 1]); - NSString *codecName = [self codecDisplayName:codec] ?: [self codecDisplayName:decoderDescription]; - NSString *channelName = [type isEqualToString:@"audio"] - ? [self channelLayoutNameWithChannels:channels channelCount:channelCount] - : nil; - NSMutableArray *details = [NSMutableArray array]; - for (NSString *detail in @[channelName ?: @"", codecName ?: @""]) { - if (detail.length == 0) continue; - if ([base rangeOfString:detail options:NSCaseInsensitiveSearch].location == NSNotFound) { - [details addObject:detail]; - } - } - return details.count == 0 - ? base - : [NSString stringWithFormat:@"%@ (%@)", base, [details componentsJoinedByString:@", "]]; -} - -- (NSString *)ifNotBlank:(NSString *)value { - NSString *trimmed = [(value ?: @"") stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - return trimmed.length == 0 ? nil : trimmed; -} - -- (NSString *)localizedLanguageName:(NSString *)languageCode { - NSString *code = [self ifNotBlank:languageCode]; - if (!code) return nil; - return [[NSLocale currentLocale] displayNameForKey:NSLocaleLanguageCode value:code] ?: code; -} - -- (NSString *)channelLayoutNameWithChannels:(NSString *)channels channelCount:(int)channelCount { - NSString *normalized = [self ifNotBlank:channels]; - if (normalized && ![normalized isEqualToString:@"unknown"]) { - NSString *lower = normalized.lowercaseString; - if ([lower isEqualToString:@"mono"]) return @"Mono"; - if ([lower isEqualToString:@"stereo"]) return @"Stereo"; - return normalized; - } - switch (channelCount) { - case 1: return @"Mono"; - case 2: return @"Stereo"; - case 6: return @"5.1"; - case 8: return @"7.1"; - default: - return channelCount > 0 ? [NSString stringWithFormat:@"%dch", channelCount] : nil; - } -} - -- (NSString *)codecDisplayName:(NSString *)value { - NSString *raw = [self ifNotBlank:value]; - if (!raw) return nil; - NSString *codec = raw.lowercaseString; - if ([codec containsString:@"eac3"] || [codec containsString:@"e-ac-3"] || [codec containsString:@"e ac-3"]) { - return ([codec containsString:@"joc"] || [codec containsString:@"atmos"]) ? @"E-AC-3-JOC" : @"E-AC-3"; - } - if ([codec containsString:@"truehd"] || [codec containsString:@"true hd"]) return @"TrueHD"; - if ([codec containsString:@"ac3"] || [codec containsString:@"ac-3"]) return @"AC-3"; - if ([codec containsString:@"dts-hd"] || [codec containsString:@"dtshd"] || [codec containsString:@"dts hd"]) return @"DTS-HD"; - if ([codec containsString:@"dts"] || [codec isEqualToString:@"dca"]) return @"DTS"; - if ([codec containsString:@"aac"]) return @"AAC"; - if ([codec containsString:@"mp3"] || [codec containsString:@"mpeg audio"]) return @"MP3"; - if ([codec containsString:@"mp2"]) return @"MP2"; - if ([codec containsString:@"opus"]) return @"Opus"; - if ([codec containsString:@"vorbis"]) return @"Vorbis"; - if ([codec containsString:@"flac"]) return @"FLAC"; - if ([codec containsString:@"alac"]) return @"ALAC"; - if ([codec containsString:@"pcm"] || [codec containsString:@"wav"]) return @"WAV"; - if ([codec containsString:@"amr_wb"] || [codec containsString:@"amr-wb"]) return @"AMR-WB"; - if ([codec containsString:@"amr_nb"] || [codec containsString:@"amr-nb"]) return @"AMR-NB"; - if ([codec containsString:@"amr"]) return @"AMR"; - if ([codec containsString:@"iamf"]) return @"IAMF"; - if ([codec containsString:@"mpegh"] || [codec containsString:@"mpeg-h"]) return @"MPEG-H"; - if ([codec containsString:@"pgs"] || [codec containsString:@"hdmv"]) return @"PGS"; - if ([codec containsString:@"subrip"] || [codec isEqualToString:@"srt"]) return @"SRT"; - if ([codec containsString:@"ass"] || [codec containsString:@"ssa"]) return @"SSA"; - if ([codec containsString:@"webvtt"] || [codec isEqualToString:@"vtt"]) return @"VTT"; - if ([codec containsString:@"ttml"]) return @"TTML"; - if ([codec containsString:@"mov_text"] || [codec containsString:@"tx3g"]) return @"TX3G"; - if ([codec containsString:@"dvb"]) return @"DVB"; - return raw; -} - -- (void)handleScriptMessage:(NSDictionary *)message { - NSString *type = message[@"type"]; - if (![type isKindOfClass:[NSString class]]) { - return; - } - - id rawValue = message[@"value"]; - NSNumber *value = [rawValue isKindOfClass:[NSNumber class]] ? rawValue : nil; - if ([type isEqualToString:@"controlsReady"]) { - _controlsWebReady = YES; - [self flushPendingControlsJsonIfReady]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"selectAudioTrack"] && value) { - [self selectAudioTrackId:(int)llround(value.doubleValue)]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"selectSubtitleTrack"] && value) { - [self selectSubtitleTrackId:(int)llround(value.doubleValue)]; - [self syncControls]; - return; - } - if ([type isEqualToString:@"toggleFullscreen"]) { - [self beginFullscreenTransitionWithReason:@"control-toggle"]; - } - - if (_eventSink && _eventMethod) { - [self sendPlayerEvent:type value:value ? value.doubleValue : 0.0]; - return; - } - - if ([type isEqualToString:@"toggle"]) { - [self setPaused:![self isPaused]]; - [self syncControls]; - } else if ([type isEqualToString:@"seekPercent"]) { - double duration = [self doubleProperty:"duration" fallback:0.0]; - if (duration > 0.0 && value) { - [self seekToMilliseconds:(long long)llround(duration * value.doubleValue * 1000.0)]; - } - } else if ([type isEqualToString:@"scrubFinish"] && value) { - [self seekToMilliseconds:(long long)llround(value.doubleValue)]; - } -} - -@end - -static void runOnMainSync(dispatch_block_t block) { - if ([NSThread isMainThread]) { - block(); - } else { - dispatch_sync(dispatch_get_main_queue(), block); - } -} - -static void runOnMainAsync(dispatch_block_t block) { - if ([NSThread isMainThread]) { - block(); - } else { - dispatch_async(dispatch_get_main_queue(), block); - } -} - -static void throwJavaError(JNIEnv *env, NSString *message) { - jclass exceptionClass = env->FindClass("java/lang/IllegalStateException"); - if (exceptionClass) { - env->ThrowNew(exceptionClass, message.UTF8String); - } -} - -static std::string jstringToString(JNIEnv *env, jstring value) { - if (!value) return std::string(); - jsize length = env->GetStringLength(value); - const jchar *chars = env->GetStringChars(value, nullptr); - if (!chars) { - return std::string(); - } - NSString *string = [NSString stringWithCharacters:(const unichar *)chars length:(NSUInteger)length]; - env->ReleaseStringChars(value, chars); - - NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding]; - if (!data) { - return std::string(); - } - std::string result((const char *)data.bytes, data.length); - return result; -} - -static NSArray *jstringArrayToNSArray(JNIEnv *env, jobjectArray values) { - NSMutableArray *result = [NSMutableArray array]; - if (!values) { - return result; - } - jsize count = env->GetArrayLength(values); - for (jsize index = 0; index < count; index++) { - jstring item = (jstring)env->GetObjectArrayElement(values, index); - std::string value = jstringToString(env, item); - if (!value.empty()) { - [result addObject:[NSString stringWithUTF8String:value.c_str()]]; - } - env->DeleteLocalRef(item); - } - return result; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( - JNIEnv *env, - jobject /* bridge */, - jlong hostViewPtr, - jstring sourceUrl, - jobjectArray headerLines, - jboolean playWhenReady, - jlong initialPositionMs, - jstring controlsPageUrl, - jobject eventSink -) { - NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; - if (!hostView) { - throwJavaError(env, @"Unable to resolve the AWT host NSView for native playback."); - return 0; - } - - JavaVM *javaVm = nullptr; - env->GetJavaVM(&javaVm); - jobject eventSinkRef = nullptr; - jmethodID eventMethod = nullptr; - if (eventSink) { - eventSinkRef = env->NewGlobalRef(eventSink); - jclass eventSinkClass = env->GetObjectClass(eventSink); - eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); - env->DeleteLocalRef(eventSinkClass); - if (!eventMethod) { - if (eventSinkRef) { - env->DeleteGlobalRef(eventSinkRef); - } - throwJavaError(env, @"Native player event sink is missing onPlayerEvent(String, Double)."); - return 0; - } - } - - std::string source = jstringToString(env, sourceUrl); - std::string controls = jstringToString(env, controlsPageUrl); - NSArray *headers = jstringArrayToNSArray(env, headerLines); - __block MpvWebPlayer *player = nil; - __block NSString *error = nil; - runOnMainSync(^{ - @try { - player = [[MpvWebPlayer alloc] - initWithHostView:hostView - sourceUrl:[NSString stringWithUTF8String:source.c_str()] - headerLines:headers - playWhenReady:playWhenReady == JNI_TRUE - initialPositionMs:initialPositionMs - controlsUrl:[NSString stringWithUTF8String:controls.c_str()] - javaVm:javaVm - eventSink:eventSinkRef - eventMethod:eventMethod]; - } @catch (NSException *exception) { - error = exception.reason ?: exception.name; - } - }); - - if (error) { - if (eventSinkRef) { - env->DeleteGlobalRef(eventSinkRef); - } - throwJavaError(env, error); - return 0; - } - - return (jlong)(intptr_t)CFBridgingRetain(player); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge_transfer MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainSync(^{ - [player shutdown]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring controlsJson -) { - if (handle == 0) return; - std::string controls = jstringToString(env, controlsJson); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player updateControlsJson:[NSString stringWithUTF8String:controls.c_str()]]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jboolean paused -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setPaused:paused == JNI_TRUE]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jlong positionMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player seekToMilliseconds:positionMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jlong offsetMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player seekByMilliseconds:offsetMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jfloat speed -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setSpeed:speed]; - }); -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player durationMs]; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player positionMs]; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 0; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player bufferedPositionMs]; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_TRUE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isLoading] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_FALSE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isEnded] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return JNI_TRUE; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return [player isPaused] ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jfloat JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return 1.0f; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - return (jfloat)[player speed]; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setResizeMode( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint mode -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setResizeMode:(int)mode]; - }); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_audioTracksJson( - JNIEnv *env, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return env->NewStringUTF("[]"); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - NSString *json = [player audioTracksJson] ?: @"[]"; - return env->NewStringUTF(json.UTF8String); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_subtitleTracksJson( - JNIEnv *env, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return env->NewStringUTF("[]"); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - NSString *json = [player subtitleTracksJson] ?: @"[]"; - return env->NewStringUTF(json.UTF8String); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectAudioTrack( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player selectAudioTrackId:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectSubtitleTrack( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player selectSubtitleTrackId:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring url -) { - if (handle == 0) return; - std::string subtitleUrl = jstringToString(env, url); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player addSubtitleUrl:[NSString stringWithUTF8String:subtitleUrl.c_str()]]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player removeExternalSubtitles]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitlesAndSelect( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint trackId -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player removeExternalSubtitlesAndSelect:(int)trackId]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSubtitleDelayMs( - JNIEnv * /* env */, - jobject /* bridge */, - jlong handle, - jint delayMs -) { - if (handle == 0) return; - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player setSubtitleDelayMs:(int)delayMs]; - }); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle( - JNIEnv *env, - jobject /* bridge */, - jlong handle, - jstring textColor, - jstring backgroundColor, - jstring outlineColor, - jfloat outlineSize, - jboolean bold, - jfloat fontSize, - jint subPos -) { - if (handle == 0) return; - std::string text = jstringToString(env, textColor); - std::string background = jstringToString(env, backgroundColor); - std::string outline = jstringToString(env, outlineColor); - MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; - runOnMainAsync(^{ - [player applySubtitleStyleWithTextColor:[NSString stringWithUTF8String:text.c_str()] - backgroundColor:[NSString stringWithUTF8String:background.c_str()] - outlineColor:[NSString stringWithUTF8String:outline.c_str()] - outlineSize:(double)outlineSize - bold:bold == JNI_TRUE - fontSize:(double)fontSize - subPos:(int)subPos]; - }); -} diff --git a/composeApp/src/desktopMain/native/windows/player_bridge.cpp b/composeApp/src/desktopMain/native/windows/player_bridge.cpp deleted file mode 100644 index 249009a5f..000000000 --- a/composeApp/src/desktopMain/native/windows/player_bridge.cpp +++ /dev/null @@ -1,1965 +0,0 @@ -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using Microsoft::WRL::Callback; -using Microsoft::WRL::ComPtr; - -extern "C" { -typedef struct mpv_handle mpv_handle; - -typedef enum mpv_format { - MPV_FORMAT_NONE = 0, - MPV_FORMAT_STRING = 1, - MPV_FORMAT_OSD_STRING = 2, - MPV_FORMAT_FLAG = 3, - MPV_FORMAT_INT64 = 4, - MPV_FORMAT_DOUBLE = 5, -} mpv_format; - -typedef enum mpv_event_id { - MPV_EVENT_NONE = 0, - MPV_EVENT_SHUTDOWN = 1, -} mpv_event_id; - -typedef struct mpv_event { - mpv_event_id event_id; - int error; - uint64_t reply_userdata; - void *data; -} mpv_event; -} - -namespace { - -HMODULE gModule = nullptr; -constexpr UINT WM_NUVIO_TASK = WM_APP + 0x4E50; -constexpr UINT_PTR NUVIO_TIMER_ID = 0x4E50; -const wchar_t *kMessageWindowClass = L"NuvioPlayerBridgeMessageWindow"; -const wchar_t *kContainerWindowClass = L"NuvioPlayerBridgeContainerWindow"; -constexpr DWORD kDwmwaUseImmersiveDarkMode = 20; -constexpr DWORD kDwmwaUseImmersiveDarkModeLegacy = 19; -constexpr DWORD kDwmwaBorderColor = 34; -constexpr DWORD kDwmwaCaptionColor = 35; -constexpr DWORD kDwmwaTextColor = 36; - -std::wstring toWide(const std::string &value) { - if (value.empty()) return std::wstring(); - int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), (int)value.size(), nullptr, 0); - if (size <= 0) return std::wstring(); - std::wstring result((size_t)size, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, value.data(), (int)value.size(), result.data(), size); - return result; -} - -std::string toUtf8(const std::wstring &value) { - if (value.empty()) return std::string(); - int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), (int)value.size(), nullptr, 0, nullptr, nullptr); - if (size <= 0) return std::string(); - std::string result((size_t)size, '\0'); - WideCharToMultiByte(CP_UTF8, 0, value.data(), (int)value.size(), result.data(), size, nullptr, nullptr); - return result; -} - -std::string jstringToUtf8(JNIEnv *env, jstring value) { - if (!value) return std::string(); - jsize length = env->GetStringLength(value); - const jchar *chars = env->GetStringChars(value, nullptr); - if (!chars) return std::string(); - std::wstring wide(reinterpret_cast(chars), (size_t)length); - env->ReleaseStringChars(value, chars); - return toUtf8(wide); -} - -jstring newJavaStringUtf8(JNIEnv *env, const std::string &value) { - std::wstring wide = toWide(value); - return env->NewString(reinterpret_cast(wide.data()), (jsize)wide.size()); -} - -std::vector jstringArrayToVector(JNIEnv *env, jobjectArray values) { - std::vector result; - if (!values) return result; - jsize count = env->GetArrayLength(values); - result.reserve((size_t)count); - for (jsize index = 0; index < count; index++) { - jstring item = (jstring)env->GetObjectArrayElement(values, index); - std::string value = jstringToUtf8(env, item); - if (!value.empty()) { - result.push_back(value); - } - env->DeleteLocalRef(item); - } - return result; -} - -void throwJavaError(JNIEnv *env, const std::string &message) { - jclass exceptionClass = env->FindClass("java/lang/IllegalStateException"); - if (exceptionClass) { - env->ThrowNew(exceptionClass, message.c_str()); - } -} - -std::string trim(const std::string &value) { - const char *spaces = " \t\r\n"; - size_t start = value.find_first_not_of(spaces); - if (start == std::string::npos) return std::string(); - size_t end = value.find_last_not_of(spaces); - return value.substr(start, end - start + 1); -} - -std::string lowerCopy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { - return (char)std::tolower(ch); - }); - return value; -} - -COLORREF rgbIntToColorRef(jint rgb) { - BYTE red = (BYTE)((rgb >> 16) & 0xFF); - BYTE green = (BYTE)((rgb >> 8) & 0xFF); - BYTE blue = (BYTE)(rgb & 0xFF); - return RGB(red, green, blue); -} - -void setDwmWindowAttribute(HWND hwnd, DWORD attribute, const void *value, DWORD valueSize) { - (void)DwmSetWindowAttribute(hwnd, attribute, value, valueSize); -} - -void applyDwmWindowChrome(HWND hwnd, bool darkMode, COLORREF captionColor, COLORREF borderColor, COLORREF textColor) { - if (!hwnd || !IsWindow(hwnd)) return; - - BOOL enabled = darkMode ? TRUE : FALSE; - HRESULT darkModeResult = DwmSetWindowAttribute( - hwnd, - kDwmwaUseImmersiveDarkMode, - &enabled, - sizeof(enabled) - ); - if (FAILED(darkModeResult)) { - setDwmWindowAttribute(hwnd, kDwmwaUseImmersiveDarkModeLegacy, &enabled, sizeof(enabled)); - } - - setDwmWindowAttribute(hwnd, kDwmwaCaptionColor, &captionColor, sizeof(captionColor)); - setDwmWindowAttribute(hwnd, kDwmwaBorderColor, &borderColor, sizeof(borderColor)); - setDwmWindowAttribute(hwnd, kDwmwaTextColor, &textColor, sizeof(textColor)); -} - -bool containsCaseInsensitive(const std::string &haystack, const std::string &needle) { - return lowerCopy(haystack).find(lowerCopy(needle)) != std::string::npos; -} - -std::string jsonEscape(const std::string &value) { - std::string result; - result.reserve(value.size() + 8); - for (unsigned char ch : value) { - switch (ch) { - case '\\': result += "\\\\"; break; - case '"': result += "\\\""; break; - case '\b': result += "\\b"; break; - case '\f': result += "\\f"; break; - case '\n': result += "\\n"; break; - case '\r': result += "\\r"; break; - case '\t': result += "\\t"; break; - default: - if (ch < 0x20) { - char buffer[8]; - std::snprintf(buffer, sizeof(buffer), "\\u%04x", ch); - result += buffer; - } else { - result.push_back((char)ch); - } - } - } - return result; -} - -std::wstring javaScriptStringLiteral(const std::string &value) { - std::string escaped; - escaped.reserve(value.size() + 8); - escaped.push_back('"'); - escaped += jsonEscape(value); - escaped.push_back('"'); - return toWide(escaped); -} - -std::wstring moduleDirectory() { - wchar_t buffer[MAX_PATH] = {}; - DWORD length = GetModuleFileNameW(gModule, buffer, MAX_PATH); - if (length == 0 || length >= MAX_PATH) return std::wstring(); - std::wstring path(buffer, buffer + length); - size_t separator = path.find_last_of(L"\\/"); - if (separator == std::wstring::npos) return std::wstring(); - return path.substr(0, separator); -} - -std::wstring tempUserDataDirectory() { - wchar_t tempPath[MAX_PATH] = {}; - DWORD length = GetTempPathW(MAX_PATH, tempPath); - std::wstring result = length > 0 ? std::wstring(tempPath, tempPath + length) : L".\\"; - if (!result.empty() && result.back() != L'\\' && result.back() != L'/') { - result.push_back(L'\\'); - } - result += L"NuvioWebView2"; - CreateDirectoryW(result.c_str(), nullptr); - return result; -} - -std::string hresultMessage(const char *operation, HRESULT hr) { - std::ostringstream builder; - builder << operation << " failed: 0x" << std::hex << (unsigned long)hr; - return builder.str(); -} - -struct MpvApi { - using mpv_create_fn = mpv_handle *(*)(); - using mpv_initialize_fn = int (*)(mpv_handle *); - using mpv_terminate_destroy_fn = void (*)(mpv_handle *); - using mpv_set_option_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_set_option_string_fn = int (*)(mpv_handle *, const char *, const char *); - using mpv_set_property_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_set_property_string_fn = int (*)(mpv_handle *, const char *, const char *); - using mpv_get_property_fn = int (*)(mpv_handle *, const char *, mpv_format, void *); - using mpv_command_fn = int (*)(mpv_handle *, const char **); - using mpv_error_string_fn = const char *(*)(int); - using mpv_free_fn = void (*)(void *); - using mpv_wait_event_fn = mpv_event *(*)(mpv_handle *, double); - using mpv_wakeup_fn = void (*)(mpv_handle *); - - HMODULE library = nullptr; - std::once_flag loadOnce; - std::string loadFailure; - - mpv_create_fn create = nullptr; - mpv_initialize_fn initialize = nullptr; - mpv_terminate_destroy_fn terminateDestroy = nullptr; - mpv_set_option_fn setOption = nullptr; - mpv_set_option_string_fn setOptionString = nullptr; - mpv_set_property_fn setProperty = nullptr; - mpv_set_property_string_fn setPropertyString = nullptr; - mpv_get_property_fn getProperty = nullptr; - mpv_command_fn command = nullptr; - mpv_error_string_fn errorString = nullptr; - mpv_free_fn freeValue = nullptr; - mpv_wait_event_fn waitEvent = nullptr; - mpv_wakeup_fn wakeup = nullptr; - - void ensureLoaded() { - std::call_once(loadOnce, [this]() { load(); }); - if (!library) { - throw std::runtime_error(loadFailure.empty() ? "Unable to load libmpv-2.dll." : loadFailure); - } - } - - std::string errorText(int error) { - if (!errorString) return "unknown"; - const char *text = errorString(error); - return text ? text : "unknown"; - } - - void load() { - std::vector candidates; - - wchar_t envPath[32768] = {}; - DWORD envCapacity = (DWORD)(sizeof(envPath) / sizeof(envPath[0])); - DWORD envLength = GetEnvironmentVariableW(L"NUVIO_LIBMPV_PATH", envPath, envCapacity); - if (envLength > 0 && envLength < envCapacity) { - candidates.emplace_back(envPath, envPath + envLength); - } - - std::wstring moduleDir = moduleDirectory(); - if (!moduleDir.empty()) { - candidates.push_back(moduleDir + L"\\libmpv-2.dll"); - } - candidates.push_back(L"libmpv-2.dll"); - candidates.push_back(L"C:\\Program Files (x86)\\Nuvio\\app\\native\\libmpv-2.dll"); - candidates.push_back(L"C:\\msys64\\ucrt64\\bin\\libmpv-2.dll"); - - for (const std::wstring &candidate : candidates) { - if (candidate.find(L'\\') != std::wstring::npos || candidate.find(L'/') != std::wstring::npos) { - library = LoadLibraryExW(candidate.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); - } else { - library = LoadLibraryW(candidate.c_str()); - } - if (library) break; - } - - if (!library) { - loadFailure = "Unable to load libmpv-2.dll. Bundle it under native/windows or set NUVIO_LIBMPV_PATH."; - return; - } - - create = loadSymbol("mpv_create"); - initialize = loadSymbol("mpv_initialize"); - terminateDestroy = loadSymbol("mpv_terminate_destroy"); - setOption = loadSymbol("mpv_set_option"); - setOptionString = loadSymbol("mpv_set_option_string"); - setProperty = loadSymbol("mpv_set_property"); - setPropertyString = loadSymbol("mpv_set_property_string"); - getProperty = loadSymbol("mpv_get_property"); - command = loadSymbol("mpv_command"); - errorString = loadSymbol("mpv_error_string"); - freeValue = loadSymbol("mpv_free"); - waitEvent = loadSymbol("mpv_wait_event"); - wakeup = loadSymbol("mpv_wakeup"); - } - - template - T loadSymbol(const char *name) { - FARPROC symbol = GetProcAddress(library, name); - if (!symbol) { - loadFailure = std::string("libmpv-2.dll is missing export ") + name + "."; - FreeLibrary(library); - library = nullptr; - throw std::runtime_error(loadFailure); - } - return reinterpret_cast(symbol); - } -}; - -MpvApi &mpvApi() { - static MpvApi api; - api.ensureLoaded(); - return api; -} - -class WindowsMpvWebPlayer; -LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); -LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - -void registerWindowClasses() { - static std::once_flag once; - std::call_once(once, []() { - WNDCLASSEXW messageClass = {}; - messageClass.cbSize = sizeof(messageClass); - messageClass.lpfnWndProc = messageWindowProc; - messageClass.hInstance = gModule; - messageClass.lpszClassName = kMessageWindowClass; - RegisterClassExW(&messageClass); - - WNDCLASSEXW containerClass = {}; - containerClass.cbSize = sizeof(containerClass); - containerClass.lpfnWndProc = containerWindowProc; - containerClass.hInstance = gModule; - containerClass.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); - containerClass.lpszClassName = kContainerWindowClass; - RegisterClassExW(&containerClass); - }); -} - -std::mutex gWebView2WarmupMutex; -std::condition_variable gWebView2WarmupCv; -std::thread gWebView2WarmupThread; -DWORD gWebView2WarmupThreadId = 0; -bool gWebView2WarmupStarted = false; -bool gWebView2WarmupReady = false; -bool gWebView2WarmupSucceeded = false; - -void notifyWebView2WarmupReady(bool succeeded) { - { - std::lock_guard lock(gWebView2WarmupMutex); - if (!gWebView2WarmupReady) { - gWebView2WarmupReady = true; - gWebView2WarmupSucceeded = succeeded; - } - } - gWebView2WarmupCv.notify_all(); -} - -void runWebView2WarmupThread(std::string controlsUrl) { - { - std::lock_guard lock(gWebView2WarmupMutex); - gWebView2WarmupThreadId = GetCurrentThreadId(); - } - gWebView2WarmupCv.notify_all(); - - MSG queueProbe = {}; - PeekMessageW(&queueProbe, nullptr, WM_USER, WM_USER, PM_NOREMOVE); - - bool didOleInitialize = false; - ComPtr environment; - ComPtr controller; - ComPtr webView; - EventRegistrationToken messageToken = {}; - EventRegistrationToken navigationToken = {}; - - HRESULT oleResult = OleInitialize(nullptr); - didOleInitialize = SUCCEEDED(oleResult); - if (FAILED(oleResult)) { - notifyWebView2WarmupReady(false); - return; - } - - std::wstring userDataDir = tempUserDataDirectory(); - HRESULT envCallResult = CreateCoreWebView2EnvironmentWithOptions( - nullptr, - userDataDir.c_str(), - nullptr, - Callback( - [&](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { - if (FAILED(envResult) || !createdEnvironment) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - environment = createdEnvironment; - HRESULT controllerCallResult = createdEnvironment->CreateCoreWebView2Controller( - HWND_MESSAGE, - Callback( - [&](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { - if (FAILED(controllerResult) || !createdController) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - controller = createdController; - controller->put_IsVisible(FALSE); - createdController->get_CoreWebView2(&webView); - if (!webView) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - return S_OK; - } - - webView->add_WebMessageReceived( - Callback( - [&](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { - if (!args) return S_OK; - PWSTR messageJson = nullptr; - if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { - std::wstring message(messageJson); - CoTaskMemFree(messageJson); - if (message.find(L"controlsReady") != std::wstring::npos) { - notifyWebView2WarmupReady(true); - } - } - return S_OK; - } - ).Get(), - &messageToken - ); - - webView->add_NavigationCompleted( - Callback( - [&](ICoreWebView2 *, ICoreWebView2NavigationCompletedEventArgs *args) -> HRESULT { - BOOL navigationSucceeded = FALSE; - if (args) { - args->get_IsSuccess(&navigationSucceeded); - } - notifyWebView2WarmupReady(navigationSucceeded == TRUE); - return S_OK; - } - ).Get(), - &navigationToken - ); - - std::wstring url = toWide(controlsUrl); - HRESULT navigateResult = webView->Navigate(url.c_str()); - if (FAILED(navigateResult)) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - } - return S_OK; - } - ).Get() - ); - if (FAILED(controllerCallResult)) { - notifyWebView2WarmupReady(false); - PostQuitMessage(0); - } - return S_OK; - } - ).Get() - ); - if (FAILED(envCallResult)) { - notifyWebView2WarmupReady(false); - } else { - MSG msg = {}; - while (GetMessageW(&msg, nullptr, 0, 0) > 0) { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - - if (webView && messageToken.value != 0) { - webView->remove_WebMessageReceived(messageToken); - } - if (webView && navigationToken.value != 0) { - webView->remove_NavigationCompleted(navigationToken); - } - if (controller) { - controller->Close(); - controller.Reset(); - } - webView.Reset(); - environment.Reset(); - if (didOleInitialize) { - OleUninitialize(); - } -} - -bool startWebView2Warmup(const std::string &controlsUrl) { - { - std::lock_guard lock(gWebView2WarmupMutex); - if (!gWebView2WarmupStarted) { - gWebView2WarmupStarted = true; - gWebView2WarmupReady = false; - gWebView2WarmupSucceeded = false; - gWebView2WarmupThread = std::thread(runWebView2WarmupThread, controlsUrl); - } - } - - std::unique_lock waitLock(gWebView2WarmupMutex); - bool completed = gWebView2WarmupCv.wait_for( - waitLock, - std::chrono::seconds(5), - []() { return gWebView2WarmupReady; } - ); - if (!completed) return false; - return gWebView2WarmupSucceeded; -} - -void stopWebView2Warmup() { - std::thread threadToJoin; - DWORD threadId = 0; - { - std::unique_lock lock(gWebView2WarmupMutex); - if (!gWebView2WarmupStarted) return; - gWebView2WarmupCv.wait_for( - lock, - std::chrono::seconds(1), - []() { return gWebView2WarmupThreadId != 0; } - ); - threadId = gWebView2WarmupThreadId; - } - - if (threadId != 0) { - PostThreadMessageW(threadId, WM_QUIT, 0, 0); - } - - { - std::lock_guard lock(gWebView2WarmupMutex); - if (gWebView2WarmupThread.joinable()) { - threadToJoin = std::move(gWebView2WarmupThread); - } - } - if (threadToJoin.joinable()) { - threadToJoin.join(); - } - - { - std::lock_guard lock(gWebView2WarmupMutex); - gWebView2WarmupStarted = false; - gWebView2WarmupReady = false; - gWebView2WarmupSucceeded = false; - gWebView2WarmupThreadId = 0; - } -} - -class WindowsMpvWebPlayer : public std::enable_shared_from_this { - struct InitializationState { - std::mutex mutex; - std::condition_variable cv; - bool complete = false; - std::string failure; - }; - -public: - void initialize( - HWND host, - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs, - const std::string &controlsUrl, - JavaVM *vm, - jobject sink, - jmethodID method - ) { - if (!host || !IsWindow(host)) { - throw std::runtime_error("Unable to resolve the AWT host HWND for native playback."); - } - - javaVm = vm; - eventSink = sink; - eventMethod = method; - hostHwnd = host; - - auto initState = std::make_shared(); - auto self = shared_from_this(); - uiThread = std::thread( - [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState]() { - self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState); - } - ); - - std::unique_lock lock(initState->mutex); - initState->cv.wait(lock, [&]() { return initState->complete; }); - if (!initState->failure.empty()) { - lock.unlock(); - if (uiThread.joinable()) { - uiThread.join(); - } - throw std::runtime_error(initState->failure); - } - } - - void shutdown() { - if (shuttingDown.exchange(true)) { - return; - } - - sendUiTask([self = shared_from_this()]() { - self->cleanupUiResources(); - PostQuitMessage(0); - }); - - stopping.store(true); - { - std::lock_guard lock(mpvMutex); - if (mpv && mpvApi().wakeup) { - mpvApi().wakeup(mpv); - } - } - if (eventThread.joinable()) { - eventThread.join(); - } - { - std::lock_guard lock(mpvMutex); - if (mpv) { - mpvApi().terminateDestroy(mpv); - mpv = nullptr; - } - } - if (uiThread.joinable() && GetCurrentThreadId() != uiThreadId) { - uiThread.join(); - } - - if (eventSink) { - bool didAttach = false; - JNIEnv *env = jniEnvDidAttach(&didAttach); - if (env) { - env->DeleteGlobalRef(eventSink); - } - if (didAttach) { - javaVm->DetachCurrentThread(); - } - eventSink = nullptr; - } - eventMethod = nullptr; - javaVm = nullptr; - } - - void processUiTasks() { - std::deque> tasks; - { - std::lock_guard lock(uiTaskMutex); - tasks.swap(uiTasks); - } - for (auto &task : tasks) { - task(); - } - } - - void onTimer() { - if (shuttingDown.load()) return; - layoutNativeSubviews(); - syncControls(); - } - - void updateControlsJson(const std::string &controlsJson) { - if (controlsJson.empty()) return; - { - std::lock_guard lock(controlsMutex); - pendingControlsJson = controlsJson; - } - postUiTask([self = shared_from_this()]() { - self->flushPendingControlsJsonIfReady(); - }); - } - - void setPaused(bool paused) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int flag = paused ? 1 : 0; - mpvApi().setProperty(mpv, "pause", MPV_FORMAT_FLAG, &flag); - } - - bool isPaused() { - return flagProperty("pause", true); - } - - void seekToMilliseconds(long long positionMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::string seconds = std::to_string((double)positionMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "absolute+keyframes", nullptr}; - mpvApi().command(mpv, command); - } - - void seekByMilliseconds(long long offsetMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::string seconds = std::to_string((double)offsetMs / 1000.0); - const char *command[] = {"seek", seconds.c_str(), "relative+keyframes", nullptr}; - mpvApi().command(mpv, command); - } - - void setSpeed(double speed) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - double clamped = std::max(0.25, std::min(4.0, speed)); - mpvApi().setProperty(mpv, "speed", MPV_FORMAT_DOUBLE, &clamped); - } - - double speed() { - return doubleProperty("speed", 1.0); - } - - void setResizeMode(int mode) { - switch (mode) { - case 1: - case 2: - setStringProperty("panscan", "1.0"); - setStringProperty("video-unscaled", "no"); - break; - default: - setStringProperty("panscan", "0.0"); - setStringProperty("video-unscaled", "no"); - break; - } - } - - long long durationMs() { - return (long long)std::llround(doubleProperty("duration", 0.0) * 1000.0); - } - - long long positionMs() { - return (long long)std::llround(doubleProperty("time-pos", 0.0) * 1000.0); - } - - long long bufferedPositionMs() { - double buffered = rawPositionSeconds() + cacheAheadSeconds(); - return (long long)std::llround(std::max(buffered, 0.0) * 1000.0); - } - - bool isLoading() { - bool paused = isPaused(); - bool eofReached = isEnded(); - bool idle = flagProperty("core-idle", true); - bool seeking = flagProperty("seeking", false); - bool bufferingCache = flagProperty("paused-for-cache", false); - bool fileReady = doubleProperty("duration", 0.0) > 0.0 || int64Property("track-list/count", 0) > 0; - return !fileReady || (idle && !paused && !eofReached) || seeking || bufferingCache; - } - - bool isEnded() { - return flagProperty("eof-reached", false); - } - - std::string audioTracksJson() { - return tracksJsonForType("audio"); - } - - std::string subtitleTracksJson() { - return tracksJsonForType("sub"); - } - - void selectAudioTrackId(int trackId) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int64_t id = trackId; - mpvApi().setProperty(mpv, "aid", MPV_FORMAT_INT64, &id); - } - - void selectSubtitleTrackId(int trackId) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - if (trackId < 0) { - mpvApi().setPropertyString(mpv, "sid", "no"); - return; - } - int64_t id = trackId; - mpvApi().setProperty(mpv, "sid", MPV_FORMAT_INT64, &id); - } - - void addSubtitleUrl(const std::string &url) { - if (url.empty()) return; - command({"sub-add", url, "select"}); - } - - void removeExternalSubtitles() { - removeExternalSubtitleTracks(); - setStringProperty("sid", "no"); - } - - void removeExternalSubtitlesAndSelect(int trackId) { - removeExternalSubtitleTracks(); - if (trackId >= 0) { - selectSubtitleTrackId(trackId); - } else { - setStringProperty("sid", "no"); - } - } - - void setSubtitleDelayMs(int delayMs) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - int clamped = std::max(-60000, std::min(60000, delayMs)); - double delaySeconds = (double)clamped / 1000.0; - mpvApi().setProperty(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds); - } - - void applySubtitleStyle( - const std::string &textColor, - const std::string &backgroundColor, - const std::string &outlineColor, - double outlineSize, - bool bold, - double fontSize, - int subPos - ) { - setStringProperty("sub-ass-override", "force"); - setStringProperty("sub-color", textColor.empty() ? "#FFFFFFFF" : textColor); - setStringProperty("sub-back-color", backgroundColor.empty() ? "#00000000" : backgroundColor); - setStringProperty("sub-outline-color", outlineColor.empty() ? "#FF000000" : outlineColor); - setStringProperty( - "sub-border-style", - backgroundColor.rfind("#00", 0) == 0 ? "outline-and-shadow" : "opaque-box" - ); - setStringProperty("sub-bold", bold ? "yes" : "no"); - - { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - double outline = std::max(0.0, std::min(8.0, outlineSize)); - double size = std::max(24.0, std::min(96.0, fontSize)); - int64_t position = std::max(0, std::min(150, subPos)); - mpvApi().setProperty(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline); - mpvApi().setProperty(mpv, "sub-font-size", MPV_FORMAT_DOUBLE, &size); - mpvApi().setProperty(mpv, "sub-pos", MPV_FORMAT_INT64, &position); - } - } - -private: - HWND hostHwnd = nullptr; - HWND containerHwnd = nullptr; - HWND messageHwnd = nullptr; - DWORD uiThreadId = 0; - bool didOleInitialize = false; - std::thread uiThread; - - ComPtr environment; - ComPtr controller; - ComPtr webView; - EventRegistrationToken messageToken = {}; - - std::mutex uiTaskMutex; - std::deque> uiTasks; - - std::mutex mpvMutex; - mpv_handle *mpv = nullptr; - std::thread eventThread; - std::atomic_bool stopping = false; - std::atomic_bool shuttingDown = false; - - JavaVM *javaVm = nullptr; - jobject eventSink = nullptr; - jmethodID eventMethod = nullptr; - - std::atomic_bool controlsWebReady = false; - std::mutex controlsMutex; - std::string pendingControlsJson; - double initialStartSeconds = 0.0; - - friend LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - friend LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); - - void runNativeUiThread( - std::string sourceUrl, - std::vector headerLines, - bool playWhenReady, - long long initialPositionMs, - std::string controlsUrl, - std::shared_ptr initState - ) { - std::string failure; - try { - initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl); - } catch (const std::exception &error) { - failure = error.what(); - cleanupUiResources(); - } - - { - std::lock_guard lock(initState->mutex); - initState->failure = failure; - initState->complete = true; - } - initState->cv.notify_one(); - - if (!failure.empty()) { - return; - } - - MSG msg = {}; - while (GetMessageW(&msg, nullptr, 0, 0) > 0) { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - - void initializeOnNativeUiThread( - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs, - const std::string &controlsUrl - ) { - registerWindowClasses(); - uiThreadId = GetCurrentThreadId(); - HRESULT oleResult = OleInitialize(nullptr); - didOleInitialize = SUCCEEDED(oleResult); - if (FAILED(oleResult)) { - throw std::runtime_error(hresultMessage("OleInitialize", oleResult)); - } - - messageHwnd = CreateWindowExW( - 0, - kMessageWindowClass, - L"", - 0, - 0, - 0, - 0, - 0, - HWND_MESSAGE, - nullptr, - gModule, - this - ); - if (!messageHwnd) { - throw std::runtime_error("Unable to create Windows player message window."); - } - - RECT bounds = {}; - GetClientRect(hostHwnd, &bounds); - LONG width = std::max(1, bounds.right - bounds.left); - LONG height = std::max(1, bounds.bottom - bounds.top); - containerHwnd = CreateWindowExW( - 0, - kContainerWindowClass, - L"", - WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, - 0, - 0, - width, - height, - hostHwnd, - nullptr, - gModule, - this - ); - if (!containerHwnd) { - throw std::runtime_error("Unable to create native player container window."); - } - - startWebView(controlsUrl); - startMpv(sourceUrl, headerLines, playWhenReady, initialPositionMs); - layoutNativeSubviews(); - if (!SetTimer(messageHwnd, NUVIO_TIMER_ID, 500, nullptr)) { - throw std::runtime_error("Unable to start native player timer."); - } - } - - void cleanupUiResources() { - if (messageHwnd) { - KillTimer(messageHwnd, NUVIO_TIMER_ID); - } - if (webView && messageToken.value != 0) { - webView->remove_WebMessageReceived(messageToken); - messageToken.value = 0; - } - if (controller) { - controller->Close(); - controller.Reset(); - } - webView.Reset(); - environment.Reset(); - if (containerHwnd) { - DestroyWindow(containerHwnd); - containerHwnd = nullptr; - } - if (messageHwnd) { - HWND hwnd = messageHwnd; - messageHwnd = nullptr; - DestroyWindow(hwnd); - } - if (didOleInitialize) { - OleUninitialize(); - didOleInitialize = false; - } - } - - void postUiTask(std::function task) { - if (shuttingDown.load()) return; - { - std::lock_guard lock(uiTaskMutex); - uiTasks.push_back(std::move(task)); - } - HWND target = messageHwnd; - if (target) { - PostMessageW(target, WM_NUVIO_TASK, 0, 0); - } - } - - void sendUiTask(std::function task) { - if (GetCurrentThreadId() == uiThreadId || !messageHwnd) { - task(); - return; - } - - auto done = std::make_shared(false); - auto doneMutex = std::make_shared(); - auto doneCv = std::make_shared(); - { - std::lock_guard lock(uiTaskMutex); - uiTasks.push_back([task = std::move(task), done, doneMutex, doneCv]() mutable { - task(); - { - std::lock_guard doneLock(*doneMutex); - *done = true; - } - doneCv->notify_one(); - }); - } - SendMessageW(messageHwnd, WM_NUVIO_TASK, 0, 0); - - std::unique_lock waitLock(*doneMutex); - doneCv->wait(waitLock, [&]() { return *done; }); - } - - void startWebView(const std::string &controlsUrl) { - std::wstring userDataDir = tempUserDataDirectory(); - auto weakSelf = weak_from_this(); - HRESULT result = CreateCoreWebView2EnvironmentWithOptions( - nullptr, - userDataDir.c_str(), - nullptr, - Callback( - [weakSelf, controlsUrl](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { - auto self = weakSelf.lock(); - if (!self || self->shuttingDown.load()) return S_OK; - if (FAILED(envResult) || !createdEnvironment) { - return S_OK; - } - self->environment = createdEnvironment; - auto controllerWeakSelf = weakSelf; - HRESULT controllerResult = createdEnvironment->CreateCoreWebView2Controller( - self->containerHwnd, - Callback( - [controllerWeakSelf, controlsUrl](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { - auto controllerSelf = controllerWeakSelf.lock(); - if (!controllerSelf || controllerSelf->shuttingDown.load()) return S_OK; - if (FAILED(controllerResult) || !createdController) { - return S_OK; - } - controllerSelf->controller = createdController; - createdController->get_CoreWebView2(&controllerSelf->webView); - - ComPtr controller2; - if (SUCCEEDED(createdController->QueryInterface(IID_PPV_ARGS(&controller2))) && controller2) { - COREWEBVIEW2_COLOR transparent = {0, 0, 0, 0}; - controller2->put_DefaultBackgroundColor(transparent); - } - - ComPtr settings; - if (controllerSelf->webView && SUCCEEDED(controllerSelf->webView->get_Settings(&settings)) && settings) { - settings->put_AreDefaultContextMenusEnabled(FALSE); - settings->put_IsStatusBarEnabled(FALSE); - } - - if (controllerSelf->webView) { - auto messageWeakSelf = controllerWeakSelf; - controllerSelf->webView->add_WebMessageReceived( - Callback( - [messageWeakSelf](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { - auto messageSelf = messageWeakSelf.lock(); - if (!messageSelf || messageSelf->shuttingDown.load() || !args) return S_OK; - PWSTR messageJson = nullptr; - if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { - messageSelf->handleWebMessage(std::wstring(messageJson)); - CoTaskMemFree(messageJson); - } - return S_OK; - } - ).Get(), - &controllerSelf->messageToken - ); - controllerSelf->layoutNativeSubviews(); - std::wstring url = toWide(controlsUrl); - controllerSelf->webView->Navigate(url.c_str()); - createdController->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); - } - return S_OK; - } - ).Get() - ); - (void)controllerResult; - return S_OK; - } - ).Get() - ); - if (FAILED(result)) { - throw std::runtime_error(hresultMessage("CreateCoreWebView2EnvironmentWithOptions", result)); - } - } - - void startMpv( - const std::string &sourceUrl, - const std::vector &headerLines, - bool playWhenReady, - long long initialPositionMs - ) { - MpvApi &api = mpvApi(); - { - std::lock_guard lock(mpvMutex); - mpv = api.create(); - if (!mpv) { - throw std::runtime_error("mpv_create failed."); - } - initialStartSeconds = initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0; - - setMpvOptionStringLocked("config", "no"); - setMpvOptionStringLocked("osc", "no"); - setMpvOptionStringLocked("input-default-bindings", "yes"); - setMpvOptionStringLocked("input-vo-keyboard", "no"); - setMpvOptionStringLocked("keep-open", "yes"); - setMpvOptionStringLocked("vo", "gpu-next"); - setMpvOptionStringLocked("gpu-api", "d3d11"); - setMpvOptionStringLocked("hwdec", "d3d11va"); - setMpvOptionStringLocked("hwdec-codecs", "all"); - setMpvOptionStringLocked("vd-lavc-software-fallback", "no"); - setMpvOptionStringLocked("vd-lavc-threads", "4"); - setMpvOptionStringLocked("target-colorspace-hint", "yes"); - setMpvOptionStringLocked("tone-mapping", "auto"); - setMpvOptionStringLocked("dither-depth", "auto"); - setMpvOptionStringLocked("deband", "yes"); - setMpvOptionStringLocked("scale", "spline36"); - setMpvOptionStringLocked("cscale", "spline36"); - setMpvOptionStringLocked("demuxer-max-bytes", "64MiB"); - setMpvOptionStringLocked("demuxer-max-back-bytes", "16MiB"); - setMpvOptionStringLocked("demuxer-seekable-cache", "no"); - setMpvOptionStringLocked("cache-secs", "30"); - setMpvOptionStringLocked("hr-seek", "no"); - - int64_t wid = (int64_t)(intptr_t)containerHwnd; - int widResult = api.setOption(mpv, "wid", MPV_FORMAT_INT64, &wid); - if (widResult < 0) { - throw std::runtime_error(std::string("mpv wid option failed: ") + api.errorText(widResult)); - } - - if (!headerLines.empty()) { - std::string headers; - for (size_t index = 0; index < headerLines.size(); index++) { - if (index > 0) headers.push_back(','); - headers += headerLines[index]; - } - setMpvOptionStringLocked("http-header-fields", headers.c_str()); - } - - int initResult = api.initialize(mpv); - if (initResult < 0) { - throw std::runtime_error(std::string("mpv_initialize failed: ") + api.errorText(initResult)); - } - - std::vector loadCommand = {"loadfile", sourceUrl.c_str()}; - std::string loadOptions; - if (initialPositionMs > 0) { - char startBuffer[64]; - std::snprintf(startBuffer, sizeof(startBuffer), "start=%.3f", (double)initialPositionMs / 1000.0); - loadOptions = startBuffer; - loadCommand.push_back("replace"); - loadCommand.push_back("-1"); - loadCommand.push_back(loadOptions.c_str()); - } - loadCommand.push_back(nullptr); - - int commandResult = api.command(mpv, loadCommand.data()); - if (commandResult < 0) { - throw std::runtime_error(std::string("mpv loadfile failed: ") + api.errorText(commandResult)); - } - } - - setPaused(!playWhenReady); - auto self = shared_from_this(); - eventThread = std::thread([self]() { self->drainMpvEvents(); }); - } - - void layoutNativeSubviews() { - if (!hostHwnd || !IsWindow(hostHwnd)) { - return; - } - RECT bounds = {}; - GetClientRect(hostHwnd, &bounds); - LONG width = std::max(1, bounds.right - bounds.left); - LONG height = std::max(1, bounds.bottom - bounds.top); - if (containerHwnd) { - SetWindowPos(containerHwnd, HWND_TOP, 0, 0, width, height, SWP_SHOWWINDOW | SWP_NOACTIVATE); - } - if (controller) { - RECT webBounds = {0, 0, width, height}; - controller->put_Bounds(webBounds); - controller->put_IsVisible(TRUE); - } - } - - void flushPendingControlsJsonIfReady() { - if (!webView || !controlsWebReady.load()) { - return; - } - std::string controlsJson; - { - std::lock_guard lock(controlsMutex); - controlsJson = pendingControlsJson; - pendingControlsJson.clear(); - } - if (controlsJson.empty()) return; - std::wstring script = - L"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(" + - javaScriptStringLiteral(controlsJson) + - L"));return 'applied';})()"; - webView->ExecuteScript(script.c_str(), nullptr); - } - - void syncControls() { - if (!webView) return; - double duration = doubleProperty("duration", 0.0); - double position = doubleProperty("time-pos", 0.0); - bool paused = isPaused(); - bool loading = isLoading(); - std::string audioTracks = audioTracksJson(); - std::string subtitleTracks = subtitleTracksJson(); - - std::ostringstream script; - script << "window.playerUpdate({duration:" << duration - << ",position:" << position - << ",paused:" << (paused ? "true" : "false") - << ",loading:" << (loading ? "true" : "false") - << ",audioTracks:" << audioTracks - << ",subtitleTracks:" << subtitleTracks - << "})"; - std::wstring wideScript = toWide(script.str()); - webView->ExecuteScript(wideScript.c_str(), nullptr); - } - - void handleWebMessage(const std::wstring &messageJson) { - std::string type = extractJsonString(messageJson, L"type"); - if (type.empty()) return; - double value = extractJsonNumber(messageJson, L"value", 0.0); - - if (type == "controlsReady") { - controlsWebReady.store(true); - flushPendingControlsJsonIfReady(); - syncControls(); - return; - } - if (type == "selectAudioTrack") { - selectAudioTrackId((int)std::llround(value)); - syncControls(); - return; - } - if (type == "selectSubtitleTrack") { - selectSubtitleTrackId((int)std::llround(value)); - syncControls(); - return; - } - sendPlayerEvent(type, value); - } - - static std::string extractJsonString(const std::wstring &json, const std::wstring &field) { - std::wstring key = L"\"" + field + L"\""; - size_t keyIndex = json.find(key); - if (keyIndex == std::wstring::npos) return std::string(); - size_t colon = json.find(L':', keyIndex + key.size()); - if (colon == std::wstring::npos) return std::string(); - size_t quote = json.find(L'"', colon + 1); - if (quote == std::wstring::npos) return std::string(); - - std::wstring result; - bool escaping = false; - for (size_t index = quote + 1; index < json.size(); index++) { - wchar_t ch = json[index]; - if (escaping) { - switch (ch) { - case L'"': result.push_back(L'"'); break; - case L'\\': result.push_back(L'\\'); break; - case L'/': result.push_back(L'/'); break; - case L'b': result.push_back(L'\b'); break; - case L'f': result.push_back(L'\f'); break; - case L'n': result.push_back(L'\n'); break; - case L'r': result.push_back(L'\r'); break; - case L't': result.push_back(L'\t'); break; - default: result.push_back(ch); break; - } - escaping = false; - continue; - } - if (ch == L'\\') { - escaping = true; - continue; - } - if (ch == L'"') break; - result.push_back(ch); - } - return toUtf8(result); - } - - static double extractJsonNumber(const std::wstring &json, const std::wstring &field, double fallback) { - std::wstring key = L"\"" + field + L"\""; - size_t keyIndex = json.find(key); - if (keyIndex == std::wstring::npos) return fallback; - size_t colon = json.find(L':', keyIndex + key.size()); - if (colon == std::wstring::npos) return fallback; - size_t start = json.find_first_not_of(L" \t\r\n", colon + 1); - if (start == std::wstring::npos) return fallback; - size_t end = start; - while (end < json.size()) { - wchar_t ch = json[end]; - if (!(std::iswdigit(ch) || ch == L'-' || ch == L'+' || ch == L'.' || ch == L'e' || ch == L'E')) { - break; - } - end++; - } - if (end <= start) return fallback; - try { - return std::stod(json.substr(start, end - start)); - } catch (...) { - return fallback; - } - } - - void drainMpvEvents() { - while (!stopping.load()) { - mpv_handle *current = nullptr; - { - std::lock_guard lock(mpvMutex); - current = mpv; - } - if (!current) { - return; - } - - mpv_event *event = mpvApi().waitEvent(current, 0.5); - if (!event) continue; - if (event->event_id == MPV_EVENT_SHUTDOWN) { - return; - } - } - } - - JNIEnv *jniEnvDidAttach(bool *didAttach) { - if (didAttach) *didAttach = false; - if (!javaVm) return nullptr; - JNIEnv *env = nullptr; - jint status = javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); - if (status == JNI_OK) return env; - if (status != JNI_EDETACHED) return nullptr; - if (javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { - return nullptr; - } - if (didAttach) *didAttach = true; - return env; - } - - void sendPlayerEvent(const std::string &type, double value) { - if (!eventSink || !eventMethod) return; - bool didAttach = false; - JNIEnv *env = jniEnvDidAttach(&didAttach); - if (!env) return; - - jstring eventType = newJavaStringUtf8(env, type); - env->CallVoidMethod(eventSink, eventMethod, eventType, (jdouble)value); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } - if (eventType) { - env->DeleteLocalRef(eventType); - } - if (didAttach) { - javaVm->DetachCurrentThread(); - } - } - - void setMpvOptionStringLocked(const char *name, const char *value) { - (void)mpvApi().setOptionString(mpv, name, value); - } - - double doubleProperty(const char *name, double fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - double value = fallback; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_DOUBLE, &value); - if (result < 0) { - return fallback; - } - return value; - } - - long long int64Property(const char *name, long long fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - int64_t value = fallback; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_INT64, &value); - if (result < 0) { - return fallback; - } - return value; - } - - bool flagProperty(const char *name, bool fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - int flag = fallback ? 1 : 0; - int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_FLAG, &flag); - if (result < 0) { - return fallback; - } - return flag != 0; - } - - std::string stringProperty(const char *name, const std::string &fallback) { - std::lock_guard lock(mpvMutex); - if (!mpv) return fallback; - char *value = nullptr; - int propertyResult = mpvApi().getProperty(mpv, name, MPV_FORMAT_STRING, &value); - if (propertyResult < 0 || !value) { - return fallback; - } - std::string result(value); - mpvApi().freeValue(value); - return result; - } - - void setStringProperty(const char *name, const std::string &value) { - std::lock_guard lock(mpvMutex); - if (!mpv) return; - mpvApi().setPropertyString(mpv, name, value.c_str()); - } - - void command(const std::vector &args) { - if (args.empty()) return; - std::lock_guard lock(mpvMutex); - if (!mpv) return; - std::vector cargs; - cargs.reserve(args.size() + 1); - for (const std::string &arg : args) { - cargs.push_back(arg.c_str()); - } - cargs.push_back(nullptr); - mpvApi().command(mpv, cargs.data()); - } - - double rawPositionSeconds() { - double position = doubleProperty("time-pos", 0.0); - return std::isfinite(position) ? std::max(position, 0.0) : 0.0; - } - - double effectiveCachePositionSeconds() { - double position = rawPositionSeconds(); - if (initialStartSeconds > 0.0 && position + 5.0 < initialStartSeconds) { - return initialStartSeconds; - } - return position; - } - - double cacheAheadSeconds() { - double effectivePosition = effectiveCachePositionSeconds(); - double cacheTime = doubleProperty("demuxer-cache-time", 0.0); - if (std::isfinite(cacheTime) && cacheTime > 0.0) { - if (cacheTime >= effectivePosition - 5.0) { - return std::max(cacheTime - effectivePosition, 0.0); - } - return cacheTime; - } - - double cacheDuration = doubleProperty("demuxer-cache-duration", 0.0); - if (std::isfinite(cacheDuration) && cacheDuration > 0.0) { - return cacheDuration; - } - return 0.0; - } - - void removeExternalSubtitleTracks() { - long long count = int64Property("track-list/count", 0); - if (count <= 0) return; - for (long long index = count - 1; index >= 0; index--) { - std::string prefix = "track-list/" + std::to_string(index); - std::string type = stringProperty((prefix + "/type").c_str(), ""); - bool external = flagProperty((prefix + "/external").c_str(), false); - if (type == "sub" && external) { - long long trackId = int64Property((prefix + "/id").c_str(), -1); - if (trackId >= 0) { - command({"sub-remove", std::to_string(trackId)}); - } - } - } - } - - std::string tracksJsonForType(const std::string &wantedType) { - long long count = int64Property("track-list/count", 0); - std::ostringstream json; - json << "["; - int logicalIndex = 0; - bool first = true; - for (long long index = 0; index < count; index++) { - std::string prefix = "track-list/" + std::to_string(index); - std::string type = stringProperty((prefix + "/type").c_str(), ""); - if (type != wantedType) continue; - - long long trackId = int64Property((prefix + "/id").c_str(), logicalIndex + 1); - std::string title = trackStringAtIndex(index, "title"); - std::string language = trackStringAtIndex(index, "lang"); - std::string codec = trackStringAtIndex(index, "codec"); - std::string decoderDescription = trackStringAtIndex(index, "decoder-desc"); - std::string channels = trackStringAtIndex(index, "demux-channels"); - long long channelCount = int64Property((prefix + "/demux-channel-count").c_str(), 0); - bool selected = flagProperty((prefix + "/selected").c_str(), false); - bool forced = flagProperty((prefix + "/forced").c_str(), false); - std::string label = formatTrackTitle(type, logicalIndex, title, language, codec, decoderDescription, channels, (int)channelCount); - - if (!first) json << ","; - first = false; - json << "{" - << "\"index\":" << logicalIndex << "," - << "\"id\":\"" << jsonEscape(std::to_string(trackId)) << "\"," - << "\"label\":\"" << jsonEscape(label) << "\"," - << "\"language\":\"" << jsonEscape(language) << "\"," - << "\"selected\":" << (selected ? "true" : "false") << "," - << "\"forced\":" << (forced ? "true" : "false") - << "}"; - logicalIndex++; - } - json << "]"; - return json.str(); - } - - std::string trackStringAtIndex(long long index, const std::string &field) { - return trim(stringProperty(("track-list/" + std::to_string(index) + "/" + field).c_str(), "")); - } - - std::string formatTrackTitle( - const std::string &type, - int index, - const std::string &title, - const std::string &language, - const std::string &codec, - const std::string &decoderDescription, - const std::string &channels, - int channelCount - ) { - std::string base = !trim(title).empty() - ? trim(title) - : (!trim(language).empty() - ? trim(language) - : (type == "sub" ? "Subtitle " + std::to_string(index + 1) : "Track " + std::to_string(index + 1))); - std::string codecName = codecDisplayName(codec); - if (codecName.empty()) codecName = codecDisplayName(decoderDescription); - std::string channelName = type == "audio" ? channelLayoutName(channels, channelCount) : ""; - - std::vector details; - for (const std::string &detail : {channelName, codecName}) { - if (!detail.empty() && !containsCaseInsensitive(base, detail)) { - details.push_back(detail); - } - } - if (details.empty()) return base; - std::string suffix; - for (size_t detailIndex = 0; detailIndex < details.size(); detailIndex++) { - if (detailIndex > 0) suffix += ", "; - suffix += details[detailIndex]; - } - return base + " (" + suffix + ")"; - } - - std::string channelLayoutName(const std::string &channels, int channelCount) { - std::string normalized = trim(channels); - if (!normalized.empty() && lowerCopy(normalized) != "unknown") { - std::string lower = lowerCopy(normalized); - if (lower == "mono") return "Mono"; - if (lower == "stereo") return "Stereo"; - return normalized; - } - switch (channelCount) { - case 1: return "Mono"; - case 2: return "Stereo"; - case 6: return "5.1"; - case 8: return "7.1"; - default: return channelCount > 0 ? std::to_string(channelCount) + "ch" : ""; - } - } - - std::string codecDisplayName(const std::string &value) { - std::string raw = trim(value); - if (raw.empty()) return ""; - std::string codec = lowerCopy(raw); - if (codec.find("eac3") != std::string::npos || codec.find("e-ac-3") != std::string::npos || codec.find("e ac-3") != std::string::npos) { - return codec.find("joc") != std::string::npos || codec.find("atmos") != std::string::npos ? "E-AC-3-JOC" : "E-AC-3"; - } - if (codec.find("truehd") != std::string::npos || codec.find("true hd") != std::string::npos) return "TrueHD"; - if (codec.find("ac3") != std::string::npos || codec.find("ac-3") != std::string::npos) return "AC-3"; - if (codec.find("dts-hd") != std::string::npos || codec.find("dtshd") != std::string::npos || codec.find("dts hd") != std::string::npos) return "DTS-HD"; - if (codec.find("dts") != std::string::npos || codec == "dca") return "DTS"; - if (codec.find("aac") != std::string::npos) return "AAC"; - if (codec.find("mp3") != std::string::npos || codec.find("mpeg audio") != std::string::npos) return "MP3"; - if (codec.find("mp2") != std::string::npos) return "MP2"; - if (codec.find("opus") != std::string::npos) return "Opus"; - if (codec.find("vorbis") != std::string::npos) return "Vorbis"; - if (codec.find("flac") != std::string::npos) return "FLAC"; - if (codec.find("alac") != std::string::npos) return "ALAC"; - if (codec.find("pcm") != std::string::npos || codec.find("wav") != std::string::npos) return "WAV"; - if (codec.find("pgs") != std::string::npos || codec.find("hdmv") != std::string::npos) return "PGS"; - if (codec.find("subrip") != std::string::npos || codec == "srt") return "SRT"; - if (codec.find("ass") != std::string::npos || codec.find("ssa") != std::string::npos) return "SSA"; - if (codec.find("webvtt") != std::string::npos || codec == "vtt") return "VTT"; - if (codec.find("ttml") != std::string::npos) return "TTML"; - if (codec.find("mov_text") != std::string::npos || codec.find("tx3g") != std::string::npos) return "TX3G"; - if (codec.find("dvb") != std::string::npos) return "DVB"; - return raw; - } -}; - -LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { - if (message == WM_NCCREATE) { - auto *create = reinterpret_cast(lParam); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); - return TRUE; - } - auto *player = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); - switch (message) { - case WM_NUVIO_TASK: - if (player) player->processUiTasks(); - return 0; - case WM_TIMER: - if (player && wParam == NUVIO_TIMER_ID) player->onTimer(); - return 0; - case WM_NCDESTROY: - SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); - return 0; - default: - return DefWindowProcW(hwnd, message, wParam, lParam); - } -} - -LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { - if (message == WM_NCCREATE) { - auto *create = reinterpret_cast(lParam); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); - return TRUE; - } - switch (message) { - case WM_SIZE: - return 0; - case WM_ERASEBKGND: { - RECT rect = {}; - GetClientRect(hwnd, &rect); - FillRect((HDC)wParam, &rect, (HBRUSH)GetStockObject(BLACK_BRUSH)); - return 1; - } - case WM_NCDESTROY: - SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); - return 0; - default: - return DefWindowProcW(hwnd, message, wParam, lParam); - } -} - -std::shared_ptr playerFromHandle(jlong handle) { - if (handle == 0) return nullptr; - auto *holder = reinterpret_cast *>(handle); - return holder ? *holder : nullptr; -} - -} // namespace - -BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { - if (reason == DLL_PROCESS_ATTACH) { - gModule = module; - DisableThreadLibraryCalls(module); - } - return TRUE; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( - JNIEnv *env, - jobject, - jlong hostViewPtr, - jstring sourceUrl, - jobjectArray headerLines, - jboolean playWhenReady, - jlong initialPositionMs, - jstring controlsPageUrl, - jobject eventSink -) { - HWND hostHwnd = (HWND)(intptr_t)hostViewPtr; - std::string sourceUrlText = jstringToUtf8(env, sourceUrl); - std::vector headerLineValues = jstringArrayToVector(env, headerLines); - std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); - JavaVM *javaVm = nullptr; - env->GetJavaVM(&javaVm); - - jobject eventSinkRef = nullptr; - jmethodID eventMethod = nullptr; - if (eventSink) { - eventSinkRef = env->NewGlobalRef(eventSink); - jclass eventSinkClass = env->GetObjectClass(eventSink); - eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); - env->DeleteLocalRef(eventSinkClass); - if (!eventMethod) { - if (eventSinkRef) env->DeleteGlobalRef(eventSinkRef); - throwJavaError(env, "Native player event sink is missing onPlayerEvent(String, Double)."); - return 0; - } - } - - auto player = std::make_shared(); - try { - player->initialize( - hostHwnd, - sourceUrlText, - headerLineValues, - playWhenReady == JNI_TRUE, - initialPositionMs, - controlsPageUrlText, - javaVm, - eventSinkRef, - eventMethod - ); - } catch (const std::exception &error) { - player->shutdown(); - throwJavaError(env, error.what()); - return 0; - } - - auto *holder = new std::shared_ptr(player); - jlong handle = (jlong)(intptr_t)holder; - return handle; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_warmupWebView2(JNIEnv *env, jobject, jstring controlsPageUrl) { - std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); - return startWebView2Warmup(controlsPageUrlText) ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_shutdownWebView2Warmup(JNIEnv *, jobject) { - stopWebView2Warmup(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose(JNIEnv *, jobject, jlong handle) { - if (handle == 0) return; - auto *holder = reinterpret_cast *>(handle); - std::shared_ptr player = *holder; - delete holder; - if (player) player->shutdown(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls(JNIEnv *env, jobject, jlong handle, jstring controlsJson) { - auto player = playerFromHandle(handle); - std::string controlsJsonText = jstringToUtf8(env, controlsJson); - if (player) player->updateControlsJson(controlsJsonText); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused(JNIEnv *, jobject, jlong handle, jboolean paused) { - auto player = playerFromHandle(handle); - if (player) player->setPaused(paused == JNI_TRUE); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo(JNIEnv *, jobject, jlong handle, jlong positionMs) { - auto player = playerFromHandle(handle); - if (player) player->seekToMilliseconds(positionMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy(JNIEnv *, jobject, jlong handle, jlong offsetMs) { - auto player = playerFromHandle(handle); - if (player) player->seekByMilliseconds(offsetMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed(JNIEnv *, jobject, jlong handle, jfloat speed) { - auto player = playerFromHandle(handle); - if (player) player->setSpeed(speed); -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->durationMs() : 0; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->positionMs() : 0; -} - -extern "C" JNIEXPORT jlong JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? player->bufferedPositionMs() : 0; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player && player->isLoading() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player && player->isEnded() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jboolean JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return !player || player->isPaused() ? JNI_TRUE : JNI_FALSE; -} - -extern "C" JNIEXPORT jfloat JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return player ? (jfloat)player->speed() : 1.0f; -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setResizeMode(JNIEnv *, jobject, jlong handle, jint mode) { - auto player = playerFromHandle(handle); - if (player) player->setResizeMode(mode); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_audioTracksJson(JNIEnv *env, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return newJavaStringUtf8(env, player ? player->audioTracksJson() : "[]"); -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_subtitleTracksJson(JNIEnv *env, jobject, jlong handle) { - auto player = playerFromHandle(handle); - return newJavaStringUtf8(env, player ? player->subtitleTracksJson() : "[]"); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectAudioTrack(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->selectAudioTrackId(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectSubtitleTrack(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->selectSubtitleTrackId(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl(JNIEnv *env, jobject, jlong handle, jstring url) { - auto player = playerFromHandle(handle); - if (player) player->addSubtitleUrl(jstringToUtf8(env, url)); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles(JNIEnv *, jobject, jlong handle) { - auto player = playerFromHandle(handle); - if (player) player->removeExternalSubtitles(); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitlesAndSelect(JNIEnv *, jobject, jlong handle, jint trackId) { - auto player = playerFromHandle(handle); - if (player) player->removeExternalSubtitlesAndSelect(trackId); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applyWindowChrome( - JNIEnv *, - jobject, - jlong windowHwnd, - jboolean darkMode, - jint captionColorRgb, - jint borderColorRgb, - jint textColorRgb -) { - applyDwmWindowChrome( - (HWND)(intptr_t)windowHwnd, - darkMode == JNI_TRUE, - rgbIntToColorRef(captionColorRgb), - rgbIntToColorRef(borderColorRgb), - rgbIntToColorRef(textColorRgb) - ); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSubtitleDelayMs(JNIEnv *, jobject, jlong handle, jint delayMs) { - auto player = playerFromHandle(handle); - if (player) player->setSubtitleDelayMs(delayMs); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle( - JNIEnv *env, - jobject, - jlong handle, - jstring textColor, - jstring backgroundColor, - jstring outlineColor, - jfloat outlineSize, - jboolean bold, - jfloat fontSize, - jint subPos -) { - auto player = playerFromHandle(handle); - if (!player) return; - player->applySubtitleStyle( - jstringToUtf8(env, textColor), - jstringToUtf8(env, backgroundColor), - jstringToUtf8(env, outlineColor), - outlineSize, - bold == JNI_TRUE, - fontSize, - subPos - ); -} diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns deleted file mode 100644 index 2c68eb9cb..000000000 Binary files a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns and /dev/null differ diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico deleted file mode 100644 index edcfdfcc3..000000000 Binary files a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico and /dev/null differ diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png deleted file mode 100644 index 9fc0d4ade..000000000 Binary files a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png and /dev/null differ diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.css b/composeApp/src/desktopMain/resources/player-ui/controls.css deleted file mode 100644 index 0f54e0d98..000000000 --- a/composeApp/src/desktopMain/resources/player-ui/controls.css +++ /dev/null @@ -1,2144 +0,0 @@ -/* __NUVIO_PLAYER_FONT_FACES__ */ - -:root { - --hp: 20px; - --vp: 16px; - --title: 18px; - --episode: 14px; - --meta: 12px; - --center-gap: 56px; - --center-lift: 10px; - --slider-bottom: 16px; - --slider-touch: 22px; - --slider-scale: .82; - --overlay-bottom: calc(var(--slider-bottom) + var(--slider-touch) + 98px); - --time: 12px; - --header-icon: 20px; - --header-button: 36px; - --side-pad: 10px; - --side-icon: 26px; - --play-pad: 13px; - --play-icon: 34px; - --motion-fast: 120ms; - --motion-standard: 180ms; - --motion-panel: 220ms; - --ease-out: cubic-bezier(.2, .8, .2, 1); - --theme-accent: #2f6fed; - --theme-accent-strong: #3c7bff; - --theme-on-accent: #fff; - --theme-focus: #9ecaff; - --theme-selected-surface: #26384f; - --theme-selected-surface-hover: #2d4565; - --theme-selected-ring: rgba(47, 111, 237, .35); - --theme-timeline-fill: #fff; - --theme-timeline-track: rgba(255, 255, 255, .28); - --theme-buffering: #fff; - --theme-buffering-track: rgba(255, 255, 255, .28); - --theme-control-foreground: #fff; - color-scheme: dark; -} - -@media (min-width: 768px) { - :root { - --hp: 20px; - --vp: 16px; - --title: 22px; - --episode: 14px; - --meta: 12px; - --center-gap: 72px; - --center-lift: 14px; - --slider-bottom: 20px; - --slider-touch: 24px; - --slider-scale: .78; - --time: 12px; - --header-icon: 20px; - --header-button: 36px; - --side-pad: 12px; - --side-icon: 30px; - --play-pad: 15px; - --play-icon: 38px; - } -} - -@media (min-width: 1024px) { - :root { - --hp: 24px; - --vp: 20px; - --title: 24px; - --episode: 15px; - --meta: 13px; - --center-gap: 88px; - --center-lift: 18px; - --slider-bottom: 24px; - --slider-touch: 26px; - --slider-scale: .74; - --time: 13px; - --header-icon: 22px; - --header-button: 38px; - --side-pad: 13px; - --side-icon: 32px; - --play-pad: 16px; - --play-icon: 42px; - } -} - -@media (min-width: 1440px) { - :root { - --hp: 28px; - --vp: 24px; - --title: 28px; - --episode: 16px; - --meta: 14px; - --center-gap: 112px; - --center-lift: 24px; - --slider-bottom: 28px; - --slider-touch: 28px; - --slider-scale: .72; - --time: 14px; - --header-icon: 24px; - --header-button: 40px; - --side-pad: 14px; - --side-icon: 34px; - --play-pad: 18px; - --play-icon: 44px; - } -} - -* { - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; -} - -html, -body { - width: 100%; - height: 100%; - margin: 0; - overflow: hidden; - background: transparent; - color: #fff; - font-family: "Nuvio JetBrains Sans", "JetBrains Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - letter-spacing: 0; - user-select: none; -} - -button { - appearance: none; - border: 0; - padding: 0; - margin: 0; - color: inherit; - background: transparent; - font: inherit; - cursor: pointer; - touch-action: manipulation; -} - -button:disabled { - cursor: default; -} - -.player { - position: relative; - width: 100%; - height: 100%; -} - -.player:focus { - outline: none; -} - -.top-gradient, -.bottom-gradient, -.metadata, -.parental-guide, -.skip-prompt, -.next-episode-card, -.header-actions, -.center-controls, -.progress { - transition: opacity 160ms ease; -} - -.player.native-resizing .top-gradient, -.player.native-resizing .bottom-gradient, -.player.native-resizing .metadata, -.player.native-resizing .parental-guide, -.player.native-resizing .skip-prompt, -.player.native-resizing .next-episode-card, -.player.native-resizing .header-actions, -.player.native-resizing .center-controls, -.player.native-resizing .progress, -.player.native-resizing .locked-overlay, -.player.native-resizing .modal-layer, -.player.native-resizing .track-panel { - transition-duration: 1ms !important; -} - -.player.chrome-hidden .top-gradient, -.player.chrome-hidden .bottom-gradient, -.player.chrome-hidden .metadata, -.player.chrome-hidden .header-actions, -.player.chrome-hidden .center-controls, -.player.chrome-hidden .progress, -.player.locked .metadata, -.player.locked .parental-guide, -.player.locked .header-actions, -.player.locked .center-controls { - opacity: 0; - pointer-events: none; -} - -.player.opening-active .top-gradient, -.player.opening-active .bottom-gradient, -.player.opening-active .parental-guide, -.player.opening-active .pause-metadata-overlay, -.player.opening-active .skip-prompt, -.player.opening-active .next-episode-card, -.player.opening-active .content { - visibility: hidden; - opacity: 0; - pointer-events: none; - transition: none !important; -} - -.player.error-active .top-gradient, -.player.error-active .bottom-gradient, -.player.error-active .metadata, -.player.error-active .parental-guide, -.player.error-active .pause-metadata-overlay, -.player.error-active .skip-prompt, -.player.error-active .next-episode-card, -.player.error-active .header-actions, -.player.error-active .center-controls, -.player.error-active .progress, -.player.error-active .locked-overlay, -.player.error-active .modal-layer { - opacity: 0; - pointer-events: none; -} - -.player.locked.locked-visible .bottom-gradient, -.player.locked.locked-visible .progress { - opacity: 1; - pointer-events: none; -} - -[hidden] { - display: none !important; -} - -.next-episode-thumb, -.episode-thumb { - isolation: isolate; -} - -.next-episode-thumb::before, -.episode-thumb::before, -.opening-overlay::before { - content: ""; - position: absolute; - inset: 0; - z-index: 0; - pointer-events: none; - background: - linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, .14) 48%, rgba(255, 255, 255, 0) 92%), - linear-gradient(135deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .028)); - background-size: 260% 100%, 100% 100%; - opacity: 0; - transform: translateZ(0); - transition: opacity 260ms ease; -} - -.opening-overlay::before { - z-index: 1; -} - -.next-episode-thumb.image-loading::before, -.episode-thumb.image-loading::before { - opacity: .78; - animation: image-shimmer 1600ms ease-in-out infinite; -} - -.opening-overlay.visible:not(.image-loaded)::before { - opacity: .68; - animation: image-shimmer 3200ms ease-in-out infinite; -} - -.next-episode-thumb img, -.episode-thumb img, -.opening-artwork, -.opening-logo { - opacity: 0; - filter: blur(14px) saturate(.92); - transform: scale(1.035); - transform-origin: center; - will-change: opacity, filter, transform; - transition: - opacity 260ms ease, - filter 520ms var(--ease-out), - transform 620ms var(--ease-out); -} - -.next-episode-thumb img.image-loaded, -.episode-thumb img.image-loaded, -.opening-artwork.image-loaded, -.opening-logo.image-loaded { - opacity: 1; - filter: blur(0) saturate(1); - transform: scale(1); -} - -.image-error::before { - opacity: .65; - animation: none; - background: - radial-gradient(circle at 30% 24%, rgba(255, 255, 255, .12), rgba(255, 255, 255, 0) 34%), - linear-gradient(135deg, rgba(255, 255, 255, .08), rgba(255, 255, 255, .03)); -} - -@keyframes image-shimmer { - from { background-position: 190% 0, 0 0; } - to { background-position: -90% 0, 0 0; } -} - -@keyframes opening-artwork-drift { - from { transform: scale(1.015) translate3d(-.35%, -.25%, 0); } - to { transform: scale(1.04) translate3d(.35%, .25%, 0); } -} - -.top-gradient, -.bottom-gradient { - position: absolute; - left: 0; - right: 0; - pointer-events: none; -} - -.top-gradient { - top: 0; - height: 160px; - background: linear-gradient(to bottom, rgba(0, 0, 0, .7), rgba(0, 0, 0, 0)); -} - -.bottom-gradient { - bottom: 0; - height: 220px; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, .7)); -} - -.content { - position: absolute; - inset: 0; - padding-inline: var(--hp); -} - -.header { - position: absolute; - top: calc(var(--slider-bottom) + env(safe-area-inset-top)); - left: var(--hp); - right: var(--hp); - display: flex; - justify-content: flex-end; - align-items: flex-start; - gap: 18px; -} - -.metadata { - min-width: 0; - display: flex; - flex-direction: column; - gap: 0; - opacity: 1; - padding: 0 14px 10px; -} - -.title { - max-width: min(760px, 100%); - font-size: var(--title); - line-height: 1.16; - font-weight: 700; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.episode { - max-width: min(760px, 100%); - margin-top: 4px; - font-size: var(--episode); - line-height: 1.3; - color: rgba(255, 255, 255, .9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.meta-row { - display: flex; - align-items: center; - gap: 8px; - max-width: min(760px, 100%); - min-width: 0; - max-height: 0; - margin-top: 0; - opacity: 0; - overflow: hidden; - pointer-events: none; - transform: translateY(6px); - transition: - opacity 180ms ease, - max-height 180ms ease, - margin-top 180ms ease, - transform 180ms var(--ease-out); -} - -.player.source-visible .meta-row { - max-height: 24px; - margin-top: 6px; - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.stream-title, -.provider { - min-width: 0; - font-size: var(--meta); - line-height: 1.25; - color: rgba(255, 255, 255, .7); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.provider { - font-style: italic; -} - -.parental-guide { - position: absolute; - top: calc(var(--slider-bottom) + env(safe-area-inset-top)); - left: var(--hp); - z-index: 5; - display: flex; - align-items: flex-start; - opacity: 0; - pointer-events: none; - transition: opacity 300ms ease; -} - -.player.parental-visible .parental-guide { - opacity: 1; -} - -.parental-guide-line { - width: 3px; - height: 0; - border-radius: 1px; - background: var(--theme-accent); - transform: scaleY(0); - transform-origin: top; - transition: transform 400ms var(--ease-out); -} - -.player.parental-line-visible .parental-guide-line { - transform: scaleY(1); -} - -.parental-guide-list { - display: flex; - flex-direction: column; - gap: 2px; - padding-left: 10px; -} - -.parental-guide-row { - height: 18px; - display: flex; - align-items: center; - opacity: 0; - transform: translateY(2px); - transition: - opacity 200ms ease, - transform 200ms var(--ease-out); - white-space: nowrap; -} - -.parental-guide-row.visible { - opacity: 1; - transform: translateY(0); -} - -.parental-guide-label, -.parental-guide-separator, -.parental-guide-severity { - font-size: 11px; - line-height: 18px; -} - -.parental-guide-label { - color: rgba(255, 255, 255, .85); - font-weight: 600; -} - -.parental-guide-separator { - color: rgba(255, 255, 255, .4); -} - -.parental-guide-severity { - color: rgba(255, 255, 255, .5); -} - -.skip-prompt { - position: absolute; - left: var(--hp); - bottom: calc(var(--overlay-bottom) + env(safe-area-inset-bottom)); - z-index: 7; - min-width: max-content; - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 16px; - background: rgba(30, 30, 30, .85); - color: #fff; - opacity: 0; - pointer-events: none; - transform: scale(.8); - transform-origin: left bottom; - transition: - opacity 300ms ease, - transform 300ms var(--ease-out); -} - -.skip-prompt.visible { - opacity: 1; - pointer-events: auto; - transform: scale(1); -} - -.skip-prompt-row { - display: flex; - align-items: center; - padding: 12px 18px; -} - -.skip-prompt svg { - width: 20px; - height: 20px; - flex: 0 0 auto; -} - -.skip-prompt-label { - padding-left: 8px; - font-size: 14px; - line-height: 1.2; - white-space: nowrap; -} - -.skip-progress-track { - display: none; - width: 100%; - height: 3px; - overflow: hidden; - border-radius: 0 0 16px 16px; - background: rgba(255, 255, 255, .15); -} - -.skip-prompt.show-progress .skip-progress-track { - display: block; -} - -.skip-progress-fill { - width: 0%; - height: 100%; - background: rgba(30, 30, 30, .85); -} - -.next-episode-card { - position: absolute; - right: var(--hp); - bottom: calc(var(--overlay-bottom) + env(safe-area-inset-bottom)); - z-index: 7; - width: min(292px, calc(100vw - (var(--hp) * 2))); - display: flex; - align-items: center; - padding: 8px 9px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(25, 25, 25, .89); - color: #fff; - opacity: 0; - pointer-events: none; - transform: translateX(50%) scale(.98); - transition: - opacity 220ms ease, - transform 260ms var(--ease-out); -} - -.next-episode-card.visible { - opacity: 1; - pointer-events: auto; - transform: translateX(0) scale(1); -} - -.next-episode-card:not(.playable) { - cursor: default; -} - -.next-episode-thumb { - position: relative; - width: 78px; - height: 44px; - flex: 0 0 auto; - overflow: hidden; - border-radius: 9px; - background: rgba(255, 255, 255, .08); -} - -.next-episode-thumb img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.next-episode-card:not(.has-thumb) .next-episode-thumb img { - display: none; -} - -.next-episode-thumb::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, .32)); -} - -.next-episode-copy { - min-width: 0; - flex: 1 1 auto; - padding-left: 8px; -} - -.next-episode-header { - display: block; - color: rgba(255, 255, 255, .8); - font-size: 10px; - line-height: 1.25; - font-weight: 500; -} - -.next-episode-title { - display: block; - margin-top: 2px; - color: #fff; - font-size: 12px; - line-height: 1.25; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.next-episode-status { - display: block; - margin-top: 2px; - color: rgba(255, 255, 255, .78); - font-size: 10px; - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.next-episode-action { - display: flex; - align-items: center; - gap: 3px; - flex: 0 0 auto; - margin-left: 4px; - padding: 5px 8px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, .2); - color: #fff; - font-size: 11px; - line-height: 1.2; - white-space: nowrap; -} - -.next-episode-card:not(.playable) .next-episode-action { - color: rgba(255, 255, 255, .72); -} - -.next-episode-action svg { - width: 13px; - height: 13px; - color: inherit; -} - -.header-actions { - display: flex; - gap: 10px; - align-items: center; - flex: 0 0 auto; -} - -.header-button { - width: var(--header-button); - height: var(--header-button); - display: grid; - place-items: center; - border-radius: 999px; - background: rgba(0, 0, 0, .35); -} - -.header-button, -.side-control, -.play-control, -.skip-prompt, -.next-episode-card, -.locked-button, -.action, -.panel-button, -.filter-chip, -.capture-button, -.subtitle-tab, -.round-step, -.swatch, -.sync-cue, -.track-row, -.segment-button { - transform: translateZ(0); - transition: - transform var(--motion-fast) var(--ease-out), - background-color var(--motion-standard) ease, - border-color var(--motion-standard) ease, - color var(--motion-standard) ease, - opacity var(--motion-standard) ease, - box-shadow var(--motion-standard) ease; - will-change: transform; -} - -.header-button:hover, -.locked-button:hover { - background: rgba(255, 255, 255, .14); -} - -.side-control:hover, -.play-control:hover, -.action:hover { - background: rgba(255, 255, 255, .12); -} - -.header-button:active, -.header-button.is-pressed, -.skip-prompt:active, -.skip-prompt.is-pressed, -.next-episode-card.playable:active, -.next-episode-card.playable.is-pressed, -.locked-button:active, -.locked-button.is-pressed, -.side-control:active, -.side-control.is-pressed, -.action:active, -.action.is-pressed { - transform: scale(.94); -} - -.play-control:active, -.play-control.is-pressed { - transform: scale(.91); -} - -.header-button svg { - width: var(--header-icon); - height: var(--header-icon); -} - -.center-controls { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, calc(-50% - var(--center-lift))); - display: flex; - align-items: center; - gap: var(--center-gap); -} - -.center-status { - position: absolute; - left: 50%; - top: 50%; - width: 48px; - height: 48px; - display: grid; - place-items: center; - opacity: 0; - pointer-events: none; - transform: translate(-50%, calc(-50% - var(--center-lift) + 10px)) scale(.98); - transition: - opacity var(--motion-standard) ease, - transform var(--motion-standard) var(--ease-out); - will-change: opacity, transform; -} - -.center-status.visible { - opacity: 1; - transform: translate(-50%, calc(-50% - var(--center-lift))) scale(1); -} - -.center-spinner { - width: 42px; - height: 42px; - border-radius: 999px; - border: 3px solid var(--theme-buffering-track); - border-top-color: var(--theme-buffering); - animation: center-spin 760ms linear infinite; -} - -.playback-error { - position: absolute; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 32px; - background: rgba(0, 0, 0, .9); - opacity: 0; - pointer-events: none; - transition: opacity 180ms ease; -} - -.playback-error.visible { - opacity: 1; - pointer-events: auto; -} - -.playback-error-content { - width: min(560px, 100%); - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 16px; -} - -.playback-error-title { - margin: 0; - color: #fff; - font-size: clamp(32px, 4vw, 42px); - line-height: 1.1; - font-weight: 700; -} - -.playback-error-message { - max-width: 560px; - margin: 0; - color: rgba(255, 255, 255, .72); - font-size: 18px; - line-height: 24px; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; -} - -.playback-error-action { - width: min(260px, 100%); - min-width: 180px; - margin-top: 4px; - padding: 12px 20px; - border-radius: 12px; - background: var(--theme-accent); - color: var(--theme-on-accent); - font-size: 18px; - line-height: 24px; - font-weight: 700; - text-align: center; -} - -.playback-error-action:hover { - background: var(--theme-accent-strong); -} - -.playback-error-action:active, -.playback-error-action.is-pressed { - transform: scale(.96); -} - -.pause-metadata-overlay { - position: absolute; - inset: 0; - z-index: 6; - display: block; - pointer-events: none; - opacity: 0; - background: - linear-gradient( - 90deg, - rgba(0, 0, 0, .85) 0%, - rgba(0, 0, 0, .45) 48%, - rgba(0, 0, 0, 0) 100% - ); - transition: opacity 180ms ease; -} - -.pause-metadata-overlay.visible { - opacity: 1; - transition-duration: 220ms; -} - -.pause-metadata-content { - height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: - 40px - calc(var(--hp) + env(safe-area-inset-right)) - 120px - calc(var(--hp) + env(safe-area-inset-left)); -} - -.pause-kicker { - color: #b8b8b8; - font-size: 18px; - line-height: 1.3; - font-weight: 500; -} - -.pause-logo { - width: min(360px, 62vw); - height: 96px; - margin-top: 12px; - object-fit: contain; - object-position: left bottom; -} - -.pause-logo[hidden], -.pause-title[hidden], -.pause-episode-info[hidden], -.pause-episode-title[hidden], -.pause-description[hidden] { - display: none; -} - -.pause-title { - max-width: 62%; - margin-top: 12px; - color: #fff; - font-size: max(calc(var(--title) * 1.8), 32px); - line-height: 1.08; - font-weight: 800; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.pause-episode-info { - margin-top: 8px; - color: #ccc; - font-size: 18px; - line-height: 1.3; -} - -.pause-episode-title { - max-width: 62%; - margin-top: 12px; - color: #fff; - font-size: 22px; - line-height: 1.25; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.pause-description { - width: 62%; - margin-top: 16px; - color: #d6d6d6; - font-size: 18px; - line-height: 24px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; -} - -@media (max-height: 419px) { - .pause-metadata-content { - padding-top: 24px; - padding-bottom: 40px; - } - - .pause-kicker, - .pause-episode-info { - font-size: 16px; - } - - .pause-logo { - height: 64px; - margin-top: 8px; - } - - .pause-title { - max-width: 82%; - margin-top: 8px; - font-size: max(calc(var(--title) * 1.35), 32px); - -webkit-line-clamp: 1; - } - - .pause-episode-info { - margin-top: 6px; - } - - .pause-episode-title { - max-width: 82%; - margin-top: 8px; - -webkit-line-clamp: 1; - } - - .pause-description { - width: 82%; - margin-top: 10px; - font-size: 16px; - line-height: 20px; - -webkit-line-clamp: 2; - } -} - -@media (max-height: 339px) { - .pause-metadata-content { - padding-bottom: 24px; - } - - .pause-logo { - height: 48px; - } -} - -.opening-overlay { - position: absolute; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - overflow: hidden; - background: rgba(0, 0, 0, .85); - opacity: 0; - pointer-events: none; - transition: opacity 180ms ease; -} - -.opening-overlay.visible { - opacity: 1; - pointer-events: auto; -} - -.opening-back { - position: absolute; - top: calc(20px + env(safe-area-inset-top)); - right: calc(var(--hp) + 20px); - z-index: 3; - width: 44px; - height: 44px; - display: grid; - place-items: center; - border-radius: 999px; - background: rgba(0, 0, 0, .3); -} - -.opening-back:hover { - background: rgba(255, 255, 255, .14); -} - -.opening-back:active, -.opening-back.is-pressed { - transform: scale(.94); -} - -.opening-back svg { - width: 24px; - height: 24px; -} - -.opening-artwork { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; -} - -.opening-overlay.has-artwork.image-loading::before { - z-index: 1; -} - -.opening-overlay.has-artwork .opening-artwork.image-loaded { - animation: opening-artwork-drift 14000ms ease-in-out infinite alternate; -} - -.opening-scrim { - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - rgba(0, 0, 0, .3), - rgba(0, 0, 0, .6), - rgba(0, 0, 0, .8), - rgba(0, 0, 0, .9) - ); - opacity: 0; -} - -.opening-overlay.has-artwork .opening-scrim { - opacity: 1; -} - -.opening-content { - position: relative; - z-index: 2; - width: min(100%, 720px); - min-height: 260px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px; - text-align: center; - opacity: 1; -} - -.opening-logo-slot { - position: relative; - width: 300px; - max-width: min(78vw, 300px); - height: 180px; -} - -.opening-logo { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: contain; - transform-origin: center; - animation: opening-logo-pulse 2000ms linear infinite alternate !important; -} - -.opening-logo-base { - opacity: 0; -} - -.opening-logo-fill-clip { - position: absolute; - inset: 0 auto 0 0; - width: 0%; - overflow: hidden; -} - -.opening-logo-fill { - width: 300px; - max-width: min(78vw, 300px); - height: 180px; - object-fit: contain; -} - -.opening-title { - max-width: min(84vw, 680px); - margin: 0; - color: #fff; - font-size: 42px; - line-height: 1.12; - font-weight: 800; - text-align: center; - opacity: 1; - -webkit-text-fill-color: #fff; - text-shadow: 0 2px 22px rgba(0, 0, 0, .42); - transform-origin: center; - will-change: transform; - animation: opening-logo-pulse 2000ms linear infinite alternate !important; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.opening-spinner { - width: 54px; - height: 54px; - border-radius: 999px; - border: 3px solid var(--theme-buffering-track); - border-top-color: var(--theme-buffering); - animation: center-spin 760ms linear infinite; -} - -.opening-status { - width: 100%; - min-height: 40px; - margin-top: 16px; - display: grid; - place-items: center; - color: rgba(255, 255, 255, .72); - font-size: 13px; - line-height: 1.35; - font-weight: 600; - text-align: center; -} - -.opening-status span { - max-width: min(84vw, 620px); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.opening-progress-track { - width: 240px; - height: 4px; - margin-top: 10px; - overflow: hidden; - border-radius: 2px; - background: rgba(255, 255, 255, .2); -} - -.opening-progress-bar { - width: 0%; - height: 100%; - border-radius: inherit; - background: var(--theme-timeline-fill); - transition: width 900ms cubic-bezier(.4, 0, .2, 1); -} - -@keyframes opening-logo-pulse { - to { - transform: scale(1.04); - } -} - -@keyframes center-spin { - to { - transform: rotate(360deg); - } -} - -.side-control, -.play-control { - display: grid; - place-items: center; - border-radius: 999px; -} - -.side-control { - padding: var(--side-pad); -} - -.play-control { - padding: var(--play-pad); -} - -.side-control svg { - width: var(--side-icon); - height: var(--side-icon); -} - -.play-control svg { - width: var(--play-icon); - height: var(--play-icon); -} - -.progress { - position: absolute; - left: var(--hp); - right: var(--hp); - bottom: var(--slider-bottom); - display: flex; - flex-direction: column; -} - -.locked-overlay { - position: absolute; - inset: 0; - display: none; - place-items: center; - text-align: center; - pointer-events: none; -} - -.player.locked.locked-visible .locked-overlay { - display: grid; -} - -.locked-content { - display: grid; - justify-items: center; - gap: 12px; - pointer-events: auto; -} - -.locked-button { - width: 78px; - height: 78px; - display: grid; - place-items: center; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, .18); - background: rgba(0, 0, 0, .52); -} - -.locked-button svg { - width: 34px; - height: 34px; -} - -.locked-label { - font-size: 15px; - line-height: 1.25; - font-weight: 600; - color: rgba(255, 255, 255, .92); -} - -.scrub { - width: calc(100% - 28px); - height: var(--slider-touch); - transform: scaleY(var(--slider-scale)); - transform-origin: center; - margin: 0 14px; - appearance: none; - background: transparent; -} - -.scrub::-webkit-slider-runnable-track { - height: 4px; - border-radius: 999px; - background: - linear-gradient(to right, var(--theme-timeline-fill) 0 var(--progress, 0%), var(--theme-timeline-track) var(--progress, 0%) 100%); -} - -.scrub::-webkit-slider-thumb { - appearance: none; - width: 14px; - height: 14px; - margin-top: -5px; - border-radius: 999px; - background: var(--theme-control-foreground); - box-shadow: 0 0 0 1px rgba(255, 255, 255, .18); - transition: - transform var(--motion-fast) var(--ease-out), - box-shadow var(--motion-standard) ease; -} - -.scrub:active::-webkit-slider-thumb { - transform: scale(1.18); - box-shadow: 0 0 0 6px rgba(255, 255, 255, .16); -} - -.time-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 14px 8px; -} - -.time-pill { - border-radius: 12px; - padding: 4px 10px; - border: 1px solid rgba(255, 255, 255, .2); - background: rgba(0, 0, 0, .5); - font-size: var(--time); - line-height: 1.25; - font-weight: 500; - font-variant-numeric: tabular-nums; -} - -.action-row { - display: flex; - justify-content: center; - min-width: 0; -} - -.action-pill { - max-width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 0; - padding: 2px 4px; - border: 1px solid rgba(255, 255, 255, .2); - border-radius: 24px; - background: rgba(0, 0, 0, .5); - overflow: hidden; -} - -.action { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - border-radius: 22px; - color: #fff; - white-space: nowrap; -} - -.action svg { - width: 18px; - height: 18px; - flex: 0 0 auto; -} - -.action span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: 12px; - line-height: 1.2; - font-weight: 500; -} - -.modal-layer { - position: absolute; - inset: 0; - z-index: 30; - display: grid; - place-items: center; - padding: 24px; - background-color: rgba(0, 0, 0, .54); - opacity: 0; - pointer-events: none; - transition: opacity var(--motion-standard) ease; -} - -.modal-layer.modal-visible { - opacity: 1; - pointer-events: auto; -} - -.modal-layer.modal-closing { - opacity: 0; - pointer-events: none; -} - -.track-panel { - width: min(90vw, 420px); - max-height: min(90vh, 600px); - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, .16); - background: #16171d; - color: #fff; - box-shadow: 0 20px 70px rgba(0, 0, 0, .45); - opacity: 0; - transform: translateY(14px) scale(.985); - transition: - transform var(--motion-panel) var(--ease-out), - opacity var(--motion-standard) ease; - will-change: transform, opacity; -} - -.modal-layer.modal-visible .track-panel { - opacity: 1; - transform: translateY(0) scale(1); -} - -.modal-layer.modal-closing .track-panel { - opacity: 0; - transform: translateY(10px) scale(.99); -} - -.track-panel.wide { - width: min(94vw, 860px); - max-height: min(92vh, 760px); -} - -.track-panel.submit { - width: min(92vw, 520px); -} - -.track-header { - padding: 20px; - font-size: 18px; - line-height: 1.25; - font-weight: 700; -} - -.track-header-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 18px 20px 14px; -} - -.track-title { - min-width: 0; - font-size: 18px; - line-height: 1.25; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.panel-actions { - display: flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; -} - -.panel-button, -.filter-chip, -.capture-button { - min-height: 32px; - padding: 7px 12px; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(255, 255, 255, .08); - color: rgba(255, 255, 255, .72); - font-size: 12px; - line-height: 1.2; - font-weight: 600; - white-space: nowrap; -} - -.panel-button:hover:not(:disabled), -.filter-chip:hover:not(:disabled), -.capture-button:hover:not(:disabled), -.subtitle-tab:hover:not(:disabled), -.round-step:hover:not(:disabled), -.segment-button:hover:not(:disabled) { - background: rgba(255, 255, 255, .13); - color: rgba(255, 255, 255, .9); -} - -.panel-button:active:not(:disabled), -.panel-button.is-pressed:not(:disabled), -.filter-chip:active:not(:disabled), -.filter-chip.is-pressed:not(:disabled), -.capture-button:active:not(:disabled), -.capture-button.is-pressed:not(:disabled), -.subtitle-tab:active:not(:disabled), -.subtitle-tab.is-pressed:not(:disabled), -.round-step:active:not(:disabled), -.round-step.is-pressed:not(:disabled), -.segment-button:active:not(:disabled), -.segment-button.is-pressed:not(:disabled), -.swatch:active:not(:disabled), -.swatch.is-pressed:not(:disabled), -.sync-cue:active:not(:disabled), -.sync-cue.is-pressed:not(:disabled) { - transform: scale(.96); -} - -.panel-button.primary, -.filter-chip.selected, -.segment-button.selected { - background: var(--theme-accent); - color: var(--theme-on-accent); - border-color: rgba(255, 255, 255, .18); -} - -.panel-button.primary:hover:not(:disabled), -.filter-chip.selected:hover:not(:disabled), -.segment-button.selected:hover:not(:disabled) { - background: var(--theme-accent-strong); -} - -.filter-row { - display: flex; - gap: 8px; - overflow-x: auto; - padding: 0 20px 14px; - flex: 0 0 auto; -} - -.subtitle-tabs { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - padding: 0 20px 16px; -} - -.subtitle-tab { - min-width: 0; - min-height: 34px; - padding: 8px 10px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(255, 255, 255, .08); - color: rgba(255, 255, 255, .72); - font-size: 12px; - line-height: 1.2; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.subtitle-tab.selected { - border-radius: 12px; - background: var(--theme-accent); - color: var(--theme-on-accent); -} - -.style-panel { - display: flex; - flex-direction: column; - gap: 14px; - overflow: auto; - padding: 0 20px 20px; -} - -.style-card { - display: flex; - flex-direction: column; - gap: 13px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(255, 255, 255, .07); - padding: 14px; -} - -.style-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; -} - -.style-label { - min-width: 0; - color: rgba(255, 255, 255, .72); - font-size: 13px; - line-height: 1.25; - font-weight: 700; -} - -.stepper { - display: flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; -} - -.stepper-value { - min-width: 64px; - text-align: center; - color: rgba(255, 255, 255, .92); - font-size: 13px; - line-height: 1.2; - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.round-step { - width: 30px; - height: 30px; - display: grid; - place-items: center; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(255, 255, 255, .08); - color: #fff; - font-size: 18px; - font-weight: 800; - line-height: 1; -} - -.swatch-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -.swatch-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.swatch { - width: 24px; - height: 24px; - border-radius: 999px; - border: 2px solid rgba(255, 255, 255, .28); - background: var(--swatch); -} - -.swatch.transparent { - background: - linear-gradient(45deg, rgba(255,255,255,.16) 25%, transparent 25% 75%, rgba(255,255,255,.16) 75%), - linear-gradient(45deg, rgba(255,255,255,.16) 25%, transparent 25% 75%, rgba(255,255,255,.16) 75%); - background-position: 0 0, 6px 6px; - background-size: 12px 12px; -} - -.swatch.selected { - border-color: var(--theme-focus); - box-shadow: 0 0 0 2px var(--theme-selected-ring); -} - -.auto-sync-card { - display: flex; - flex-direction: column; - gap: 8px; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, .1); - background: rgba(0, 0, 0, .22); - padding: 12px; -} - -.sync-cue { - display: flex; - align-items: flex-start; - gap: 8px; - width: 100%; - padding: 8px; - border-radius: 8px; - background: rgba(255, 255, 255, .08); - text-align: left; -} - -.sync-time { - flex: 0 0 auto; - color: var(--theme-focus); - font-size: 11px; - line-height: 1.25; - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.sync-text { - min-width: 0; - color: rgba(255, 255, 255, .82); - font-size: 12px; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.track-list { - display: flex; - flex-direction: column; - gap: 10px; - flex: 1 1 auto; - overflow: auto; - padding: 4px 20px 20px; - min-height: 0; -} - -#episodeListView, -#episodeStreamsView { - min-height: 0; - flex: 1 1 auto; - flex-direction: column; -} - -#episodeListView:not([hidden]), -#episodeStreamsView:not([hidden]) { - display: flex; -} - -.track-list.virtualized { - display: block; - gap: 0; - contain: content; -} - -.source-virtual-spacer { - position: relative; - width: 100%; -} - -.source-virtual-row { - position: absolute; - left: 0; - right: 0; - top: 0; -} - -.track-row { - width: 100%; - min-height: 42px; - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-radius: 12px; - background: rgba(255, 255, 255, .08); - color: rgba(255, 255, 255, .92); - text-align: left; -} - -.track-row.stream-row, -.track-row.episode-row { - align-items: stretch; - justify-content: flex-start; - min-height: 72px; -} - -.track-row.source-row { - min-height: 0; - align-items: flex-start; -} - -.track-row.disabled { - opacity: .45; -} - -.track-row:hover:not(:disabled) { - background: rgba(255, 255, 255, .13); -} - -.track-row:active:not(:disabled), -.track-row.is-pressed:not(:disabled) { - transform: scale(.985); -} - -.track-row.selected { - background: var(--theme-selected-surface); - color: var(--theme-control-foreground); - font-weight: 700; -} - -.track-row.selected:hover:not(:disabled) { - background: var(--theme-selected-surface-hover); -} - -.track-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 15px; - line-height: 1.25; -} - -.track-copy, -.episode-copy { - min-width: 0; - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 4px; -} - -.track-row-top, -.episode-row-top { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - flex: 0 0 auto; -} - -.stream-name, -.episode-name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 1.25; - font-weight: 700; -} - -.stream-subtitle, -.episode-overview, -.stream-addon { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - color: rgba(255, 255, 255, .64); - font-size: 12px; - line-height: 1.3; -} - -.stream-subtitle { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - white-space: normal; -} - -.episode-overview { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - white-space: normal; -} - -.stream-addon { - color: rgba(255, 255, 255, .5); - font-style: italic; - white-space: nowrap; -} - -.source-row .stream-name, -.source-row .stream-subtitle, -.source-row .stream-addon { - display: block; - overflow: visible; - overflow-wrap: anywhere; - text-overflow: clip; - white-space: normal; - -webkit-line-clamp: unset; - -webkit-box-orient: initial; -} - -.episode-row .episode-row-top { - flex-wrap: wrap; -} - -.episode-row .episode-name, -.episode-row .episode-overview { - display: block; - overflow: visible; - overflow-wrap: anywhere; - text-overflow: clip; - white-space: normal; - -webkit-line-clamp: unset; - -webkit-box-orient: initial; -} - -.status-chip, -.episode-code { - flex: 0 0 auto; - border-radius: 999px; - padding: 3px 7px; - font-size: 10px; - line-height: 1.1; - font-weight: 800; - letter-spacing: 0; - background: rgba(255, 255, 255, .12); - color: rgba(255, 255, 255, .68); -} - -.status-chip { - background: var(--theme-accent); - color: var(--theme-on-accent); -} - -.episode-thumb { - position: relative; - width: 82px; - height: 48px; - flex: 0 0 auto; - overflow: hidden; - border-radius: 10px; - background: rgba(255, 255, 255, .08); -} - -.episode-thumb img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.track-check { - width: 18px; - height: 18px; - flex: 0 0 auto; - color: var(--theme-focus); - opacity: 0; -} - -.track-row.selected .track-check { - opacity: 1; -} - -button:focus-visible, -.scrub:focus-visible { - outline: 2px solid rgba(158, 202, 255, .95); - outline-offset: 3px; -} - -.track-empty { - padding: 36px 12px 44px; - color: rgba(255, 255, 255, .64); - font-size: 14px; - line-height: 1.35; - text-align: center; -} - -.intro-form { - display: flex; - flex-direction: column; - gap: 16px; - overflow: auto; - padding: 0 20px 20px; -} - -.form-label { - color: rgba(255, 255, 255, .64); - font-size: 11px; - line-height: 1.2; - font-weight: 800; -} - -.segment-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - -.segment-button { - min-width: 0; - min-height: 42px; - padding: 9px 8px; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, .12); - background: rgba(255, 255, 255, .08); - color: rgba(255, 255, 255, .72); - font-size: 13px; - line-height: 1.2; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; -} - -.time-field-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: end; -} - -.time-field-wrap { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; -} - -.time-input { - width: 100%; - min-height: 46px; - border: 1px solid rgba(255, 255, 255, .12); - border-radius: 12px; - padding: 10px 12px; - background: rgba(255, 255, 255, .08); - color: #fff; - font: inherit; - font-size: 16px; - outline: none; -} - -.submit-status { - min-height: 18px; - color: #ffb4b4; - font-size: 12px; - line-height: 1.35; -} - -.consent-body { - overflow: auto; - padding: 0 20px 20px; - color: rgba(255, 255, 255, .74); - font-size: 13px; - line-height: 1.45; - white-space: pre-line; -} - -.consent-body + .modal-actions { - padding: 0 20px 20px; -} - -.modal-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); - gap: 10px; -} - -@media (max-width: 760px) { - .header { - left: var(--hp); - right: var(--hp); - } - - .title, - .episode, - .meta-row { - max-width: 100%; - } - - .progress { - left: var(--hp); - right: var(--hp); - } - - .action.optional { - display: none; - } - - .modal-layer { - padding: 14px; - } - - .episode-thumb { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - .top-gradient, - .bottom-gradient, - .metadata, - .header-actions, - .center-controls, - .center-status, - .pause-metadata-overlay, - .skip-prompt, - .next-episode-card, - .skip-progress-fill, - .progress, - .opening-overlay, - .opening-progress-bar, - .opening-artwork, - .opening-logo, - .pause-logo, - .next-episode-thumb img, - .episode-thumb img, - .image-loading::before, - .modal-layer, - .track-panel, - .header-button, - .side-control, - .play-control, - .locked-button, - .action, - .panel-button, - .filter-chip, - .capture-button, - .subtitle-tab, - .round-step, - .swatch, - .sync-cue, - .track-row, - .segment-button { - transition-duration: 1ms; - animation-duration: 1ms; - } -} - -svg { - display: block; - fill: currentColor; - stroke: none; -} diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html deleted file mode 100644 index 877d6658d..000000000 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ /dev/null @@ -1,380 +0,0 @@ - - - - - - - - - - -
-
-
- -
-
- -
- - - - - - - - - - - - - -
- - -
- 00:00 - 00:00 -
-
-
- - - - - - -
-
-
-
-
- -
Tap to unlock
-
-
- - - - - - -
-
- - - - diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.js b/composeApp/src/desktopMain/resources/player-ui/controls.js deleted file mode 100644 index 2bb636700..000000000 --- a/composeApp/src/desktopMain/resources/player-ui/controls.js +++ /dev/null @@ -1,2196 +0,0 @@ -const root = document.getElementById("playerRoot"); -const seek = document.getElementById("seek"); -const positionLabel = document.getElementById("position"); -const durationLabel = document.getElementById("duration"); -const bufferingStatus = document.getElementById("bufferingStatus"); -const playbackError = document.getElementById("playbackError"); -const playbackErrorTitle = document.getElementById("playbackErrorTitle"); -const playbackErrorMessage = document.getElementById("playbackErrorMessage"); -const playbackErrorAction = document.getElementById("playbackErrorAction"); -const playbackErrorActionLabel = document.getElementById("playbackErrorActionLabel"); -const pauseMetadataOverlay = document.getElementById("pauseMetadataOverlay"); -const pauseWatchingLabel = document.getElementById("pauseWatchingLabel"); -const pauseLogo = document.getElementById("pauseLogo"); -const pauseTitle = document.getElementById("pauseTitle"); -const pauseEpisodeInfo = document.getElementById("pauseEpisodeInfo"); -const pauseEpisodeTitle = document.getElementById("pauseEpisodeTitle"); -const pauseDescription = document.getElementById("pauseDescription"); -const toggle = document.getElementById("toggle"); -const toggleIcon = document.getElementById("toggleIcon"); -const lockIcon = document.getElementById("lockIcon"); -const title = document.getElementById("title"); -const episode = document.getElementById("episode"); -const streamTitle = document.getElementById("streamTitle"); -const providerName = document.getElementById("providerName"); -const resizeLabel = document.getElementById("resizeLabel"); -const speedLabel = document.getElementById("speedLabel"); -const subtitlesLabel = document.getElementById("subtitlesLabel"); -const audioLabel = document.getElementById("audioLabel"); -const sourcesLabel = document.getElementById("sourcesLabel"); -const episodesLabel = document.getElementById("episodesLabel"); -const submitIntroButton = document.getElementById("submitIntroButton"); -const lockButton = document.getElementById("lockButton"); -const videoSettingsButton = document.getElementById("videoSettingsButton"); -const backButton = document.getElementById("backButton"); -const openingOverlay = document.getElementById("openingOverlay"); -const openingArtwork = document.getElementById("openingArtwork"); -const openingBackButton = document.getElementById("openingBackButton"); -const openingLogoSlot = document.getElementById("openingLogoSlot"); -const openingLogoBase = document.getElementById("openingLogoBase"); -const openingLogoFillClip = document.getElementById("openingLogoFillClip"); -const openingLogoFill = document.getElementById("openingLogoFill"); -const openingTitle = document.getElementById("openingTitle"); -const openingSpinner = document.getElementById("openingSpinner"); -const openingStatus = document.getElementById("openingStatus"); -const openingMessage = document.getElementById("openingMessage"); -const openingProgressTrack = document.getElementById("openingProgressTrack"); -const openingProgressBar = document.getElementById("openingProgressBar"); -const parentalGuide = document.getElementById("parentalGuide"); -const parentalGuideLine = document.getElementById("parentalGuideLine"); -const parentalGuideList = document.getElementById("parentalGuideList"); -const skipPrompt = document.getElementById("skipPrompt"); -const skipPromptLabel = document.getElementById("skipPromptLabel"); -const skipPromptProgress = document.getElementById("skipPromptProgress"); -const nextEpisodeCard = document.getElementById("nextEpisodeCard"); -const nextEpisodeThumb = document.getElementById("nextEpisodeThumb"); -const nextEpisodeHeader = document.getElementById("nextEpisodeHeader"); -const nextEpisodeTitle = document.getElementById("nextEpisodeTitle"); -const nextEpisodeStatus = document.getElementById("nextEpisodeStatus"); -const nextEpisodeAction = document.getElementById("nextEpisodeAction"); -const sourcesButton = document.getElementById("sourcesButton"); -const episodesButton = document.getElementById("episodesButton"); -const lockedLabel = document.getElementById("lockedLabel"); -const audioModal = document.getElementById("audioModal"); -const subtitleModal = document.getElementById("subtitleModal"); -const audioTrackList = document.getElementById("audioTrackList"); -const subtitleTrackList = document.getElementById("subtitleTrackList"); -const subtitlePanelTitle = document.getElementById("subtitlePanelTitle"); -const subtitleBuiltInTab = document.getElementById("subtitleBuiltInTab"); -const subtitleAddonsTab = document.getElementById("subtitleAddonsTab"); -const subtitleStyleTab = document.getElementById("subtitleStyleTab"); -const addonSubtitleList = document.getElementById("addonSubtitleList"); -const subtitleStylePanel = document.getElementById("subtitleStylePanel"); -const subtitleDelayLabel = document.getElementById("subtitleDelayLabel"); -const subtitleDelayMinus = document.getElementById("subtitleDelayMinus"); -const subtitleDelayValue = document.getElementById("subtitleDelayValue"); -const subtitleDelayPlus = document.getElementById("subtitleDelayPlus"); -const subtitleDelayReset = document.getElementById("subtitleDelayReset"); -const autoSyncLabel = document.getElementById("autoSyncLabel"); -const autoSyncReload = document.getElementById("autoSyncReload"); -const autoSyncCapture = document.getElementById("autoSyncCapture"); -const autoSyncStatus = document.getElementById("autoSyncStatus"); -const autoSyncCueList = document.getElementById("autoSyncCueList"); -const fontSizeLabel = document.getElementById("fontSizeLabel"); -const fontSizeMinus = document.getElementById("fontSizeMinus"); -const fontSizeValue = document.getElementById("fontSizeValue"); -const fontSizePlus = document.getElementById("fontSizePlus"); -const outlineLabel = document.getElementById("outlineLabel"); -const outlineToggle = document.getElementById("outlineToggle"); -const boldLabel = document.getElementById("boldLabel"); -const boldToggle = document.getElementById("boldToggle"); -const bottomOffsetLabel = document.getElementById("bottomOffsetLabel"); -const bottomOffsetMinus = document.getElementById("bottomOffsetMinus"); -const bottomOffsetValue = document.getElementById("bottomOffsetValue"); -const bottomOffsetPlus = document.getElementById("bottomOffsetPlus"); -const subtitleColorLabel = document.getElementById("subtitleColorLabel"); -const subtitleColorSwatches = document.getElementById("subtitleColorSwatches"); -const textOpacityLabel = document.getElementById("textOpacityLabel"); -const textOpacityMinus = document.getElementById("textOpacityMinus"); -const textOpacityValue = document.getElementById("textOpacityValue"); -const textOpacityPlus = document.getElementById("textOpacityPlus"); -const outlineColorLabel = document.getElementById("outlineColorLabel"); -const outlineColorSwatches = document.getElementById("outlineColorSwatches"); -const subtitleStyleReset = document.getElementById("subtitleStyleReset"); -const sourceModal = document.getElementById("sourceModal"); -const sourcePanelTitle = document.getElementById("sourcePanelTitle"); -const sourceReloadButton = document.getElementById("sourceReloadButton"); -const sourceCloseButton = document.getElementById("sourceCloseButton"); -const sourceFilterList = document.getElementById("sourceFilterList"); -const sourceList = document.getElementById("sourceList"); -const episodesModal = document.getElementById("episodesModal"); -const episodeListView = document.getElementById("episodeListView"); -const episodeStreamsView = document.getElementById("episodeStreamsView"); -const episodesPanelTitle = document.getElementById("episodesPanelTitle"); -const episodesCloseButton = document.getElementById("episodesCloseButton"); -const seasonFilterList = document.getElementById("seasonFilterList"); -const episodeList = document.getElementById("episodeList"); -const streamsPanelTitle = document.getElementById("streamsPanelTitle"); -const episodeBackButton = document.getElementById("episodeBackButton"); -const episodeReloadButton = document.getElementById("episodeReloadButton"); -const episodeStreamsCloseButton = document.getElementById("episodeStreamsCloseButton"); -const episodeStreamFilterList = document.getElementById("episodeStreamFilterList"); -const episodeStreamList = document.getElementById("episodeStreamList"); -const submitIntroModal = document.getElementById("submitIntroModal"); -const submitIntroPanelTitle = document.getElementById("submitIntroPanelTitle"); -const submitIntroCloseButton = document.getElementById("submitIntroCloseButton"); -const segmentTypeLabel = document.getElementById("segmentTypeLabel"); -const segmentIntroButton = document.getElementById("segmentIntroButton"); -const segmentRecapButton = document.getElementById("segmentRecapButton"); -const segmentOutroButton = document.getElementById("segmentOutroButton"); -const startTimeLabel = document.getElementById("startTimeLabel"); -const endTimeLabel = document.getElementById("endTimeLabel"); -const submitIntroStartInput = document.getElementById("submitIntroStartInput"); -const submitIntroEndInput = document.getElementById("submitIntroEndInput"); -const captureStartButton = document.getElementById("captureStartButton"); -const captureEndButton = document.getElementById("captureEndButton"); -const submitIntroStatus = document.getElementById("submitIntroStatus"); -const submitIntroCancelButton = document.getElementById("submitIntroCancelButton"); -const submitIntroSubmitButton = document.getElementById("submitIntroSubmitButton"); -const p2pConsentModal = document.getElementById("p2pConsentModal"); -const p2pConsentTitle = document.getElementById("p2pConsentTitle"); -const p2pConsentCloseButton = document.getElementById("p2pConsentCloseButton"); -const p2pConsentBody = document.getElementById("p2pConsentBody"); -const p2pConsentCancelButton = document.getElementById("p2pConsentCancelButton"); -const p2pConsentEnableButton = document.getElementById("p2pConsentEnableButton"); - -let state = { - title: "", - episodeText: "", - streamTitle: "", - providerName: "", - pauseOverlayWatchingLabel: "You're watching", - pauseOverlayLogo: "", - pauseOverlayEpisodeInfo: "", - pauseOverlayEpisodeTitle: "", - pauseOverlayDescription: "", - resizeModeLabel: "Fit", - playbackSpeedLabel: "1x", - subtitlesLabel: "Subs", - audioLabel: "Audio", - sourcesLabel: "Sources", - episodesLabel: "Episodes", - externalPlayerLabel: "External", - playLabel: "Play", - pauseLabel: "Pause", - closeLabel: "Close player", - lockLabel: "Lock player controls", - unlockLabel: "Unlock player controls", - submitIntroLabel: "Submit Intro", - videoSettingsLabel: "Video settings", - tapToUnlockLabel: "Tap to unlock", - playbackErrorTitle: "Playback error", - playbackErrorMessage: "", - playbackErrorActionLabel: "Go back", - sourcesPanelTitle: "Sources", - episodesPanelTitle: "Episodes", - streamsPanelTitle: "Streams", - allFilterLabel: "All", - reloadLabel: "Reload", - backLabel: "Back", - panelCloseLabel: "Close", - cancelLabel: "Cancel", - playingLabel: "Playing", - noStreamsLabel: "No streams found", - noEpisodesLabel: "No episodes available", - submitIntroPanelTitle: "Submit Timestamps", - submitIntroSegmentTypeLabel: "SEGMENT TYPE", - submitIntroSegmentIntroLabel: "Intro", - submitIntroSegmentRecapLabel: "Recap", - submitIntroSegmentOutroLabel: "Outro", - submitIntroStartTimeLabel: "START TIME (MM:SS)", - submitIntroEndTimeLabel: "END TIME (MM:SS)", - submitIntroCaptureLabel: "Capture", - submitIntroSubmitLabel: "Submit", - p2pConsentTitle: "P2P Streaming", - p2pConsentBody: "", - p2pConsentEnableLabel: "Enable P2P", - p2pConsentCancelLabel: "Cancel", - subtitlesPanelTitle: "Subtitles", - subtitleBuiltInTabLabel: "Built-in", - subtitleAddonsTabLabel: "Addons", - subtitleStyleTabLabel: "Style", - noneLabel: "None", - fetchSubtitlesLabel: "Tap to fetch subtitles", - subtitleDelayLabel: "Subtitle Delay", - resetLabel: "Reset", - autoSyncLabel: "Auto Sync", - reloadSmallLabel: "Reload", - captureLineLabel: "Capture", - selectAddonSubtitleFirstLabel: "Select an addon subtitle first", - loadingSubtitleLinesLabel: "Loading subtitle lines...", - fontSizeLabel: "Font Size", - outlineLabel: "Outline", - boldLabel: "Bold", - bottomOffsetLabel: "Bottom Offset", - colorLabel: "Color", - textOpacityLabel: "Text Opacity", - outlineColorLabel: "Outline Color", - resetDefaultsLabel: "Reset Defaults", - onLabel: "On", - offLabel: "Off", - themeAccentColor: "#2f6fed", - themeAccentStrongColor: "#3c7bff", - themeOnAccentColor: "#fff", - themeFocusColor: "#9ecaff", - themeSelectedSurfaceColor: "#26384f", - themeSelectedSurfaceHoverColor: "#2d4565", - themeSelectedRingColor: "rgba(47, 111, 237, .35)", - themeTimelineFillColor: "#fff", - themeTimelineTrackColor: "rgba(255, 255, 255, .28)", - themeBufferingColor: "#fff", - themeBufferingTrackColor: "rgba(255, 255, 255, .28)", - themeControlForegroundColor: "#fff", - isPlaying: false, - isLoading: true, - isLocked: false, - lockedOverlayVisible: false, - controlsVisible: true, - parentalWarnings: [], - showParentalGuide: false, - showOpeningOverlay: false, - openingArtwork: "", - openingLogo: "", - openingTitle: "", - openingMessage: "", - openingProgress: null, - skipPromptVisible: false, - skipPromptLabel: "Skip", - skipPromptStartMs: 0, - skipPromptEndMs: 0, - skipPromptDismissed: false, - nextEpisodeVisible: false, - nextEpisodeHeaderLabel: "Next episode", - nextEpisodeTitle: "", - nextEpisodeThumbnail: "", - nextEpisodeStatus: "", - nextEpisodeActionLabel: "Play", - nextEpisodePlayable: false, - showSubmitIntro: false, - showVideoSettings: false, - showSources: false, - showEpisodes: false, - showExternalPlayer: false, - durationMs: 0, - positionMs: 0, - audioTracks: [], - subtitleTracks: [], - sourceIsLoading: false, - sourceFilters: [], - sourceItems: [], - episodeItems: [], - episodeSeasons: [], - episodeStreamsVisible: false, - episodeStreamsIsLoading: false, - selectedEpisodeLabel: "", - episodeStreamFilters: [], - episodeStreamItems: [], - submitIntroSegmentType: "intro", - submitIntroStartTime: "00:00", - submitIntroEndTime: "00:00", - isSubmitIntroSubmitting: false, - submitIntroStatusMessage: "", - showP2pConsent: false, - subtitleActiveTab: "BuiltIn", - addonSubtitleItems: [], - isLoadingAddonSubtitles: false, - selectedAddonSubtitleId: "", - useCustomSubtitles: false, - subtitleDelayMs: 0, - hasSelectedAddonSubtitle: false, - subtitleAutoSyncCapturedPositionMs: -1, - subtitleAutoSyncCues: [], - subtitleAutoSyncIsLoading: false, - subtitleAutoSyncErrorMessage: "", - subtitleStyle: { - textColor: "#FFFFFFFF", - outlineColor: "#FF000000", - outlineEnabled: true, - bold: false, - fontSizeSp: 18, - bottomOffset: 20, - }, - subtitleColorSwatches: [], - closeModalsToken: 0, -}; -let isScrubbing = false; -let scrubPositionMs = 0; -let tapTimer = 0; -let activeModal = ""; -let pressedButton = null; -let sourceFilterId = ""; -let sourceVirtualKey = ""; -let sourceVirtualItems = []; -let sourceVirtualHeights = []; -let sourceVirtualOffsets = []; -let sourceVirtualTotalHeight = 0; -let sourceVirtualSpacer = null; -let sourceVirtualRenderRaf = 0; -let selectedEpisodeSeason = null; -let episodeStreamFilterId = ""; -let submitIntroDraft = { - segmentType: "intro", - startTime: "00:00", - endTime: "00:00", - status: "", -}; -let hasReceivedPlayerControls = false; -let parentalGuideRunId = 0; -let parentalGuideStartedKey = ""; -let parentalGuideCompletedKey = ""; -let skipPromptKey = ""; -let skipPromptWasDismissed = false; -let skipPromptAutoHidden = false; -let skipPromptAutoHideTimer = 0; -let skipPromptAutoHideActive = false; -let pauseMetadataReady = false; -let pauseMetadataTimer = 0; -let pauseMetadataEligibilityKey = ""; -let chromeAutoHideTimer = 0; -let chromeAutoHideKey = ""; -let chromeAutoHideActivity = 0; -let chromeInteractionLastNotedAt = 0; -let isChromePointerInside = false; -let isChromePointerDown = false; -let isChromeFocusInside = false; -let nativeViewportTimer = 0; -const prefersReducedMotion = window.matchMedia && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; -const modalTransitionMs = prefersReducedMotion ? 1 : 240; -const chromeAutoHideDelayMs = 3500; -const chromeActivityThrottleMs = 300; -const chromeInteractionSelector = [ - "button", - "input", - "textarea", - "select", - "[contenteditable='true']", - ".header-actions", - ".center-controls", - ".progress", - ".locked-overlay", - ".modal-layer", - ".skip-prompt", - ".next-episode-card", -].join(","); - -const send = (type, value = 0) => { - const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.player; - if (bridge) { - bridge.postMessage({ type, value }); - return; - } - const webViewBridge = window.chrome && window.chrome.webview; - if (webViewBridge) webViewBridge.postMessage({ type, value }); -}; - -const animationDelay = ms => new Promise(resolve => { - window.setTimeout(resolve, prefersReducedMotion ? 1 : ms); -}); - -const normalizedParentalWarnings = () => - Array.isArray(state.parentalWarnings) - ? state.parentalWarnings - .map(warning => ({ - label: String(warning && warning.label || "").trim(), - severity: String(warning && warning.severity || "").trim(), - })) - .filter(warning => warning.label || warning.severity) - .slice(0, 5) - : []; - -const parentalWarningKey = warnings => - warnings.map(warning => `${warning.label}\u0000${warning.severity}`).join("\u0001"); - -const hideParentalGuide = () => { - root.classList.remove("parental-visible", "parental-line-visible"); - parentalGuide.setAttribute("aria-hidden", "true"); - parentalGuideList.querySelectorAll(".parental-guide-row").forEach(row => { - row.classList.remove("visible"); - }); -}; - -const renderParentalGuideRows = warnings => { - parentalGuideList.innerHTML = ""; - const rowHeight = 18; - const rowGap = 2; - const totalHeight = warnings.length > 0 - ? (rowHeight * warnings.length) + (rowGap * (warnings.length - 1)) - : 0; - parentalGuideLine.style.height = `${totalHeight}px`; - - warnings.forEach(warning => { - const row = document.createElement("div"); - row.className = "parental-guide-row"; - - const label = document.createElement("span"); - label.className = "parental-guide-label"; - label.textContent = warning.label; - - const separator = document.createElement("span"); - separator.className = "parental-guide-separator"; - separator.textContent = " · "; - - const severity = document.createElement("span"); - severity.className = "parental-guide-severity"; - severity.textContent = warning.severity; - - row.appendChild(label); - row.appendChild(separator); - row.appendChild(severity); - parentalGuideList.appendChild(row); - }); -}; - -const runParentalGuideAnimation = async (warnings, key, runId) => { - renderParentalGuideRows(warnings); - parentalGuide.setAttribute("aria-hidden", "false"); - root.classList.add("parental-visible"); - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - - root.classList.add("parental-line-visible"); - await animationDelay(400); - if (runId !== parentalGuideRunId) return; - - const rows = Array.from(parentalGuideList.querySelectorAll(".parental-guide-row")); - for (const row of rows) { - await animationDelay(80); - if (runId !== parentalGuideRunId) return; - row.classList.add("visible"); - await animationDelay(200); - if (runId !== parentalGuideRunId) return; - } - - await animationDelay(5000); - if (runId !== parentalGuideRunId) return; - - for (const row of rows.slice().reverse()) { - await animationDelay(60); - if (runId !== parentalGuideRunId) return; - row.classList.remove("visible"); - await animationDelay(150); - if (runId !== parentalGuideRunId) return; - } - - await animationDelay(100); - if (runId !== parentalGuideRunId) return; - root.classList.remove("parental-line-visible"); - - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - - await animationDelay(200); - if (runId !== parentalGuideRunId) return; - root.classList.remove("parental-visible"); - await animationDelay(300); - if (runId !== parentalGuideRunId) return; - parentalGuide.setAttribute("aria-hidden", "true"); - parentalGuideCompletedKey = key; - send("parentalGuideComplete", 0); -}; - -const syncParentalGuide = showOpening => { - const warnings = normalizedParentalWarnings(); - const shouldShow = Boolean(state.showParentalGuide && warnings.length && !showOpening && !state.isLocked); - if (!shouldShow) { - parentalGuideRunId += 1; - parentalGuideStartedKey = ""; - if (!state.showParentalGuide) { - parentalGuideCompletedKey = ""; - } - hideParentalGuide(); - return; - } - - const key = parentalWarningKey(warnings); - if (parentalGuideStartedKey === key || parentalGuideCompletedKey === key) return; - parentalGuideStartedKey = key; - parentalGuideRunId += 1; - runParentalGuideAnimation(warnings, key, parentalGuideRunId); -}; - -const cssColorOrFallback = (value, fallback) => { - const text = String(value || "").trim(); - return /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))$/.test(text) ? text : fallback; -}; - -const applyTheme = () => { - const style = document.documentElement.style; - const setColor = (name, value, fallback) => { - style.setProperty(name, cssColorOrFallback(value, fallback)); - }; - setColor("--theme-accent", state.themeAccentColor, "#2f6fed"); - setColor("--theme-accent-strong", state.themeAccentStrongColor, "#3c7bff"); - setColor("--theme-on-accent", state.themeOnAccentColor, "#fff"); - setColor("--theme-focus", state.themeFocusColor, "#9ecaff"); - setColor("--theme-selected-surface", state.themeSelectedSurfaceColor, "#26384f"); - setColor("--theme-selected-surface-hover", state.themeSelectedSurfaceHoverColor, "#2d4565"); - setColor("--theme-selected-ring", state.themeSelectedRingColor, "rgba(47, 111, 237, .35)"); - setColor("--theme-timeline-fill", state.themeTimelineFillColor, "#fff"); - setColor("--theme-timeline-track", state.themeTimelineTrackColor, "rgba(255, 255, 255, .28)"); - setColor("--theme-buffering", state.themeBufferingColor, "#fff"); - setColor("--theme-buffering-track", state.themeBufferingTrackColor, "rgba(255, 255, 255, .28)"); - setColor("--theme-control-foreground", state.themeControlForegroundColor, "#fff"); -}; - -const formatTime = milliseconds => { - if (!Number.isFinite(milliseconds) || milliseconds < 0) milliseconds = 0; - const total = Math.floor(milliseconds / 1000); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - return h > 0 - ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` - : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; -}; - -const setProgress = (positionMs, durationMs) => { - const percent = durationMs > 0 ? Math.max(0, Math.min(100, positionMs / durationMs * 100)) : 0; - seek.value = Math.round(percent * 10); - seek.style.setProperty("--progress", `${percent}%`); - positionLabel.textContent = formatTime(positionMs); - durationLabel.textContent = formatTime(durationMs); -}; - -const setText = (element, text) => { - element.textContent = text || ""; - element.hidden = !text; -}; - -const setVisible = (element, visible) => { - element.hidden = !visible; -}; - -const setImageVisualState = (element, stateName) => { - const frame = element.parentElement; - [element, frame].filter(Boolean).forEach(target => { - target.classList.remove("image-loading", "image-loaded", "image-error"); - if (stateName) target.classList.add(`image-${stateName}`); - }); -}; - -const setImageSource = (element, source) => { - const url = String(source || "").trim(); - if (!url) { - element.removeAttribute("src"); - element.removeAttribute("data-loaded-src"); - setImageVisualState(element, ""); - return ""; - } - const currentUrl = element.getAttribute("src") || ""; - const loadedUrl = element.getAttribute("data-loaded-src") || ""; - if (currentUrl !== url) { - element.setAttribute("decoding", "async"); - setImageVisualState(element, "loading"); - element.onload = () => { - if (element.getAttribute("src") !== url) return; - element.setAttribute("data-loaded-src", url); - window.requestAnimationFrame(() => setImageVisualState(element, "loaded")); - }; - element.onerror = () => { - if (element.getAttribute("src") !== url) return; - element.removeAttribute("data-loaded-src"); - setImageVisualState(element, "error"); - }; - element.setAttribute("src", url); - if (element.complete && element.naturalWidth > 0) { - element.onload(); - } - } else if (loadedUrl === url) { - setImageVisualState(element, "loaded"); - } - return url; -}; - -const resetPauseMetadataTimer = () => { - window.clearTimeout(pauseMetadataTimer); - pauseMetadataTimer = 0; - pauseMetadataReady = false; - pauseMetadataEligibilityKey = ""; -}; - -const syncPauseMetadataTimer = showOpening => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - const eligible = Boolean(!state.isPlaying && !state.isLoading && durationMs > 0 && !showOpening); - const key = eligible ? `${Math.round(durationMs)}:${state.title || ""}:${state.pauseOverlayEpisodeInfo || ""}` : ""; - if (!eligible) { - resetPauseMetadataTimer(); - return; - } - if (pauseMetadataEligibilityKey === key) return; - window.clearTimeout(pauseMetadataTimer); - pauseMetadataReady = false; - pauseMetadataEligibilityKey = key; - pauseMetadataTimer = window.setTimeout(() => { - pauseMetadataTimer = 0; - pauseMetadataReady = true; - renderChrome(); - }, prefersReducedMotion ? 1 : 5000); -}; - -const renderPauseMetadataOverlay = showOpening => { - syncPauseMetadataTimer(showOpening); - - const logoUrl = setImageSource(pauseLogo, state.pauseOverlayLogo); - const titleText = String(state.title || "").trim(); - const episodeInfo = String(state.pauseOverlayEpisodeInfo || state.providerName || "").trim(); - const episodeTitleText = String(state.pauseOverlayEpisodeTitle || "").trim(); - const descriptionText = String(state.pauseOverlayDescription || "").trim(); - const showOverlay = Boolean( - pauseMetadataReady && - !state.controlsVisible && - !state.isLocked && - !activeModal && - !showOpening, - ); - - pauseWatchingLabel.textContent = state.pauseOverlayWatchingLabel || "You're watching"; - pauseLogo.hidden = !logoUrl; - pauseTitle.textContent = titleText; - pauseTitle.hidden = Boolean(logoUrl || !titleText); - pauseEpisodeInfo.textContent = episodeInfo; - pauseEpisodeInfo.hidden = !episodeInfo; - pauseEpisodeTitle.textContent = episodeTitleText; - pauseEpisodeTitle.hidden = !episodeTitleText; - pauseDescription.textContent = descriptionText; - pauseDescription.hidden = !descriptionText; - pauseMetadataOverlay.classList.toggle("visible", showOverlay); - pauseMetadataOverlay.setAttribute("aria-hidden", showOverlay ? "false" : "true"); -}; - -const normalizedOpeningProgress = () => { - const progress = Number(state.openingProgress); - return Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : null; -}; - -const playbackErrorText = () => String(state.playbackErrorMessage || "").trim(); - -const rangePositionMs = () => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - return durationMs > 0 ? Math.round(durationMs * Number(seek.value) / 1000) : 0; -}; - -const modalByName = { - audio: audioModal, - subtitles: subtitleModal, - sources: sourceModal, - episodes: episodesModal, - submitIntro: submitIntroModal, - p2pConsent: p2pConsentModal, -}; -const modalElements = Object.values(modalByName); -const modalCloseTimers = new Map(); - -const setModalVisibility = (modal, visible, animated = true) => { - const pendingTimer = modalCloseTimers.get(modal); - if (pendingTimer) { - window.clearTimeout(pendingTimer); - modalCloseTimers.delete(modal); - } - if (visible) { - modal.dataset.modalState = "open"; - modal.hidden = false; - modal.classList.remove("modal-closing"); - window.requestAnimationFrame(() => { - if (modal.dataset.modalState === "open") { - modal.classList.add("modal-visible"); - } - }); - return; - } - - modal.dataset.modalState = "closed"; - modal.classList.remove("modal-visible"); - if (!animated || modal.hidden) { - modal.hidden = true; - modal.classList.remove("modal-closing"); - return; - } - - modal.classList.add("modal-closing"); - const timer = window.setTimeout(() => { - modalCloseTimers.delete(modal); - if (modal.dataset.modalState === "closed") { - modal.hidden = true; - modal.classList.remove("modal-closing"); - } - }, modalTransitionMs); - modalCloseTimers.set(modal, timer); -}; - -const closePlayerModal = (notifyDismiss = false, animated = true) => { - const closingModal = activeModal; - activeModal = ""; - modalElements.forEach(modal => { - setModalVisibility(modal, false, animated); - }); - if (notifyDismiss && closingModal === "p2pConsent") { - send("cancelP2pForPlayerControls", 0); - } - renderChrome(); -}; - -const openPlayerModal = modal => { - const targetModal = modalByName[modal]; - if (!targetModal) { - closePlayerModal(false); - return; - } - activeModal = modal; - if (modal === "submitIntro") { - submitIntroDraft = { - segmentType: state.submitIntroSegmentType || "intro", - startTime: state.submitIntroStartTime || "00:00", - endTime: state.submitIntroEndTime || "00:00", - status: "", - }; - } - renderActiveModal(); - modalElements.forEach(modalElement => { - setModalVisibility(modalElement, modalElement === targetModal); - }); - renderChrome(); -}; - -const normalizeTracks = tracks => - Array.isArray(tracks) ? tracks.filter(track => track && typeof track === "object") : []; - -const trackIdValue = track => { - const parsed = Number(track && track.id); - return Number.isFinite(parsed) ? parsed : -1; -}; - -const buildCheckIcon = () => { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("class", "track-check"); - const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); - use.setAttribute("href", "#icon-check"); - svg.appendChild(use); - return svg; -}; - -const appendTrackRow = (container, label, selected, onSelect, closeAfterSelect = true) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row${selected ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - onSelect(); - if (closeAfterSelect) window.setTimeout(closePlayerModal, 120); - }); - - const text = document.createElement("span"); - text.className = "track-label"; - text.textContent = label; - row.appendChild(text); - row.appendChild(buildCheckIcon()); - container.appendChild(row); -}; - -const appendEmptyTrackState = (container, label) => { - const empty = document.createElement("div"); - empty.className = "track-empty"; - empty.textContent = label; - container.appendChild(empty); -}; - -const renderAudioTrackList = () => { - audioTrackList.textContent = ""; - const tracks = normalizeTracks(state.audioTracks); - if (tracks.length === 0) { - appendEmptyTrackState(audioTrackList, "No audio tracks available"); - return; - } - tracks.forEach(track => { - appendTrackRow( - audioTrackList, - track.label || track.language || `Track ${Number(track.index || 0) + 1}`, - Boolean(track.selected), - () => send("selectAudioTrack", trackIdValue(track)), - ); - }); -}; - -const renderSubtitleTrackList = () => { - subtitleTrackList.textContent = ""; - const tracks = normalizeTracks(state.subtitleTracks); - const hasSelected = tracks.some(track => Boolean(track.selected)); - appendTrackRow( - subtitleTrackList, - state.noneLabel || "None", - !hasSelected, - () => send("selectBuiltInSubtitleTrack", -1), - false, - ); - tracks.forEach(track => { - appendTrackRow( - subtitleTrackList, - track.label || track.language || `Subtitle ${Number(track.index || 0) + 1}`, - Boolean(track.selected), - () => send("selectBuiltInSubtitleTrack", Number(track.index) || 0), - false, - ); - }); -}; - -const renderAddonSubtitleList = () => { - addonSubtitleList.textContent = ""; - if (state.isLoadingAddonSubtitles) { - appendEmptyTrackState(addonSubtitleList, "Loading subtitles..."); - return; - } - const items = normalizeItems(state.addonSubtitleItems); - if (items.length === 0) { - const row = document.createElement("button"); - row.type = "button"; - row.className = "track-row"; - row.addEventListener("click", event => { - event.stopPropagation(); - send("fetchAddonSubtitles", 0); - }); - const text = document.createElement("span"); - text.className = "track-label"; - text.textContent = state.fetchSubtitlesLabel || "Tap to fetch subtitles"; - row.appendChild(text); - addonSubtitleList.appendChild(row); - return; - } - items.forEach(item => { - const label = item.display || item.languageLabel || "Subtitle"; - const secondary = [item.languageLabel, item.addonName].filter(Boolean).join(" • "); - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row stream-row${item.isSelected ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - send("selectAddonSubtitle", Number(item.index) || 0); - }); - const copy = document.createElement("span"); - copy.className = "track-copy"; - const name = document.createElement("span"); - name.className = "stream-name"; - name.textContent = label; - copy.appendChild(name); - if (secondary) { - const detail = document.createElement("span"); - detail.className = "stream-addon"; - detail.textContent = secondary; - copy.appendChild(detail); - } - row.appendChild(copy); - row.appendChild(buildCheckIcon()); - addonSubtitleList.appendChild(row); - }); -}; - -const formatDelay = delayMs => { - const value = Number(delayMs) || 0; - const sign = value >= 0 ? "+" : "-"; - const absolute = Math.abs(value); - const seconds = Math.floor(absolute / 1000); - const millis = absolute % 1000; - return `${sign}${seconds}.${String(millis).padStart(3, "0")}s`; -}; - -const parseArgb = value => { - const raw = String(value || "").replace("#", ""); - const hex = raw.length === 8 ? raw : `FF${raw.padStart(6, "0")}`; - const alpha = parseInt(hex.slice(0, 2), 16); - const red = parseInt(hex.slice(2, 4), 16); - const green = parseInt(hex.slice(4, 6), 16); - const blue = parseInt(hex.slice(6, 8), 16); - return { alpha, red, green, blue, css: `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})` }; -}; - -const sameRgb = (left, right) => { - const a = parseArgb(left); - const b = parseArgb(right); - return a.red === b.red && a.green === b.green && a.blue === b.blue; -}; - -const renderSwatches = (container, selectedColor, eventType) => { - container.textContent = ""; - const colors = Array.isArray(state.subtitleColorSwatches) ? state.subtitleColorSwatches : []; - colors.forEach((color, index) => { - const parsed = parseArgb(color); - const swatch = document.createElement("button"); - swatch.type = "button"; - swatch.className = `swatch${parsed.alpha === 0 ? " transparent" : ""}${sameRgb(color, selectedColor) ? " selected" : ""}`; - swatch.style.setProperty("--swatch", parsed.css); - swatch.addEventListener("click", event => { - event.stopPropagation(); - send(eventType, index); - }); - container.appendChild(swatch); - }); -}; - -const renderAutoSyncCues = () => { - autoSyncCueList.textContent = ""; - if (!state.hasSelectedAddonSubtitle) { - autoSyncStatus.textContent = state.selectAddonSubtitleFirstLabel || "Select an addon subtitle first"; - return; - } - if (state.subtitleAutoSyncIsLoading) { - autoSyncStatus.textContent = state.loadingSubtitleLinesLabel || "Loading subtitle lines..."; - } else { - autoSyncStatus.textContent = state.subtitleAutoSyncErrorMessage || ""; - } - normalizeItems(state.subtitleAutoSyncCues).forEach(cue => { - const row = document.createElement("button"); - row.type = "button"; - row.className = "sync-cue"; - row.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncCue", Number(cue.index) || 0); - }); - const time = document.createElement("span"); - time.className = "sync-time"; - time.textContent = cue.timeLabel || ""; - const text = document.createElement("span"); - text.className = "sync-text"; - text.textContent = cue.text || ""; - row.appendChild(time); - row.appendChild(text); - autoSyncCueList.appendChild(row); - }); -}; - -const renderSubtitleStylePanel = () => { - const style = state.subtitleStyle || {}; - subtitleDelayLabel.textContent = state.subtitleDelayLabel || "Subtitle Delay"; - subtitleDelayValue.textContent = formatDelay(state.subtitleDelayMs); - subtitleDelayReset.textContent = state.resetLabel || "Reset"; - autoSyncLabel.textContent = state.autoSyncLabel || "Auto Sync"; - autoSyncReload.textContent = state.reloadSmallLabel || "Reload"; - autoSyncCapture.textContent = state.captureLineLabel || "Capture"; - fontSizeLabel.textContent = state.fontSizeLabel || "Font Size"; - fontSizeValue.textContent = `${Number(style.fontSizeSp) || 18}sp`; - outlineLabel.textContent = state.outlineLabel || "Outline"; - outlineToggle.textContent = style.outlineEnabled ? (state.onLabel || "On") : (state.offLabel || "Off"); - outlineToggle.classList.toggle("primary", Boolean(style.outlineEnabled)); - boldLabel.textContent = state.boldLabel || "Bold"; - boldToggle.textContent = style.bold ? (state.onLabel || "On") : (state.offLabel || "Off"); - boldToggle.classList.toggle("primary", Boolean(style.bold)); - bottomOffsetLabel.textContent = state.bottomOffsetLabel || "Bottom Offset"; - bottomOffsetValue.textContent = String(Number(style.bottomOffset) || 0); - subtitleColorLabel.textContent = state.colorLabel || "Color"; - textOpacityLabel.textContent = state.textOpacityLabel || "Text Opacity"; - const textAlpha = Math.round((parseArgb(style.textColor).alpha / 255) * 100); - textOpacityValue.textContent = `${textAlpha}%`; - outlineColorLabel.textContent = state.outlineColorLabel || "Outline Color"; - subtitleStyleReset.textContent = state.resetDefaultsLabel || "Reset Defaults"; - renderSwatches(subtitleColorSwatches, style.textColor, "subtitleTextColor"); - renderSwatches(outlineColorSwatches, style.outlineColor, "subtitleOutlineColor"); - renderAutoSyncCues(); -}; - -const renderSubtitleModal = () => { - const tab = state.subtitleActiveTab || "BuiltIn"; - subtitlePanelTitle.textContent = state.subtitlesPanelTitle || "Subtitles"; - subtitleBuiltInTab.textContent = state.subtitleBuiltInTabLabel || "Built-in"; - subtitleAddonsTab.textContent = state.subtitleAddonsTabLabel || "Addons"; - subtitleStyleTab.textContent = state.subtitleStyleTabLabel || "Style"; - subtitleBuiltInTab.classList.toggle("selected", tab === "BuiltIn"); - subtitleAddonsTab.classList.toggle("selected", tab === "Addons"); - subtitleStyleTab.classList.toggle("selected", tab === "Style"); - subtitleTrackList.hidden = tab !== "BuiltIn"; - addonSubtitleList.hidden = tab !== "Addons"; - subtitleStylePanel.hidden = tab !== "Style"; - if (tab === "BuiltIn") renderSubtitleTrackList(); - if (tab === "Addons") renderAddonSubtitleList(); - if (tab === "Style") renderSubtitleStylePanel(); -}; - -const normalizeItems = items => - Array.isArray(items) ? items.filter(item => item && typeof item === "object") : []; - -const appendFilterChip = (container, label, selected, onSelect) => { - const chip = document.createElement("button"); - chip.type = "button"; - chip.className = `filter-chip${selected ? " selected" : ""}`; - chip.textContent = label; - chip.addEventListener("click", event => { - event.stopPropagation(); - onSelect(); - }); - container.appendChild(chip); -}; - -const renderFilterRow = (container, filters, selectedId, onSelect) => { - container.textContent = ""; - const list = normalizeItems(filters); - container.hidden = list.length === 0; - list.forEach(filter => { - appendFilterChip( - container, - filter.label || state.allFilterLabel || "All", - String(filter.id || "") === String(selectedId || ""), - () => onSelect(String(filter.id || "")), - ); - }); -}; - -const SourceRowEstimatedHeight = 116; -const SourceRowGap = 10; -const SourceRowOverscanPx = 720; - -const sourceKeyForItems = items => { - const first = items[0] || {}; - const last = items[items.length - 1] || {}; - return [ - sourceFilterId || "", - items.length, - first.index == null ? "" : first.index, - first.label || "", - last.index == null ? "" : last.index, - last.label || "", - ].join("\u0001"); -}; - -const resetSourceVirtualState = (items, key) => { - sourceVirtualKey = key; - sourceVirtualItems = items; - sourceVirtualHeights = new Array(items.length).fill(SourceRowEstimatedHeight); - sourceVirtualOffsets = []; - sourceVirtualTotalHeight = 0; - sourceVirtualSpacer = null; - window.cancelAnimationFrame(sourceVirtualRenderRaf); - sourceVirtualRenderRaf = 0; -}; - -const rebuildSourceVirtualLayout = () => { - let offset = 0; - sourceVirtualOffsets = sourceVirtualItems.map((_, index) => { - const current = offset; - offset += sourceVirtualHeights[index] || SourceRowEstimatedHeight; - if (index < sourceVirtualItems.length - 1) offset += SourceRowGap; - return current; - }); - sourceVirtualTotalHeight = offset; - if (sourceVirtualSpacer) { - sourceVirtualSpacer.style.height = `${sourceVirtualTotalHeight}px`; - } -}; - -const sourceIndexForOffset = offset => { - let low = 0; - let high = sourceVirtualOffsets.length - 1; - let result = 0; - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const rowBottom = sourceVirtualOffsets[mid] + (sourceVirtualHeights[mid] || SourceRowEstimatedHeight); - if (rowBottom < offset) { - low = mid + 1; - } else { - result = mid; - high = mid - 1; - } - } - return result; -}; - -const buildSourceRow = (item, onSelect) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row stream-row source-row${item.isCurrent ? " selected" : ""}${item.isEnabled === false ? " disabled" : ""}`; - row.disabled = item.isEnabled === false; - row.addEventListener("click", event => { - event.stopPropagation(); - onSelect(item); - }); - - const copy = document.createElement("span"); - copy.className = "track-copy"; - - const top = document.createElement("span"); - top.className = "track-row-top"; - const name = document.createElement("span"); - name.className = "stream-name"; - name.textContent = item.label || "Stream"; - top.appendChild(name); - if (item.isCurrent) { - const chip = document.createElement("span"); - chip.className = "status-chip"; - chip.textContent = state.playingLabel || "Playing"; - top.appendChild(chip); - } - copy.appendChild(top); - - if (item.subtitle) { - const subtitle = document.createElement("span"); - subtitle.className = "stream-subtitle"; - subtitle.textContent = item.subtitle; - copy.appendChild(subtitle); - } - - if (item.addonName) { - const addon = document.createElement("span"); - addon.className = "stream-addon"; - addon.textContent = item.addonName; - copy.appendChild(addon); - } - row.appendChild(copy); - return row; -}; - -const renderSourceVirtualRows = () => { - sourceVirtualRenderRaf = 0; - if (!sourceVirtualSpacer) return; - const count = sourceVirtualItems.length; - if (count === 0) return; - - const viewportTop = Math.max(0, sourceList.scrollTop - SourceRowOverscanPx); - const viewportBottom = sourceList.scrollTop + sourceList.clientHeight + SourceRowOverscanPx; - const start = sourceIndexForOffset(viewportTop); - let end = start; - while (end < count && sourceVirtualOffsets[end] <= viewportBottom) { - end += 1; - } - end = Math.min(count, Math.max(end + 1, start + 1)); - - sourceVirtualSpacer.textContent = ""; - const fragment = document.createDocumentFragment(); - const rendered = []; - for (let index = start; index < end; index += 1) { - const item = sourceVirtualItems[index]; - const wrapper = document.createElement("div"); - wrapper.className = "source-virtual-row"; - wrapper.style.transform = `translateY(${sourceVirtualOffsets[index]}px)`; - const row = buildSourceRow(item, selected => { - send("selectSource", Number(selected.index) || 0); - window.setTimeout(closePlayerModal, 120); - }); - wrapper.appendChild(row); - fragment.appendChild(wrapper); - rendered.push({ index, wrapper }); - } - sourceVirtualSpacer.appendChild(fragment); - - window.requestAnimationFrame(() => { - let changed = false; - rendered.forEach(({ index, wrapper }) => { - const measured = Math.ceil(wrapper.getBoundingClientRect().height); - if (measured > 0 && Math.abs((sourceVirtualHeights[index] || SourceRowEstimatedHeight) - measured) > 1) { - sourceVirtualHeights[index] = measured; - changed = true; - } - }); - if (changed) { - rebuildSourceVirtualLayout(); - requestSourceVirtualRender(); - } - }); -}; - -const requestSourceVirtualRender = () => { - if (sourceVirtualRenderRaf) return; - sourceVirtualRenderRaf = window.requestAnimationFrame(renderSourceVirtualRows); -}; - -const renderSourceModal = () => { - sourcePanelTitle.textContent = state.sourcesPanelTitle || "Sources"; - sourceReloadButton.textContent = state.reloadLabel || "Reload"; - sourceCloseButton.textContent = state.panelCloseLabel || "Close"; - - const filters = normalizeItems(state.sourceFilters); - if (sourceFilterId && !filters.some(filter => String(filter.id || "") === sourceFilterId)) { - sourceFilterId = ""; - } - renderFilterRow(sourceFilterList, filters, sourceFilterId, id => { - sourceFilterId = id; - sourceList.scrollTop = 0; - renderSourceModal(); - }); - - sourceList.textContent = ""; - sourceList.classList.remove("virtualized"); - let items = normalizeItems(state.sourceItems); - if (sourceFilterId) { - items = items.filter(item => String(item.filterId || "") === sourceFilterId); - } - if (items.length === 0) { - sourceVirtualItems = []; - sourceVirtualSpacer = null; - window.cancelAnimationFrame(sourceVirtualRenderRaf); - sourceVirtualRenderRaf = 0; - appendEmptyTrackState( - sourceList, - state.sourceIsLoading ? "Loading streams..." : (state.noStreamsLabel || "No streams found"), - ); - return; - } - const nextKey = sourceKeyForItems(items); - if (nextKey !== sourceVirtualKey) { - resetSourceVirtualState(items, nextKey); - sourceList.scrollTop = 0; - } else { - sourceVirtualItems = items; - } - sourceList.classList.add("virtualized"); - sourceVirtualSpacer = document.createElement("div"); - sourceVirtualSpacer.className = "source-virtual-spacer"; - sourceList.appendChild(sourceVirtualSpacer); - rebuildSourceVirtualLayout(); - renderSourceVirtualRows(); -}; - -const appendEpisodeRow = (container, item) => { - const row = document.createElement("button"); - row.type = "button"; - row.className = `track-row episode-row${item.isCurrent ? " selected" : ""}`; - row.addEventListener("click", event => { - event.stopPropagation(); - send("selectEpisode", Number(item.index) || 0); - }); - - if (item.thumbnail) { - const thumb = document.createElement("span"); - thumb.className = "episode-thumb"; - const image = document.createElement("img"); - image.alt = ""; - image.loading = "lazy"; - setImageSource(image, item.thumbnail); - thumb.appendChild(image); - row.appendChild(thumb); - } - - const copy = document.createElement("span"); - copy.className = "episode-copy"; - const top = document.createElement("span"); - top.className = "episode-row-top"; - if (item.code) { - const code = document.createElement("span"); - code.className = "episode-code"; - code.textContent = item.code; - top.appendChild(code); - } - if (item.isCurrent) { - const chip = document.createElement("span"); - chip.className = "status-chip"; - chip.textContent = state.playingLabel || "Playing"; - top.appendChild(chip); - } - copy.appendChild(top); - const name = document.createElement("span"); - name.className = "episode-name"; - name.textContent = item.title || item.code || "Episode"; - copy.appendChild(name); - if (item.overview) { - const overview = document.createElement("span"); - overview.className = "episode-overview"; - overview.textContent = item.overview; - copy.appendChild(overview); - } - row.appendChild(copy); - container.appendChild(row); -}; - -const ensureEpisodeSeason = () => { - const seasons = normalizeItems(state.episodeSeasons); - if (seasons.length === 0) { - selectedEpisodeSeason = null; - return null; - } - if (!seasons.some(season => Number(season.season) === Number(selectedEpisodeSeason))) { - const preferred = seasons.find(season => Boolean(season.isSelected)) || seasons[0]; - selectedEpisodeSeason = Number(preferred.season) || 0; - } - return selectedEpisodeSeason; -}; - -const renderEpisodeList = () => { - episodesPanelTitle.textContent = state.episodesPanelTitle || "Episodes"; - episodesCloseButton.textContent = state.panelCloseLabel || "Close"; - - const selectedSeason = ensureEpisodeSeason(); - const seasons = normalizeItems(state.episodeSeasons); - renderFilterRow( - seasonFilterList, - seasons.map(season => ({ id: String(season.season), label: season.label })), - selectedSeason == null ? "" : String(selectedSeason), - id => { - selectedEpisodeSeason = Number(id); - renderEpisodeList(); - }, - ); - - episodeList.textContent = ""; - let items = normalizeItems(state.episodeItems); - if (selectedSeason != null) { - items = items.filter(item => Number(item.season) === Number(selectedSeason)); - } - if (items.length === 0) { - appendEmptyTrackState(episodeList, state.noEpisodesLabel || "No episodes available"); - return; - } - items.forEach(item => appendEpisodeRow(episodeList, item)); -}; - -const renderEpisodeStreams = () => { - streamsPanelTitle.textContent = state.selectedEpisodeLabel || state.streamsPanelTitle || "Streams"; - episodeBackButton.textContent = state.backLabel || "Back"; - episodeReloadButton.textContent = state.reloadLabel || "Reload"; - episodeStreamsCloseButton.textContent = state.panelCloseLabel || "Close"; - - const filters = normalizeItems(state.episodeStreamFilters); - if (episodeStreamFilterId && !filters.some(filter => String(filter.id || "") === episodeStreamFilterId)) { - episodeStreamFilterId = ""; - } - renderFilterRow(episodeStreamFilterList, filters, episodeStreamFilterId, id => { - episodeStreamFilterId = id; - renderEpisodeStreams(); - }); - - episodeStreamList.textContent = ""; - let items = normalizeItems(state.episodeStreamItems); - if (episodeStreamFilterId) { - items = items.filter(item => String(item.filterId || "") === episodeStreamFilterId); - } - if (items.length === 0) { - appendEmptyTrackState( - episodeStreamList, - state.episodeStreamsIsLoading ? "Loading streams..." : (state.noStreamsLabel || "No streams found"), - ); - return; - } - items.forEach(item => appendSourceRow(episodeStreamList, item, selected => { - send("selectEpisodeStream", Number(selected.index) || 0); - window.setTimeout(closePlayerModal, 120); - })); -}; - -const renderEpisodesModal = () => { - const showStreams = Boolean(state.episodeStreamsVisible); - episodeListView.hidden = showStreams; - episodeStreamsView.hidden = !showStreams; - if (showStreams) { - renderEpisodeStreams(); - } else { - renderEpisodeList(); - } -}; - -const setInputValue = (input, value) => { - if (document.activeElement !== input && input.value !== value) { - input.value = value; - } -}; - -const renderSubmitIntroModal = () => { - submitIntroPanelTitle.textContent = state.submitIntroPanelTitle || "Submit Timestamps"; - submitIntroCloseButton.textContent = state.panelCloseLabel || "Close"; - segmentTypeLabel.textContent = state.submitIntroSegmentTypeLabel || "SEGMENT TYPE"; - segmentIntroButton.textContent = state.submitIntroSegmentIntroLabel || "Intro"; - segmentRecapButton.textContent = state.submitIntroSegmentRecapLabel || "Recap"; - segmentOutroButton.textContent = state.submitIntroSegmentOutroLabel || "Outro"; - startTimeLabel.textContent = state.submitIntroStartTimeLabel || "START TIME (MM:SS)"; - endTimeLabel.textContent = state.submitIntroEndTimeLabel || "END TIME (MM:SS)"; - captureStartButton.textContent = state.submitIntroCaptureLabel || "Capture"; - captureEndButton.textContent = state.submitIntroCaptureLabel || "Capture"; - submitIntroCancelButton.textContent = state.cancelLabel || "Cancel"; - submitIntroSubmitButton.textContent = state.isSubmitIntroSubmitting - ? `${state.submitIntroSubmitLabel || "Submit"}...` - : (state.submitIntroSubmitLabel || "Submit"); - submitIntroSubmitButton.disabled = Boolean(state.isSubmitIntroSubmitting); - - [segmentIntroButton, segmentRecapButton, segmentOutroButton].forEach(button => { - button.classList.toggle("selected", button.dataset.segment === submitIntroDraft.segmentType); - }); - setInputValue(submitIntroStartInput, submitIntroDraft.startTime); - setInputValue(submitIntroEndInput, submitIntroDraft.endTime); - submitIntroStatus.textContent = submitIntroDraft.status || state.submitIntroStatusMessage || ""; -}; - -const renderP2pConsentModal = () => { - p2pConsentTitle.textContent = state.p2pConsentTitle || "P2P Streaming"; - p2pConsentBody.textContent = state.p2pConsentBody || ""; - p2pConsentCloseButton.textContent = state.p2pConsentCancelLabel || "Cancel"; - p2pConsentCancelButton.textContent = state.p2pConsentCancelLabel || "Cancel"; - p2pConsentEnableButton.textContent = state.p2pConsentEnableLabel || "Enable P2P"; -}; - -const renderActiveModal = () => { - if (activeModal === "audio") renderAudioTrackList(); - if (activeModal === "subtitles") renderSubtitleModal(); - if (activeModal === "sources") renderSourceModal(); - if (activeModal === "episodes") renderEpisodesModal(); - if (activeModal === "submitIntro") renderSubmitIntroModal(); - if (activeModal === "p2pConsent") renderP2pConsentModal(); -}; - -window.nuvioNativeViewportChanged = () => { - root.classList.add("native-resizing"); - window.clearTimeout(nativeViewportTimer); - nativeViewportTimer = window.setTimeout(() => { - root.classList.remove("native-resizing"); - }, 180); - if (activeModal) renderActiveModal(); -}; - -const trackListSignature = tracks => - normalizeTracks(tracks) - .map(track => [ - track.id == null ? "" : String(track.id), - track.index == null ? "" : String(track.index), - track.label == null ? "" : String(track.label), - track.language == null ? "" : String(track.language), - Boolean(track.selected) ? "1" : "0", - ].join(":")) - .join("|"); - -const renderOpeningOverlay = suppress => { - const progress = normalizedOpeningProgress(); - const artworkUrl = setImageSource(openingArtwork, state.openingArtwork); - const logoUrl = setImageSource(openingLogoBase, state.openingLogo); - setImageSource(openingLogoFill, state.openingLogo); - - const hasProgress = progress !== null; - const openingBootstrap = !hasReceivedPlayerControls; - const wantsOpening = Boolean(openingBootstrap || state.showOpeningOverlay); - const showOpening = Boolean(!suppress && wantsOpening && state.isLoading); - const titleText = String(state.openingTitle || state.title || "").trim(); - const messageText = String(state.openingMessage || "").trim(); - const showHorizontalProgress = hasProgress && !logoUrl; - - root.classList.toggle("opening-active", showOpening); - openingOverlay.classList.toggle("visible", showOpening); - openingOverlay.classList.toggle("has-artwork", Boolean(artworkUrl)); - openingOverlay.classList.toggle("has-progress", hasProgress); - openingOverlay.setAttribute("aria-hidden", showOpening ? "false" : "true"); - openingBackButton.setAttribute("aria-label", state.closeLabel || "Close player"); - - openingLogoSlot.hidden = !logoUrl; - openingLogoFillClip.style.width = `${(progress || 0) * 100}%`; - - openingTitle.textContent = titleText; - openingTitle.hidden = Boolean(logoUrl || !titleText); - openingSpinner.hidden = Boolean(logoUrl || titleText); - - openingMessage.textContent = messageText; - openingStatus.hidden = !(messageText || showHorizontalProgress); - openingProgressTrack.hidden = !showHorizontalProgress; - openingProgressBar.style.width = `${(progress || 0) * 100}%`; - - return showOpening; -}; - -const renderPlaybackError = () => { - const messageText = playbackErrorText(); - const showError = Boolean(messageText); - const titleText = String(state.playbackErrorTitle || "Playback error").trim(); - const actionText = String(state.playbackErrorActionLabel || "Go back").trim(); - - root.classList.toggle("error-active", showError); - playbackError.classList.toggle("visible", showError); - playbackError.setAttribute("aria-hidden", showError ? "false" : "true"); - playbackError.setAttribute("aria-label", titleText || "Playback error"); - playbackErrorTitle.textContent = titleText || "Playback error"; - playbackErrorMessage.textContent = messageText; - playbackErrorActionLabel.textContent = actionText || "Go back"; - playbackErrorAction.setAttribute("aria-label", actionText || "Go back"); - - return showError; -}; - -const resetSkipPromptAutoHide = () => { - window.clearTimeout(skipPromptAutoHideTimer); - skipPromptAutoHideTimer = 0; - skipPromptAutoHideActive = false; - skipPromptAutoHidden = false; - skipPromptProgress.style.transition = "none"; - skipPromptProgress.style.width = "0%"; -}; - -const startSkipPromptAutoHide = () => { - if (skipPromptAutoHideActive || skipPromptAutoHidden) return; - skipPromptAutoHideActive = true; - skipPromptProgress.style.transition = "none"; - skipPromptProgress.style.width = "0%"; - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - skipPromptProgress.style.transition = `width ${prefersReducedMotion ? 1 : 10000}ms linear`; - skipPromptProgress.style.width = "100%"; - }); - }); - skipPromptAutoHideTimer = window.setTimeout(() => { - skipPromptAutoHideActive = false; - skipPromptAutoHidden = true; - renderNativePlaybackPrompts(); - }, prefersReducedMotion ? 1 : 10000); -}; - -const renderNativePlaybackPrompts = () => { - const nextSkipKey = [ - state.skipPromptStartMs || 0, - state.skipPromptEndMs || 0, - state.skipPromptLabel || "", - ].join(":"); - if ( - nextSkipKey !== skipPromptKey || - !state.skipPromptVisible || - (skipPromptWasDismissed && !state.skipPromptDismissed) - ) { - skipPromptKey = nextSkipKey; - resetSkipPromptAutoHide(); - } - skipPromptWasDismissed = Boolean(state.skipPromptDismissed); - - const shouldShowSkip = Boolean(state.skipPromptVisible && (!state.skipPromptDismissed || state.controlsVisible)); - const showSkip = Boolean(shouldShowSkip && (!skipPromptAutoHidden || state.controlsVisible)); - const showSkipProgress = Boolean(showSkip && !state.controlsVisible && !skipPromptAutoHidden && !state.skipPromptDismissed); - skipPromptLabel.textContent = state.skipPromptLabel || "Skip"; - skipPrompt.setAttribute("aria-label", state.skipPromptLabel || "Skip"); - skipPrompt.setAttribute("aria-hidden", showSkip ? "false" : "true"); - skipPrompt.classList.toggle("visible", showSkip); - skipPrompt.classList.toggle("show-progress", showSkipProgress); - if (showSkipProgress) { - startSkipPromptAutoHide(); - } else if (!showSkip || state.controlsVisible || state.skipPromptDismissed) { - window.clearTimeout(skipPromptAutoHideTimer); - skipPromptAutoHideTimer = 0; - skipPromptAutoHideActive = false; - } - - const showNextEpisode = Boolean(state.nextEpisodeVisible); - const nextThumbUrl = setImageSource(nextEpisodeThumb, state.nextEpisodeThumbnail); - nextEpisodeHeader.textContent = state.nextEpisodeHeaderLabel || "Next episode"; - nextEpisodeTitle.textContent = state.nextEpisodeTitle || ""; - nextEpisodeStatus.textContent = state.nextEpisodeStatus || ""; - nextEpisodeStatus.hidden = !state.nextEpisodeStatus; - nextEpisodeAction.textContent = state.nextEpisodeActionLabel || "Play"; - nextEpisodeCard.setAttribute("aria-hidden", showNextEpisode ? "false" : "true"); - nextEpisodeCard.classList.toggle("visible", showNextEpisode); - nextEpisodeCard.classList.toggle("playable", Boolean(state.nextEpisodePlayable)); - nextEpisodeCard.classList.toggle("has-thumb", Boolean(nextThumbUrl)); -}; - -const isOpeningOverlayActive = () => - Boolean((!hasReceivedPlayerControls || state.showOpeningOverlay) && state.isLoading); - -const isChromeInteractionTarget = target => - Boolean(target && target.closest && target.closest(chromeInteractionSelector)); - -const isInteractingWithChrome = () => - Boolean(isChromePointerInside || isChromePointerDown || isChromeFocusInside); - -const canAutoHideChrome = showOpening => Boolean( - state.controlsVisible && - state.isPlaying && - !state.isLoading && - !state.isLocked && - !activeModal && - !isScrubbing && - !isInteractingWithChrome() && - !playbackErrorText() && - !showOpening, -); - -const currentChromeAutoHideKey = showOpening => { - if (!canAutoHideChrome(showOpening)) return ""; - return [ - chromeAutoHideActivity, - state.controlsVisible ? "visible" : "hidden", - state.isPlaying ? "playing" : "paused", - state.isLoading ? "loading" : "ready", - state.isLocked ? "locked" : "unlocked", - activeModal || "none", - isScrubbing ? "scrubbing" : "idle", - isInteractingWithChrome() ? "interacting" : "idle-controls", - showOpening ? "opening" : "ready", - ].join(":"); -}; - -const clearChromeAutoHideTimer = () => { - window.clearTimeout(chromeAutoHideTimer); - chromeAutoHideTimer = 0; - chromeAutoHideKey = ""; -}; - -const hideChromeFromAutoTimer = () => { - if (!canAutoHideChrome(isOpeningOverlayActive())) return; - state = { ...state, controlsVisible: false }; - renderChrome(); - send("hideChrome", 0); -}; - -const syncChromeAutoHideTimer = showOpening => { - const key = currentChromeAutoHideKey(showOpening); - if (!key) { - clearChromeAutoHideTimer(); - return; - } - if (chromeAutoHideKey === key) return; - - window.clearTimeout(chromeAutoHideTimer); - chromeAutoHideKey = key; - chromeAutoHideTimer = window.setTimeout(() => { - chromeAutoHideTimer = 0; - if (currentChromeAutoHideKey(isOpeningOverlayActive()) !== key) return; - chromeAutoHideKey = ""; - hideChromeFromAutoTimer(); - }, chromeAutoHideDelayMs); -}; - -const noteChromeActivity = (force = false) => { - if (!state.controlsVisible || state.isLocked) return; - const now = window.performance ? window.performance.now() : Date.now(); - if (!force && now - chromeInteractionLastNotedAt < chromeActivityThrottleMs) { - syncChromeAutoHideTimer(isOpeningOverlayActive()); - return; - } - chromeInteractionLastNotedAt = now; - chromeAutoHideActivity += 1; - syncChromeAutoHideTimer(isOpeningOverlayActive()); -}; - -const updateChromePointerInside = inside => { - if (isChromePointerInside === inside) return; - isChromePointerInside = inside; - noteChromeActivity(true); -}; - -const finishChromePointerInteraction = event => { - isChromePointerDown = false; - if (event && event.type !== "pointercancel") { - isChromePointerInside = isChromeInteractionTarget(event.target); - } else { - isChromePointerInside = false; - } - clearPressedButton(); - noteChromeActivity(true); -}; - -const renderChrome = () => { - const durationMs = Math.max(0, Number(state.durationMs) || 0); - const positionMs = isScrubbing ? scrubPositionMs : Math.max(0, Number(state.positionMs) || 0); - const isPlaying = Boolean(state.isPlaying); - const showError = renderPlaybackError(); - root.classList.toggle("locked", Boolean(state.isLocked)); - root.classList.toggle("locked-visible", Boolean(state.isLocked && state.lockedOverlayVisible)); - root.classList.toggle("chrome-hidden", Boolean(showError || (!state.controlsVisible && !(state.isLocked && state.lockedOverlayVisible)))); - root.classList.toggle("source-visible", Boolean(!showError && !isPlaying && !state.isLoading && (state.streamTitle || state.providerName))); - const showOpening = renderOpeningOverlay(showError); - renderPauseMetadataOverlay(showOpening || showError); - syncParentalGuide(showOpening || showError); - - title.textContent = state.title || ""; - setText(episode, state.episodeText); - setText(streamTitle, state.streamTitle); - setText(providerName, state.providerName); - resizeLabel.textContent = state.resizeModeLabel || "Fit"; - speedLabel.textContent = state.playbackSpeedLabel || "1x"; - subtitlesLabel.textContent = state.subtitlesLabel || "Subs"; - audioLabel.textContent = state.audioLabel || "Audio"; - sourcesLabel.textContent = state.sourcesLabel || "Sources"; - episodesLabel.textContent = state.episodesLabel || "Episodes"; - lockedLabel.textContent = state.tapToUnlockLabel || "Tap to unlock"; - const showBuffering = Boolean(!showError && state.isLoading && !state.isLocked && !activeModal && !showOpening); - bufferingStatus.classList.toggle("visible", showBuffering); - bufferingStatus.setAttribute("aria-hidden", showBuffering ? "false" : "true"); - - setVisible(submitIntroButton, Boolean(state.showSubmitIntro)); - setVisible(videoSettingsButton, Boolean(state.showVideoSettings)); - setVisible(sourcesButton, Boolean(state.showSources)); - setVisible(episodesButton, Boolean(state.showEpisodes)); - - const playPauseLabel = isPlaying ? state.pauseLabel : state.playLabel; - if (toggle) { - toggle.setAttribute("aria-label", playPauseLabel || (isPlaying ? "Pause" : "Play")); - } - if (toggleIcon) { - toggleIcon.setAttribute("href", isPlaying ? "#icon-pause" : "#icon-play"); - } - lockButton.setAttribute("aria-label", state.isLocked ? state.unlockLabel : state.lockLabel); - lockIcon.setAttribute("href", state.isLocked ? "#icon-lock-open" : "#icon-lock"); - backButton.setAttribute("aria-label", state.closeLabel || "Close player"); - submitIntroButton.setAttribute("aria-label", state.submitIntroLabel || "Submit Intro"); - videoSettingsButton.setAttribute("aria-label", state.videoSettingsLabel || "Video settings"); - seek.disabled = Boolean(state.isLocked); - setProgress(positionMs, durationMs); - if (showError) { - skipPrompt.classList.remove("visible", "show-progress"); - skipPrompt.setAttribute("aria-hidden", "true"); - nextEpisodeCard.classList.remove("visible"); - nextEpisodeCard.setAttribute("aria-hidden", "true"); - } else { - renderNativePlaybackPrompts(); - } - syncChromeAutoHideTimer(showOpening); -}; - -const render = () => { - applyTheme(); - renderChrome(); - renderActiveModal(); -}; - -const focusShortcutRoot = () => { - if (document.activeElement !== root) { - root.focus({ preventScroll: true }); - } -}; - -const isTextEntryTarget = target => { - const element = target && target.closest && target.closest("input, textarea, select, [contenteditable='true']"); - return Boolean(element); -}; - -const shortcutCommandForEvent = event => { - if (event.metaKey || event.ctrlKey || event.altKey) return ""; - switch (event.code) { - case "Space": - case "KeyK": - return "keyboardToggle"; - case "ArrowLeft": - case "KeyJ": - return "keyboardSeekBack"; - case "ArrowRight": - case "KeyL": - return "keyboardSeekForward"; - default: - return ""; - } -}; - -const toggleChrome = () => { - if (playbackErrorText()) return; - if (state.isLocked) { - send("revealLockedOverlay", 0); - return; - } - const nextControlsVisible = !state.controlsVisible; - if (nextControlsVisible) { - chromeAutoHideActivity += 1; - } else { - clearChromeAutoHideTimer(); - } - state = { ...state, controlsVisible: nextControlsVisible }; - renderChrome(); - send("toggleChrome", 0); -}; - -const clearPressedButton = () => { - if (!pressedButton) return; - pressedButton.classList.remove("is-pressed"); - pressedButton = null; -}; - -document.addEventListener("pointerdown", event => { - const interactingWithChrome = isChromeInteractionTarget(event.target); - if (interactingWithChrome) { - isChromePointerDown = true; - isChromePointerInside = true; - noteChromeActivity(true); - } - if (!isTextEntryTarget(event.target)) { - focusShortcutRoot(); - if (!interactingWithChrome) { - noteChromeActivity(true); - } - } - const button = event.target.closest("button"); - if (!button || button.disabled) return; - clearPressedButton(); - pressedButton = button; - button.classList.add("is-pressed"); -}, true); - -document.addEventListener("pointermove", event => { - const inside = isChromeInteractionTarget(event.target); - updateChromePointerInside(inside); - if (inside) { - noteChromeActivity(); - } -}, true); - -document.addEventListener("pointerup", finishChromePointerInteraction, true); -document.addEventListener("pointercancel", finishChromePointerInteraction, true); -document.addEventListener("dragend", clearPressedButton, true); -document.addEventListener("pointerleave", () => { - updateChromePointerInside(false); -}, true); -document.addEventListener("focusin", event => { - isChromeFocusInside = isChromeInteractionTarget(event.target); - if (isChromeFocusInside) { - noteChromeActivity(true); - } -}, true); -document.addEventListener("focusout", () => { - window.setTimeout(() => { - isChromeFocusInside = isChromeInteractionTarget(document.activeElement); - noteChromeActivity(true); - }, 0); -}, true); -window.addEventListener("blur", () => { - isChromePointerInside = false; - isChromePointerDown = false; - isChromeFocusInside = false; - clearPressedButton(); - syncChromeAutoHideTimer(isOpeningOverlayActive()); -}); - -document.querySelectorAll("[data-command]").forEach(button => { - button.addEventListener("click", event => { - event.stopPropagation(); - noteChromeActivity(true); - const command = button.dataset.command; - if (command === "audio") { - openPlayerModal("audio"); - return; - } - if (command === "subtitles") { - openPlayerModal("subtitles"); - return; - } - if (command === "sources") { - sourceFilterId = ""; - openPlayerModal("sources"); - send("sources", 0); - return; - } - if (command === "episodes") { - episodeStreamFilterId = ""; - openPlayerModal("episodes"); - send("episodes", 0); - return; - } - if (command === "submitIntro") { - openPlayerModal("submitIntro"); - return; - } - send(command, 0); - }); -}); - -openingOverlay.addEventListener("click", event => { - event.stopPropagation(); - if (!event.target.closest("button,input")) { - toggleChrome(); - } -}); - -modalElements.forEach(modal => { - modal.addEventListener("click", event => { - event.stopPropagation(); - if (event.target === modal) closePlayerModal(true); - }); -}); - -const selectSubtitleTab = (tab, index) => { - state = { ...state, subtitleActiveTab: tab }; - renderSubtitleModal(); - send("subtitleTab", index); -}; - -subtitleBuiltInTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("BuiltIn", 0); -}); -subtitleAddonsTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("Addons", 1); -}); -subtitleStyleTab.addEventListener("click", event => { - event.stopPropagation(); - selectSubtitleTab("Style", 2); -}); -subtitleDelayMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayDelta", -100); -}); -subtitleDelayPlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayDelta", 100); -}); -subtitleDelayReset.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleDelayReset", 0); -}); -autoSyncReload.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncReload", 0); -}); -autoSyncCapture.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleAutoSyncCapture", 0); -}); -fontSizeMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleFontSizeDelta", -2); -}); -fontSizePlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleFontSizeDelta", 2); -}); -outlineToggle.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleOutlineToggle", 0); -}); -boldToggle.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBoldToggle", 0); -}); -bottomOffsetMinus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBottomOffsetDelta", -5); -}); -bottomOffsetPlus.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleBottomOffsetDelta", 5); -}); -textOpacityMinus.addEventListener("click", event => { - event.stopPropagation(); - const style = state.subtitleStyle || {}; - const next = Math.max(0, Math.round((parseArgb(style.textColor).alpha / 255) * 100) - 10); - send("subtitleTextOpacity", next); -}); -textOpacityPlus.addEventListener("click", event => { - event.stopPropagation(); - const style = state.subtitleStyle || {}; - const next = Math.min(100, Math.round((parseArgb(style.textColor).alpha / 255) * 100) + 10); - send("subtitleTextOpacity", next); -}); -subtitleStyleReset.addEventListener("click", event => { - event.stopPropagation(); - send("subtitleStyleReset", 0); -}); - -sourceReloadButton.addEventListener("click", event => { - event.stopPropagation(); - send("reloadSources", 0); -}); -sourceCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -sourceList.addEventListener("scroll", () => { - if (activeModal === "sources") { - requestSourceVirtualRender(); - } -}, { passive: true }); -episodesCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -episodeStreamsCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -episodeBackButton.addEventListener("click", event => { - event.stopPropagation(); - episodeStreamFilterId = ""; - send("backToEpisodes", 0); -}); -episodeReloadButton.addEventListener("click", event => { - event.stopPropagation(); - send("reloadEpisodeStreams", 0); -}); - -const updateSubmitSegment = segment => { - submitIntroDraft.segmentType = segment; - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}; - -[segmentIntroButton, segmentRecapButton, segmentOutroButton].forEach(button => { - button.addEventListener("click", event => { - event.stopPropagation(); - updateSubmitSegment(button.dataset.segment || "intro"); - }); -}); - -const currentTimeText = () => formatTime(isScrubbing ? scrubPositionMs : state.positionMs); - -captureStartButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.startTime = currentTimeText(); - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}); -captureEndButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.endTime = currentTimeText(); - submitIntroDraft.status = ""; - renderSubmitIntroModal(); -}); - -submitIntroCloseButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); -submitIntroCancelButton.addEventListener("click", event => { - event.stopPropagation(); - closePlayerModal(); -}); - -const parseIntroTime = raw => { - const value = String(raw || "").trim(); - if (!value) return null; - const separator = value.includes(":") ? ":" : (value.includes(".") ? "." : ""); - if (separator) { - const parts = value.split(separator); - if (parts.length !== 2) return null; - const minutes = Number(parts[0]); - const seconds = Number(parts[1]); - if (!Number.isFinite(minutes) || !Number.isFinite(seconds) || seconds < 0 || seconds >= 60) return null; - return minutes * 60 + seconds; - } - const seconds = Number(value); - return Number.isFinite(seconds) && seconds >= 0 ? seconds : null; -}; - -submitIntroSubmitButton.addEventListener("click", event => { - event.stopPropagation(); - submitIntroDraft.startTime = submitIntroStartInput.value; - submitIntroDraft.endTime = submitIntroEndInput.value; - const start = parseIntroTime(submitIntroDraft.startTime); - const end = parseIntroTime(submitIntroDraft.endTime); - if (start == null || end == null || end <= start) { - submitIntroDraft.status = "Check the start and end times."; - renderSubmitIntroModal(); - return; - } - const segmentIndex = submitIntroDraft.segmentType === "recap" ? 1 : (submitIntroDraft.segmentType === "outro" ? 2 : 0); - submitIntroDraft.status = ""; - send("submitIntroSegment", segmentIndex); - send("submitIntroStart", start); - send("submitIntroEnd", end); - send("submitIntroCommit", 0); -}); - -const cancelP2pConsent = () => { - send("cancelP2pForPlayerControls", 0); - closePlayerModal(); -}; - -p2pConsentCloseButton.addEventListener("click", event => { - event.stopPropagation(); - cancelP2pConsent(); -}); -p2pConsentCancelButton.addEventListener("click", event => { - event.stopPropagation(); - cancelP2pConsent(); -}); -p2pConsentEnableButton.addEventListener("click", event => { - event.stopPropagation(); - send("enableP2pForPlayerControls", 0); -}); - -skipPrompt.addEventListener("click", event => { - event.stopPropagation(); - send("skipInterval", 0); -}); - -nextEpisodeCard.addEventListener("click", event => { - event.stopPropagation(); - if (state.nextEpisodePlayable) { - send("playNextEpisode", 0); - } -}); - -seek.addEventListener("input", () => { - noteChromeActivity(); - isScrubbing = true; - scrubPositionMs = rangePositionMs(); - setProgress(scrubPositionMs, state.durationMs); - send("scrubChange", scrubPositionMs); -}); - -seek.addEventListener("change", () => { - noteChromeActivity(); - scrubPositionMs = rangePositionMs(); - isScrubbing = false; - send("scrubFinish", scrubPositionMs); - state.positionMs = scrubPositionMs; - render(); -}); - -window.playerUpdate = update => { - const durationMs = Math.round((Number(update.duration) || 0) * 1000); - const positionMs = Math.round((Number(update.position) || 0) * 1000); - const audioTracks = normalizeTracks(update.audioTracks); - const subtitleTracks = normalizeTracks(update.subtitleTracks); - const audioTracksChanged = trackListSignature(audioTracks) !== trackListSignature(state.audioTracks); - const subtitleTracksChanged = trackListSignature(subtitleTracks) !== trackListSignature(state.subtitleTracks); - state = { - ...state, - durationMs, - positionMs, - isPlaying: !Boolean(update.paused), - isLoading: Boolean(update.loading || update.isLoading), - audioTracks, - subtitleTracks, - }; - renderChrome(); - if ((audioTracksChanged && activeModal === "audio") || - (subtitleTracksChanged && activeModal === "subtitles")) { - renderActiveModal(); - } -}; - -window.playerControls = nextState => { - const previousCloseToken = Number(state.closeModalsToken) || 0; - state = { ...state, ...nextState }; - hasReceivedPlayerControls = true; - const closeToken = Number(state.closeModalsToken) || 0; - if (closeToken !== previousCloseToken) { - closePlayerModal(); - } - if (state.showP2pConsent && activeModal !== "p2pConsent") { - openPlayerModal("p2pConsent"); - } else if (!state.showP2pConsent && activeModal === "p2pConsent") { - closePlayerModal(); - } - render(); -}; - -root.addEventListener("click", event => { - if (playbackErrorText()) return; - if (event.target.closest("button,input")) return; - window.clearTimeout(tapTimer); - tapTimer = window.setTimeout(() => { - toggleChrome(); - }, 220); -}); - -root.addEventListener("dblclick", event => { - if (playbackErrorText()) return; - if (event.target.closest("button,input")) return; - event.preventDefault(); - window.clearTimeout(tapTimer); - send("toggleFullscreen", 0); -}); - -document.addEventListener("keydown", event => { - if (event.key === "Escape" && playbackErrorText()) { - event.preventDefault(); - send("back", 0); - return; - } - if (event.key === "Escape" && activeModal) { - event.preventDefault(); - closePlayerModal(true); - focusShortcutRoot(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - send("back", 0); - return; - } - if (playbackErrorText()) return; - const isMacFullscreenShortcut = event.code === "KeyF" && event.metaKey && event.ctrlKey && !event.altKey; - if (event.code === "F11" || isMacFullscreenShortcut) { - event.preventDefault(); - focusShortcutRoot(); - send("toggleFullscreen", 0); - return; - } - if (activeModal || isTextEntryTarget(event.target)) { - return; - } - const command = shortcutCommandForEvent(event); - if (!command) { - return; - } - event.preventDefault(); - focusShortcutRoot(); - noteChromeActivity(); - send(command, 0); -}); - -setProgress(0, 0); -focusShortcutRoot(); -render(); -send("controlsReady", 0); diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt deleted file mode 100644 index ecf9739cf..000000000 --- a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nuvio.app.features.plugins - -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertEquals - -class PluginRuntimeDesktopTest { - @Test - fun `desktop runtime executes scraper code`() = runBlocking { - val results = PluginRuntime.executePlugin( - code = """ - module.exports.getStreams = async function(tmdbId, mediaType) { - return [{ - title: "Desktop stream " + tmdbId + " " + mediaType, - url: "https://example.test/movie.mp4", - quality: "1080p", - provider: "Desktop Test" - }]; - }; - """.trimIndent(), - tmdbId = "603", - mediaType = "movie", - season = null, - episode = null, - scraperId = "desktop-runtime-test", - ) - - assertEquals(1, results.size) - assertEquals("Desktop stream 603 movie", results.single().title) - assertEquals("https://example.test/movie.mp4", results.single().url) - assertEquals("1080p", results.single().quality) - assertEquals("Desktop Test", results.single().provider) - } -} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index da5a0e8fe..88439c511 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -413,11 +413,11 @@ actual object PluginRepository { } private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean { - val platformTags = currentPluginPlatformTags().map { it.lowercase() }.toSet() + val platform = currentPluginPlatform().lowercase() val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty() val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty() - if (supported.isNotEmpty() && platformTags.none { it in supported }) return false - if (platformTags.any { it in disabled }) return false + if (supported.isNotEmpty() && platform !in supported) return false + if (platform in disabled) return false return true } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 70588602e..5f7b3114f 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -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 = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt deleted file mode 100644 index 1d181ca62..000000000 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt +++ /dev/null @@ -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 -} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index 6f46e9e9f..670f8092a 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -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 = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = false diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt deleted file mode 100644 index 1d181ca62..000000000 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt +++ /dev/null @@ -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 -} diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index 0d304e969..a30ad428e 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -19,7 +19,5 @@ internal object PluginStorage { internal fun currentPluginPlatform(): String = "ios" -internal fun currentPluginPlatformTags(): Set = setOf(currentPluginPlatform()) - internal fun currentEpochMillis(): Long = (platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt index 4f79e7c8e..ee348bc59 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -8,5 +8,4 @@ class IOSPlatform: Platform { actual fun getPlatform(): Platform = IOSPlatform() -internal actual val isIos: Boolean = true -internal actual val isDesktop: Boolean = false +internal actual val isIos: Boolean = true \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt new file mode 100644 index 000000000..f9fde4450 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.isSuccess +import platform.Foundation.NSUserDefaults + +internal actual object SyncBackendStorage { + private const val KEY_SELECTION_PAYLOAD = "nuvio_sync_backend_selection_payload_v1" + + actual fun loadSelectionPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(KEY_SELECTION_PAYLOAD) + + actual fun saveSelectionPayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = KEY_SELECTION_PAYLOAD) + } +} + +private val syncBackendHttpClient = HttpClient(Darwin) { + install(HttpTimeout) { + requestTimeoutMillis = 10_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } + expectSuccess = false +} + +internal actual suspend fun fetchSyncBackendManifestText(url: String): String { + val response = syncBackendHttpClient.get(url) { + accept(ContentType.Application.Json) + } + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Sync backend manifest request failed with HTTP ${response.status.value}") + } + return payload.takeIf { it.isNotBlank() } + ?: error("Sync backend manifest response was empty") +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt deleted file mode 100644 index fa48a20ca..000000000 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt +++ /dev/null @@ -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, - ) -} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt deleted file mode 100644 index a01bf27a9..000000000 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.core.ui - -import androidx.compose.ui.Modifier - -internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index faf7d864c..261e73ac9 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -4,9 +4,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,17 +41,12 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + 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, @@ -324,11 +331,42 @@ actual fun PlatformPlayerSurface( } // Render the player view - UIKitViewController( - factory = { bridge.createPlayerViewController() }, - modifier = modifier, - interactive = false, - ) + Box(modifier = modifier) { + UIKitViewController( + factory = { bridge.createPlayerViewController() }, + modifier = Modifier.fillMaxSize(), + interactive = false, + ) + + if (useNativeController) { + var isPlayingLocal by remember { mutableStateOf(playWhenReady) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(if (!isPlayingLocal) Color.Black.copy(alpha = 0.4f) else Color.Transparent) + .clickable { + if (isPlayingLocal) { + bridge.pause() + isPlayingLocal = false + } else { + bridge.play() + isPlayingLocal = true + } + }, + contentAlignment = Alignment.Center + ) { + if (!isPlayingLocal) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(64.dp) + ) + } + } + } + } } private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 6da10affb..8541b4079 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -21,6 +21,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" @@ -83,6 +84,7 @@ actual object PlayerSettingsStorage { resizeModeKey, holdToSpeedEnabledKey, holdToSpeedValueKey, + touchGesturesEnabledKey, externalPlayerEnabledKey, externalPlayerForwardSubtitlesKey, externalPlayerIdKey, @@ -212,6 +214,12 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey)) } + actual fun loadTouchGesturesEnabled(): Boolean? = loadBoolean(touchGesturesEnabledKey) + + actual fun saveTouchGesturesEnabled(enabled: Boolean) { + saveBoolean(touchGesturesEnabledKey, enabled) + } + actual fun loadExternalPlayerEnabled(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(externalPlayerEnabledKey) @@ -808,6 +816,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)) } @@ -874,6 +883,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) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index 1737c08e7..bf14a4528 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -14,13 +14,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, ) actual fun loadSelectedTheme(): String? = @@ -61,16 +59,6 @@ actual object ThemeSettingsStorage { ) } - actual fun loadDesktopNavigationLayout(): String? = - NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(desktopNavigationLayoutKey)) - - actual fun saveDesktopNavigationLayout(layoutName: String) { - NSUserDefaults.standardUserDefaults.setObject( - layoutName, - forKey = ProfileScopedKey.of(desktopNavigationLayoutKey), - ) - } - actual fun loadSelectedAppLanguage(): String? { val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) if (value != null) return value @@ -99,7 +87,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) { @@ -110,7 +97,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) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 412fcceb6..39ae4c826 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,6 @@ kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" -kotlinx-coroutines = "1.10.2" ktor = "3.4.1" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" @@ -54,9 +53,7 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } -ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e9ae08672..232b20376 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=77 -MARKETING_VERSION=0.2.5 +CURRENT_PROJECT_VERSION=80 +MARKETING_VERSION=0.2.8 diff --git a/mpvEx b/mpvEx new file mode 160000 index 000000000..4151a45f8 --- /dev/null +++ b/mpvEx @@ -0,0 +1 @@ +Subproject commit 4151a45f862550a91b7a8efe35a6b19841242d48 diff --git a/scripts/set-version.sh b/scripts/set-version.sh deleted file mode 100755 index 04a87aadd..000000000 --- a/scripts/set-version.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BASE_VERSION_FILE="$ROOT_DIR/iosApp/Configuration/Version.xcconfig" -DESKTOP_VERSION_FILE="$ROOT_DIR/composeApp/Configuration/DesktopVersion.properties" - -BASE_VERSION="" -BASE_CODE="" -DESKTOP_VERSION="" -DESKTOP_CODE="" -DRY_RUN=false -SHOW=false - -usage() { - cat <<'EOF' -Usage: - ./scripts/set-version.sh --desktop 0.1.0 --desktop-code 1 - ./scripts/set-version.sh --base 0.2.4 --base-code 75 - ./scripts/set-version.sh --base 0.2.4 --base-code 75 --desktop 0.1.0 --desktop-code 1 - ./scripts/set-version.sh --show - -Options: - --base VERSION Set the upstream/mobile Nuvio version. - --base-code CODE Set the upstream/mobile build code. - --desktop VERSION Set the desktop app release version. - --desktop-code CODE Set the desktop app build code. - --dry-run Print changes without writing files. - --show Print current configured versions. - -h, --help Show this help. - -The base version is stored in iosApp/Configuration/Version.xcconfig. -The desktop version is stored in composeApp/Configuration/DesktopVersion.properties. -EOF -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -read_key() { - local file="$1" - local key="$2" - - [[ -f "$file" ]] || return 0 - awk -F '=' -v target="$key" ' - { - raw_key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", raw_key) - if (raw_key == target) { - sub(/^[^=]*=/, "", $0) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0) - print $0 - exit - } - } - ' "$file" -} - -validate_version() { - local label="$1" - local value="$2" - - [[ "$value" =~ ^[0-9]+(\.[0-9]+){1,3}([._-][0-9A-Za-z][0-9A-Za-z._-]*)?$ ]] || - die "$label must look like 0.1.0 or 0.1.0-alpha.1" -} - -validate_code() { - local label="$1" - local value="$2" - - [[ "$value" =~ ^[0-9]+$ ]] || die "$label must be a positive integer" - (( 10#$value > 0 )) || die "$label must be greater than 0" -} - -write_key() { - local file="$1" - local key="$2" - local value="$3" - - mkdir -p "$(dirname "$file")" - local tmp - tmp="$(mktemp "${TMPDIR:-/tmp}/nuvio-version.XXXXXX")" - - if [[ -f "$file" ]]; then - awk -v key="$key" -v value="$value" ' - BEGIN { seen = 0 } - { - raw_key = $0 - sub(/[[:space:]]*=.*/, "", raw_key) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", raw_key) - if (raw_key == key) { - print key "=" value - seen = 1 - next - } - print - } - END { - if (!seen) print key "=" value - } - ' "$file" > "$tmp" - else - printf '%s=%s\n' "$key" "$value" > "$tmp" - fi - - mv "$tmp" "$file" -} - -print_current_versions() { - local base_version base_code desktop_version desktop_code - base_version="$(read_key "$BASE_VERSION_FILE" "MARKETING_VERSION")" - base_code="$(read_key "$BASE_VERSION_FILE" "CURRENT_PROJECT_VERSION")" - desktop_version="$(read_key "$DESKTOP_VERSION_FILE" "VERSION_NAME")" - desktop_code="$(read_key "$DESKTOP_VERSION_FILE" "VERSION_CODE")" - - echo "Base/mobile: ${base_version:-unset} (${base_code:-unset})" - echo "Desktop: ${desktop_version:-unset} (${desktop_code:-unset})" -} - -queue_change() { - local file="$1" - local key="$2" - local value="$3" - local current - current="$(read_key "$file" "$key")" - - if [[ "$current" == "$value" ]]; then - echo "unchanged: $key=$value" - return 0 - fi - - echo "set: $key ${current:-unset} -> $value" - if [[ "$DRY_RUN" == false ]]; then - write_key "$file" "$key" "$value" - fi -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --base) - [[ $# -ge 2 ]] || die "--base requires a value" - BASE_VERSION="$2" - shift 2 - ;; - --base-code) - [[ $# -ge 2 ]] || die "--base-code requires a value" - BASE_CODE="$2" - shift 2 - ;; - --desktop) - [[ $# -ge 2 ]] || die "--desktop requires a value" - DESKTOP_VERSION="$2" - shift 2 - ;; - --desktop-code) - [[ $# -ge 2 ]] || die "--desktop-code requires a value" - DESKTOP_CODE="$2" - shift 2 - ;; - --dry-run) - DRY_RUN=true - shift - ;; - --show) - SHOW=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown argument: $1" - ;; - esac -done - -if [[ "$SHOW" == true ]]; then - print_current_versions - if [[ -z "$BASE_VERSION$BASE_CODE$DESKTOP_VERSION$DESKTOP_CODE" ]]; then - exit 0 - fi - echo -fi - -[[ -n "$BASE_VERSION$BASE_CODE$DESKTOP_VERSION$DESKTOP_CODE" ]] || - die "nothing to change; pass --base/--base-code/--desktop/--desktop-code or --show" - -if [[ -n "$BASE_VERSION" ]]; then - validate_version "base version" "$BASE_VERSION" -fi -if [[ -n "$BASE_CODE" ]]; then - validate_code "base code" "$BASE_CODE" -fi -if [[ -n "$DESKTOP_VERSION" ]]; then - validate_version "desktop version" "$DESKTOP_VERSION" -fi -if [[ -n "$DESKTOP_CODE" ]]; then - validate_code "desktop code" "$DESKTOP_CODE" -fi - -if [[ -n "$BASE_VERSION$BASE_CODE" ]]; then - echo "Base/mobile version file: $BASE_VERSION_FILE" - [[ -z "$BASE_VERSION" ]] || queue_change "$BASE_VERSION_FILE" "MARKETING_VERSION" "$BASE_VERSION" - [[ -z "$BASE_CODE" ]] || queue_change "$BASE_VERSION_FILE" "CURRENT_PROJECT_VERSION" "$BASE_CODE" -fi - -if [[ -n "$DESKTOP_VERSION$DESKTOP_CODE" ]]; then - echo "Desktop version file: $DESKTOP_VERSION_FILE" - [[ -z "$DESKTOP_VERSION" ]] || queue_change "$DESKTOP_VERSION_FILE" "VERSION_NAME" "$DESKTOP_VERSION" - [[ -z "$DESKTOP_CODE" ]] || queue_change "$DESKTOP_VERSION_FILE" "VERSION_CODE" "$DESKTOP_CODE" -fi - -echo -print_current_versions