diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index 2bea1d46..bf699cdf 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/composeApp/Configuration/DesktopVersion.properties b/composeApp/Configuration/DesktopVersion.properties deleted file mode 100644 index 8cf3c539..00000000 --- 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 a38669b5..aef2d547 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 @@ -30,12 +26,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property - @get:Input - abstract val desktopAppVersionName: Property - - @get:Input - abstract val desktopAppVersionCode: Property - @TaskAction fun generate() { val props = Properties() @@ -122,8 +112,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 +148,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 +171,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 +187,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() @@ -260,356 +205,6 @@ val generateRuntimeConfigs = tasks.register("generat localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) - desktopAppVersionName.set(desktopReleaseVersionName) - desktopAppVersionCode.set(desktopReleaseVersionCode) -} - -val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true) -val isWindowsHost = System.getProperty("os.name").contains("win", ignoreCase = true) -val mpvKitDir = providers.gradleProperty("nuvio.mpvkit.dir") - .orElse(rootProject.layout.projectDirectory.dir("MPVKit").asFile.absolutePath) -val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm") -val macosPlayerBridgeOutput = layout.buildDirectory.file("native/macos/libplayer_bridge.dylib") -val macosPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) { - "aarch64", "arm64" -> "arm64" - else -> "x86_64" -} -val mpvKitRoot = File(mpvKitDir.get()) -val mpvKitDistRoot = File(mpvKitRoot, "dist") -val mpvKitLibmpvRoot = File(mpvKitDistRoot, "libmpv/macos/thin/$macosPlayerBridgeArch") -val mpvKitLibmpvPkgConfigFile = File(mpvKitLibmpvRoot, "lib/pkgconfig/mpv.pc") -val mpvKitGeneratedPkgConfigDirs = if (mpvKitDistRoot.exists()) { - mpvKitDistRoot.walkTopDown() - .filter { it.isDirectory && it.invariantSeparatorsPath.endsWith("/macos/thin/$macosPlayerBridgeArch/lib/pkgconfig") } - .toList() - .sortedBy { it.absolutePath } -} else { - emptyList() -} -val mpvKitGeneratedLibSearchArgs = mpvKitGeneratedPkgConfigDirs - .mapNotNull { it.parentFile } - .distinctBy { it.absolutePath } - .joinToString(" ") { "-L${shellQuote(it.absolutePath)}" } -val missingMpvKitMacosFrameworks = if (mpvKitLibmpvPkgConfigFile.exists()) emptyList() else listOf("mpv.pc") -val missingMpvKitMacosMessage = """ - MPVKit macOS libmpv artifacts are missing for $macosPlayerBridgeArch: ${missingMpvKitMacosFrameworks.joinToString()}. - Build MPVKit's macOS runtime first: - cd ${mpvKitRoot.absolutePath} - make build platform=macos - Or pass -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit. -""".trimIndent() -val missingMpvKitMacosShellMessage = missingMpvKitMacosMessage.replace("'", "'\"'\"'") -val macosPlayerBridgeSourceFile = macosPlayerBridgeSource.asFile -val macosPlayerBridgeOutputFile = macosPlayerBridgeOutput.get().asFile -val macosPlayerBridgeJavaHome = providers.systemProperty("java.home").get() -val mpvKitLibmpvStaticLib = File(mpvKitLibmpvRoot, "lib/libmpv.a") -if (isMacHost) { - macosPlayerBridgeOutputFile.parentFile.mkdirs() -} -val macosPlayerBridgeCommand = if (missingMpvKitMacosFrameworks.isNotEmpty()) { - listOf( - "/bin/sh", - "-c", - "printf '%s\\n' '$missingMpvKitMacosShellMessage' >&2; exit 1", - ) -} else { - mutableListOf( - "/bin/sh", - "-c", - """ - set -eu - SDKROOT="${'$'}(xcrun --sdk macosx --show-sdk-path)" - SWIFTC="${'$'}(xcrun --toolchain XcodeDefault --find swiftc)" - SWIFT_TOOLCHAIN="${'$'}{SWIFTC%/usr/bin/swiftc}" - SWIFT_LIB="${'$'}{SWIFT_TOOLCHAIN}/usr/lib/swift/macosx" - DEFAULT_PC="${'$'}(pkg-config --variable pc_path pkg-config)" - export PKG_CONFIG_LIBDIR=${shellQuote(mpvKitGeneratedPkgConfigDirs.joinToString(":"))}:"${'$'}{DEFAULT_PC}" - exec xcrun clang++ \ - -std=c++17 \ - -dynamiclib \ - -fobjc-arc \ - -ObjC++ \ - -arch ${shellQuote(macosPlayerBridgeArch)} \ - -isysroot "${'$'}{SDKROOT}" \ - -mmacosx-version-min=11.0 \ - ${shellQuote(macosPlayerBridgeSourceFile.absolutePath)} \ - -o ${shellQuote(macosPlayerBridgeOutputFile.absolutePath)} \ - -I${shellQuote("$macosPlayerBridgeJavaHome/include")} \ - -I${shellQuote("$macosPlayerBridgeJavaHome/include/darwin")} \ - -I${shellQuote(File(mpvKitLibmpvRoot, "include").absolutePath)} \ - $mpvKitGeneratedLibSearchArgs \ - -L"${'$'}{SWIFT_LIB}" \ - -L/usr/lib/swift \ - -framework AppKit \ - -framework WebKit \ - -framework Metal \ - -framework Security \ - -lswiftCompatibility56 \ - -lswiftCompatibilityConcurrency \ - -lswiftCompatibilityPacks \ - -lc++ \ - ${'$'}(pkg-config --libs --static mpv) - """.trimIndent(), - ) -} -val buildMacosPlayerBridge = tasks.register("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) - } } tasks.withType>().configureEach { @@ -622,12 +217,6 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } - - jvm("desktop") { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } val iosTargets = listOf( iosArm64(), @@ -692,16 +281,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 +309,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 565df23b..3d934685 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/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 9d407edf..996401f6 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 1d181ca6..00000000 --- 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 f44e40cd..6e77db32 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/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index 2c5d6eca..5ba9da3c 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/ui/NuvioAsyncImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt deleted file mode 100644 index fa48a20c..00000000 --- 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 a01bf27a..00000000 --- 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/p2p/P2pStreamingEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt index 7497c060..93aecad7 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 038a472a..fc6d4af6 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 e4e83323..dcc8de9f 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 @@ -56,6 +56,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 @@ -75,17 +76,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, @@ -107,6 +103,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) @@ -116,6 +115,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl.orEmpty(), sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + normalizedStreamType.orEmpty(), useYoutubeChunkedPlayback, ) var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) } @@ -126,6 +126,17 @@ actual fun PlatformPlayerSurface( var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority + 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) @@ -168,6 +179,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + normalizedStreamType, useYoutubeChunkedPlayback, effectiveDecoderPriority, ) { @@ -227,17 +239,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() } @@ -262,25 +270,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 2a98502d..32b4c4b1 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 0d8e5b9b..6a2e1584 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 4ceee466..2b308a23 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 1d181ca6..00000000 --- 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-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a09f3175..f4a6a3dd 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -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,11 +335,15 @@ 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 @@ -344,6 +351,8 @@ Lecture en cours Appuie pour chercher des sous-titres Retour + Recharger + Réinitialiser Rétablir les valeurs par défaut Remplir Ajuster @@ -356,8 +365,11 @@ Avancer de 10 secondes Sources Style + Sélectionne 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 @@ -386,6 +398,7 @@ Général Compte Addons + Avancé Apparence Contenu et découverte Continuer à regarder @@ -400,12 +413,15 @@ Plugins Personnalisation des affiches Paramètres + Streams Supporters et contributeurs Enrichissement TMDB Trakt À PROPOS Gère ton compte, déconnecte-toi ou supprime-le. COMPTE + Comportement au démarrage et des profils. + AVANCÉ Ajuste la présentation de l’accueil et les préférences visuelles. Rechercher de nouvelles versions de l’application. Vérifier les mises à jour @@ -415,12 +431,20 @@ GÉNÉRAL Connecte les services TMDB et MDBList. Gère les alertes de sortie d’épisodes et envoie 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. 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 @@ -584,6 +608,8 @@ 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 @@ -646,6 +672,38 @@ 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 + Choisis si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux. + Position des badges + Choisis où les badges de flux apparaissent sur les cartes de flux. + En haut + En bas + URL de badges Fusion + Importe 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. + Saisis une URL JSON de badge. + L’URL du badge doit commencer par http:// ou https://. + Tu peux 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. @@ -669,6 +727,8 @@ 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. Horizontal @@ -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 ta langue préférée et les transmet au lecteur externe. Aucun lecteur externe compatible n’est installé + Lecteur + Interne + Externe + Choisis 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. + 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 tu la demandes 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 tes 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 @@ -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 à tes 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 tes 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 @@ -928,6 +1017,12 @@ Nuvio Sync Source de progression réglée sur Trakt Source de progression réglée sur Nuvio Sync + Source de la section Similaires + Choisis la provenance des recommandations sur les pages de détails + Source de la section Similaires + Sélectionne 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 @@ -1049,10 +1144,13 @@ Quitter l’application Ce catalogue n’a renvoyé aucun élément. Aucun titre trouvé + Plus d’actions Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie. 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. @@ -1084,6 +1182,8 @@ Marquer comme vu À suivre %1$s vu + %1$d h %2$d min restantes + %1$d min restantes Installe et valide 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 @@ -1179,6 +1279,7 @@ 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 @@ -1283,6 +1384,16 @@ Impossible de terminer la connexion Trakt Utilisateur Trakt Liste de suivi + + 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 à ta liste de suivi Trakt + Échec de l’ajout à la liste Trakt + Identifiants Trakt compatibles manquants + Réponse vide du serveur Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. Collection TMDB %1$d Collection TMDB introuvable @@ -1483,6 +1594,11 @@ 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 @@ -1498,6 +1614,7 @@ En savoir plus Format par défaut + Format d’origine Saisis un groupe par ligne. Ordre original @@ -1582,6 +1699,7 @@ Capturer Décodeur matériel + Sortie audio Primaires cibles Transfert cible @@ -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, tu reconnais et acceptes que :\n\n• Ton adresse IP sera visible par les autres pairs du réseau\n• Tu es seul responsable du contenu auquel tu accèdes\n• Tu confirmes avoir le droit légal de visionner ce contenu dans ta 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 à ton utilisation du streaming P2P\n\nTu utilises cette fonctionnalité entièrement à tes 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 725e8cc2..d7ddbc3d 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çado 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 f849d933..313d5333 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 @@ -536,10 +537,6 @@ 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. @@ -698,6 +695,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 @@ -845,6 +846,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 +1152,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 @@ -1392,6 +1396,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 +1879,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 458c752c..929ae5a8 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 @@ -117,7 +107,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 +154,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 +169,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 +193,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 +205,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 +302,64 @@ data class StreamRoute( data class CatalogRoute( val title: String, val subtitle: String, - val manifestUrl: String, - val type: String, - val catalogId: String, + val targetKind: CatalogTargetKind, + 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 + }, + 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 (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 +374,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,37 +404,10 @@ private enum class AppGateScreen { Loading, Auth, ProfileSelection, - ProfileSwitching, ProfileEdit, Main, } -private data class PendingProfileSwitch( - val profile: NuvioProfile, - val syncOnEnter: Boolean, -) - -private suspend fun warmProfileBoundRepositories() { - withContext(Dispatchers.Default) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - DownloadsRepository.ensureLoaded() - EpisodeReleaseNotificationsRepository.ensureLoaded() - HomeCatalogSettingsRepository.snapshot() - LibraryRepository.ensureLoaded() - P2pSettingsRepository.ensureLoaded() - PlayerSettingsRepository.ensureLoaded() - TraktAuthRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() - CollectionSyncService.startObserving() - HomeCatalogSettingsSyncService.startObserving() - ProfileSettingsSync.startObserving() - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -469,7 +470,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 +485,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 +493,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 +509,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 +578,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 +590,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 +606,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 +627,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 +677,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 +697,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 +713,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 +884,6 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect val hasPlayableDownload = downloadsUiState.completedItems.any { DownloadsRepository.playableLocalFileUri(it) != null } @@ -965,7 +971,10 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by remember { + ContinueWatchingPreferencesRepository.ensureLoaded() + ContinueWatchingPreferencesRepository.uiState + }.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -1000,11 +1009,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) } @@ -1131,7 +1138,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, @@ -1251,11 +1258,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 +1274,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 +1391,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 +1448,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 +1505,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 +1541,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 +1774,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 +1789,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,7 +1809,7 @@ 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, ) @@ -1845,6 +1819,7 @@ private fun MainAppContent( sourceUrl = sentinelUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1863,7 +1838,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, @@ -1964,6 +1939,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + streamType = cached.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2089,6 +2065,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2096,6 +2073,7 @@ private fun MainAppContent( 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, @@ -2213,6 +2191,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2220,6 +2199,7 @@ private fun MainAppContent( 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, @@ -2378,6 +2358,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + streamType = launch.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2455,14 +2436,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 +2450,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 +2490,51 @@ 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( + 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 +2801,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 +2867,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 +2924,6 @@ private fun AppTabHost( AppScreenTab.Search -> { SearchScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, @@ -2951,7 +2934,6 @@ private fun AppTabHost( AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), - topChromePadding = topChromePadding, scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, @@ -2987,260 +2969,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 20d37440..5b1cae86 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/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt index f231462b..8e62eb8e 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 24e0a028..00000000 --- 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 ca955abb..3c96e28c 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 @@ -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/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 969acc3c..0702be8d 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() @@ -101,13 +91,19 @@ object ProfileSettingsSync { return syncMutex.withLock { isServerSyncInFlight = true try { - val remoteJson = fetchRemoteSettingsJson(profileId) + val localBlob = exportSettingsBlob() + val localSignature = buildSignature(localBlob) + + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", MOBILE_SYNC_PLATFORM) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params) + val response = result.decodeList().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 +119,13 @@ 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) + applyRemoteBlob(remoteBlob) skipNextPushSignature = currentObservedStateSignature() } finally { isApplyingRemoteBlob = false @@ -154,8 +146,7 @@ object ProfileSettingsSync { ensureRepositoriesLoaded() syncMutex.withLock { runCatching { - val profileId = ProfileRepository.activeProfileId - pushToRemoteLocked(profileId, exportSettingsBlob(profileId)) + pushToRemoteLocked(ProfileRepository.activeProfileId, exportSettingsBlob()) }.onFailure { error -> log.e(error) { "pushCurrentProfileToRemote() — FAILED" } } @@ -164,25 +155,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 +192,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 +224,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 +268,7 @@ object ProfileSettingsSync { private fun ensureRepositoriesLoaded() { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() - if (syncPlayerSettings) { - PlayerSettingsRepository.ensureLoaded() - } + PlayerSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.ensureLoaded() DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() @@ -305,90 +287,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 c773a3a7..00000000 --- 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 6e841b46..f19d3b21 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 bf5c3dac..e6214c9e 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 bf2e6149..6ad66ea4 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 aa3932ac..1569c849 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 eea6d19b..00000000 --- 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 87abf388..7e12666d 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 f2f763a9..90154cac 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 da473cb2..e78987b7 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 716346a8..2d2c4909 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 a251166e..e60bb169 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 00000000..e3ae9303 --- /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 c3843bdd..e4cec4b5 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 578445ae..0203771f 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/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 77704a7e..2cdbd37e 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 008b1cf5..5881ce2e 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 e45d32bb..11e95c0c 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 adcf6811..073ae7f7 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 aef463c9..56677608 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 9c968612..f209f7f2 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 0803aacb..04abb4ca 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 b9bd0b6d..b0c97f51 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 a6b498ce..306152a5 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 700aa41e..1b7ecaf8 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 5754a250..a97e8ee6 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 bdb1b6e8..b9308252 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 82740e74..3601b1d0 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 f5226e9b..e9ef5fa8 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/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 0b4b7404..0b2c774e 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 54b1e30e..e0711eb1 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 2f052064..de8b4983 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 @@ -105,21 +104,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 +124,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 +281,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() + } val shouldValidateMissingNextUpSeeds = remember( isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, @@ -647,7 +659,6 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, - sectionPadding = if (isDesktop) homeSectionPadding else null, ) homeUiState.heroItems.isNotEmpty() -> HomeHeroSection( @@ -655,7 +666,6 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, - sectionPadding = if (isDesktop) homeSectionPadding else null, listState = homeListState, onItemClick = onPosterClick, ) 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 9ad94cb4..99f36172 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 734bea9d..2cfebe19 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 a2bcd03c..d95b26bc 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 c4c86bb0..7e5242e1 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 @@ -218,7 +220,13 @@ object LibraryRepository { 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 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 4826efa0..c64c449e 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 cdd15b8b..41fdf26b 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 @@ -23,233 +23,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() @@ -285,17 +58,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 288fafda..7a0af09c 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 8441b89d..1989a25f 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 @@ -25,6 +25,7 @@ data class PlayerLaunch( 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 eeaa2ef9..6ff03c85 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 725ecf22..431e4978 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 @@ -10,6 +10,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + streamType: String? = null, providerName: String, streamTitle: String, streamSubtitle: String?, @@ -44,6 +45,7 @@ fun PlayerScreen( 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 71705dc8..2aa779d2 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 @@ -8,6 +8,7 @@ internal data class PlayerScreenArgs( 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 e1e5f81f..0b2d5f3e 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 afabfa86..f1c50cec 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, @@ -162,33 +163,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)) @@ -196,17 +174,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) { @@ -230,9 +197,7 @@ private fun PlayerScreenRuntime.handleDoubleTapSeek( maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped } } - if (sendToController) { - playerController?.seekTo(targetPositionMs) - } + playerController?.seekTo(targetPositionMs) scheduleProgressSyncAfterSeek() showSeekFeedback(direction, nextState.amountMs) @@ -314,6 +279,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) @@ -335,6 +304,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 22828532..52ee175e 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 @@ -80,17 +80,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 @@ -120,8 +143,9 @@ 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( 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 12b82dc3..ade131f5 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 ed4f44ea..a455674e 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 @@ -32,6 +32,7 @@ internal class PlayerScreenRuntime( 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 +67,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 +96,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 +157,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 fa9c8afc..0823f820 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 0d28e6df..bd6446fb 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( @@ -354,6 +101,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { layoutSize = layoutSize, sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx, playerControlsLockedState = gestureCallbacks.playerControlsLocked, + touchGesturesEnabledState = gestureCallbacks.touchGesturesEnabled, isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive, currentPositionMsState = gestureCallbacks.currentPositionMs, currentDurationMsState = gestureCallbacks.currentDurationMs, @@ -366,43 +114,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 @@ -458,7 +180,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, - suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -571,606 +292,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, @@ -1181,66 +302,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() @@ -1405,9 +522,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 a4c94820..d8160862 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 0196d338..b2b0f298 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 ec1fbe89..b370fb3f 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 c78e27da..86ea300c 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 de46194d..01818a71 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 @@ -43,6 +43,7 @@ internal fun Modifier.playerSurfaceDragGestures( layoutSize: IntSize, sideGestureSystemEdgeExclusionPx: Float, playerControlsLockedState: State, + touchGesturesEnabledState: State, isHoldToSpeedGestureActiveState: State, currentPositionMsState: State, currentDurationMsState: State, @@ -66,6 +67,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/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index dcb5b496..2de18276 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 9dd949ae..54d60675 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 b6a8524b..5f00697d 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/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index c2033993..ab6b5b2b 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( 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 da6c96b9..195ba674 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 81545f14..5e812b28 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 2df20248..2cf53cba 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 43a243b1..4468d8cf 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 6ea474da..4383a637 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 7b8f781a..c2603126 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/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt index fc9f77cc..2bffab37 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), @@ -56,6 +60,11 @@ internal fun LazyListScope.advancedSettingsContent( if (!cleared) { ContinueWatchingEnrichmentCache.clearAll() 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 07bb324b..157bed8e 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 507a8ea5..00000000 --- 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 e58a91f7..d86eb0a6 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 ed5ce9da..54718c44 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 c882944e..f557f762 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 35566fe4..dd1551b0 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 b500ad0d..248d6397 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 664cfd36..3b3f8056 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 @@ -49,7 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import 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 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 956dc217..431b9d20 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 5bb6a284..2a788baf 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 6d9682e6..00000000 --- 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 08241f19..f0e9ebd2 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 397d26ad..febd2b9b 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 00000000..3f15b63b --- /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 00000000..84d67e88 --- /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/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index f218ae74..e3a7e8ef 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 4719a89d..d45b5148 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 a27033ed..ab1df959 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 49fc361a..d0f5cddf 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 f9138cc1..8fdf7e11 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 ff5ab11b..98c225a6 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 c8d365ab..39f82e9c 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/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 5c461349..d4e45e9e 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 @@ -255,7 +255,7 @@ internal object TraktScrobbleRepository { ids = item.ids.toRequestBodyOrNull(), ), progress = clampedProgress, - appVersion = AppVersionPolicy.displayVersionName, + appVersion = AppVersionConfig.VERSION_NAME, ) is TraktScrobbleItem.Episode -> TraktScrobbleRequest( @@ -270,7 +270,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/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index b1a681b5..13e2ebd5 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 00226f24..d9d5a5aa 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 @@ -63,6 +66,8 @@ internal object ContinueWatchingEnrichmentCache { private const val storageKey = "cw_enrichment_cache" private var lastPayloadHash: Int? = null + private val _cacheCleared = MutableStateFlow(0) + val cacheCleared: StateFlow = _cacheCleared.asStateFlow() fun getNextUpSnapshot(): List = loadPayload()?.nextUp ?: emptyList() @@ -98,6 +103,7 @@ internal object ContinueWatchingEnrichmentCache { fun clearAll() { ContinueWatchingEnrichmentStorage.removePayload(ProfileScopedKey.of(storageKey)) lastPayloadHash = null + _cacheCleared.value += 1 } private fun loadPayload(): CachedEnrichmentPayload? { 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 bc695a47..478dcabd 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 @@ -251,6 +251,35 @@ 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, 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 00000000..7d25a08d --- /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 00000000..b772f791 --- /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/home/HomeCatalogSectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt new file mode 100644 index 00000000..03fa958f --- /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/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt index dc00ef0b..349a57e9 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/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt new file mode 100644 index 00000000..d0241a33 --- /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 41519dac..d6ddf28d 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/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt deleted file mode 100644 index 04cded01..00000000 --- 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 79fd9ee7..00000000 --- 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 c0dc1da4..00000000 --- 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 a989c77d..2dda9682 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 88ada633..00000000 --- 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 ea0a4eeb..00000000 --- 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 b7b0305d..00000000 --- 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 e0c469fc..00000000 --- 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 86ad57cd..00000000 --- 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 c7c556c5..00000000 --- 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 24d3d73d..00000000 --- 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 5fdd5960..00000000 --- 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 6434bd49..00000000 --- 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 d96114b5..00000000 --- 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 6734e5fd..00000000 --- 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 687ee798..00000000 --- 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 4b20c306..00000000 --- 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 bff3bf30..00000000 --- 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 8598fa62..00000000 --- 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 3bd8bb6c..00000000 --- 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 d47c69b4..00000000 --- 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 5112956a..00000000 --- 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 2c961b32..00000000 --- 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 85aeb501..00000000 --- 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 2a9a1c8e..00000000 --- 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 5e0e33f5..00000000 --- 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 12097a65..00000000 --- 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 04ac0d9e..00000000 --- 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 2972496e..00000000 --- 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 4b2b4083..00000000 --- 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 4869d713..00000000 --- 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 659d5815..00000000 --- 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 0d95cfeb..00000000 --- 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 6d464738..00000000 --- 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 3ee8ee44..00000000 --- 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 473aee28..00000000 --- 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 e19e8912..00000000 --- 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 5eebed0c..00000000 --- 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 280256c7..00000000 --- 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 6a9a820f..00000000 --- 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 b1c20ca2..00000000 --- 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 3dd1535b..00000000 --- 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 91ad9cef..00000000 --- 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 1498a959..00000000 --- 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 d35d9894..00000000 --- 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 a47ecaaf..00000000 --- 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 fb63eae7..00000000 --- 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 1da37699..00000000 --- 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 34296259..00000000 --- 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 aea9d4b7..00000000 --- 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 149078b7..00000000 --- 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 9bf4f3cc..00000000 --- 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 a1675948..00000000 --- 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 443eebab..00000000 --- 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 0b73ade0..00000000 --- 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 1217b448..00000000 --- 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 8c00aeda..00000000 --- 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 486f1477..00000000 --- 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 bcca6616..00000000 --- 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 70cd338b..00000000 --- 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 38b7776d..00000000 --- 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 639c2b8e..00000000 --- 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 e3f4a5f0..00000000 --- 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 31fed517..00000000 --- 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 0ca7369e..00000000 --- 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 3f83db89..00000000 --- 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 1cc7d469..00000000 --- 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 f801c332..00000000 --- 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 cf5b689c..00000000 --- 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 349f3c6a..00000000 --- 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 3c05dff1..00000000 --- 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 b768fef1..00000000 --- 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 bca3536f..00000000 --- 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 6da1187c..00000000 --- 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 b4433d6d..00000000 --- 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 f94a38b2..00000000 --- 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 44da853a..00000000 --- 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 ce44c1c1..00000000 --- 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 d3eb3d1b..00000000 --- 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 ccae7137..00000000 --- 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 82b82d1a..00000000 --- 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 faead006..00000000 --- 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 cc922a39..00000000 --- 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 389f383a..00000000 --- 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 249009a5..00000000 --- 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 2c68eb9c..00000000 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 edcfdfcc..00000000 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 9fc0d4ad..00000000 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 0f54e0d9..00000000 --- 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 877d6658..00000000 --- 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 2bb63670..00000000 --- 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 ecf9739c..00000000 --- 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 da5a0e8f..88439c51 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 70588602..5f7b3114 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 1d181ca6..00000000 --- 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 6f46e9e9..670f8092 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 1d181ca6..00000000 --- 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 0d304e96..a30ad428 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 4f79e7c8..ee348bc5 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/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt deleted file mode 100644 index fa48a20c..00000000 --- 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 a01bf27a..00000000 --- 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 7559cb33..261e73ac 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 @@ -41,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, 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 6da10aff..8541b407 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 1737c08e..bf14a452 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 412fcceb..39ae4c82 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 e9ae0867..4e6da8c0 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=79 +MARKETING_VERSION=0.2.7 diff --git a/scripts/set-version.sh b/scripts/set-version.sh deleted file mode 100755 index 04a87aad..00000000 --- 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