diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 48a9a8f11..1229aba48 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,10 +9,11 @@ body: value: | Thanks for reporting a bug. - If we can reproduce it, we can usually fix it. This form is just to get the basics in one place. + If we can reproduce it, we can usually fix it. Please describe the bug in enough detail that someone else can understand what failed without watching a video or guessing from the title. Please replace the default title with a short summary of the actual problem. - If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`. + Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed if the missing details are not added. + If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown attributes: @@ -121,11 +122,26 @@ body: validations: required: true + - type: textarea + id: description + attributes: + label: Bug description + description: | + Explain the problem in detail. Do not only write "not working" or "same issue". + Include what screen/flow you were using, what you tapped or tried to do, what changed on screen, and any visible error text. + placeholder: | + I was trying to ... + The app ... + I saw ... + This happens on ... + validations: + required: true + - type: textarea id: steps attributes: label: Steps to reproduce - description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links. + description: Exact steps, one action per line. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links. placeholder: | 1. Open ... 2. Navigate to ... @@ -146,7 +162,7 @@ body: id: actual attributes: label: Actual behavior - placeholder: "What actually happened (include any on-screen error text/codes)." + placeholder: "What actually happened? Include any on-screen error text/codes and whether the app froze, crashed, ignored input, showed the wrong content, etc." validations: required: true @@ -197,7 +213,7 @@ body: attributes: label: Logs (required for crash reports) description: | - Required if the app crashes or force closes. + Required if the app crashes or force closes. Crash reports without logs will be closed. For other bug reports, logs are optional but still helpful. **Android:** `adb logcat -d | tail -n 300` diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index 1073a6783..2bea1d468 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -55,10 +55,37 @@ jobs: return (value || "").replace(/\s+/g, " ").trim(); } + function normalizedMeaningfulText(value) { + return normalizeText(value) + .replace(/^```[a-zA-Z0-9_-]*/i, "") + .replace(/```$/i, "") + .trim(); + } + function stripIssuePrefix(value) { return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim(); } + function isEmptyish(value) { + const text = normalizedMeaningfulText(value); + return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); + } + + function isVague(value) { + const text = normalizedMeaningfulText(value); + return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); + } + + function hasUsefulText(value, minimumLength) { + const text = normalizedMeaningfulText(value); + return !isEmptyish(text) && !isVague(text) && text.length >= minimumLength; + } + + const description = extractFirstSection([ + "Bug description", + "Describe the bug", + "Detailed description", + ]); const steps = extractSection("Steps to reproduce"); const expected = extractSection("Expected behavior"); const actual = extractSection("Actual behavior"); @@ -69,7 +96,7 @@ jobs: const extra = extractSection("Anything else? (optional)"); const summaryTitle = stripIssuePrefix(title); - const looksLikeBugForm = !!(steps || expected || actual); + const looksLikeBugForm = !!(description || steps || expected || actual); const isBugIssue = hasLabel("bug") || looksLikeBugForm; const isFeatureIssue = hasLabel("enhancement") || @@ -84,20 +111,20 @@ jobs: const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i; const numericOnlyTitle = /^#?\d+$/; const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i; - const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n"); + const crashContext = [summaryTitle, description, steps, actual, extra].map(normalizeText).join("\n"); const isCrashIssue = crashPattern.test(crashContext); - const normalizedLogs = normalizeText(logs); - const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs); + const normalizedLogs = normalizedMeaningfulText(logs); + const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs); if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) { problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)"); } - if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)"); - if (!expected || expected.length < 10) problems.push("Expected behavior"); - if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)"); - if (isCrashIssue && !hasLogs) { - problems.push("Logs (required for crash reports; include a log snippet or stack trace)"); + if (!hasUsefulText(description, 50)) { + problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")"); } + if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)"); + if (!hasUsefulText(expected, 10)) problems.push("Expected behavior"); + if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)"); async function ensureLabel(name, color, description) { try { @@ -111,6 +138,50 @@ jobs: const hasNeedsInfo = hasLabel(NEEDS_INFO); + if (isCrashIssue && !hasLogs) { + await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC); + if (!hasNeedsInfo) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [NEEDS_INFO], + }); + } + + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const alreadyCommented = comments.some(c => (c.body || "").includes(marker)); + if (!alreadyCommented) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: + `${marker}\n` + + `Closing this crash report because crash reports must include logs.\n\n` + + `Please open a new report, or ask us to reopen this one, with a log snippet or stack trace from around the crash.\n\n` + + `Useful examples:\n` + + `- Android: \`adb logcat -d | tail -n 300\`\n` + + `- iOS: crash log from Xcode Organizer or Console.app\n` + + `- Desktop: terminal/console output from around the crash`, + }); + } + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + state_reason: "not_planned", + }); + return; + } + if (problems.length > 0) { await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC); if (!hasNeedsInfo) { @@ -125,12 +196,13 @@ jobs: const marker = ""; const commentBody = `${marker}\n` + - `Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` + - `Missing / too short:\n` + + `Thanks for the report. Could you add more detail so we can reproduce it?\n\n` + + `Missing / too vague:\n` + problems.map(p => `- ${p}`).join("\n") + `\n\n` + `Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` + - `${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`; + `Reports that only say "not working", "broken", or "same issue" are not enough to debug.\n` + + `Logs are optional for most issues, but they help a lot. Crash reports without logs are closed automatically.\n`; const comments = await github.paginate(github.rest.issues.listComments, { owner, diff --git a/composeApp/Configuration/DesktopVersion.properties b/composeApp/Configuration/DesktopVersion.properties new file mode 100644 index 000000000..8cf3c5393 --- /dev/null +++ b/composeApp/Configuration/DesktopVersion.properties @@ -0,0 +1,2 @@ +VERSION_NAME=0.1.1 +VERSION_CODE=2 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ff4be0f3d..a38669b52 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -5,9 +5,13 @@ import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskAction +import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import java.util.Properties @@ -26,6 +30,12 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property + @get:Input + abstract val desktopAppVersionName: Property + + @get:Input + abstract val desktopAppVersionCode: Property + @TaskAction fun generate() { val props = Properties() @@ -112,6 +122,8 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |object AppVersionConfig { | const val VERSION_NAME = "${appVersionName.get()}" | const val VERSION_CODE = ${appVersionCode.get()} + | const val DESKTOP_VERSION_NAME = "${desktopAppVersionName.get()}" + | const val DESKTOP_VERSION_CODE = ${desktopAppVersionCode.get()} |} """.trimMargin() ) @@ -148,6 +160,23 @@ fun readXcconfigValue(file: File, key: String): String? { ?.second } +fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'" + +fun cmdQuote(value: String): String = "\"${value.replace("\"", "\"\"")}\"" + +fun psSingleQuote(value: String): String = "'${value.replace("'", "''")}'" + +fun semanticVersionSortKey(value: String): String = + value.split('.', '-', '_') + .joinToString(".") { part -> + part.toIntOrNull()?.toString()?.padStart(8, '0') ?: part + } + +fun newestDirectory(root: File): File? = + root.takeIf(File::exists) + ?.listFiles(File::isDirectory) + ?.maxByOrNull { semanticVersionSortKey(it.name) } + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) @@ -171,6 +200,31 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION") ?.toIntOrNull() ?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}") +val desktopVersionConfigFile = rootProject.file("composeApp/Configuration/DesktopVersion.properties") +val desktopVersionProps = Properties().apply { + if (desktopVersionConfigFile.exists()) { + desktopVersionConfigFile.inputStream().use { load(it) } + } +} +val desktopReleaseVersionName = ( + providers.gradleProperty("nuvio.desktop.versionName").orNull + ?: System.getenv("NUVIO_DESKTOP_VERSION_NAME") + ?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_NAME") + ?: desktopVersionProps.getProperty("VERSION_NAME") + ?: "0.1.0" + ).trim() +require(desktopReleaseVersionName.isNotBlank()) { + "Desktop version name must not be blank." +} +val desktopReleaseVersionCode = ( + providers.gradleProperty("nuvio.desktop.versionCode").orNull + ?: System.getenv("NUVIO_DESKTOP_VERSION_CODE") + ?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_CODE") + ?: desktopVersionProps.getProperty("VERSION_CODE") + )?.trim() + ?.takeIf { it.isNotBlank() } + ?.toIntOrNull() + ?: 1 val iosDistribution = ( providers.gradleProperty("nuvio.ios.distribution").orNull ?: System.getenv("NUVIO_IOS_DISTRIBUTION") @@ -187,6 +241,7 @@ val iosDistributionSourceDir = if (iosDistribution == "full") { } val iosFrameworkBundleId = "com.nuvio.media" val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin") +val fullPluginSourceDir = fullCommonSourceDir.resolve("com/nuvio/app/features/plugins") val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin") val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName -> taskName.substringAfterLast(':').lowercase() @@ -205,6 +260,356 @@ val generateRuntimeConfigs = tasks.register("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 { @@ -217,6 +622,12 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } + + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } val iosTargets = listOf( iosArm64(), @@ -281,6 +692,16 @@ kotlin { implementation(libs.androidx.media3.extractor) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) } + val desktopMain by getting { + kotlin.srcDir(fullPluginSourceDir) + dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.ktor.client.cio) + implementation(libs.quickjs.kt) + implementation(libs.ksoup) + } + } commonMain.dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -309,6 +730,46 @@ kotlin { } } +compose.desktop { + application { + mainClass = "com.nuvio.app.MainKt" + val smokePlayerUrl = providers.gradleProperty("nuvio.desktop.smokePlayerUrl").orNull + ?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL") + jvmArgs += listOfNotNull( + "-Dapple.awt.application.appearance=NSAppearanceNameDarkAqua", + "--add-opens=java.desktop/java.awt=ALL-UNNAMED", + "--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED", + "--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED", + "--add-opens=java.desktop/sun.awt.windows=ALL-UNNAMED", + smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" }, + ) + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "Nuvio" + packageVersion = desktopReleaseVersionName + vendor = "Nuvio Media" + modules("java.net.http") + macOS { + iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.icns")) + } + windows { + iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.ico")) + shortcut = true + menu = true + menuGroup = "Nuvio" + } + linux { + iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.png")) + } + } + + buildTypes.release.proguard { + isEnabled.set(false) + } + } +} + afterEvaluate { dependencies { add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) @@ -316,6 +777,10 @@ afterEvaluate { } } +configurations.matching { it.name == "iosMainImplementation" }.configureEach { + project.dependencies.add(name, libs.ktor.client.darwin) +} + dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) debugImplementation(libs.compose.uiTooling) @@ -361,14 +826,6 @@ android { manifest.srcFile("src/androidFull/AndroidManifest.xml") java.srcDir(fullCommonSourceDir) } - splits { - abi { - isEnable = !isAndroidAppBundleBuild - reset() - include("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - isUniversalApk = false - } - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" 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 996401f6b..9d407edf9 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,6 +2,8 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true + actual val downloadsEnabled: Boolean = true + actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = true 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 new file mode 100644 index 000000000..1d181ca62 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.build + +actual object AppVersionPolicy { + actual val displayVersionName: String = AppVersionConfig.VERSION_NAME + actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE + actual val basedOnVersionName: String? = null +} 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 6e77db321..f44e40cd2 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,4 +26,6 @@ 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/jniLibs/arm64-v8a/libtorrserver.so b/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so index 844e339ed..12994d13e 100755 Binary files a/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so and b/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so differ diff --git a/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so b/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so index 3c9935edd..6e7bc6bcb 100755 Binary files a/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so and b/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so differ diff --git a/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so b/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so index edc2bf081..6309221c4 100755 Binary files a/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so and b/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so differ diff --git a/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so b/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so index 5fc0b7a13..039eba7d5 100755 Binary files a/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so and b/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so differ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 4f0136540..ad83b451b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerTrackPreferenceStorage import com.nuvio.app.features.player.ExternalPlayerPlatform +import com.nuvio.app.features.player.SubtitleFileCache import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine @@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() { P2pSettingsStorage.initialize(applicationContext) P2pStreamingEngine.initialize(applicationContext) ExternalPlayerPlatform.initialize(applicationContext) + SubtitleFileCache.initialize(applicationContext) ProfileStorage.initialize(applicationContext) AvatarStorage.initialize(applicationContext) ProfilePinCacheStorage.initialize(applicationContext) 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 5ba9da3cc..2c5d6eca2 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -8,4 +8,5 @@ class AndroidPlatform : Platform { actual fun getPlatform(): Platform = AndroidPlatform() -internal actual val isIos: Boolean = false \ No newline at end of file +internal actual val isIos: Boolean = false +internal actual val isDesktop: Boolean = false diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 27b3d1f52..d289ea5a4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_profile_pin_cache", "nuvio_theme_settings", "nuvio_poster_card_style", + "nuvio_debrid_settings", "nuvio_mdblist_settings", + "nuvio_downloads", "nuvio_trakt_auth", "nuvio_trakt_library", "nuvio_trakt_settings", 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 new file mode 100644 index 000000000..fa48a20ca --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter + +@Composable +internal actual fun NuvioAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier, + placeholder: Painter?, + error: Painter?, + fallback: Painter?, + onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?, + onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?, + onError: ((AsyncImagePainter.State.Error) -> Unit)?, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, + filterQuality: FilterQuality?, + clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, +) { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = placeholder, + error = error, + fallback = fallback, + onLoading = onLoading, + onSuccess = onSuccess, + onError = onError, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality ?: DefaultFilterQuality, + clipToBounds = clipToBounds, + ) +} 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 new file mode 100644 index 000000000..a01bf27a9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.android.kt @@ -0,0 +1,5 @@ +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/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index de8e76b1e..08751085a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,20 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId))) + ?.apply() + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) @@ -249,4 +264,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } 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 d9e952b9e..7497c0607 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 @@ -7,15 +7,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -24,29 +21,10 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.net.URLDecoder import java.net.URLEncoder -import java.util.Locale import java.util.concurrent.TimeUnit private const val TAG = "P2pStreamingEngine" -private const val IDLE_TORRENT_TTL_MS = 120_000L -private const val FILE_INDEX_METADATA_TIMEOUT_MS = 15_000L -private const val FILE_INDEX_FAST_VALIDATION_TIMEOUT_MS = 10_000L -private const val FILE_INDEX_POLL_INTERVAL_MS = 250L -private const val STREAMING_CACHE_SIZE_BYTES = 128L * 1024L * 1024L -private const val STREAMING_CONNECTION_LIMIT = 160 -private const val STREAMING_HALF_OPEN_CONNECTION_LIMIT = 120 -private const val STREAMING_TOTAL_HALF_OPEN_CONNECTION_LIMIT = 500 -private const val STREAMING_PEERS_HIGH_WATER = 900 -private const val STREAMING_PEERS_LOW_WATER = 120 -private const val STREAMING_NOMINAL_DIAL_TIMEOUT_MS = 8_000 -private const val STREAMING_MIN_DIAL_TIMEOUT_MS = 1_500 -private const val STREAMING_HANDSHAKE_TIMEOUT_MS = 3_000 -private const val STREAMING_DISCONNECT_TIMEOUT_SECONDS = 120 -private const val STREAMING_READ_AHEAD_PERCENT = 95 -private const val STREAMING_PRELOAD_CACHE_PERCENT = 50 -private const val STREAM_SCREEN_WARMUP_COOLDOWN_MS = 10_000L private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "webm", "ts", "m4v", "mov", "wmv", "flv") actual object P2pStreamingEngine { @@ -56,13 +34,10 @@ actual object P2pStreamingEngine { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lifecycleLock = Any() private var statsJob: Job? = null - private var preloadJob: Job? = null - private var warmupJob: Job? = null - private var warmupCooldownJob: Job? = null + private var cleanupJob: Job? = null private var currentHash: String? = null private var streamGeneration = 0L private var appContext: Context? = null - private val idleDropJobs = mutableMapOf() private val binary = TorrServerBinary() private val api = TorrServerApi(binary) @@ -71,134 +46,36 @@ actual object P2pStreamingEngine { binary.initialize(context.applicationContext) } - actual fun warmup() { - if (appContext == null) return - synchronized(lifecycleLock) { - warmupCooldownJob?.cancel() - warmupCooldownJob = null - if (warmupJob?.isActive == true) return - warmupJob = scope.launch { - try { - binary.start() - if (api.ensureStreamingSettings().changed) { - binary.stop() - binary.start() - api.ensureStreamingSettings() - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "TorrServer warmup failed", e) - } - } - } - } - - actual fun cooldownWarmup() { - synchronized(lifecycleLock) { - if (currentHash != null) { - return - } - warmupCooldownJob?.cancel() - warmupCooldownJob = scope.launch { - delay(STREAM_SCREEN_WARMUP_COOLDOWN_MS) - val warmup = synchronized(lifecycleLock) { - if (currentHash != null) { - warmupCooldownJob = null - return@launch - } - val job = warmupJob - warmupJob = null - warmupCooldownJob = null - job - } - try { - warmup?.join() - } catch (_: CancellationException) { - } - val shouldStop = synchronized(lifecycleLock) { currentHash == null } - if (!shouldStop) { - return@launch - } - try { - binary.stop() - } catch (e: Exception) { - Log.w(TAG, "Failed to stop idle TorrServer", e) - } - } - } - } - actual suspend fun startStream(request: P2pStreamRequest): String = withContext(Dispatchers.IO) { - val requestedHash = request.infoHash.trim().takeIf { it.isNotEmpty() } - ?: throw P2pStreamingException("Missing torrent info hash") - val detached = beginStreamGeneration() - val generation = detached.generation - detached.hash?.let(::scheduleIdleDrop) + stopStreamNow(stopBinary = false) + val generation = nextStreamGeneration() _state.value = P2pStreamingState.Connecting - var attachedHash: String? = null try { - cancelWarmupCooldown() - awaitWarmup() binary.start() ensureCurrentGeneration(generation) - val settingsResult = api.ensureStreamingSettings() - if (settingsResult.changed) { - binary.stop() - binary.start() - api.ensureStreamingSettings() - } - ensureCurrentGeneration(generation) - val magnetLink = buildMagnetUri( - infoHash = requestedHash, - magnetUri = request.magnetUri, - extraTrackers = request.trackers, - ) + val magnetLink = buildMagnetUri(request.infoHash, request.trackers) + Log.d(TAG, "Starting stream: $magnetLink") val hash = api.addTorrent(magnetLink) ?: throw P2pStreamingException("Failed to add torrent") - attachedHash = hash - cancelIdleDrop(hash) if (!attachTorrentIfCurrent(generation, hash)) { - scheduleIdleDrop(hash) + api.dropTorrent(hash) throw CancellationException("P2P stream start was cancelled") } - val requestedName = request.filename?.trim()?.takeIf { it.isNotEmpty() } - val useEngineFileSelector = requestedName != null || request.fileIdx != null - val resolvedIdx = if (useEngineFileSelector) { - null - } else { - resolveFileIndex( - hash = hash, - requestedIdx = request.fileIdx, - filename = request.filename, - ) - } + val resolvedIdx = resolveFileIndex( + hash = hash, + requestedIdx = request.fileIdx, + filename = request.filename, + ) ensureCurrentGeneration(generation) - val streamSelector = TorrServerStreamSelector( - legacyIndex = resolvedIdx, - fileIdx = request.fileIdx, - filename = requestedName, - ) - val streamUrl = api.getStreamUrl( - magnetLink = magnetLink, - selector = streamSelector, - ) + val streamUrl = api.getStreamUrl(magnetLink, resolvedIdx) + Log.d(TAG, "Stream URL: $streamUrl") - startPreload( - hash = hash, - generation = generation, - magnetLink = magnetLink, - selector = streamSelector, - ) - startStatsPolling( - hash = hash, - generation = generation, - ) + startStatsPolling(hash, generation) ensureCurrentGeneration(generation) _state.value = P2pStreamingState.Streaming( @@ -213,11 +90,8 @@ actual object P2pStreamingEngine { streamUrl } catch (e: CancellationException) { - attachedHash?.takeUnless(::isHashCurrent)?.let(::scheduleIdleDrop) throw e } catch (e: Exception) { - Log.w(TAG, "Failed to start P2P stream", e) - attachedHash?.takeUnless(::isHashCurrent)?.let(::scheduleIdleDrop) if (isCurrentGeneration(generation)) { _state.value = P2pStreamingState.Error(e.message ?: "Unknown torrent error") } @@ -226,36 +100,52 @@ actual object P2pStreamingEngine { } actual fun stopStream() { - detachActiveStream()?.let(::scheduleIdleDrop) + scheduleStop(stopBinary = false) } actual fun shutdown() { + scheduleStop(stopBinary = true) + } + + private fun scheduleStop(stopBinary: Boolean) { val hash = detachActiveStream() - val idleHashes = cancelScheduledIdleDrops() - val warmup = synchronized(lifecycleLock) { - warmupCooldownJob?.cancel() - warmupCooldownJob = null - val job = warmupJob - warmupJob = null - job + val previousCleanup = cleanupJob + cleanupJob = scope.launch { + previousCleanup?.join() + cleanupDetachedStream(hash, stopBinary) } - warmup?.cancel() - scope.launch { + } + + private suspend fun stopStreamNow(stopBinary: Boolean) { + cleanupJob?.join() + val hash = detachActiveStream() + cleanupDetachedStream(hash, stopBinary) + } + + private fun detachActiveStream(): String? { + val detached = synchronized(lifecycleLock) { + streamGeneration += 1 + val hash = currentHash + val job = statsJob + currentHash = null + statsJob = null + hash to job + } + detached.second?.cancel() + _state.value = P2pStreamingState.Idle + return detached.first + } + + private suspend fun cleanupDetachedStream(hash: String?, stopBinary: Boolean) { + hash?.let { try { - warmup?.join() - } catch (_: CancellationException) { + api.dropTorrent(it) + } catch (e: Exception) { + Log.w(TAG, "Error dropping torrent", e) } + } - (listOfNotNull(hash) + idleHashes) - .distinctBy { hashKey(it) } - .forEach { - try { - api.dropTorrent(it) - } catch (e: Exception) { - Log.w(TAG, "Error dropping torrent", e) - } - } - + if (stopBinary) { try { binary.stop() } catch (e: Exception) { @@ -264,49 +154,11 @@ actual object P2pStreamingEngine { } } - private data class DetachedStream( - val generation: Long, - val hash: String?, - val statsJob: Job?, - val preloadJob: Job?, - ) - - private fun beginStreamGeneration(): DetachedStream { - val detached = synchronized(lifecycleLock) { - streamGeneration += 1 - val detached = DetachedStream( - generation = streamGeneration, - hash = currentHash, - statsJob = statsJob, - preloadJob = preloadJob, - ) - currentHash = null - statsJob = null - preloadJob = null - detached - } - detached.statsJob?.cancel() - detached.preloadJob?.cancel() - return detached - } - - private fun detachActiveStream(): String? { - val detached = beginStreamGeneration() - _state.value = P2pStreamingState.Idle - return detached.hash - } - - private suspend fun awaitWarmup() { - val job = synchronized(lifecycleLock) { warmupJob?.takeIf { it.isActive } } - job?.join() - } - - private fun cancelWarmupCooldown() { + private fun nextStreamGeneration(): Long = synchronized(lifecycleLock) { - warmupCooldownJob?.cancel() - warmupCooldownJob = null + streamGeneration += 1 + streamGeneration } - } private fun attachTorrentIfCurrent(generation: Long, hash: String): Boolean = synchronized(lifecycleLock) { @@ -324,171 +176,59 @@ actual object P2pStreamingEngine { } } - private fun isHashCurrent(hash: String): Boolean = - synchronized(lifecycleLock) { hashMatches(currentHash, hash) } - - private fun scheduleIdleDrop(hash: String, delayMs: Long = IDLE_TORRENT_TTL_MS) { - val key = hashKey(hash) - if (key.isBlank()) return - val job = scope.launch { - delay(delayMs) - val shouldDrop = synchronized(lifecycleLock) { - if (hashMatches(currentHash, hash)) { - idleDropJobs.remove(key) - false - } else { - idleDropJobs.remove(key) - true - } - } - if (shouldDrop) { - api.dropTorrent(hash) - } - } - synchronized(lifecycleLock) { - if (hashMatches(currentHash, hash)) { - job.cancel() - return - } - idleDropJobs.remove(key)?.cancel() - idleDropJobs[key] = job - } + private fun buildMagnetUri(infoHash: String, extraTrackers: List): String { + val trackers = (DEFAULT_TRACKERS + extraTrackers).distinct() + val trackerParams = trackers.joinToString("") { "&tr=$it" } + return "magnet:?xt=urn:btih:$infoHash$trackerParams" } - private fun cancelIdleDrop(hash: String) { - synchronized(lifecycleLock) { - idleDropJobs.remove(hashKey(hash))?.let { - it.cancel() - } - } - } - - private fun cancelScheduledIdleDrops(): List = - synchronized(lifecycleLock) { - val hashes = idleDropJobs.keys.toList() - idleDropJobs.values.forEach { it.cancel() } - idleDropJobs.clear() - hashes - } - - private fun hashMatches(left: String?, right: String?): Boolean { - if (left.isNullOrBlank() || right.isNullOrBlank()) return false - return hashKey(left) == hashKey(right) - } - - private fun hashKey(hash: String): String = - hash.trim().lowercase(Locale.US) - - private fun buildMagnetUri( - infoHash: String, - magnetUri: String?, - extraTrackers: List, - ): String { - val parsedMagnet = parseMagnetUri(magnetUri) - val trackers = (DEFAULT_TRACKERS + parsedMagnet.trackers + extraTrackers) - .asSequence() - .mapNotNull(::normalizeTracker) - .distinctBy { it.lowercase(Locale.US) } - .toList() - - return buildString { - append("magnet:?xt=urn:btih:") - append(infoHash.trim()) - parsedMagnet.passthroughParams.forEach { param -> - append('&') - append(param) - } - trackers.forEach { tracker -> - append("&tr=") - append(URLEncoder.encode(tracker, "UTF-8")) - } - } - } - - private data class ParsedMagnet( - val trackers: List, - val passthroughParams: List, - ) - - private fun parseMagnetUri(magnetUri: String?): ParsedMagnet { - val raw = magnetUri - ?.trim() - ?.takeIf { it.startsWith("magnet:", ignoreCase = true) } - ?: return ParsedMagnet(trackers = emptyList(), passthroughParams = emptyList()) - val query = raw.substringAfter('?', missingDelimiterValue = "") - if (query.isBlank()) return ParsedMagnet(trackers = emptyList(), passthroughParams = emptyList()) - - val trackers = mutableListOf() - val passthroughParams = mutableListOf() - query.split('&') - .filter { it.isNotBlank() } - .forEach { param -> - val key = param.substringBefore('=').lowercase(Locale.US) - when (key) { - "tr" -> trackers += decodeQueryValue(param.substringAfter('=', missingDelimiterValue = "")) - "xt" -> Unit - else -> passthroughParams += param - } - } - return ParsedMagnet( - trackers = trackers, - passthroughParams = passthroughParams.distinct(), - ) - } - - private fun decodeQueryValue(value: String): String = - runCatching { URLDecoder.decode(value, "UTF-8") } - .getOrDefault(value) - - private fun normalizeTracker(value: String): String? = - value - .trim() - .removePrefix("tracker:") - .trim() - .takeIf { it.isNotEmpty() } - private suspend fun resolveFileIndex(hash: String, requestedIdx: Int?, filename: String?): Int { - val requestedName = filename?.trim()?.takeIf { it.isNotEmpty() } - if (requestedIdx != null) { - val torrServerIndex = requestedIdx + 1 + val deadline = System.currentTimeMillis() + 15_000L + var files: List = emptyList() - if (requestedName != null) { - val files = waitForTorrentFiles( - hash = hash, - timeoutMs = FILE_INDEX_FAST_VALIDATION_TIMEOUT_MS, - ) - if (files.isNotEmpty()) { - resolveByFilename(files, requestedName)?.let { match -> - return match.id - } - - val requestedIdMatch = files.firstOrNull { it.id == torrServerIndex } - if (requestedIdMatch != null) { - return torrServerIndex - } - - val positionalFile = files.getOrNull(requestedIdx) - if (positionalFile != null) { - return positionalFile.id - } - } - } - return torrServerIndex + while (System.currentTimeMillis() < deadline) { + files = api.getTorrentStats(hash)?.files ?: emptyList() + if (files.isNotEmpty()) break + Log.d(TAG, "Waiting for torrent metadata...") + delay(1_000L) } - val files = waitForTorrentFiles( - hash = hash, - timeoutMs = FILE_INDEX_METADATA_TIMEOUT_MS, - ) - if (files.isEmpty()) { - return 1 + Log.w(TAG, "No files after metadata timeout, guessing index ${requestedIdx?.plus(1) ?: 1}") + return requestedIdx?.plus(1) ?: 1 } - if (requestedName != null) { - resolveByFilename(files, requestedName)?.let { match -> - return match.id + if (!filename.isNullOrBlank()) { + val name = filename.trim() + val exact = files.firstOrNull { file -> + file.path.substringAfterLast('/').equals(name, ignoreCase = true) } + if (exact != null) { + Log.d(TAG, "File resolved by exact filename match: ${exact.path} -> id=${exact.id}") + return exact.id + } + + val contains = files.firstOrNull { file -> + file.path.contains(name, ignoreCase = true) + } + if (contains != null) { + Log.d(TAG, "File resolved by filename contains match: ${contains.path} -> id=${contains.id}") + return contains.id + } + } + + if (requestedIdx != null) { + val torrServerIndex = requestedIdx + 1 + if (files.any { it.id == torrServerIndex }) { + Log.d(TAG, "File resolved by ID offset: id=$torrServerIndex") + return torrServerIndex + } + } + + if (requestedIdx != null && requestedIdx in files.indices) { + val positionalFile = files[requestedIdx] + Log.d(TAG, "File resolved by positional index: [$requestedIdx] -> ${positionalFile.path} (id=${positionalFile.id})") + return positionalFile.id } val videoFile = files @@ -499,89 +239,13 @@ actual object P2pStreamingEngine { .maxByOrNull { it.length } val result = videoFile?.id ?: files.maxByOrNull { it.length }?.id ?: 1 + Log.d(TAG, "File resolved by largest video fallback: id=$result") return result } - private suspend fun waitForTorrentFiles( - hash: String, - timeoutMs: Long, - ): List { - val startedAt = System.currentTimeMillis() - var attempt = 0 - while (System.currentTimeMillis() - startedAt < timeoutMs) { - attempt += 1 - val stats = api.getTorrentStats(hash) - val files = stats?.files.orEmpty() - if (files.isNotEmpty()) { - return files - } - delay(FILE_INDEX_POLL_INTERVAL_MS) - } - return emptyList() - } - - private fun resolveByFilename(files: List, filename: String): TorrServerFile? { - val name = filename.trim() - val exactBasename = files.firstOrNull { file -> - file.path.substringAfterLast('/').equals(name, ignoreCase = true) - } - if (exactBasename != null) { - return exactBasename - } - - val exactPath = files.firstOrNull { file -> - file.path.equals(name, ignoreCase = true) - } - if (exactPath != null) { - return exactPath - } - - val contains = files.firstOrNull { file -> - file.path.contains(name, ignoreCase = true) - } - if (contains != null) { - return contains - } - - return null - } - - private fun startPreload( - hash: String, - generation: Long, - magnetLink: String, - selector: TorrServerStreamSelector, - ) { - val job = scope.launch { - try { - api.preloadTorrent( - magnetLink = magnetLink, - selector = selector, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Torrent preload failed", e) - } - } - val previousJob = synchronized(lifecycleLock) { - if (streamGeneration == generation && hashMatches(currentHash, hash)) { - val previous = preloadJob - preloadJob = job - previous - } else { - job.cancel() - null - } - } - previousJob?.cancel() - } - - private fun startStatsPolling( - hash: String, - generation: Long, - ) { - val job = scope.launch { + private fun startStatsPolling(hash: String, generation: Long) { + statsJob?.cancel() + statsJob = scope.launch { while (isActive) { if (!isCurrentGeneration(generation)) return@launch try { @@ -608,46 +272,22 @@ actual object P2pStreamingEngine { delay(1_000L) } } - val previousJob = synchronized(lifecycleLock) { - if (streamGeneration == generation && hashMatches(currentHash, hash)) { - val previous = statsJob - statsJob = job - previous - } else { - job.cancel() - null - } - } - previousJob?.cancel() } + private fun requireContext(): Context = + appContext ?: throw P2pStreamingException("P2P streaming engine is not initialized") + private val DEFAULT_TRACKERS = listOf( "udp://tracker.opentrackr.org:1337/announce", - "udp://open.demonii.com:1337/announce", "udp://open.stealth.si:80/announce", - "https://torrent.tracker.durukanbal.com:443/announce", - "udp://wepzone.net:6969/announce", - "udp://tracker.wepzone.net:6969/announce", - "udp://tracker.torrent.eu.org:451/announce", - "udp://tracker.theoks.net:6969/announce", - "udp://tracker.t-1.org:6969/announce", - "udp://tracker.darkness.services:6969/announce", - "udp://tracker-udp.gbitt.info:80/announce", - "udp://t.overflow.biz:6969/announce", - "udp://open.dstud.io:6969/announce", - "udp://explodie.org:6969/announce", + "udp://tracker.openbittorrent.com:6969/announce", "udp://exodus.desync.com:6969/announce", - "udp://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce", - "https://tracker.zhuqiy.com:443/announce", - "https://tracker.pmman.tech:443/announce", - "https://tracker.moeblog.cn:443/announce", - "https://tracker.bt4g.com:443/announce", + "udp://tracker.torrent.eu.org:451/announce", ) private class TorrServerBinary { private var context: Context? = null private var process: Process? = null - private val startMutex = Mutex() private val healthClient = OkHttpClient.Builder() .connectTimeout(2, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS) @@ -659,64 +299,67 @@ actual object P2pStreamingEngine { this.context = context.applicationContext } - suspend fun start() = startMutex.withLock { - withContext(Dispatchers.IO) { + suspend fun start() = withContext(Dispatchers.IO) { + if (isRunning()) { + Log.d(TAG, "TorrServer already running") + return@withContext + } + + killOrphanedProcess() + + val ctx = requireContext() + val binaryFile = File(ctx.applicationInfo.nativeLibraryDir, "libtorrserver.so") + if (!binaryFile.exists()) { + throw P2pStreamingException("TorrServer binary not found at ${binaryFile.absolutePath}") + } + + if (!binaryFile.canExecute()) { + binaryFile.setExecutable(true) + } + + val configDir = File(ctx.filesDir, "torrserver").also { it.mkdirs() } + val processBuilder = ProcessBuilder( + binaryFile.absolutePath, + "--port", + PORT.toString(), + "--path", + configDir.absolutePath, + ) + processBuilder.directory(configDir) + processBuilder.redirectErrorStream(true) + + Log.d(TAG, "Starting TorrServer on port $PORT from ${binaryFile.absolutePath}") + process = processBuilder.start() + + val proc = process!! + Thread { + try { + proc.inputStream.bufferedReader().forEachLine { line -> + Log.d(TAG, "[server] $line") + } + } catch (_: Exception) { + } + }.apply { + isDaemon = true + start() + } + + val deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { if (isRunning()) { + Log.d(TAG, "TorrServer started successfully") return@withContext } - - killOrphanedProcess() - - val ctx = requireContext() - val binaryFile = File(ctx.applicationInfo.nativeLibraryDir, "libtorrserver.so") - if (!binaryFile.exists()) { - throw P2pStreamingException("TorrServer binary not found at ${binaryFile.absolutePath}") + if (!isProcessAlive(process)) { + val exitCode = process?.exitValue() ?: -1 + process = null + throw P2pStreamingException("TorrServer process died on startup (exit code $exitCode)") } - - if (!binaryFile.canExecute()) { - binaryFile.setExecutable(true) - } - - val configDir = File(ctx.filesDir, "torrserver").also { it.mkdirs() } - val processBuilder = ProcessBuilder( - binaryFile.absolutePath, - "--port", - PORT.toString(), - "--path", - configDir.absolutePath, - ) - processBuilder.directory(configDir) - processBuilder.redirectErrorStream(true) - - process = processBuilder.start() - - val proc = process!! - Thread { - try { - proc.inputStream.bufferedReader().forEachLine { } - } catch (_: Exception) { - } - }.apply { - isDaemon = true - start() - } - - val deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS - while (System.currentTimeMillis() < deadline) { - if (isRunning()) { - return@withContext - } - if (!isProcessAlive(process)) { - val exitCode = process?.exitValue() ?: -1 - process = null - throw P2pStreamingException("TorrServer process died on startup (exit code $exitCode)") - } - delay(HEALTH_CHECK_INTERVAL_MS) - } - - stop() - throw P2pStreamingException("TorrServer failed to start within ${STARTUP_TIMEOUT_MS / 1000}s") + delay(HEALTH_CHECK_INTERVAL_MS) } + + stop() + throw P2pStreamingException("TorrServer failed to start within ${STARTUP_TIMEOUT_MS / 1000}s") } fun isRunning(): Boolean { @@ -746,6 +389,7 @@ actual object P2pStreamingEngine { } } process = null + Log.d(TAG, "TorrServer stopped") } private fun killOrphanedProcess() { @@ -753,6 +397,7 @@ actual object P2pStreamingEngine { val request = Request.Builder().url("$baseUrl/shutdown").build() healthClient.newCall(request).execute().close() Thread.sleep(1_000L) + Log.d(TAG, "Shut down orphaned TorrServer instance") } catch (_: Exception) { } } @@ -781,194 +426,31 @@ actual object P2pStreamingEngine { private data class TorrServerFile( val id: Int, - val index: Int, val path: String, val length: Long, ) - private data class TorrServerStreamSelector( - val legacyIndex: Int?, - val fileIdx: Int?, - val filename: String?, - ) - private data class TorrServerStats( - val status: String, val downloadSpeed: Long, val uploadSpeed: Long, val peers: Int, val seeds: Int, - val totalPeers: Int, - val pendingPeers: Int, - val halfOpenPeers: Int, val preloadedBytes: Long, - val preloadSize: Long, val loadedSize: Long, val torrentSize: Long, - val bytesRead: Long, - val bytesReadUsefulData: Long, - val chunksRead: Long, - val chunksReadUseful: Long, - val chunksReadWasted: Long, - val piecesDirtiedGood: Long, - val piecesDirtiedBad: Long, val files: List, ) - private data class StreamingSettingsResult( - val success: Boolean, - val changed: Boolean, - ) - private class TorrServerApi( private val binary: TorrServerBinary, ) { - private val settingsMutex = Mutex() private val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() - private val preloadClient = client.newBuilder() - .readTimeout(0, TimeUnit.MILLISECONDS) - .build() private val baseUrl: String get() = binary.baseUrl - suspend fun ensureStreamingSettings(): StreamingSettingsResult = settingsMutex.withLock { - withContext(Dispatchers.IO) { - try { - val settings = getSettings() ?: return@withContext StreamingSettingsResult( - success = false, - changed = false, - ) - val changes = mutableListOf() - - putIfDifferent(settings, "CacheSize", STREAMING_CACHE_SIZE_BYTES, changes) - putIfDifferent(settings, "ConnectionsLimit", STREAMING_CONNECTION_LIMIT, changes) - putIfDifferent(settings, "HalfOpenConnectionsLimit", STREAMING_HALF_OPEN_CONNECTION_LIMIT, changes) - putIfDifferent(settings, "TotalHalfOpenConnectionsLimit", STREAMING_TOTAL_HALF_OPEN_CONNECTION_LIMIT, changes) - putIfDifferent(settings, "TorrentPeersHighWater", STREAMING_PEERS_HIGH_WATER, changes) - putIfDifferent(settings, "TorrentPeersLowWater", STREAMING_PEERS_LOW_WATER, changes) - putIfDifferent(settings, "NominalDialTimeoutMs", STREAMING_NOMINAL_DIAL_TIMEOUT_MS, changes) - putIfDifferent(settings, "MinDialTimeoutMs", STREAMING_MIN_DIAL_TIMEOUT_MS, changes) - putIfDifferent(settings, "HandshakeTimeoutMs", STREAMING_HANDSHAKE_TIMEOUT_MS, changes) - putIfDifferent(settings, "TorrentDisconnectTimeout", STREAMING_DISCONNECT_TIMEOUT_SECONDS, changes) - putIfDifferent(settings, "ReaderReadAHead", STREAMING_READ_AHEAD_PERCENT, changes) - putIfDifferent(settings, "PreloadCache", STREAMING_PRELOAD_CACHE_PERCENT, changes) - putIfDifferent(settings, "ResponsiveMode", true, changes) - putIfDifferent(settings, "DisableDHT", false, changes) - putIfDifferent(settings, "DisablePEX", false, changes) - putIfDifferent(settings, "DisableTCP", false, changes) - putIfDifferent(settings, "DisableUTP", false, changes) - putIfDifferent(settings, "DisableUpload", false, changes) - putIfDifferent(settings, "ForceEncrypt", false, changes) - putIfDifferent(settings, "DownloadRateLimit", 0, changes) - putIfDifferent(settings, "UploadRateLimit", 0, changes) - putIfDifferent(settings, "RetrackersMode", 1, changes) - putIfDifferent(settings, "EnableLPD", true, changes) - putIfDifferent(settings, "LPDIPv6", false, changes) - putIfDifferent(settings, "StoreSettingsInJson", true, changes) - - if (changes.isEmpty()) { - return@withContext StreamingSettingsResult( - success = true, - changed = false, - ) - } - - val body = JSONObject().apply { - put("action", "set") - put("sets", settings) - } - val request = Request.Builder() - .url("$baseUrl/settings") - .post(body.toString().toRequestBody(JSON_TYPE)) - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Log.w(TAG, "streaming-settings: set failed code=${response.code}") - return@withContext StreamingSettingsResult( - success = false, - changed = false, - ) - } - } - StreamingSettingsResult( - success = true, - changed = true, - ) - } catch (e: Exception) { - Log.w(TAG, "streaming-settings: failed", e) - StreamingSettingsResult( - success = false, - changed = false, - ) - } - } - } - - private fun getSettings(): JSONObject? { - val body = JSONObject().apply { - put("action", "get") - } - val request = Request.Builder() - .url("$baseUrl/settings") - .post(body.toString().toRequestBody(JSON_TYPE)) - .build() - - return client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Log.w(TAG, "streaming-settings: get failed code=${response.code}") - null - } else { - JSONObject(response.body?.string()?.takeIf { it.isNotBlank() } ?: "{}") - } - } - } - - private fun putIfDifferent( - settings: JSONObject, - key: String, - desiredValue: Int, - changes: MutableList, - ) { - val hasKey = settings.has(key) - val currentValue = settings.optInt(key, Int.MIN_VALUE) - if (!hasKey || currentValue != desiredValue) { - settings.put(key, desiredValue) - changes += key - } - } - - private fun putIfDifferent( - settings: JSONObject, - key: String, - desiredValue: Long, - changes: MutableList, - ) { - val hasKey = settings.has(key) - val currentValue = settings.optLong(key, Long.MIN_VALUE) - if (!hasKey || currentValue != desiredValue) { - settings.put(key, desiredValue) - changes += key - } - } - - private fun putIfDifferent( - settings: JSONObject, - key: String, - desiredValue: Boolean, - changes: MutableList, - ) { - val hasKey = settings.has(key) - val currentValue = settings.optBoolean(key, !desiredValue) - if (!hasKey || currentValue != desiredValue) { - settings.put(key, desiredValue) - changes += key - } - } - suspend fun addTorrent(magnetLink: String, title: String? = null): String? = withContext(Dispatchers.IO) { val body = JSONObject().apply { put("action", "add") @@ -990,6 +472,7 @@ actual object P2pStreamingEngine { } val json = JSONObject(response.body?.string() ?: "{}") val hash = json.optString("hash", "") + Log.d(TAG, "Torrent added: $hash") hash.ifEmpty { null } } } catch (e: Exception) { @@ -998,38 +481,6 @@ actual object P2pStreamingEngine { } } - suspend fun preloadTorrent( - magnetLink: String, - selector: TorrServerStreamSelector, - ): Boolean = withContext(Dispatchers.IO) { - val url = getStreamUrl( - magnetLink = magnetLink, - selector = selector, - mode = "preload", - ) - val request = Request.Builder() - .url(url) - .get() - .build() - val call = preloadClient.newCall(request) - val cancellationHandle = currentCoroutineContext()[Job]?.invokeOnCompletion { cause -> - if (cause is CancellationException) { - call.cancel() - } - } - try { - call.execute().use { response -> - if (!response.isSuccessful) { - Log.w(TAG, "preload: request failed code=${response.code}") - return@withContext false - } - true - } - } finally { - cancellationHandle?.dispose() - } - } - suspend fun getTorrentStats(hash: String): TorrServerStats? = withContext(Dispatchers.IO) { val body = JSONObject().apply { put("action", "get") @@ -1053,7 +504,6 @@ actual object P2pStreamingEngine { files.add( TorrServerFile( id = file.optInt("id", i + 1), - index = file.optInt("index", i), path = file.optString("path", ""), length = file.optLong("length", 0), ), @@ -1061,25 +511,13 @@ actual object P2pStreamingEngine { } TorrServerStats( - status = json.optString("stat_string", ""), downloadSpeed = json.optLong("download_speed", 0), uploadSpeed = json.optLong("upload_speed", 0), peers = json.optInt("active_peers", 0), seeds = json.optInt("connected_seeders", 0), - totalPeers = json.optInt("total_peers", 0), - pendingPeers = json.optInt("pending_peers", 0), - halfOpenPeers = json.optInt("half_open_peers", 0), preloadedBytes = json.optLong("preloaded_bytes", 0), - preloadSize = json.optLong("preload_size", 0), loadedSize = json.optLong("loaded_size", 0), torrentSize = json.optLong("torrent_size", 0), - bytesRead = json.optLong("bytes_read", 0), - bytesReadUsefulData = json.optLong("bytes_read_useful_data", 0), - chunksRead = json.optLong("chunks_read", 0), - chunksReadUseful = json.optLong("chunks_read_useful", 0), - chunksReadWasted = json.optLong("chunks_read_wasted", 0), - piecesDirtiedGood = json.optLong("pieces_dirtied_good", 0), - piecesDirtiedBad = json.optLong("pieces_dirtied_bad", 0), files = files, ) } @@ -1102,27 +540,15 @@ actual object P2pStreamingEngine { try { client.newCall(request).execute().close() + Log.d(TAG, "Torrent dropped: $hash") } catch (e: Exception) { Log.w(TAG, "dropTorrent error", e) } } - fun getStreamUrl( - magnetLink: String, - selector: TorrServerStreamSelector, - mode: String = "play", - ): String { - val params = mutableListOf( - "link=${URLEncoder.encode(magnetLink, "UTF-8")}", - mode, - ) - selector.legacyIndex?.let { params += "index=$it" } - selector.fileIdx?.let { params += "fileIdx=$it" } - selector.filename - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { params += "filename=${URLEncoder.encode(it, "UTF-8")}" } - return "$baseUrl/stream?${params.joinToString("&")}" + fun getStreamUrl(magnetLink: String, fileIdx: Int): String { + val encodedLink = URLEncoder.encode(magnetLink, "UTF-8") + return "$baseUrl/stream?link=$encodedLink&index=$fileIdx&play" } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 19bb30218..2afd14d52 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform { } // Title extras - val displayTitle = request.streamTitle ?: request.title + val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true) putExtra(Intent.EXTRA_TITLE, displayTitle) putExtra("title", displayTitle) putExtra("forcename", displayTitle) // Vimu Player @@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform { if (request.resumePositionMs > 0L) { putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player + putExtra("forceresume", true) // Vimu: enable resume for network streams putExtra("from_start", false) // VLC: don't force start from beginning } @@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform { val subtitleNames = subtitles.map { it.name }.toTypedArray() val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray() + // Grant read permission for content:// URIs via ClipData. + // FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras. + // Adding all subtitle URIs to ClipData ensures the receiving player + // gets read access to all of them. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val clipData = android.content.ClipData( + "subtitles", + arrayOf("application/x-subrip", "text/vtt"), + android.content.ClipData.Item(subtitleUris.first()) + ) + subtitleUris.drop(1).forEach { subtitleUri -> + clipData.addItem(android.content.ClipData.Item(subtitleUri)) + } + setClipData(clipData) + // MX Player / mpv-android / Nova putExtra("subs", subtitleUris) putExtra("subs.name", subtitleNames) putExtra("subs.filename", subtitleFilenames) - putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url))) + putExtra("subs.enable", arrayOf(subtitleUris.first())) // Just Player putExtra("subtitle_uri", subtitleUris) putExtra("subtitle_name", subtitleNames) // VLC (single subtitle — use first one) - putExtra("subtitles_location", Uri.parse(subtitles.first().url)) + putExtra("subtitles_location", subtitleUris.first()) // Vimu Player putExtra("forcedsrt", subtitles.first().url) 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 db1bb81d2..dafaedee3 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 @@ -85,7 +85,13 @@ actual fun PlatformPlayerSurface( modifier: Modifier, playWhenReady: Boolean, resizeMode: PlayerResizeMode, + initialPositionMs: Long, useNativeController: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsEvent: (String, Double) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt new file mode 100644 index 000000000..1fc7223af --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.features.player + +/** + * Android implementation: downloads subtitles to local cache and returns content:// URIs + * via FileProvider so external players can read them. + */ +actual object SubtitleCacheProvider { + actual suspend fun cacheForExternalPlayer(subtitles: List): List? { + return SubtitleFileCache.cacheSubtitles(subtitles) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt new file mode 100644 index 000000000..3fa7106da --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt @@ -0,0 +1,130 @@ +package com.nuvio.app.features.player + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +/** + * Downloads subtitle files from remote URLs to local cache and provides + * content:// URIs via FileProvider so external players can read them. + */ +object SubtitleFileCache { + private const val TAG = "SubtitleFileCache" + private const val SUBTITLE_CACHE_DIR = "subtitles" + + private var appContext: Context? = null + private val okHttpClient: OkHttpClient by lazy { OkHttpClient() } + + fun initialize(context: Context) { + appContext = context.applicationContext + } + + private val cacheDir: File? + get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } } + + /** + * Downloads subtitle files and returns updated [SubtitleInput] list with + * content:// URIs instead of HTTP URLs. + * + * Subtitles that fail to download are silently skipped (not included in result). + * Returns null if no subtitles could be downloaded. + */ + suspend fun cacheSubtitles(subtitles: List): List? { + if (subtitles.isEmpty()) return null + val context = appContext ?: return null + + // Clean old cached subtitles before downloading new ones + clearCache() + + val cached = subtitles.mapNotNull { input -> + try { + val localUri = downloadToCache(context, input) + if (localUri != null) { + input.copy(url = localUri.toString()) + } else { + Log.w(TAG, "Failed to download subtitle: ${input.name}") + null + } + } catch (e: Exception) { + Log.w(TAG, "Error caching subtitle: ${input.name}", e) + null + } + } + + return cached.ifEmpty { null } + } + + /** + * Downloads a single subtitle file and returns a content:// URI. + */ + private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? = + withContext(Dispatchers.IO) { + val dir = cacheDir ?: return@withContext null + val extension = guessExtension(input.url) + val filename = sanitizeFilename("${input.lang}_${input.name}.$extension") + val file = File(dir, filename) + + val request = Request.Builder() + .url(input.url) + .build() + + try { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}") + return@withContext null + } + + val body = response.body ?: return@withContext null + file.outputStream().use { output -> + body.byteStream().copyTo(output) + } + } + + // Return content:// URI via FileProvider + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to download subtitle file: ${input.url}", e) + file.delete() + null + } + } + + /** + * Removes all cached subtitle files. + */ + fun clearCache() { + try { + cacheDir?.listFiles()?.forEach { it.delete() } + } catch (e: Exception) { + Log.w(TAG, "Failed to clear subtitle cache", e) + } + } + + private fun guessExtension(url: String): String { + val path = url.substringBefore('?').substringBefore('#').trimEnd('/') + return when { + path.endsWith(".vtt", ignoreCase = true) -> "vtt" + path.endsWith(".ass", ignoreCase = true) -> "ass" + path.endsWith(".ssa", ignoreCase = true) -> "ssa" + path.endsWith(".ttml", ignoreCase = true) -> "ttml" + path.endsWith(".dfxp", ignoreCase = true) -> "dfxp" + else -> "srt" + } + } + + private fun sanitizeFilename(name: String): String { + return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_") + .take(100) // Limit filename length + } +} 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 6a2e1584d..0d8e5b9b6 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,11 +18,13 @@ actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" + private const val desktopNavigationLayoutKey = "desktop_navigation_layout" private const val selectedAppLanguageKey = "selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + desktopNavigationLayoutKey, ) private var preferences: SharedPreferences? = null @@ -68,6 +70,16 @@ actual object ThemeSettingsStorage { ?.apply() } + actual fun loadDesktopNavigationLayout(): String? = + preferences?.getString(ProfileScopedKey.of(desktopNavigationLayoutKey), null) + + actual fun saveDesktopNavigationLayout(layoutName: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName) + ?.apply() + } + actual fun loadSelectedAppLanguage(): String? { val value = preferences?.getString(selectedAppLanguageKey, null) if (value != null) return value @@ -93,6 +105,7 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -103,6 +116,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt index cfab3bd0f..64a951188 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt @@ -16,6 +16,7 @@ actual object StreamBadgeSettingsStorage { private const val legacyDebridPreferencesName = "nuvio_debrid_settings" private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" @@ -41,6 +42,12 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) actual fun saveStreamBadgePlacement(placement: String) { diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml index 9759b2796..e9db651dc 100644 --- a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml +++ b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml @@ -3,6 +3,9 @@ + 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 2b308a232..4ceee4668 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,6 +2,8 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false + actual val downloadsEnabled: Boolean = true + actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL actual val heroTrailerPlaybackSupported: Boolean = false 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 new file mode 100644 index 000000000..1d181ca62 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.build + +actual object AppVersionPolicy { + actual val displayVersionName: String = AppVersionConfig.VERSION_NAME + actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE + actual val basedOnVersionName: String? = null +} diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3e487c29c..f179fcf56 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1456,4 +1456,7 @@ Torrenty Usenet Web + Logo dodatku + Pokazuj logo i nazwę dodatku obok źródeł. + Wyświetlanie diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 582b500a1..f849d9339 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -300,6 +300,7 @@ Pinned All Your Collections + Based on Nuvio %1$s Made with ❤️ by Tapframe and friends Version %1$s (%2$s) Off @@ -535,6 +536,10 @@ 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. @@ -681,6 +686,9 @@ Fusion Style Size badges Show file size badges in stream results and player source panels. + Addon logo + Show addon logo and name next to stream sources. + Display Badge position Choose whether Fusion and size badges appear above or below stream cards. Badge position diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 32b14c525..458c752c5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -3,6 +3,7 @@ package com.nuvio.app import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -11,6 +12,9 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.Image +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -19,15 +23,18 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -55,7 +62,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -153,12 +163,15 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders +import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar +import com.nuvio.app.features.profiles.AvatarCatalogItem import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab +import com.nuvio.app.features.profiles.SidebarProfileSwitcherStack import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen @@ -168,13 +181,16 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen +import com.nuvio.app.features.settings.DesktopNavigationLayout import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository +import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository @@ -192,6 +208,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository @@ -204,10 +221,13 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark @@ -321,6 +341,11 @@ enum class AppScreenTab { Settings, } +private val DesktopSidebarCollapsedWidth = 76.dp +private val DesktopSidebarExpandedWidth = 184.dp +private val DesktopSidebarExpandedContentWidth = 144.dp +private val DesktopSidebarIconSlotSize = 36.dp + private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { AppScreenTab.Home -> NativeNavigationTab.Home AppScreenTab.Search -> NativeNavigationTab.Search @@ -342,16 +367,46 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba streamTitle = streamTitle, sourceHeaders = sourceHeaders, resumePositionMs = initialPositionMs, + season = seasonNumber, + episode = episodeNumber, + episodeTitle = episodeTitle, ) private enum class AppGateScreen { Loading, Auth, ProfileSelection, + ProfileSwitching, ProfileEdit, Main, } +private data class PendingProfileSwitch( + val profile: NuvioProfile, + val syncOnEnter: Boolean, +) + +private suspend fun warmProfileBoundRepositories() { + withContext(Dispatchers.Default) { + AddonRepository.initialize() + CollectionRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() + DownloadsRepository.ensureLoaded() + EpisodeReleaseNotificationsRepository.ensureLoaded() + HomeCatalogSettingsRepository.snapshot() + LibraryRepository.ensureLoaded() + P2pSettingsRepository.ensureLoaded() + PlayerSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + WatchedRepository.ensureLoaded() + WatchProgressRepository.ensureLoaded() + CollectionSyncService.startObserving() + HomeCatalogSettingsSyncService.startObserving() + ProfileSettingsSync.startObserving() + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -414,6 +469,7 @@ fun App() { var editingProfile by remember { mutableStateOf(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 @@ -429,6 +485,12 @@ fun App() { ?.takeUnless { it.pinEnabled } } + fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) { + autoSkipProfileSelection = false + pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter) + gateScreen = AppGateScreen.ProfileSwitching.name + } + fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { if (profiles.isEmpty()) { autoSkipProfileSelection = true @@ -437,12 +499,7 @@ fun App() { } rememberedStartupProfile(profiles)?.let { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - if (syncOnEnter) { - SyncManager.pullAllForProfile(profile.profileIndex) - } - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch(profile, syncOnEnter) return } @@ -453,34 +510,61 @@ fun App() { gateScreen = AppGateScreen.ProfileSelection.name return } - ProfileRepository.selectProfile(onlyProfile.profileIndex) - if (syncOnEnter) { - SyncManager.pullAllForProfile(onlyProfile.profileIndex) - } - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch(onlyProfile, syncOnEnter) } else { gateScreen = AppGateScreen.ProfileSelection.name } } + LaunchedEffect(gateScreen, pendingProfileSwitch) { + if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) { + gateScreen = AppGateScreen.Loading.name + } + } + + LaunchedEffect(pendingProfileSwitch) { + val request = pendingProfileSwitch ?: return@LaunchedEffect + runCatching { + ProfileRepository.switchToProfile(request.profile.profileIndex) + warmProfileBoundRepositories() + if (request.syncOnEnter) { + SyncManager.pullAllForProfile(request.profile.profileIndex) + } + }.onSuccess { + pendingProfileSwitch = null + autoSkipProfileSelection = false + gateScreen = AppGateScreen.Main.name + }.onFailure { + pendingProfileSwitch = null + autoSkipProfileSelection = false + gateScreen = AppGateScreen.ProfileSelection.name + } + } + LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { + if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect + val cachedProfiles = profileState.profiles - val allowOfflineProfileAccess = + val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && - authState !is AuthState.Authenticated && - networkStatusUiState.condition != NetworkCondition.Online + authState !is AuthState.Authenticated + val allowCachedProfileAccess = + hasCachedProfileAccess && + ( + networkStatusUiState.condition != NetworkCondition.Online || + gateScreen != AppGateScreen.Auth.name + ) when (authState) { is AuthState.Loading -> { - if (allowOfflineProfileAccess) { + if (hasCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { gateScreen = AppGateScreen.Loading.name } } is AuthState.Unauthenticated -> { - if (allowOfflineProfileAccess) { + if (allowCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { ProfileRepository.clearInMemory() @@ -517,10 +601,10 @@ fun App() { gateScreen == AppGateScreen.ProfileSelection.name ) { rememberedStartupProfile(profileState.profiles)?.let { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - SyncManager.pullAllForProfile(profile.profileIndex) - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch( + profile = profile, + syncOnEnter = authState is AuthState.Authenticated, + ) return@LaunchedEffect } @@ -529,10 +613,10 @@ fun App() { val onlyProfile = profileState.profiles.first() if (onlyProfile.pinEnabled) return@LaunchedEffect - ProfileRepository.selectProfile(onlyProfile.profileIndex) - SyncManager.pullAllForProfile(onlyProfile.profileIndex) - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false + requestProfileSwitch( + profile = onlyProfile, + syncOnEnter = authState is AuthState.Authenticated, + ) } } @@ -545,15 +629,9 @@ fun App() { }, ) { currentGate -> when (currentGate) { - AppGateScreen.Loading.name -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.nuvio.colors.background), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) - } + AppGateScreen.Loading.name, + AppGateScreen.ProfileSwitching.name -> { + AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } AppGateScreen.Auth.name -> { AuthScreen(modifier = Modifier.fillMaxSize()) @@ -566,11 +644,10 @@ fun App() { } ProfileSelectionScreen( onProfileSelected = { profile -> - ProfileRepository.selectProfile(profile.profileIndex) - if (authState is AuthState.Authenticated) { - SyncManager.pullAllForProfile(profile.profileIndex) - } - gateScreen = AppGateScreen.Main.name + requestProfileSwitch( + profile = profile, + syncOnEnter = authState is AuthState.Authenticated, + ) }, onEditProfile = { profile -> editingProfile = profile @@ -616,18 +693,6 @@ private fun MainAppContent( ) { val navController = rememberNavController() val appUpdaterController = rememberAppUpdaterController() - remember { - EpisodeReleaseNotificationsRepository.ensureLoaded() - } - remember { - CollectionSyncService.startObserving() - } - remember { - HomeCatalogSettingsSyncService.startObserving() - } - remember { - ProfileSettingsSync.startObserving() - } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } @@ -636,10 +701,17 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(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) } @@ -652,32 +724,14 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } - val addonsUiState by remember { - AddonRepository.initialize() - AddonRepository.uiState - }.collectAsStateWithLifecycle() - val libraryUiState by remember { - LibraryRepository.ensureLoaded() - LibraryRepository.uiState - }.collectAsStateWithLifecycle() + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() - val playerSettingsUiState by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pSettingsUiState by remember { - P2pSettingsRepository.ensureLoaded() - P2pSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val watchedUiState by remember { - WatchedRepository.ensureLoaded() - WatchedRepository.uiState - }.collectAsStateWithLifecycle() - val downloadsUiState by remember { - DownloadsRepository.ensureLoaded() - DownloadsRepository.uiState - }.collectAsStateWithLifecycle() + val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle() + val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() @@ -823,6 +877,7 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true + if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect val hasPlayableDownload = downloadsUiState.completedItems.any { DownloadsRepository.playableLocalFileUri(it) != null } @@ -910,10 +965,7 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by remember { - ContinueWatchingPreferencesRepository.ensureLoaded() - ContinueWatchingPreferencesRepository.uiState - }.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -928,6 +980,14 @@ private fun MainAppContent( } } + LaunchedEffect(currentBackStackEntry?.destination) { + val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute() == true || + currentBackStackEntry?.destination?.hasRoute() == true + if (inPlaybackFlow) { + resumePromptItem = null + } + } + LaunchedEffect(navController) { AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink -> when (deepLink) { @@ -940,9 +1000,11 @@ private fun MainAppContent( } AppDeepLink.Downloads -> { - selectedTab = AppScreenTab.Settings - navController.navigate(DownloadsSettingsRoute) { - launchSingleTop = true + if (AppFeaturePolicy.downloadsEnabled) { + selectedTab = AppScreenTab.Settings + navController.navigate(DownloadsSettingsRoute) { + launchSingleTop = true + } } AppDeepLinkRepository.markConsumed(deepLink) } @@ -1069,7 +1131,7 @@ private fun MainAppContent( val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction - if (!manualSelection) { + if (!manualSelection && AppFeaturePolicy.downloadsEnabled) { val downloadedItem = DownloadsRepository.findPlayableDownload( parentMetaId = parentMetaId, seasonNumber = seasonNumber, @@ -1193,6 +1255,7 @@ private fun MainAppContent( type = section.type, catalogId = section.catalogId, supportsPagination = section.supportsPagination, + genre = section.genre, ), ) } @@ -1217,6 +1280,7 @@ private fun MainAppContent( } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> + resumePromptItem = null if (item.isCloudLibraryContinueWatchingItem()) { coroutineScope.launch { when ( @@ -1324,12 +1388,31 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val useDesktopSidebar = isDesktop && + isTabletLayout && + !useNativeBottomTabs && + desktopNavigationLayout == DesktopNavigationLayout.Sidebar + val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar + val topChromePadding = if (useFloatingTopBar) { + val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + max(statusBarPadding + 24.dp, 48.dp) + 64.dp + } else { + null + } val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home - ProfileRepository.selectProfile(profile.profileIndex) - com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) + coroutineScope.launch { + try { + ProfileRepository.switchToProfile(profile.profileIndex) + warmProfileBoundRepositories() + SyncManager.pullAllForProfile(profile.profileIndex) + delay(300) + } finally { + profileSwitchLoading = false + } + } } Scaffold( @@ -1381,8 +1464,10 @@ private fun MainAppContent( AppTabHost( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp), selectedTab = selectedTab, + topChromePadding = topChromePadding, searchFocusRequestCount = searchFocusRequestCount, rootActionsEnabled = tabsRouteActive, homeScrollToTopRequests = homeScrollToTopRequests, @@ -1438,7 +1523,11 @@ private fun MainAppContent( onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, + onDownloadsSettingsClick = { + if (AppFeaturePolicy.downloadsEnabled) { + navController.navigate(DownloadsSettingsRoute) + } + }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { @@ -1474,7 +1563,14 @@ private fun MainAppContent( ) } - if (isTabletLayout && !useNativeBottomTabs) { + if (useDesktopSidebar) { + DesktopHoverSidebar( + selectedTab = selectedTab, + onTabSelected = ::handleRootTabClick, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } else if (useFloatingTopBar) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = ::handleRootTabClick, @@ -1707,10 +1803,7 @@ private fun MainAppContent( hasResolvedVideoId = true } - val playerSettings by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() + val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" @@ -1722,7 +1815,7 @@ private fun MainAppContent( replaceStreamRoute: Boolean, ) { val infoHash = stream.p2pInfoHash ?: return - val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx) if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, @@ -1739,12 +1832,11 @@ private fun MainAppContent( addonId = stream.addonId, requestHeaders = emptyMap(), responseHeaders = emptyMap(), - filename = stream.p2pFilename, + filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.p2pFileIdx, - magnetUri = stream.torrentMagnetUri, - sources = stream.p2pSourceHints, + fileIdx = stream.fileIdx, + sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) } @@ -1771,9 +1863,8 @@ private fun MainAppContent( parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, torrentInfoHash = infoHash, - torrentFileIdx = stream.p2pFileIdx, - torrentFilename = stream.p2pFilename, - torrentMagnetUri = stream.torrentMagnetUri, + torrentFileIdx = stream.fileIdx, + torrentFilename = stream.behaviorHints.filename, torrentTrackers = stream.p2pTrackers, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, @@ -1842,10 +1933,10 @@ private fun MainAppContent( val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { - if (cached.url.isBlank() && (!cached.infoHash.isNullOrBlank() || !cached.magnetUri.isNullOrBlank())) { + if (cached.url.isBlank() && !cached.infoHash.isNullOrBlank()) { val cachedStream = StreamItem( name = cached.streamName, - url = cached.magnetUri, + url = null, infoHash = cached.infoHash, fileIdx = cached.fileIdx, sources = cached.sources, @@ -2307,7 +2398,6 @@ private fun MainAppContent( torrentInfoHash = launch.torrentInfoHash, torrentFileIdx = launch.torrentFileIdx, torrentFilename = launch.torrentFilename, - torrentMagnetUri = launch.torrentMagnetUri, torrentTrackers = launch.torrentTrackers, initialPositionMs = launch.initialPositionMs, initialProgressFraction = launch.initialProgressFraction, @@ -2422,51 +2512,53 @@ private fun MainAppContent( onBack = onBack, ) } - composable { backStackEntry -> - val onBack = rememberGuardedPopBackStack( - navController = navController, - backStackEntry = backStackEntry, - ) - DownloadsScreen( - onBack = onBack, - onOpenDownload = { item -> - val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen - val resumeEntry = item.videoId - .takeIf { it.isNotBlank() } - ?.let(WatchProgressRepository::progressForVideo) - ?.takeIf { it.isResumable } + if (AppFeaturePolicy.downloadsEnabled) { + composable { 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( @@ -2733,15 +2825,6 @@ private fun MainAppContent( AppLaunchOverlay(modifier = Modifier.fillMaxSize()) } - // Auto-dismiss profile switch overlay - if (profileSwitchLoading) { - LaunchedEffect(Unit) { - // Brief loading screen while home refreshes for the new profile - kotlinx.coroutines.delay(1200) - profileSwitchLoading = false - } - } - NuvioFloatingPrompt( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, @@ -2799,6 +2882,7 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, + topChromePadding: Dp? = null, searchFocusRequestCount: Int = 0, rootActionsEnabled: Boolean = true, homeScrollToTopRequests: Flow, @@ -2856,6 +2940,7 @@ private fun AppTabHost( AppScreenTab.Search -> { SearchScreen( modifier = Modifier.fillMaxSize(), + topChromePadding = topChromePadding, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, @@ -2866,6 +2951,7 @@ private fun AppTabHost( AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), + topChromePadding = topChromePadding, scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, @@ -2901,6 +2987,260 @@ private fun AppTabHost( } } +@Composable +private fun DesktopHoverSidebar( + selectedTab: AppScreenTab, + onTabSelected: (AppScreenTab) -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + val activeProfile = profileState.activeProfile + val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile) + val hoverSource = remember { MutableInteractionSource() } + val hovered by hoverSource.collectIsHoveredAsState() + var profileStackVisible by remember { mutableStateOf(false) } + val sidebarExpanded = hovered || profileStackVisible + val profileTopPadding = statusBarPadding + 18.dp + fun selectTab(tab: AppScreenTab) { + profileStackVisible = false + onTabSelected(tab) + } + val sidebarWidth by animateDpAsState( + targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth, + animationSpec = tween(durationMillis = 180), + label = "desktop_sidebar_width", + ) + + Surface( + modifier = modifier + .width(sidebarWidth) + .fillMaxHeight() + .hoverable(hoverSource) + .zIndex(NuvioTokens.Z.navigation), + color = tokens.colors.background, + contentColor = tokens.colors.textPrimary, + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = profileTopPadding) + .fillMaxWidth() + .height(52.dp) + .padding(horizontal = 10.dp, vertical = 4.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { profileStackVisible = !profileStackVisible }, + ), + contentAlignment = Alignment.Center, + ) { + DesktopSidebarProfileTrigger( + profile = activeProfile, + avatars = avatars, + label = activeProfileName, + expanded = sidebarExpanded, + ) + } + + if (profileStackVisible) { + SidebarProfileSwitcherStack( + onProfileSelected = onProfileSelected, + onAddProfileRequested = onAddProfileRequested, + onDismissRequest = { profileStackVisible = false }, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = profileTopPadding + 58.dp) + .width(DesktopSidebarExpandedContentWidth), + ) + } + + Column( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DesktopSidebarItem( + label = stringResource(Res.string.compose_nav_home), + selected = selectedTab == AppScreenTab.Home, + expanded = sidebarExpanded, + onClick = { selectTab(AppScreenTab.Home) }, + ) { color -> + Icon( + imageVector = Icons.Filled.Home, + contentDescription = stringResource(Res.string.compose_nav_home), + modifier = Modifier.size(NuvioTokens.Space.s20), + tint = color, + ) + } + DesktopSidebarItem( + label = stringResource(Res.string.compose_nav_search), + selected = selectedTab == AppScreenTab.Search, + expanded = sidebarExpanded, + onClick = { selectTab(AppScreenTab.Search) }, + ) { color -> + Icon( + painter = painterResource(Res.drawable.sidebar_search), + contentDescription = stringResource(Res.string.compose_nav_search), + modifier = Modifier.size(NuvioTokens.Space.s20), + tint = color, + ) + } + DesktopSidebarItem( + label = stringResource(Res.string.compose_nav_library), + selected = selectedTab == AppScreenTab.Library, + expanded = sidebarExpanded, + onClick = { selectTab(AppScreenTab.Library) }, + ) { color -> + Icon( + painter = painterResource(Res.drawable.sidebar_library), + contentDescription = stringResource(Res.string.compose_nav_library), + modifier = Modifier.size(NuvioTokens.Space.s20), + tint = color, + ) + } + DesktopSidebarItem( + label = stringResource(Res.string.compose_settings_page_root), + selected = selectedTab == AppScreenTab.Settings, + expanded = sidebarExpanded, + onClick = { selectTab(AppScreenTab.Settings) }, + ) { color -> + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = stringResource(Res.string.compose_settings_page_root), + modifier = Modifier.size(NuvioTokens.Space.s20), + tint = color, + ) + } + } + } + } +} + +@Composable +private fun DesktopSidebarProfileTrigger( + profile: NuvioProfile?, + avatars: List, + 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 5b1cae86f..20d374409 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -6,4 +6,5 @@ interface Platform { expect fun getPlatform(): Platform -internal expect val isIos: Boolean \ No newline at end of file +internal expect val isIos: Boolean +internal expect val isDesktop: Boolean 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 8e62eb8ed..f231462b3 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,6 +7,8 @@ enum class TrailerPlaybackMode { expect object AppFeaturePolicy { val pluginsEnabled: Boolean + val downloadsEnabled: Boolean + val notificationsEnabled: Boolean val p2pEnabled: Boolean val trailerPlaybackMode: TrailerPlaybackMode val heroTrailerPlaybackSupported: Boolean 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 new file mode 100644 index 000000000..24e0a028c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.kt @@ -0,0 +1,7 @@ +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/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 0702be8d0..969acc3c4 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,6 +1,7 @@ package com.nuvio.app.core.sync import co.touchlab.kermit.Logger +import com.nuvio.app.isDesktop import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider @@ -78,8 +79,17 @@ object ProfileSettingsSync { @Volatile private var skipNextPushSignature: String? = null + @Volatile + private var preservedRemotePlayerSettings: JsonObject? = null + + @Volatile + private var preservedRemotePlayerSettingsProfileId: Int? = null + private var observeJob: Job? = null + private val syncPlayerSettings: Boolean + get() = !isDesktop + fun startObserving() { if (observeJob?.isActive == true) return ensureRepositoriesLoaded() @@ -91,19 +101,13 @@ object ProfileSettingsSync { return syncMutex.withLock { isServerSyncInFlight = true try { - val localBlob = exportSettingsBlob() - val localSignature = buildSignature(localBlob) - - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params) - val response = result.decodeList().firstOrNull() - val remoteJson = response?.settingsJson + val remoteJson = fetchRemoteSettingsJson(profileId) if (remoteJson == null) { log.i { "pull(profileId=$profileId) — no remote settings blob found" } + clearPreservedRemotePlayerSettings(profileId) + val localBlob = exportSettingsBlob(profileId) + val localSignature = buildSignature(localBlob) if (localSignature != defaultSignature()) { pushToRemoteLocked(profileId, localBlob) } @@ -119,13 +123,17 @@ object ProfileSettingsSync { return@withLock false } + preserveRemotePlayerSettings(profileId, remoteBlob) + + val localBlob = exportSettingsBlob(profileId) + val localSignature = buildSignature(localBlob) val remoteSignature = buildSignature(remoteBlob) if (remoteSignature == localSignature) { log.d { "pull(profileId=$profileId) — remote matches local" } return@withLock false } - applyRemoteBlob(remoteBlob) + applyRemoteBlob(profileId, remoteBlob) skipNextPushSignature = currentObservedStateSignature() } finally { isApplyingRemoteBlob = false @@ -146,7 +154,8 @@ object ProfileSettingsSync { ensureRepositoriesLoaded() syncMutex.withLock { runCatching { - pushToRemoteLocked(ProfileRepository.activeProfileId, exportSettingsBlob()) + val profileId = ProfileRepository.activeProfileId + pushToRemoteLocked(profileId, exportSettingsBlob(profileId)) }.onFailure { error -> log.e(error) { "pushCurrentProfileToRemote() — FAILED" } } @@ -155,23 +164,25 @@ object ProfileSettingsSync { @OptIn(FlowPreview::class) private fun observeLocalChangesAndPush() { - val signatureFlows = listOf( - ThemeSettingsRepository.selectedTheme.map { "theme" }, - ThemeSettingsRepository.amoledEnabled.map { "amoled" }, - ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, - PosterCardStyleRepository.uiState.map { "poster_card_style" }, - PlayerSettingsRepository.uiState.map { "player" }, - StreamBadgeSettingsRepository.uiState.map { "stream_badges" }, - DebridSettingsRepository.uiState.map { "debrid" }, - TmdbSettingsRepository.uiState.map { "tmdb" }, - MdbListSettingsRepository.uiState.map { "mdblist" }, - MetaScreenSettingsRepository.uiState.map { "meta" }, - CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, - ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, - TraktSettingsRepository.uiState.map { "trakt_settings" }, - TraktCommentsSettings.enabled.map { "trakt_comments" }, - EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, - ) + val signatureFlows = buildList { + add(ThemeSettingsRepository.selectedTheme.map { "theme" }) + add(ThemeSettingsRepository.amoledEnabled.map { "amoled" }) + add(ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }) + add(PosterCardStyleRepository.uiState.map { "poster_card_style" }) + if (syncPlayerSettings) { + add(PlayerSettingsRepository.uiState.map { "player" }) + } + add(StreamBadgeSettingsRepository.uiState.map { "stream_badges" }) + add(DebridSettingsRepository.uiState.map { "debrid" }) + add(TmdbSettingsRepository.uiState.map { "tmdb" }) + add(MdbListSettingsRepository.uiState.map { "mdblist" }) + add(MetaScreenSettingsRepository.uiState.map { "meta" }) + add(CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }) + add(ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }) + add(TraktSettingsRepository.uiState.map { "trakt_settings" }) + add(TraktCommentsSettings.enabled.map { "trakt_comments" }) + add(EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }) + } observeJob = scope.launch { combine(signatureFlows) { currentObservedStateSignature() } @@ -192,22 +203,23 @@ object ProfileSettingsSync { } private suspend fun pushToRemoteLocked(profileId: Int, blob: MobileProfileSettingsBlob) { + val blobToPush = withPreservedDesktopPlayerSettings(profileId, blob) val params = buildJsonObject { put("p_profile_id", profileId) put("p_platform", MOBILE_SYNC_PLATFORM) - put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob)) + put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blobToPush)) } SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params) log.d { "pushToRemoteLocked(profileId=$profileId) — success" } } - private fun exportSettingsBlob(): MobileProfileSettingsBlob { + private fun exportSettingsBlob(profileId: Int = ProfileRepository.activeProfileId): MobileProfileSettingsBlob { ensureRepositoriesLoaded() return MobileProfileSettingsBlob( features = MobileProfileSettingsFeatures( themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), - playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + playerSettings = exportPlayerSettingsPayload(profileId), streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(), debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), @@ -224,15 +236,19 @@ object ProfileSettingsSync { ) } - private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) { + private fun applyRemoteBlob(profileId: Int, blob: MobileProfileSettingsBlob) { ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings) ThemeSettingsRepository.onProfileChanged() PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload) PosterCardStyleRepository.onProfileChanged() - PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) - PlayerSettingsRepository.onProfileChanged() + if (syncPlayerSettings) { + PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) + PlayerSettingsRepository.onProfileChanged() + } else { + preserveRemotePlayerSettings(profileId, blob) + } StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings) StreamBadgeSettingsRepository.onProfileChanged() @@ -268,7 +284,9 @@ object ProfileSettingsSync { private fun ensureRepositoriesLoaded() { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() - PlayerSettingsRepository.ensureLoaded() + if (syncPlayerSettings) { + PlayerSettingsRepository.ensureLoaded() + } StreamBadgeSettingsRepository.ensureLoaded() DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() @@ -287,23 +305,90 @@ object ProfileSettingsSync { private fun defaultSignature(): String = buildSignature(MobileProfileSettingsBlob()) - private fun currentObservedStateSignature(): String = listOf( - "theme=${ThemeSettingsRepository.selectedTheme.value.name}", - "amoled=${ThemeSettingsRepository.amoledEnabled.value}", - "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", - "poster_card_style=${PosterCardStyleRepository.uiState.value}", - "player=${PlayerSettingsRepository.uiState.value}", - "stream_badges=${StreamBadgeSettingsRepository.uiState.value}", - "debrid=${DebridSettingsRepository.uiState.value}", - "tmdb=${TmdbSettingsRepository.uiState.value}", - "mdblist=${MdbListSettingsRepository.uiState.value}", - "meta=${MetaScreenSettingsRepository.uiState.value}", - "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}", - "continue=${ContinueWatchingPreferencesRepository.uiState.value}", - "trakt_settings=${TraktSettingsRepository.uiState.value}", - "trakt_comments=${TraktCommentsSettings.enabled.value}", - "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", - ).joinToString(separator = "||") + private fun currentObservedStateSignature(): String = buildList { + add("theme=${ThemeSettingsRepository.selectedTheme.value.name}") + add("amoled=${ThemeSettingsRepository.amoledEnabled.value}") + add("liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}") + add("poster_card_style=${PosterCardStyleRepository.uiState.value}") + if (syncPlayerSettings) { + add("player=${PlayerSettingsRepository.uiState.value}") + } + add("stream_badges=${StreamBadgeSettingsRepository.uiState.value}") + add("debrid=${DebridSettingsRepository.uiState.value}") + add("tmdb=${TmdbSettingsRepository.uiState.value}") + add("mdblist=${MdbListSettingsRepository.uiState.value}") + add("meta=${MetaScreenSettingsRepository.uiState.value}") + add("collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}") + add("continue=${ContinueWatchingPreferencesRepository.uiState.value}") + add("trakt_settings=${TraktSettingsRepository.uiState.value}") + add("trakt_comments=${TraktCommentsSettings.enabled.value}") + add("episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}") + }.joinToString(separator = "||") + + private fun exportPlayerSettingsPayload(profileId: Int): JsonObject = + if (syncPlayerSettings) { + PlayerSettingsStorage.exportToSyncPayload() + } else { + preservedRemotePlayerSettingsFor(profileId) ?: JsonObject(emptyMap()) + } + + private fun preserveRemotePlayerSettings(profileId: Int, blob: MobileProfileSettingsBlob) { + if (!syncPlayerSettings) { + preservedRemotePlayerSettingsProfileId = profileId + preservedRemotePlayerSettings = blob.features.playerSettings + } + } + + private fun clearPreservedRemotePlayerSettings(profileId: Int) { + if (!syncPlayerSettings) { + preservedRemotePlayerSettingsProfileId = profileId + preservedRemotePlayerSettings = null + } + } + + private fun preservedRemotePlayerSettingsFor(profileId: Int): JsonObject? = + preservedRemotePlayerSettings + ?.takeIf { preservedRemotePlayerSettingsProfileId == profileId } + + private suspend fun withPreservedDesktopPlayerSettings( + profileId: Int, + blob: MobileProfileSettingsBlob, + ): MobileProfileSettingsBlob { + if (syncPlayerSettings) return blob + + val remoteBlobResult = runCatching { + fetchRemoteSettingsJson(profileId) + ?.let { remoteJson -> + json.decodeFromJsonElement(MobileProfileSettingsBlob.serializer(), remoteJson) + } + } + val remotePlayerSettings = if (remoteBlobResult.isSuccess) { + remoteBlobResult.getOrNull()?.features?.playerSettings ?: JsonObject(emptyMap()) + } else { + val error = remoteBlobResult.exceptionOrNull() + if (error != null) { + log.e(error) { "pushToRemoteLocked(profileId=$profileId) — failed to preserve remote player settings" } + } + preservedRemotePlayerSettingsFor(profileId) ?: throw (error ?: IllegalStateException("Missing remote player settings")) + } + + preservedRemotePlayerSettingsProfileId = profileId + preservedRemotePlayerSettings = remotePlayerSettings + return blob.copy( + features = blob.features.copy( + playerSettings = remotePlayerSettings, + ), + ) + } + + private suspend fun fetchRemoteSettingsJson(profileId: Int): JsonObject? { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", MOBILE_SYNC_PLATFORM) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params) + return result.decodeList().firstOrNull()?.settingsJson + } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt index 041ec0664..e81119573 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt @@ -1,3 +1,5 @@ package com.nuvio.app.core.sync internal const val MOBILE_SYNC_PLATFORM = "mobile" +internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared" +internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv") 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 new file mode 100644 index 000000000..c773a3a7d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImagePainter + +internal enum class NuvioDesktopImageScaling { + Auto, + Disabled, +} + +@Composable +internal expect fun NuvioAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + placeholder: Painter? = null, + error: Painter? = null, + fallback: Painter? = error, + onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null, + onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, + onError: ((AsyncImagePainter.State.Error) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality? = null, + clipToBounds: Boolean = true, + desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 27029a306..7b0be071b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -100,7 +100,7 @@ fun NuvioScreen( ) } -internal fun Modifier.nuvioBlockPointerPassthrough(): Modifier = +internal fun Modifier.nuvioConsumePointerEvents(): Modifier = pointerInput(Unit) { awaitPointerEventScope { while (true) { @@ -144,45 +144,53 @@ fun NuvioScreenHeader( val tokens = MaterialTheme.nuvio val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none - Row( - modifier = modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(tokens.colors.background) - .padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, + Box( + modifier = modifier.fillMaxWidth(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + modifier = Modifier + .matchParentSize() + .background(tokens.colors.background) + .nuvioConsumePointerEvents(), + ) {} + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, ) { - if (onBack != null) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = tokens.colors.textPrimary, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + ) { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = tokens.colors.textPrimary, + ) + } + } + AnimatedContent( + targetState = title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "screen_header_title", + ) { currentTitle -> + Text( + text = currentTitle, + style = MaterialTheme.typography.displayLarge, + color = tokens.colors.textPrimary, ) } } - AnimatedContent( - targetState = title, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "screen_header_title", - ) { currentTitle -> - Text( - text = currentTitle, - style = MaterialTheme.typography.displayLarge, - color = tokens.colors.textPrimary, - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) } - Row( - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2), - verticalAlignment = Alignment.CenterVertically, - content = actions, - ) } } 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 f19d3b21e..6e841b468 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,7 +26,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -133,7 +132,7 @@ private fun ContinueWatchingSheetHeader( ) { val artwork = item.poster ?: item.imageUrl if (artwork != null) { - AsyncImage( + NuvioAsyncImage( model = cloudLibraryDisplayArtworkUrl(artwork), contentDescription = item.title, modifier = Modifier.matchParentSize(), 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 e6214c9e2..bf5c3dac9 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,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -191,7 +190,7 @@ fun NuvioFloatingPrompt( contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - AsyncImage( + NuvioAsyncImage( model = imageUrl, contentDescription = null, modifier = Modifier.matchParentSize(), 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 6ad66ea4a..bf2e6149d 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,7 +33,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.launch @@ -189,7 +188,7 @@ private fun PosterSheetHeader( contentAlignment = Alignment.Center, ) { if (item.poster != null) { - AsyncImage( + NuvioAsyncImage( model = item.poster, contentDescription = item.name, modifier = Modifier.matchParentSize(), 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 fafed653f..aa3932acc 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 @@ -15,8 +15,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight @@ -28,15 +32,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage +import com.nuvio.app.isDesktop import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.home_view_all import nuvio.composeapp.generated.resources.poster_logo_content_description import org.jetbrains.compose.resources.stringResource +import kotlin.math.abs enum class NuvioPosterShape { Poster, @@ -64,6 +71,7 @@ fun NuvioShelfSection( itemContent: @Composable (T) -> Unit, ) { val tokens = MaterialTheme.nuvio + val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2), @@ -78,6 +86,8 @@ fun NuvioShelfSection( ) } LazyRow( + state = rowState, + modifier = Modifier.desktopShelfDragScroll(rowState), contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -97,6 +107,47 @@ 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, @@ -135,7 +186,7 @@ fun NuvioPosterCard( contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - AsyncImage( + NuvioAsyncImage( model = imageUrl, contentDescription = title, modifier = Modifier.matchParentSize(), @@ -160,7 +211,7 @@ fun NuvioPosterCard( .padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10), ) { if (!bottomLeftLogoUrl.isNullOrBlank()) { - AsyncImage( + NuvioAsyncImage( model = bottomLeftLogoUrl, contentDescription = stringResource(Res.string.poster_logo_content_description, title), modifier = Modifier @@ -344,6 +395,7 @@ internal fun Modifier.posterCardClickable( onClick = { onClick?.invoke() }, onLongClick = onLongClick, ) + .secondaryClick(onLongClick) } else { this } 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 new file mode 100644 index 000000000..eea6d19b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.kt @@ -0,0 +1,5 @@ +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 7e12666dc..87abf3882 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioIconActionButton import com.nuvio.app.core.ui.NuvioInfoBadge import com.nuvio.app.core.ui.NuvioInputField 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 e20cbbbac..a251166e4 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay 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 d4e6d9ef5..77704a7e7 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 @@ -421,6 +421,7 @@ object FolderDetailRepository { items = tab.items, availableItemCount = tab.items.size, supportsPagination = tab.supportsPagination, + genre = tab.genre, ) } } 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 5881ce2e4..008b1cf54 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterShape import com.nuvio.app.core.ui.NuvioScreenHeader diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt index 295179d8e..1b0db4b14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable internal interface DebridProviderApi { val provider: DebridProvider @@ -35,6 +36,7 @@ internal object DebridProviderApis { } } +@Serializable internal data class DebridDeviceAuthorization( val providerId: String, val deviceCode: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 7d68db489..700914e34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadPendingDeviceAuthorization(providerId: String): String? + fun savePendingDeviceAuthorization(providerId: String, payload: String) + fun clearPendingDeviceAuthorization(providerId: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 1e48bb62d..aef463c9a 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 @@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -59,7 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.network.NetworkCondition @@ -111,6 +112,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -189,20 +191,30 @@ fun MetaDetailsScreen( var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } + var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" } - LaunchedEffect(displayedMeta?.id, shouldShowComments) { - if (!shouldShowComments || displayedMeta == null) { + LaunchedEffect(displayedMeta?.id) { + deferredMetaWorkAllowed = false + if (displayedMeta != null) { + delay(250) + deferredMetaWorkAllowed = true + } + } + + LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) { + if (displayedMeta == null || !shouldShowComments) { comments = emptyList() commentsCurrentPage = 0 commentsPageCount = 0 commentsError = null return@LaunchedEffect } + if (!deferredMetaWorkAllowed) return@LaunchedEffect isCommentsLoading = true commentsError = null try { @@ -216,8 +228,9 @@ fun MetaDetailsScreen( isCommentsLoading = false } - LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) { + LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) { val metaForRatings = displayedMeta + if (!deferredMetaWorkAllowed) return@LaunchedEffect if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) { episodeImdbRatings = emptyMap() return@LaunchedEffect @@ -462,7 +475,8 @@ fun MetaDetailsScreen( ) } } - LaunchedEffect(meta.type, debridWarmupTarget) { + LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) { + if (!deferredMetaWorkAllowed) return@LaunchedEffect AddonStreamWarmupRepository.preload( type = meta.type, videoId = debridWarmupTarget.videoId, @@ -509,11 +523,16 @@ fun MetaDetailsScreen( var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle() - LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) { + LaunchedEffect( + heroTrailerPlaybackEnabled, + heroTrailerCandidate?.id, + heroTrailerCandidate?.key, + deferredMetaWorkAllowed, + ) { heroTrailerPlaybackSource = null heroTrailerReady = false heroTrailerFinished = false - if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { + if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { return@LaunchedEffect } val resolvedSource = runCatching { @@ -718,7 +737,7 @@ fun MetaDetailsScreen( savedProgress?.lastPositionMs, ) } - val scrollState = rememberScrollState() + val listState = rememberLazyListState() val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -728,7 +747,20 @@ fun MetaDetailsScreen( } var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f + val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + } + val heroScrollOffset = detailScrollOffsetPx.toInt() + val headerTarget = if ( + heroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) + ) { + 1f + } else { + 0f + } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl ?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails } @@ -737,7 +769,7 @@ fun MetaDetailsScreen( ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && !isLeavingDetails && - (heroHeightPx == 0 || scrollState.value <= thresholdPx) + (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) val headerProgress by animateFloatAsState( targetValue = headerTarget, animationSpec = tween( @@ -749,9 +781,10 @@ fun MetaDetailsScreen( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTablet = maxWidth >= 720.dp + val viewportHeight = maxHeight val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) - val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground + val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed Box(modifier = Modifier.fillMaxSize()) { if (cinematicEnabled) { @@ -772,123 +805,121 @@ fun MetaDetailsScreen( ) } } - Column( + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .zIndex(1f) - .verticalScroll(scrollState), + .zIndex(1f), ) { - DetailHero( - meta = meta, - isTablet = isTablet, - contentMaxWidth = contentMaxWidth, - scrollOffset = scrollState.value, - onHeightChanged = { heroHeightPx = it }, - heroTrailerSourceUrl = heroTrailerSourceUrl, - heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, - heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, - heroTrailerMuted = heroTrailerMuted, - onHeroTrailerMuteToggle = { - HeroTrailerAudioState.toggleMuted() - }, - onHeroTrailerReady = { - if (!heroTrailerFinished) { - heroTrailerReady = true - } - }, - onHeroTrailerEnded = { - heroTrailerReady = false - heroTrailerFinished = true - }, - onHeroTrailerError = { - heroTrailerReady = false - heroTrailerFinished = true - }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentHorizontalPadding) - .widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ConfiguredMetaSections( - settings = metaScreenSettingsUiState, + item(key = "detail-hero") { + DetailHero( meta = meta, isTablet = isTablet, - playButtonLabel = playButtonLabel, - isSaved = isSaved, - isWatched = isWatched, - onPrimaryPlayClick = onPrimaryPlayClick, - onPrimaryPlayLongClick = onPrimaryPlayLongClick, - onSaveClick = toggleSaved, - onSaveLongClick = openLibraryListPicker, - onWatchedClick = toggleWatched, - showManualPlayOption = showManualPlayOption, - preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, - preferredEpisodeNumber = seriesAction?.episodeNumber, - hasProductionSection = hasProductionSection, - hasTrailersSection = hasTrailersSection, - hasEpisodes = hasEpisodes, - hasAdditionalInfoSection = hasAdditionalInfoSection, - hasCollectionSection = hasCollectionSection, - hasMoreLikeThisSection = hasMoreLikeThisSection, - shouldShowComments = shouldShowComments, - comments = comments, - isCommentsLoading = isCommentsLoading, - isCommentsLoadingMore = isCommentsLoadingMore, - commentsCurrentPage = commentsCurrentPage, - commentsPageCount = commentsPageCount, - commentsError = commentsError, - episodeImdbRatings = episodeImdbRatings, - onRetryComments = { - detailsScope.launch { - isCommentsLoading = true - commentsError = null - try { - val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) - comments = result.items - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (e: Exception) { - commentsError = e.message ?: getString(Res.string.details_comments_load_failed) - } - isCommentsLoading = false + contentMaxWidth = contentMaxWidth, + viewportHeight = viewportHeight, + scrollOffset = heroScrollOffset, + onHeightChanged = { heroHeightPx = it }, + heroTrailerSourceUrl = heroTrailerSourceUrl, + heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, + heroTrailerReady = heroTrailerReady, + heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerMuted = heroTrailerMuted, + onHeroTrailerMuteToggle = { + HeroTrailerAudioState.toggleMuted() + }, + onHeroTrailerReady = { + if (!heroTrailerFinished) { + heroTrailerReady = true } }, - onLoadMoreComments = { - detailsScope.launch { - isCommentsLoadingMore = true - try { - val nextPage = commentsCurrentPage + 1 - val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) - val existingIds = comments.map { it.id }.toSet() - val newComments = result.items.filter { it.id !in existingIds } - comments = comments + newComments - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (_: Exception) { } - isCommentsLoadingMore = false - } + onHeroTrailerEnded = { + heroTrailerReady = false + heroTrailerFinished = true + }, + onHeroTrailerError = { + heroTrailerReady = false + heroTrailerFinished = true }, - onCommentClick = { review -> selectedComment = review }, - onTrailerClick = resolveTrailer, - progressByVideoId = progressByVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - onEpisodeClick = onEpisodePlayClick, - onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, - onSeasonLongPress = { season -> selectedSeasonForActions = season }, - onOpenMeta = onOpenMeta, - onCastClick = onCastClick, - onCompanyClick = onCompanyClick, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, ) + } + configuredMetaSectionItems( + settings = metaScreenSettingsUiState, + meta = meta, + isTablet = isTablet, + contentHorizontalPadding = contentHorizontalPadding, + contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, + onWatchedClick = toggleWatched, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, + preferredEpisodeNumber = seriesAction?.episodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = { + detailsScope.launch { + isCommentsLoading = true + commentsError = null + try { + val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) + comments = result.items + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (e: Exception) { + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) + } + isCommentsLoading = false + } + }, + onLoadMoreComments = { + detailsScope.launch { + isCommentsLoadingMore = true + try { + val nextPage = commentsCurrentPage + 1 + val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) + val existingIds = comments.map { it.id }.toSet() + val newComments = result.items.filter { it.id !in existingIds } + comments = comments + newComments + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (_: Exception) { } + isCommentsLoadingMore = false + } + }, + onCommentClick = { review -> selectedComment = review }, + onTrailerClick = resolveTrailer, + progressByVideoId = progressByVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + onEpisodeClick = onEpisodePlayClick, + onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + + item(key = "detail-bottom-spacer") { Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp))) } } @@ -901,7 +932,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - scrollState.value + translationY = heroHeightPx.toFloat() - detailScrollOffsetPx } .background( Brush.verticalGradient( @@ -1263,6 +1294,228 @@ private fun MetaDetails.toMetaPreview(): MetaPreview = genres = genres, ) +private fun LazyListScope.configuredMetaSectionItems( + settings: MetaScreenSettingsUiState, + meta: MetaDetails, + isTablet: Boolean, + contentHorizontalPadding: Dp, + contentMaxWidth: Dp, + playButtonLabel: String, + isSaved: Boolean, + isWatched: Boolean, + onPrimaryPlayClick: () -> Unit, + onPrimaryPlayLongClick: (() -> Unit)?, + onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, + onWatchedClick: () -> Unit, + showManualPlayOption: Boolean, + preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + isCommentsLoadingMore: Boolean, + commentsCurrentPage: Int, + commentsPageCount: Int, + commentsError: String?, + episodeImdbRatings: Map, Double>, + onRetryComments: () -> Unit, + onLoadMoreComments: () -> Unit, + onCommentClick: (TraktCommentReview) -> Unit, + onTrailerClick: (MetaTrailer) -> Unit, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + onEpisodeClick: (MetaVideo) -> Unit, + onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, + onOpenMeta: ((MetaPreview) -> Unit)?, + onCastClick: ((MetaPerson, String?) -> Unit)?, + onCompanyClick: ((MetaCompany, String) -> Unit)?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, +) { + val enabledItems = settings.items.filter { it.enabled } + fun sectionHasContent(key: MetaScreenSectionKey): Boolean = + metaSectionHasContent( + key = key, + meta = meta, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + commentsError = commentsError, + ) + + fun addSectionItem( + key: String, + sectionItems: List, + forceTabLayout: Boolean = settings.tabLayout, + ) { + item(key = key) { + DetailSectionContainer( + horizontalPadding = contentHorizontalPadding, + contentMaxWidth = contentMaxWidth, + ) { + ConfiguredMetaSections( + settings = settings.copy( + items = sectionItems, + tabLayout = forceTabLayout, + ), + meta = meta, + isTablet = isTablet, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, + onWatchedClick = onWatchedClick, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber, + preferredEpisodeNumber = preferredEpisodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = onRetryComments, + onLoadMoreComments = onLoadMoreComments, + onCommentClick = onCommentClick, + onTrailerClick = onTrailerClick, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onEpisodeClick = onEpisodeClick, + onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + + if (!settings.tabLayout) { + enabledItems + .filter { sectionHasContent(it.key) } + .forEach { section -> + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = false, + ) + } + return + } + + val processedGroups = mutableSetOf() + enabledItems.forEach { section -> + val groupId = section.tabGroup + if (groupId == null) { + if (sectionHasContent(section.key)) { + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = true, + ) + } + } else if (groupId !in processedGroups) { + processedGroups.add(groupId) + val groupMembers = enabledItems.filter { item -> + item.tabGroup == groupId && sectionHasContent(item.key) + } + if (groupMembers.isNotEmpty()) { + addSectionItem( + key = "detail-section-group-$groupId", + sectionItems = groupMembers, + forceTabLayout = groupMembers.size > 1, + ) + } + } + } +} + +@Composable +private fun DetailSectionContainer( + horizontalPadding: Dp, + contentMaxWidth: Dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(bottom = 20.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentMaxWidth == Dp.Unspecified) { + Modifier + } else { + Modifier.widthIn(max = contentMaxWidth) + }, + ), + ) { + content() + } + } +} + +private fun metaSectionHasContent( + key: MetaScreenSectionKey, + meta: MetaDetails, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + commentsError: String?, +): Boolean = + when (key) { + MetaScreenSectionKey.ACTIONS -> true + MetaScreenSectionKey.OVERVIEW -> true + MetaScreenSectionKey.PRODUCTION -> hasProductionSection + MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty() + MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank()) + MetaScreenSectionKey.TRAILERS -> hasTrailersSection + MetaScreenSectionKey.EPISODES -> hasEpisodes + MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection + MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection + MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection + } + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( 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 f209f7f2f..9c9686127 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.core.i18n.localizedShortMonthName 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 04abb4ca7..0803aacbe 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import com.nuvio.app.core.ui.landscapePosterHeightForWidth 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 7c3324ba8..b9bd0b6d5 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,6 +40,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.appIconPainter +import com.nuvio.app.core.ui.secondaryClick import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_play import org.jetbrains.compose.resources.stringResource @@ -107,6 +108,7 @@ fun DetailActionButtons( onLongClick = onPlayLongClick, role = Role.Button, ) + .secondaryClick(onPlayLongClick) .height(buttonHeight), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, @@ -249,7 +251,8 @@ private fun DetailIconAction( onClick = onClick, onLongClick = onLongClick, role = Role.Button, - ), + ) + .secondaryClick(onLongClick), contentAlignment = Alignment.Center, ) { Icon( 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 306152a5e..a6b498cee 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.features.details.MetaPerson 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 88a40c556..700aa41ee 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos 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 0471ed11d..5754a2508 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 @@ -43,7 +43,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioDesktopImageScaling +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -54,6 +55,7 @@ fun DetailHero( isTablet: Boolean = false, scrollOffset: Int = 0, contentMaxWidth: Dp = 560.dp, + viewportHeight: Dp = 0.dp, onHeightChanged: (Int) -> Unit = {}, heroTrailerSourceUrl: String? = null, heroTrailerSourceAudioUrl: String? = null, @@ -69,7 +71,7 @@ fun DetailHero( BoxWithConstraints( modifier = modifier.fillMaxWidth(), ) { - val heroHeight = detailHeroHeight(maxWidth, isTablet) + val heroHeight = detailHeroHeight(maxWidth, viewportHeight, isTablet) val trailerAlpha by animateFloatAsState( targetValue = if (heroTrailerReady) 1f else 0f, animationSpec = tween(durationMillis = 300), @@ -95,6 +97,7 @@ fun DetailHero( contentAlignment = Alignment.BottomCenter, ) { val imageUrl = meta.background ?: meta.poster + val backdropScale = if (isTablet) 1f else 1.08f if (imageUrl != null) { AsyncImage( model = imageUrl, @@ -103,11 +106,12 @@ fun DetailHero( .fillMaxSize() .graphicsLayer { translationY = scrollOffset * 0.5f - scaleX = 1.08f - scaleY = 1.08f + scaleX = backdropScale + scaleY = backdropScale }, - alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, + alignment = Alignment.Center, contentScale = ContentScale.Crop, + desktopImageScaling = NuvioDesktopImageScaling.Disabled, ) } else { Box( @@ -127,8 +131,8 @@ fun DetailHero( .graphicsLayer { alpha = trailerAlpha translationY = scrollOffset * 0.5f - scaleX = 1.08f - scaleY = 1.08f + scaleX = backdropScale + scaleY = backdropScale }, onReady = onHeroTrailerReady, onEnded = onHeroTrailerEnded, @@ -233,9 +237,13 @@ fun DetailHero( } } -private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp = +private fun detailHeroHeight(maxWidth: Dp, viewportHeight: Dp, isTablet: Boolean): Dp = if (!isTablet) { (maxWidth * 1.33f).coerceIn(420.dp, 760.dp) } else { - (maxWidth * 0.42f).coerceIn(300.dp, 420.dp) + val viewportLimit = viewportHeight + .takeIf { it > 0.dp } + ?.let { it * 0.72f } + ?: 1080.dp + minOf(maxWidth * 9f / 16f, viewportLimit).coerceIn(420.dp, 1080.dp) } 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 b9308252b..bdb1b6e8d 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* 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 3601b1d01..82740e74c 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,13 +58,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioProgressBar +import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaVideo @@ -408,6 +409,7 @@ private fun SeasonTextChipScrollRow( ) { items(seasons, key = { season -> season }) { season -> val isSelected = season == currentSeason + val onSecondaryClick = onLongPress?.let { handler -> { handler(season) } } Box( modifier = Modifier .clip(RoundedCornerShape(sizing.seasonChipRadius)) @@ -420,8 +422,9 @@ private fun SeasonTextChipScrollRow( ) .combinedClickable( onClick = { onSelect(season) }, - onLongClick = onLongPress?.let { handler -> { handler(season) } }, + onLongClick = onSecondaryClick, ) + .secondaryClick(onSecondaryClick) .padding( horizontal = sizing.seasonChipHorizontalPadding, vertical = sizing.seasonChipVerticalPadding, @@ -508,7 +511,8 @@ private fun SeasonPosterButton( .combinedClickable( onClick = onClick, onLongClick = onLongClick, - ), + ) + .secondaryClick(onLongClick), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( @@ -677,7 +681,8 @@ private fun EpisodeHorizontalCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ), + ) + .secondaryClick(onLongPress), ) { val imageUrl = video.thumbnail ?: fallbackImage val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched @@ -1045,7 +1050,8 @@ private fun EpisodeListCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ), + ) + .secondaryClick(onLongPress), ) { Row( modifier = Modifier.fillMaxSize(), 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 e9ef5fa82..f5226e9b8 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index bddc4c975..5a4851042 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -3,7 +3,8 @@ package com.nuvio.app.features.home import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState -import com.nuvio.app.core.sync.MOBILE_SYNC_PLATFORM +import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS +import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest @@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put @Serializable @@ -53,6 +55,30 @@ private data class SupabaseHomeCatalogSettingsBlob( @SerialName("updated_at") val updatedAt: String? = null, ) +private data class RemoteHomeCatalogSettings( + val platform: String, + val payload: SyncHomeCatalogPayload, + val updatedAt: String?, + val hasHideUnreleasedContent: Boolean, + val hasHideCatalogUnderline: Boolean, +) + +private data class PullToken( + val userId: String, + val profileId: Int, +) + +private data class ObservedHomeCatalogChange( + val signature: String, + val token: PullToken?, + val initialPullCompleteAtEmission: Boolean, +) + +private data class HomeCatalogChangeSignature( + val signature: String, + val token: PullToken, +) + object HomeCatalogSettingsSyncService { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("HomeCatalogSettingsSyncService") @@ -62,6 +88,8 @@ object HomeCatalogSettingsSyncService { } private const val PUSH_DEBOUNCE_MS = 1500L + private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile var isSyncingFromRemote: Boolean = false @@ -69,6 +97,12 @@ object HomeCatalogSettingsSyncService { private var pushJob: Job? = null private var observeJob: Job? = null + @Volatile + private var completedInitialPull: PullToken? = null + + @Volatile + private var remoteAppliedSignature: HomeCatalogChangeSignature? = null + fun startObserving() { if (observeJob?.isActive == true) return observeLocalChangesAndPush() @@ -76,48 +110,28 @@ object HomeCatalogSettingsSyncService { suspend fun pullFromServer(profileId: Int) { runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) - val blobs = result.decodeList() - val blob = blobs.firstOrNull() + val pullToken = currentPullToken(profileId) ?: return + val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() + val remote = fetchBestRemotePayload(profileId, localPayload) - if (blob == null) { - log.i { "pullFromServer — no remote home catalog settings found" } - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() - if (localPayload.items.isNotEmpty()) { - pushToRemote(profileId) - } + if (remote == null) { + log.i { "pullFromServer — no remote home catalog settings found; preserving local" } + markInitialPullComplete(pullToken) return } - val remotePayload = runCatching { - json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson) - }.getOrNull() - - if (remotePayload == null) { - log.w { "pullFromServer — failed to parse remote home catalog settings" } - return - } + val remotePayload = remote.payload if (remotePayload.items.isEmpty()) { log.i { "pullFromServer — remote has empty items, preserving local catalog order" } - isSyncingFromRemote = true - HomeCatalogSettingsRepository.applyFromRemote(remotePayload) - isSyncingFromRemote = false - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() - if (localPayload.items.isNotEmpty()) { - pushToRemote(profileId) - } + applyRemotePayload(remotePayload, pullToken) + markInitialPullComplete(pullToken) return } - isSyncingFromRemote = true - HomeCatalogSettingsRepository.applyFromRemote(remotePayload) - isSyncingFromRemote = false + applyRemotePayload(remotePayload, pullToken) log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } + markInitialPullComplete(pullToken) }.onFailure { e -> isSyncingFromRemote = false log.e(e) { "pullFromServer — FAILED" } @@ -125,28 +139,28 @@ object HomeCatalogSettingsSyncService { } fun triggerPush() { + val requestedToken = currentPullToken() + if (requestedToken == null || !hasCompletedInitialPull(requestedToken)) { + log.d { "triggerPush — skipped before initial home catalog pull completed" } + return + } pushJob?.cancel() pushJob = scope.launch { delay(500) if (isSyncingFromRemote) return@launch - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch - pushToRemote() + if (currentPullToken() != requestedToken) return@launch + pushToRemote(requestedToken.profileId) } } - private suspend fun pushToRemote() { - pushToRemote(ProfileRepository.activeProfileId) - } - private suspend fun pushToRemote(profileId: Int) { runCatching { val payload = HomeCatalogSettingsRepository.exportToSyncPayload() - val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload) + val jsonElement = mergedSharedPayloadJson(profileId, payload) val params = buildJsonObject { put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) + put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) @@ -160,16 +174,178 @@ object HomeCatalogSettingsSyncService { private fun observeLocalChangesAndPush() { observeJob = scope.launch { HomeCatalogSettingsRepository.uiState - .map { it.signature } + .map { state -> + val token = currentPullToken() + ObservedHomeCatalogChange( + signature = state.signature, + token = token, + initialPullCompleteAtEmission = token?.let(::hasCompletedInitialPull) == true, + ) + } .drop(1) .distinctUntilChanged() .debounce(PUSH_DEBOUNCE_MS) - .collect { + .collect { change -> + val token = change.token ?: return@collect + val changeSignature = HomeCatalogChangeSignature(change.signature, token) + if (!change.initialPullCompleteAtEmission) { + if (changeSignature == remoteAppliedSignature) { + remoteAppliedSignature = null + } + log.d { "observeLocalChangesAndPush — skipped before initial home catalog pull completed" } + return@collect + } + if (changeSignature == remoteAppliedSignature) { + remoteAppliedSignature = null + log.d { "observeLocalChangesAndPush — skipped remote-applied catalog change" } + return@collect + } if (isSyncingFromRemote) return@collect - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect - pushToRemote() + if (currentPullToken() != token) return@collect + pushToRemote(token.profileId) } } } + + private fun currentPullToken(profileId: Int = ProfileRepository.activeProfileId): PullToken? { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return null + return PullToken( + userId = authState.userId, + profileId = profileId, + ) + } + + private fun hasCompletedInitialPull(token: PullToken): Boolean = + completedInitialPull == token + + private fun markInitialPullComplete(token: PullToken) { + completedInitialPull = token + } + + private fun applyRemotePayload( + payload: SyncHomeCatalogPayload, + token: PullToken, + ) { + isSyncingFromRemote = true + try { + HomeCatalogSettingsRepository.applyFromRemote(payload) + remoteAppliedSignature = HomeCatalogChangeSignature( + signature = HomeCatalogSettingsRepository.uiState.value.signature, + token = token, + ) + } finally { + isSyncingFromRemote = false + } + } + + private suspend fun fetchBestRemotePayload( + profileId: Int, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val shared = fetchRemotePayload( + profileId = profileId, + platform = HOME_CATALOG_SHARED_SYNC_PLATFORM, + localPayload = localPayload, + ) + val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS + .mapNotNull { platform -> + fetchRemotePayload( + profileId = profileId, + platform = platform, + localPayload = localPayload, + ) + } + val rows = listOfNotNull(shared) + legacyRows + val selected = rows + .filter { it.payload.items.isNotEmpty() } + .maxByOrNull { it.updatedAt.orEmpty() } + ?: shared + ?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() } + + return selected?.withNewestStandaloneSettings(rows) + } + + private suspend fun fetchRemotePayload( + profileId: Int, + platform: String, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val blob = fetchRemoteBlob(profileId, platform) ?: return null + val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload) + if (payload == null) { + log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" } + return null + } + return RemoteHomeCatalogSettings( + platform = platform, + payload = payload, + updatedAt = blob.updatedAt, + hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), + hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), + ) + } + + private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings( + rows: List, + ): RemoteHomeCatalogSettings { + val hideUnreleasedSource = rows + .filter { it.hasHideUnreleasedContent } + .maxByOrNull { it.updatedAt.orEmpty() } + val hideUnderlineSource = rows + .filter { it.hasHideCatalogUnderline } + .maxByOrNull { it.updatedAt.orEmpty() } + + return copy( + payload = payload.copy( + hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent + ?: payload.hideUnreleasedContent, + hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline + ?: payload.hideCatalogUnderline, + ), + ) + } + + private suspend fun fetchRemoteBlob( + profileId: Int, + platform: String, + ): SupabaseHomeCatalogSettingsBlob? { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", platform) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) + return result.decodeList().firstOrNull() + } + + private fun decodePayloadPreservingLocalDefaults( + settingsJson: JsonObject, + localPayload: SyncHomeCatalogPayload, + ): SyncHomeCatalogPayload? = runCatching { + val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) + decoded.copy( + hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { + decoded.hideUnreleasedContent + } else { + localPayload.hideUnreleasedContent + }, + hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) { + decoded.hideCatalogUnderline + } else { + localPayload.hideCatalogUnderline + }, + ) + }.getOrNull() + + private suspend fun mergedSharedPayloadJson( + profileId: Int, + payload: SyncHomeCatalogPayload, + ): JsonObject { + val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject + val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson + return buildJsonObject { + remoteJson?.forEach { (key, value) -> put(key, value) } + localJson.forEach { (key, value) -> put(key, value) } + } + } } 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 dbf8b793d..0b4b7404a 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 @@ -39,6 +39,7 @@ data class HomeCatalogSection( val items: List, val availableItemCount: Int = items.size, val supportsPagination: Boolean = false, + val genre: String? = null, ) fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean = 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 0d90dfad7..2f052064d 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,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.isDesktop import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -104,19 +105,21 @@ fun HomeScreen( onFirstCatalogRendered: (() -> Unit)? = null, ) { LaunchedEffect(Unit) { - AddonRepository.initialize() - CollectionRepository.initialize() - ContinueWatchingPreferencesRepository.ensureLoaded() - WatchedRepository.ensureLoaded() - WatchProgressRepository.ensureLoaded() + withContext(Dispatchers.Default) { + AddonRepository.initialize() + CollectionRepository.initialize() + ContinueWatchingPreferencesRepository.ensureLoaded() + HomeCatalogSettingsRepository.snapshot() + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + WatchedRepository.ensureLoaded() + WatchProgressRepository.ensureLoaded() + } } val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by remember { - HomeCatalogSettingsRepository.snapshot() - HomeCatalogSettingsRepository.uiState - }.collectAsStateWithLifecycle() + val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() @@ -124,14 +127,8 @@ fun HomeScreen( val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val isTraktAuthenticated by remember { - TraktAuthRepository.ensureLoaded() - TraktAuthRepository.isAuthenticated - }.collectAsStateWithLifecycle() + val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle() + val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } LaunchedEffect(scrollToTopRequests) { @@ -650,6 +647,7 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, + sectionPadding = if (isDesktop) homeSectionPadding else null, ) homeUiState.heroItems.isNotEmpty() -> HomeHeroSection( @@ -657,6 +655,7 @@ fun HomeScreen( modifier = Modifier, viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, + sectionPadding = if (isDesktop) homeSectionPadding else null, listState = homeListState, onItemClick = onPosterClick, ) 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 99f36172d..9ad94cb45 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio @@ -53,6 +53,7 @@ import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.core.ui.secondaryClick import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -803,7 +804,8 @@ private fun ContinueWatchingWideCard( enabled = onClick != null || onLongClick != null, onClick = { onClick?.invoke() }, onLongClick = onLongClick, - ), + ) + .secondaryClick(onLongClick), ) { val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) 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 d09907ec2..734bea9d0 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,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -47,7 +48,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage +import com.nuvio.app.isDesktop +import com.nuvio.app.core.ui.NuvioDesktopImageScaling +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope @@ -86,6 +89,7 @@ fun HomeHeroSection( modifier: Modifier = Modifier, viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, + sectionPadding: Dp? = null, listState: LazyListState? = null, onItemClick: ((MetaPreview) -> Unit)? = null, ) { @@ -108,6 +112,7 @@ fun HomeHeroSection( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, + preferDesktopLayout = isDesktop, ) val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() } val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() } @@ -163,136 +168,32 @@ fun HomeHeroSection( Box(modifier = Modifier.fillMaxSize()) } - Box( - modifier = Modifier.fillMaxSize(), - ) { - visiblePages.forEach { layer -> - AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, - contentDescription = items[layer.page].name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX - translationY = heroScrollTranslationY - scaleX = HERO_BACKGROUND_SCALE * heroScrollScale - scaleY = HERO_BACKGROUND_SCALE * heroScrollScale - }, - alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, - contentScale = ContentScale.Crop, - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0.02f), - MaterialTheme.colorScheme.background.copy(alpha = 0.12f), - MaterialTheme.colorScheme.background.copy(alpha = 0.34f), - MaterialTheme.colorScheme.background.copy(alpha = 0.78f), - ), - ), - ), + if (isDesktop) { + DesktopHomeHeroFrame( + items = items, + visiblePages = visiblePages, + layout = layout, + heroWidthPx = heroWidthPx, + heroScrollScale = heroScrollScale, + heroScrollTranslationY = heroScrollTranslationY, + contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding, + pagerState = pagerState, + coroutineScope = coroutineScope, + onItemClick = onItemClick, ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(layout.bottomFadeHeight) - .align(Alignment.BottomCenter) - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0f), - MaterialTheme.colorScheme.background, - ), - ), - ), + } else { + DefaultHomeHeroFrame( + items = items, + visiblePages = visiblePages, + currentItem = currentItem, + layout = layout, + heroWidthPx = heroWidthPx, + heroScrollScale = heroScrollScale, + heroScrollTranslationY = heroScrollTranslationY, + pagerState = pagerState, + coroutineScope = coroutineScope, + onItemClick = onItemClick, ) - - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding( - horizontal = layout.contentHorizontalPadding, - vertical = layout.contentVerticalPadding, - ), - horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .fillMaxWidth(layout.contentWidthFraction) - .widthIn(max = layout.contentMaxWidth), - contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, - ) { - visiblePages.forEach { layer -> - Box( - modifier = Modifier.graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX - }, - ) { - HeroContentBlock( - item = items[layer.page], - layout = layout, - onItemClick = onItemClick, - ) - } - } - } - - if (!layout.isTablet) { - Spacer(modifier = Modifier.height(14.dp)) - Surface( - modifier = Modifier - .clickable(enabled = onItemClick != null) { - onItemClick?.invoke(currentItem) - }, - color = MaterialTheme.colorScheme.onBackground, - contentColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(40.dp), - ) { - Text( - text = stringResource(Res.string.home_view_details), - modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - } - - if (items.size > 1) { - Spacer(modifier = Modifier.height(if (layout.isTablet) 14.dp else 12.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items.forEachIndexed { index, _ -> - val activeFraction = heroPageVisibility(pagerState, index) - Box( - modifier = Modifier - .clickable { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - } - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onBackground) - .graphicsLayer { - alpha = 0.35f + (0.57f * activeFraction) - } - .width(8.dp + (24.dp * activeFraction)) - .height(8.dp), - ) - } - } - } - } } } } @@ -304,6 +205,284 @@ private data class HeroPageLayer( val offset: Float, ) +@Composable +private fun DefaultHomeHeroFrame( + items: List, + 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, @@ -331,6 +510,7 @@ fun HomeHeroReservedSpace( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, + preferDesktopLayout = isDesktop, ) Spacer( @@ -408,6 +588,97 @@ private fun HeroContentBlock( } } +@Composable +private fun DesktopHeroContentBlock( + item: MetaPreview, + layout: HomeHeroLayout, + onItemClick: ((MetaPreview) -> Unit)?, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = onItemClick != null) { + onItemClick?.invoke(item) + }, + horizontalAlignment = Alignment.Start, + ) { + if (item.logo != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(desktopHeroLogoSlotHeight(layout)), + contentAlignment = Alignment.CenterStart, + ) { + AsyncImage( + model = item.logo, + contentDescription = item.name, + modifier = Modifier + .fillMaxWidth(desktopHeroLogoWidthFraction(layout)) + .fillMaxHeight(), + alignment = Alignment.CenterStart, + contentScale = ContentScale.Fit, + clipToBounds = false, + ) + } + } else { + Text( + text = item.name, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Start, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + val genreText = desktopHeroGenreText(item) + if (genreText.isNotBlank()) { + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = genreText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.76f), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + item.description?.takeIf { it.isNotBlank() }?.let { description -> + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.82f), + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun desktopHeroLogoWidthFraction(layout: HomeHeroLayout): Float = + when { + layout.contentMaxWidth >= 640.dp -> 0.74f + layout.contentMaxWidth >= 520.dp -> 0.74f + else -> 0.8f + } + +private fun desktopHeroLogoSlotHeight(layout: HomeHeroLayout): Dp = + when { + layout.contentMaxWidth >= 640.dp -> 156.dp + layout.contentMaxWidth >= 520.dp -> 136.dp + else -> 104.dp + } + +private fun desktopHeroGenreText(item: MetaPreview): String = + item.genres + .take(3) + .joinToString(" • ") + .ifBlank { item.type.replaceFirstChar(Char::uppercase) } + @Composable private fun HeroMetaText(text: String) { Text( @@ -424,6 +695,7 @@ internal fun homeHeroLayout( maxWidthDp: Float, viewportHeightDp: Float? = null, mobileBelowSectionHeightHintDp: Float? = null, + preferDesktopLayout: Boolean = false, ): HomeHeroLayout = when { maxWidthDp >= 1200f -> HomeHeroLayout( @@ -456,6 +728,16 @@ internal fun homeHeroLayout( bottomFadeHeight = 170.dp, logoWidthFraction = 0.54f, ) + preferDesktopLayout -> HomeHeroLayout( + isTablet = true, + heroHeight = (maxWidthDp * 0.68f).dp.coerceIn(300.dp, 360.dp), + contentMaxWidth = 360.dp, + contentWidthFraction = 0.56f, + contentHorizontalPadding = 16.dp, + contentVerticalPadding = 18.dp, + bottomFadeHeight = 150.dp, + logoWidthFraction = 0.64f, + ) else -> HomeHeroLayout( isTablet = false, heroHeight = mobileHeroHeight( 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 d95b26bc6..a2bcd03c2 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,6 +32,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState @@ -65,6 +66,7 @@ fun HomeSkeletonHero( modifier: Modifier = Modifier, viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, + sectionPadding: Dp? = null, ) { val brush = rememberHomeSkeletonBrush() @@ -77,8 +79,10 @@ fun HomeSkeletonHero( maxWidthDp = maxWidth.value, viewportHeightDp = viewportHeight?.value, mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value, + preferDesktopLayout = isDesktop, ) val containerWidth = maxWidth + val contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding Box( modifier = Modifier @@ -121,7 +125,7 @@ fun HomeSkeletonHero( .align(Alignment.BottomCenter) .fillMaxWidth() .padding( - horizontal = layout.contentHorizontalPadding, + horizontal = contentHorizontalPadding, vertical = layout.contentVerticalPadding, ), horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, 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 906c59e25..c4c86bb08 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 @@ -16,11 +16,6 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc -import kotlinx.coroutines.runBlocking -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.library_local_tab_title -import nuvio.composeapp.generated.resources.library_other -import org.jetbrains.compose.resources.getString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -33,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -41,6 +37,11 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_local_tab_title +import nuvio.composeapp.generated.resources.library_other +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString @Serializable private data class StoredLibraryPayload( @@ -476,11 +477,16 @@ object LibraryRepository { } internal const val LOCAL_LIBRARY_LIST_KEY = "local" +private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library" +private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other" internal fun localLibraryListTab(): TraktListTab = TraktListTab( key = LOCAL_LIBRARY_LIST_KEY, - title = runBlocking { getString(Res.string.library_local_tab_title) }, + title = localizedStringOrDefault( + resource = Res.string.library_local_tab_title, + fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE, + ), type = TraktListType.WATCHLIST, ) @@ -552,7 +558,7 @@ private fun PosterShape.toSyncName(): String = internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() - if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } + if (normalized.isBlank()) return localizedLibraryOtherTitle() return normalized .split('-', '_', ' ') @@ -560,5 +566,15 @@ internal fun String.toLibraryDisplayTitle(): String { .joinToString(" ") { token -> token.lowercase().replaceFirstChar { char -> char.uppercase() } } - .ifBlank { runBlocking { getString(Res.string.library_other) } } + .ifBlank { localizedLibraryOtherTitle() } } + +private fun localizedLibraryOtherTitle(): String = + localizedStringOrDefault( + resource = Res.string.library_other, + fallback = DEFAULT_LIBRARY_OTHER_TITLE, + ) + +private fun localizedStringOrDefault(resource: StringResource, fallback: String): String = + runCatching { runBlocking { getString(resource) } } + .getOrDefault(fallback) 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 af8b64104..4826efa00 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 @@ -65,7 +65,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryItemType @@ -88,6 +88,7 @@ import org.jetbrains.compose.resources.stringResource @Composable fun LibraryScreen( modifier: Modifier = Modifier, + topChromePadding: Dp? = null, scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, @@ -174,33 +175,40 @@ fun LibraryScreen( NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, + topPadding = if (topChromePadding != null) 0.dp else null, listState = listState, ) { stickyHeader { - androidx.compose.foundation.layout.Column( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(MaterialTheme.colorScheme.background), - ) { - NuvioScreenHeader( - title = if (sourceMode == LibraryViewMode.Cloud) { - stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) - } else { - stringResource(Res.string.library_title) - }, - modifier = Modifier.padding(horizontal = 16.dp), + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - LibrarySourceSwitch( - selectedMode = sourceMode, - onModeSelected = { mode -> - sourceModeName = mode.name - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, + modifier = Modifier.padding(horizontal = 16.dp), + topPadding = topChromePadding, + ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt index 9a2c5c516..092b855f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt @@ -45,9 +45,6 @@ object P2pSettingsRepository { if (p2pEnabled == enabled) return p2pEnabled = enabled P2pSettingsStorage.saveP2pEnabled(enabled) - if (!enabled) { - P2pStreamingEngine.shutdown() - } publish() } @@ -97,7 +94,6 @@ data class P2pStreamRequest( val infoHash: String, val fileIdx: Int?, val filename: String? = null, - val magnetUri: String? = null, val trackers: List = emptyList(), ) @@ -123,8 +119,6 @@ class P2pStreamingException(message: String) : Exception(message) expect object P2pStreamingEngine { val state: StateFlow - fun warmup() - fun cooldownWarmup() suspend fun startStream(request: P2pStreamRequest): String fun stopStream() fun shutdown() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt index 7c79566a6..511241974 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt @@ -2,8 +2,8 @@ package com.nuvio.app.features.player /** * Orchestrates the full external player launch flow: - * fetches subtitles if forwarding is enabled, then returns an enriched request - * for the caller to dispatch. + * fetches subtitles if forwarding is enabled, downloads them to local cache, + * then returns an enriched request for the caller to dispatch. */ suspend fun prepareExternalPlayerLaunch( request: ExternalPlayerPlaybackRequest, @@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch( ) if (subtitles != null) { + onOverlayMessage("Downloading subtitles...") + val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles) + if (cachedSubtitles != null) { + return request.copy(subtitles = cachedSubtitles) + } + // Fallback: use original URLs if caching fails return request.copy(subtitles = subtitles) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index b7e4d5c91..60bfd3f41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest( val sourceHeaders: Map = emptyMap(), val resumePositionMs: Long = 0L, val subtitles: List? = null, -) + val season: Int? = null, + val episode: Int? = null, + val episodeTitle: String? = null, +) { + /** + * Builds a display title for external players. + * For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title" + * For movies: just the content name (title). + */ + fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String { + if (season == null || episode == null) return title + val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}" + return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) { + "$title - $seasonEp - $episodeTitle" + } else { + "$title - $seasonEp" + } + } +} enum class ExternalPlayerOpenResult { Opened, 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 bc8039b5d..fd38064ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -31,6 +31,233 @@ interface PlayerEngineController { fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } +enum class PlayerControlsAction { + ToggleChrome, + RevealLockedOverlay, + Back, + TogglePlayback, + KeyboardTogglePlayback, + SeekBack, + KeyboardSeekBack, + SeekForward, + KeyboardSeekForward, + ResizeMode, + Speed, + Subtitles, + Audio, + Sources, + Episodes, + OpenExternalPlayer, + SubmitIntro, + LockToggle, + VideoSettings, + DoubleTapSeekBack, + DoubleTapSeekForward, +} + +data class PlayerControlsState( + val title: String = "", + val episodeText: String = "", + val streamTitle: String = "", + val providerName: String = "", + val pauseOverlayWatchingLabel: String = "You're watching", + val pauseOverlayLogo: String? = null, + val pauseOverlayEpisodeInfo: String = "", + val pauseOverlayEpisodeTitle: String = "", + val pauseOverlayDescription: String = "", + val resizeModeLabel: String = "Fit", + val playbackSpeedLabel: String = "1x", + val subtitlesLabel: String = "Subs", + val audioLabel: String = "Audio", + val sourcesLabel: String = "Sources", + val episodesLabel: String = "Episodes", + val externalPlayerLabel: String = "External", + val playLabel: String = "Play", + val pauseLabel: String = "Pause", + val closeLabel: String = "Close player", + val lockLabel: String = "Lock player controls", + val unlockLabel: String = "Unlock player controls", + val submitIntroLabel: String = "Submit Intro", + val videoSettingsLabel: String = "Video settings", + val tapToUnlockLabel: String = "Tap to unlock", + val playbackErrorTitle: String = "Playback error", + val playbackErrorMessage: String = "", + val playbackErrorActionLabel: String = "Go back", + val sourcesPanelTitle: String = "Sources", + val episodesPanelTitle: String = "Episodes", + val streamsPanelTitle: String = "Streams", + val allFilterLabel: String = "All", + val reloadLabel: String = "Reload", + val backLabel: String = "Back", + val panelCloseLabel: String = "Close", + val cancelLabel: String = "Cancel", + val playingLabel: String = "Playing", + val noStreamsLabel: String = "No streams found", + val noEpisodesLabel: String = "No episodes available", + val submitIntroPanelTitle: String = "Submit Timestamps", + val submitIntroSegmentTypeLabel: String = "SEGMENT TYPE", + val submitIntroSegmentIntroLabel: String = "Intro", + val submitIntroSegmentRecapLabel: String = "Recap", + val submitIntroSegmentOutroLabel: String = "Outro", + val submitIntroStartTimeLabel: String = "START TIME (MM:SS)", + val submitIntroEndTimeLabel: String = "END TIME (MM:SS)", + val submitIntroCaptureLabel: String = "Capture", + val submitIntroSubmitLabel: String = "Submit", + val p2pConsentTitle: String = "P2P Streaming", + val p2pConsentBody: String = "", + val p2pConsentEnableLabel: String = "Enable P2P", + val p2pConsentCancelLabel: String = "Cancel", + val subtitlesPanelTitle: String = "Subtitles", + val subtitleBuiltInTabLabel: String = "Built-in", + val subtitleAddonsTabLabel: String = "Addons", + val subtitleStyleTabLabel: String = "Style", + val noneLabel: String = "None", + val fetchSubtitlesLabel: String = "Tap to fetch subtitles", + val subtitleDelayLabel: String = "Subtitle Delay", + val resetLabel: String = "Reset", + val autoSyncLabel: String = "Auto Sync", + val reloadSmallLabel: String = "Reload", + val captureLineLabel: String = "Capture", + val selectAddonSubtitleFirstLabel: String = "Select an addon subtitle first", + val loadingSubtitleLinesLabel: String = "Loading subtitle lines...", + val fontSizeLabel: String = "Font Size", + val outlineLabel: String = "Outline", + val boldLabel: String = "Bold", + val bottomOffsetLabel: String = "Bottom Offset", + val colorLabel: String = "Color", + val textOpacityLabel: String = "Text Opacity", + val outlineColorLabel: String = "Outline Color", + val resetDefaultsLabel: String = "Reset Defaults", + val onLabel: String = "On", + val offLabel: String = "Off", + val themeAccentColor: String = "#2f6fed", + val themeAccentStrongColor: String = "#3c7bff", + val themeOnAccentColor: String = "#ffffff", + val themeFocusColor: String = "#9ecaff", + val themeSelectedSurfaceColor: String = "#26384f", + val themeSelectedSurfaceHoverColor: String = "#2d4565", + val themeSelectedRingColor: String = "rgba(47, 111, 237, .35)", + val themeTimelineFillColor: String = "#ffffff", + val themeTimelineTrackColor: String = "rgba(255, 255, 255, .28)", + val themeBufferingColor: String = "#ffffff", + val themeBufferingTrackColor: String = "rgba(255, 255, 255, .28)", + val themeControlForegroundColor: String = "#ffffff", + val isPlaying: Boolean = false, + val isLoading: Boolean = false, + val isLocked: Boolean = false, + val lockedOverlayVisible: Boolean = false, + val controlsVisible: Boolean = true, + val parentalWarnings: List = 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() @@ -70,7 +297,13 @@ expect fun PlatformPlayerSurface( modifier: Modifier = Modifier, playWhenReady: Boolean = true, resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, + initialPositionMs: Long = 0L, useNativeController: Boolean = false, + playerControlsState: PlayerControlsState = PlayerControlsState(), + onPlayerControlsAction: (PlayerControlsAction) -> Boolean = { false }, + onPlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false }, + onPlayerControlsScrubChange: (Long) -> Boolean = { false }, + onPlayerControlsScrubFinished: (Long) -> Boolean = { false }, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, 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 f6a32fe12..288fafda1 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 @@ -54,7 +54,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository 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 8205b6168..8441b89da 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 @@ -45,7 +45,6 @@ data class PlayerLaunch( val torrentInfoHash: String? = null, val torrentFileIdx: Int? = null, val torrentFilename: String? = null, - val torrentMagnetUri: String? = null, val torrentTrackers: List = emptyList(), val initialPositionMs: Long = 0L, val initialProgressFraction: Float? = 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 00405b6f0..eeaa2ef92 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 @@ -62,7 +62,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.nuvioTypeScale import nuvio.composeapp.generated.resources.Res 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 368d2e5a4..725ecf228 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 @@ -33,7 +33,6 @@ fun PlayerScreen( torrentInfoHash: String? = null, torrentFileIdx: Int? = null, torrentFilename: String? = null, - torrentMagnetUri: String? = null, torrentTrackers: List = emptyList(), initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, @@ -68,7 +67,6 @@ fun PlayerScreen( torrentInfoHash = torrentInfoHash, torrentFileIdx = torrentFileIdx, torrentFilename = torrentFilename, - torrentMagnetUri = torrentMagnetUri, torrentTrackers = torrentTrackers, initialPositionMs = initialPositionMs, initialProgressFraction = initialProgressFraction, 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 47921c816..71705dc8e 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 @@ -31,7 +31,6 @@ internal data class PlayerScreenArgs( val torrentInfoHash: String?, val torrentFileIdx: Int?, val torrentFilename: String?, - val torrentMagnetUri: String?, val torrentTrackers: List, val initialPositionMs: Long, val initialProgressFraction: Float?, 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 08ffa55aa..e1e5f81fc 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,6 +16,7 @@ import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.isDesktop import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -89,7 +90,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { activeTorrentInfoHash, activeTorrentFileIdx, activeTorrentFilename, - activeTorrentMagnetUri, activeTorrentTrackers, p2pSettingsUiState.p2pEnabled, ) { @@ -106,7 +106,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { p2pResolvedSourceUrl = null val requestedFileIdx = activeTorrentFileIdx val requestedFilename = activeTorrentFilename - val requestedMagnetUri = activeTorrentMagnetUri val requestedTrackers = activeTorrentTrackers errorMessage = null playerController = null @@ -120,7 +119,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { infoHash = infoHash, fileIdx = requestedFileIdx, filename = requestedFilename, - magnetUri = requestedMagnetUri, trackers = requestedTrackers, ), ) @@ -233,6 +231,10 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialSeekApplied = true return@LaunchedEffect } + if (isDesktop && activeInitialPositionMs > 0L) { + initialSeekApplied = true + return@LaunchedEffect + } controller.seekTo(targetPositionMs) initialSeekApplied = true 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 0e1d419af..f16bcc54b 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 @@ -165,10 +165,33 @@ internal fun PlayerScreenRuntime.togglePlayback() { controlsVisible = true } +internal fun PlayerScreenRuntime.prepareTogglePlaybackForNativeFallback(revealControls: Boolean = true) { + shouldPlay = !playbackSnapshot.isPlaying + if (revealControls) { + controlsVisible = true + } +} + internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) { playerController?.seekBy(offsetMs) + applySeekByControlFeedback(offsetMs) +} + +internal fun PlayerScreenRuntime.prepareSeekByForNativeFallback( + offsetMs: Long, + revealControls: Boolean = true, +) { + applySeekByControlFeedback(offsetMs, revealControls) +} + +private fun PlayerScreenRuntime.applySeekByControlFeedback( + offsetMs: Long, + revealControls: Boolean = true, +) { scheduleProgressSyncAfterSeek() - controlsVisible = true + if (revealControls) { + controlsVisible = true + } when { offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) @@ -176,6 +199,17 @@ internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) { } internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) { + handleDoubleTapSeek(direction, sendToController = true) +} + +internal fun PlayerScreenRuntime.prepareDoubleTapSeekForNativeFallback(direction: PlayerSeekDirection) { + handleDoubleTapSeek(direction, sendToController = false) +} + +private fun PlayerScreenRuntime.handleDoubleTapSeek( + direction: PlayerSeekDirection, + sendToController: Boolean, +) { val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) val currentSeekState = accumulatedSeekState val nextState = if (currentSeekState?.direction == direction) { @@ -199,7 +233,9 @@ internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirect maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped } } - playerController?.seekTo(targetPositionMs) + if (sendToController) { + playerController?.seekTo(targetPositionMs) + } scheduleProgressSyncAfterSeek() showSeekFeedback(direction, nextState.amountMs) 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 87bf6545e..12b82dc3b 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 @@ -49,6 +49,58 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?) internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = stream.needsLocalDebridResolve && stream.p2pInfoHash != null +internal fun StreamItem.playerSourceIdentityKey(): String? { + p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + return "torrent:$hash:${fileIdx ?: -1}" + } + + clientResolve?.let { resolve -> + val raw = resolve.stream?.raw + val keyParts = listOf( + addonId, + resolve.service, + resolve.serviceIndex?.toString(), + resolve.infoHash?.trim()?.lowercase(), + resolve.fileIdx?.toString(), + resolve.magnetUri, + resolve.torrentName, + resolve.filename, + raw?.torrentName, + raw?.filename, + raw?.size?.toString(), + behaviorHints.filename, + behaviorHints.videoSize?.toString(), + streamLabel, + streamSubtitle, + ).map { it.orEmpty().trim() } + if (keyParts.any { it.isNotBlank() }) { + return "resolve:${keyParts.joinToString("|")}" + } + } + + behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash -> + return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}" + } + + playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url -> + return "url:$url" + } + + val fallbackParts = listOf( + addonId, + addonName, + streamLabel, + streamSubtitle.orEmpty(), + behaviorHints.filename.orEmpty(), + behaviorHints.videoSize?.toString().orEmpty(), + sourceName.orEmpty(), + sources.joinToString(","), + ).map { it.trim() } + return fallbackParts + .takeIf { parts -> parts.any { it.isNotBlank() } } + ?.joinToString(separator = "|", prefix = "meta:") +} + internal fun PlayerScreenRuntime.stopActiveP2pStream() { if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { P2pStreamingEngine.stopStream() @@ -56,7 +108,6 @@ internal fun PlayerScreenRuntime.stopActiveP2pStream() { activeTorrentInfoHash = null activeTorrentFileIdx = null activeTorrentFilename = null - activeTorrentMagnetUri = null activeTorrentTrackers = emptyList() p2pResolvedSourceUrl = null } @@ -84,12 +135,11 @@ internal fun PlayerScreenRuntime.saveP2pStreamForReuse( addonId = stream.addonId, requestHeaders = emptyMap(), responseHeaders = emptyMap(), - filename = stream.p2pFilename, + filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.p2pFileIdx, - magnetUri = stream.torrentMagnetUri, - sources = stream.p2pSourceHints, + fileIdx = stream.fileIdx, + sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) } @@ -110,15 +160,15 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { season = activeSeasonNumber, episode = activeEpisodeNumber, ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.p2pFileIdx - activeTorrentFilename = stream.p2pFilename - activeTorrentMagnetUri = stream.torrentMagnetUri + activeTorrentFileIdx = stream.fileIdx + activeTorrentFilename = stream.behaviorHints.filename activeTorrentTrackers = stream.p2pTrackers + activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -152,14 +202,13 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream( season = episode.season, episode = episode.episode, ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.p2pFileIdx - activeTorrentFilename = stream.p2pFilename - activeTorrentMagnetUri = stream.torrentMagnetUri + activeTorrentFileIdx = stream.fileIdx + activeTorrentFilename = stream.behaviorHints.filename activeTorrentTrackers = stream.p2pTrackers applyEpisodeStreamMetadata(stream, episode, resume) } @@ -190,7 +239,11 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { return } val url = stream.playableDirectUrl ?: return - if (url == activeSourceUrl) return + val sourceIdentityKey = stream.playerSourceIdentityKey() + if (url == activeSourceUrl) { + activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey + return + } val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() stopActiveP2pStream() @@ -202,6 +255,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeSourceIdentityKey = sourceIdentityKey activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -275,6 +329,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() + activeSourceIdentityKey = null activeStreamTitle = downloadItem.streamTitle.ifBlank { episode.title.ifBlank { title } } @@ -380,6 +435,7 @@ private fun PlayerScreenRuntime.applyEpisodeStreamMetadata( episode: MetaVideo, resume: EpisodeResume, ) { + activeSourceIdentityKey = stream.playerSourceIdentityKey() activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName 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 3be4b9f2c..ed4f44ea0 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 @@ -52,7 +52,6 @@ internal class PlayerScreenRuntime( val torrentInfoHash: String? get() = args.torrentInfoHash val torrentFileIdx: Int? get() = args.torrentFileIdx val torrentFilename: String? get() = args.torrentFilename - val torrentMagnetUri: String? get() = args.torrentMagnetUri val torrentTrackers: List get() = args.torrentTrackers val initialPositionMs: Long get() = args.initialPositionMs val initialProgressFraction: Float? get() = args.initialProgressFraction @@ -67,8 +66,8 @@ internal class PlayerScreenRuntime( var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() var watchedUiState: WatchedUiState = WatchedUiState() var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() - var sourceStreamsState: StreamsUiState = StreamsUiState() - var episodeStreamsRepoState: StreamsUiState = StreamsUiState() + var sourceStreamsState by mutableStateOf(StreamsUiState()) + var episodeStreamsRepoState by mutableStateOf(StreamsUiState()) var metaUiState: MetaDetailsUiState = MetaDetailsUiState() var addonsUiState: AddonsUiState = AddonsUiState() var addonSubtitles: List = emptyList() @@ -99,9 +98,13 @@ internal class PlayerScreenRuntime( var activeTorrentInfoHash by mutableStateOf(torrentInfoHash) var activeTorrentFileIdx by mutableStateOf(torrentFileIdx) var activeTorrentFilename by mutableStateOf(torrentFilename) - var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri) var activeTorrentTrackers by mutableStateOf(torrentTrackers) var p2pResolvedSourceUrl by mutableStateOf(null) + var activeSourceIdentityKey by mutableStateOf( + torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + "torrent:$hash:${torrentFileIdx ?: -1}" + } ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" }, + ) var activeStreamTitle by mutableStateOf(streamTitle) var activeStreamSubtitle by mutableStateOf(streamSubtitle) var activeProviderName by mutableStateOf(providerName) @@ -152,6 +155,12 @@ internal class PlayerScreenRuntime( var submitIntroSegmentType by mutableStateOf("intro") var submitIntroStartTimeStr by mutableStateOf("00:00") var submitIntroEndTimeStr by mutableStateOf("00:00") + var submitIntroStartTimeSec by mutableStateOf(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/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 2e396b4e1..0731e8742 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,21 +7,47 @@ 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 isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + val seasonNumber = activeSeasonNumber + val episodeNumber = activeEpisodeNumber + val episodeTitle = activeEpisodeTitle + val isEpisode = seasonNumber != null && episodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback val isP2pPlaybackActive = activeTorrentInfoHash != null val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming @@ -81,6 +107,233 @@ 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( @@ -114,7 +367,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, ), ) { - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl if (playerSurfaceSourceUrl != null) { PlatformPlayerSurface( sourceUrl = playerSurfaceSourceUrl, @@ -124,6 +376,34 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { 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 @@ -179,6 +459,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, + suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -256,6 +537,9 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, sourceHeaders = activeSourceHeaders, resumePositionMs = playbackSnapshot.positionMs, subtitles = loadedSubtitles, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, ), ) } @@ -288,6 +572,606 @@ 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, @@ -298,62 +1182,66 @@ 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, - 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, + 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, onDismissError = { flushWatchProgress() args.onBack() @@ -518,6 +1406,9 @@ 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/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 1a733fcdb..c78e27da9 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 @@ -63,6 +63,7 @@ object PlayerStreamsRepository { forceRefresh: Boolean = false, ) { fetchStreams( + panelName = "sources", type = type, videoId = videoId, season = season, @@ -84,6 +85,7 @@ object PlayerStreamsRepository { forceRefresh: Boolean = false, ) { fetchStreams( + panelName = "episodeStreams", type = type, videoId = videoId, season = season, @@ -119,6 +121,7 @@ object PlayerStreamsRepository { } private fun fetchStreams( + panelName: String, type: String, videoId: String, season: Int?, @@ -137,9 +140,11 @@ object PlayerStreamsRepository { requestKeyHolder() == requestKey && (current.groups.isNotEmpty() || current.emptyStateReason != null || current.isAnyLoading) ) { + log.d { "skip $panelName request=$requestKey reason=already-active ${current.streamDiagnostics()}" } return } + log.d { "start $panelName request=$requestKey force=$forceRefresh previous=${current.streamDiagnostics()}" } setRequestKey(requestKey) jobHolder()?.cancel() stateFlow.value = StreamsUiState() @@ -147,7 +152,7 @@ object PlayerStreamsRepository { val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId) if (embeddedStreams.isNotEmpty()) { - log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" } + log.d { "using embedded $panelName request=$requestKey streams=${embeddedStreams.size}" } val group = AddonStreamGroup( addonName = embeddedStreams.first().addonName, addonId = "embedded", @@ -163,6 +168,7 @@ object PlayerStreamsRepository { activeAddonIds = setOf("embedded"), isAnyLoading = false, ) + log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" } return } @@ -183,6 +189,7 @@ object PlayerStreamsRepository { isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, ) + log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" } return } @@ -209,6 +216,10 @@ object PlayerStreamsRepository { isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, ) + log.d { + "finish $panelName request=$requestKey reason=no-compatible-addons " + + "installed=${installedAddons.size} ${stateFlow.value.streamDiagnostics()}" + } return } @@ -222,6 +233,10 @@ object PlayerStreamsRepository { .associateBy { it.addonId } } val warmedAddonIds = warmedAddonGroups.keys + log.d { + "targets $panelName request=$requestKey installed=${installedAddons.size} " + + "compatible=${streamAddons.size} plugins=${pluginScrapers.size} warmed=${warmedAddonIds.size}" + } val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, @@ -243,6 +258,7 @@ object PlayerStreamsRepository { activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = isInitiallyLoading, ) + log.d { "state $panelName request=$requestKey stage=initial ${stateFlow.value.streamDiagnostics()}" } val job = scope.launch { val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } @@ -271,6 +287,7 @@ object PlayerStreamsRepository { } fun publishStreamGroup(group: AddonStreamGroup) { + var nextState: StreamsUiState? = null stateFlow.update { current -> val updated = StreamAutoPlaySelector.orderAddonStreams( groups = current.groups.map { currentGroup -> @@ -283,7 +300,14 @@ object PlayerStreamsRepository { groups = updated, isAnyLoading = anyLoading, emptyStateReason = emptyStateReason(updated, anyLoading), - ) + ).also { nextState = it } + } + nextState?.let { state -> + log.d { + "state $panelName request=$requestKey stage=publish addon=${group.addonName} " + + "streams=${group.streams.size} loading=${group.isLoading} " + + "error=${!group.error.isNullOrBlank()} ${state.streamDiagnostics()}" + } } } @@ -329,14 +353,16 @@ object PlayerStreamsRepository { val displayName = addon.addonName runCatching { + log.d { "fetch $panelName request=$requestKey addon=$displayName" } val payload = httpGetText(url) StreamParser.parse(payload, displayName, addon.addonId) }.fold( onSuccess = { streams -> + log.d { "fetched $panelName request=$requestKey addon=$displayName streams=${streams.size}" } AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false) }, onFailure = { err -> - log.w(err) { "Failed: ${displayName}" } + log.w(err) { "failed $panelName request=$requestKey addon=$displayName" } AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message) }, ) @@ -345,6 +371,7 @@ object PlayerStreamsRepository { val pluginJobs = pluginScrapers.map { scraper -> async { + log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" } PluginRepository.executeScraper( scraper = scraper, tmdbId = pluginContentId( @@ -357,6 +384,7 @@ object PlayerStreamsRepository { episode = episode, ).fold( onSuccess = { results -> + log.d { "fetched $panelName request=$requestKey plugin=${scraper.name} streams=${results.size}" } AddonStreamGroup( addonName = scraper.name, addonId = "plugin:${scraper.id}", @@ -365,7 +393,7 @@ object PlayerStreamsRepository { ) }, onFailure = { err -> - log.w(err) { "Plugin scraper failed: ${scraper.name}" } + log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" } AddonStreamGroup( addonName = scraper.name, addonId = "plugin:${scraper.id}", @@ -392,6 +420,7 @@ object PlayerStreamsRepository { for (availabilityJob in debridAvailabilityJobs) { availabilityJob.join() } + log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" } launch { DirectDebridStreamPreparer.prepare( streams = stateFlow.value.groups @@ -426,6 +455,25 @@ private data class PlayerInstalledStreamAddonTarget( val manifest: com.nuvio.app.features.addons.AddonManifest, ) +private fun StreamsUiState.streamDiagnostics(): String { + val streamCount = groups.sumOf { it.streams.size } + val loadingCount = groups.count { it.isLoading } + val errorCount = groups.count { !it.error.isNullOrBlank() } + val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group -> + buildString { + append(group.addonName) + append(':') + append(group.streams.size) + if (group.isLoading) append(":loading") + if (!group.error.isNullOrBlank()) append(":error") + } + } + val suffix = if (groups.size > 4) "+${groups.size - 4}" else "" + return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " + + "loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " + + "sample=$sampleGroups$suffix" +} + private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = "addon:$manifestId:$manifestUrl" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt new file mode 100644 index 000000000..be4c88fd1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.player + +/** + * Platform-specific subtitle caching for external players. + * + * On Android, downloads subtitle files from remote URLs to local cache and returns + * content:// URIs so external players can access them (via intent extras + ClipData). + * + * On iOS, returns subtitles unchanged — players like Infuse accept remote URLs + * directly via their URL scheme parameters. + */ +expect object SubtitleCacheProvider { + /** + * Caches subtitle files locally and returns updated [SubtitleInput] list + * with local URIs instead of remote HTTP URLs. + * + * Returns null if caching fails or no subtitles could be downloaded. + * On platforms where caching is not needed, returns the input list unchanged. + */ + suspend fun cacheForExternalPlayer(subtitles: List): List? +} 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 2de182764..dcb5b496e 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_episode_title_format import nuvio.composeapp.generated.resources.detail_btn_play 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 5f00697d7..b6a8524be 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.ui.NuvioInputField 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 ab6b5b2b6..c2033993f 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,6 +38,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -62,6 +65,7 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val profileSwitchMutex = Mutex() private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) @@ -141,7 +145,15 @@ object ProfileRepository { } } - fun selectProfile(profileIndex: Int) { + suspend fun switchToProfile(profileIndex: Int) { + profileSwitchMutex.withLock { + withContext(Dispatchers.Default) { + selectProfile(profileIndex) + } + } + } + + private fun selectProfile(profileIndex: Int) { activeProfileIndex = profileIndex val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex } _state.value = _state.value.copy( 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 195ba6748..da6c96b97 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import kotlinx.coroutines.delay @@ -176,7 +176,6 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -217,7 +216,6 @@ fun ProfileSelectionScreen( } else if (profile.pinEnabled) { pinDialogProfile = profile } else { - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) } }, @@ -282,7 +280,6 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, 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 5e812b28e..81545f14b 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos @@ -85,6 +85,7 @@ fun ProfileSwitcherTab( onProfileSelected: (NuvioProfile) -> Unit, onAddProfileRequested: () -> Unit, triggerContent: (@Composable (selected: Boolean) -> Unit)? = null, + openPopupOnClick: Boolean = false, modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio @@ -201,7 +202,13 @@ fun ProfileSwitcherTab( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = onClick, + onClick = { + if (openPopupOnClick && profiles.isNotEmpty()) { + showPopup = true + } else { + onClick() + } + }, ) .pointerInput(profiles) { detectDragGesturesAfterLongPress( @@ -336,6 +343,358 @@ fun ProfileSwitcherTab( } } +@Composable +fun SidebarProfileSwitcherStack( + onProfileSelected: (NuvioProfile) -> Unit, + onAddProfileRequested: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val activeProfile = profileState.activeProfile + val profiles = profileState.profiles + val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() + var pinProfile by remember { mutableStateOf(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 2cf53cba2..2df202489 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip 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 0804d9040..7b8f781a2 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 @@ -46,7 +46,7 @@ import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons @@ -83,6 +83,7 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SearchScreen( modifier: Modifier = Modifier, + topChromePadding: Dp? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, searchFocusRequestCount: Int = 0, @@ -238,43 +239,50 @@ fun SearchScreen( NuvioScreen( horizontalPadding = 0.dp, + topPadding = if (topChromePadding != null) 0.dp else null, listState = listState, modifier = Modifier.fillMaxSize(), ) { stickyHeader { - androidx.compose.foundation.layout.Column( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(MaterialTheme.colorScheme.background), - ) { - NuvioScreenHeader( - title = headerTitle, - modifier = Modifier.padding(horizontal = 16.dp), + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) - androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { - NuvioInputField( - value = query, - onValueChange = { query = it }, - placeholder = stringResource(Res.string.compose_search_placeholder), - modifier = Modifier.focusRequester(focusRequester), - trailingContent = if (query.isNotBlank()) { - { - IconButton(onClick = { query = "" }) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(Res.string.compose_search_clear), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - null - }, + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = headerTitle, + modifier = Modifier.padding(horizontal = 16.dp), + topPadding = topChromePadding, ) - } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { + NuvioInputField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(Res.string.compose_search_placeholder), + modifier = Modifier.focusRequester(focusRequester), + trailingContent = if (query.isNotBlank()) { + { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + ) + } androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(14.dp)) + } } } 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 abbf58e7f..07bb324b9 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 @@ -5,9 +5,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,7 +38,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -56,6 +59,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she import nuvio.composeapp.generated.resources.settings_appearance_amoled_black import nuvio.composeapp.generated.resources.settings_appearance_amoled_description import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description +import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation +import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description @@ -67,7 +72,6 @@ import org.jetbrains.compose.resources.stringResource import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState -@OptIn(ExperimentalLayoutApi::class) internal fun LazyListScope.appearanceSettingsContent( isTablet: Boolean, selectedTheme: AppTheme, @@ -89,30 +93,57 @@ internal fun LazyListScope.appearanceSettingsContent( ) { SettingsGroup(isTablet = isTablet) { val themes = listOf(AppTheme.WHITE) + AppTheme.entries.filterNot { it == AppTheme.WHITE } - FlowRow( + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 18.dp else 14.dp + val themeSpacing = if (isTablet) 16.dp else 12.dp + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .padding( - horizontal = if (isTablet) 20.dp else 16.dp, - vertical = if (isTablet) 18.dp else 14.dp, + horizontal = horizontalPadding, + vertical = verticalPadding, ), - horizontalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), - verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), ) { - themes.forEach { theme -> - ThemeChip( - theme = theme, - isSelected = theme == selectedTheme, - onClick = { onThemeSelected(theme) }, - ) + val preferredColumns = if (isTablet) 4 else 3 + val minThemeCellWidth = if (isTablet) 92.dp else 78.dp + val themeColumns = ((maxWidth + themeSpacing) / (minThemeCellWidth + themeSpacing)) + .toInt() + .coerceAtLeast(1) + .coerceAtMost(preferredColumns) + + Column( + verticalArrangement = Arrangement.spacedBy(themeSpacing), + ) { + themes.chunked(themeColumns).forEach { rowThemes -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(themeSpacing), + ) { + rowThemes.forEach { theme -> + ThemeChip( + theme = theme, + isSelected = theme == selectedTheme, + onClick = { onThemeSelected(theme) }, + modifier = Modifier.weight(1f), + ) + } + repeat(themeColumns - rowThemes.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } } } } } } - item { var showLanguageSheet by remember { mutableStateOf(false) } + var showDesktopNavigationSheet by remember { mutableStateOf(false) } + val desktopNavigationLayout by remember { + ThemeSettingsRepository.ensureLoaded() + ThemeSettingsRepository.desktopNavigationLayout + }.collectAsStateWithLifecycle() SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -135,6 +166,16 @@ internal fun LazyListScope.appearanceSettingsContent( onCheckedChange = onLiquidGlassNativeTabBarToggle, ) } + if (isDesktop) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_desktop_navigation), + description = stringResource(desktopNavigationLayout.labelRes), + icon = Icons.Rounded.Style, + isTablet = isTablet, + onClick = { showDesktopNavigationSheet = true }, + ) + } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_appearance_app_language), @@ -146,6 +187,17 @@ internal fun LazyListScope.appearanceSettingsContent( } } + if (showDesktopNavigationSheet) { + DesktopNavigationLayoutBottomSheet( + selectedLayout = desktopNavigationLayout, + onLayoutSelected = { + ThemeSettingsRepository.setDesktopNavigationLayout(it) + showDesktopNavigationSheet = false + }, + onDismiss = { showDesktopNavigationSheet = false }, + ) + } + if (showLanguageSheet) { AppearanceLanguageBottomSheet( selectedLanguage = selectedAppLanguage, @@ -184,6 +236,66 @@ internal fun LazyListScope.appearanceSettingsContent( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DesktopNavigationLayoutBottomSheet( + selectedLayout: DesktopNavigationLayout, + onLayoutSelected: (DesktopNavigationLayout) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + item { + Text( + text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + + itemsIndexed(DesktopNavigationLayout.entries) { index, layout -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(layout.labelRes), + onClick = { + onLayoutSelected(layout) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (layout == selectedLayout) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} + private data class AppLanguageSheetOption( val language: AppLanguage, val labelRes: StringResource, @@ -262,41 +374,48 @@ private fun ThemeChip( theme: AppTheme, isSelected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val palette = ThemeColors.getColorPalette(theme) Column( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) - .then( - if (isSelected) { - Modifier.border( - width = 1.5.dp, - color = palette.focusRing, - shape = RoundedCornerShape(12.dp), - ) - } else { - Modifier - }, - ) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 4.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(palette.secondary), + .size(56.dp) + .then( + if (isSelected) { + Modifier.border( + width = 1.5.dp, + color = palette.focusRing, + shape = RoundedCornerShape(14.dp), + ) + } else { + Modifier + }, + ), contentAlignment = Alignment.Center, ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(Res.string.cd_selected), - tint = palette.onSecondary, - modifier = Modifier.size(22.dp), - ) + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(palette.secondary), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = palette.onSecondary, + modifier = Modifier.size(22.dp), + ) + } } } @@ -312,6 +431,9 @@ private fun ThemeChip( }, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(4.dp)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 646a1597e..4b812d927 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -74,6 +75,9 @@ import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear @@ -1467,12 +1471,21 @@ private fun DebridDeviceAuthDialog( isStarting = true isPolling = false statusMessage = null + if (restartNonce == 0) { + loadPendingDeviceAuthorization(provider.id)?.let { pendingSession -> + session = pendingSession + isStarting = false + statusMessage = waitingMessage + return@LaunchedEffect + } + } val startResult = runCatching { DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") }.onFailure { error -> if (error is CancellationException) throw error } session = startResult.getOrNull() + session?.let(::savePendingDeviceAuthorization) isStarting = false statusMessage = if (session == null) { startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } @@ -1504,6 +1517,7 @@ private fun DebridDeviceAuthDialog( isPolling = false when (result) { is DebridDeviceAuthorizationTokenResult.Authorized -> { + clearPendingDeviceAuthorization(provider.id) onConnected(result.accessToken) onDismiss() return@LaunchedEffect @@ -1514,16 +1528,19 @@ private fun DebridDeviceAuthDialog( } DebridDeviceAuthorizationTokenResult.Expired -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = expiredMessage return@LaunchedEffect } is DebridDeviceAuthorizationTokenResult.Failed -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) return@LaunchedEffect } DebridDeviceAuthorizationTokenResult.Unsupported -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = failedMessage return@LaunchedEffect } @@ -1621,6 +1638,7 @@ private fun DebridDeviceAuthDialog( if (isConnected) { Button( onClick = { + clearPendingDeviceAuthorization(provider.id) onDisconnect() onDismiss() }, @@ -1629,7 +1647,12 @@ private fun DebridDeviceAuthDialog( } } if (!isConnected && !isStarting && session == null) { - TextButton(onClick = { restartNonce += 1 }) { + TextButton( + onClick = { + clearPendingDeviceAuthorization(provider.id) + restartNonce += 1 + }, + ) { Text(stringResource(Res.string.action_retry)) } } @@ -1649,6 +1672,34 @@ private fun DebridDeviceAuthDialog( } } +private val debridDeviceAuthorizationJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private fun loadPendingDeviceAuthorization(providerId: String): DebridDeviceAuthorization? = + DebridSettingsStorage.loadPendingDeviceAuthorization(providerId) + .orEmpty() + .trim() + .takeIf { it.isNotBlank() } + ?.let { payload -> + runCatching { + debridDeviceAuthorizationJson.decodeFromString(payload) + }.getOrNull() + } + ?.takeIf { it.providerId == providerId } + +private fun savePendingDeviceAuthorization(session: DebridDeviceAuthorization) { + DebridSettingsStorage.savePendingDeviceAuthorization( + providerId = session.providerId, + payload = debridDeviceAuthorizationJson.encodeToString(session), + ) +} + +private fun clearPendingDeviceAuthorization(providerId: String) { + DebridSettingsStorage.clearPendingDeviceAuthorization(providerId) +} + private fun Throwable.isCancelledHttpRequest(): Boolean { val text = listOfNotNull(message, toString()) .joinToString(" ") 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 new file mode 100644 index 000000000..507a8ea58 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopNavigationLayout.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar +import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar +import org.jetbrains.compose.resources.StringResource + +enum class DesktopNavigationLayout( + val labelRes: StringResource, +) { + Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar), + TopBar(Res.string.settings_appearance_desktop_navigation_top_bar), + ; + + companion object { + val Default = Sidebar + + fun fromName(name: String?): DesktopNavigationLayout = + entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default + } +} 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 d86eb0a67..e58a91f72 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.cloud.PremiumizeCloudLibraryPosterUrl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index edfc75da2..f44bb8d31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -50,7 +51,7 @@ import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.nuvio -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.home.HomeCatalogSettingsItem import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon @@ -116,31 +117,38 @@ internal fun TabletPageHeader( onBack: () -> Unit, ) { val tokens = MaterialTheme.nuvio - Row( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + Box( + modifier = Modifier.fillMaxWidth(), ) { - if (showBack) { - NuvioBackButton( - onClick = onBack, - modifier = Modifier - .size(36.dp), - shape = tokens.shapes.compactCard, - containerColor = tokens.colors.surface, - contentColor = tokens.colors.textPrimary, - buttonSize = NuvioTokens.Space.s36, - iconSize = tokens.icons.md, + Box( + modifier = Modifier + .matchParentSize() + .nuvioConsumePointerEvents(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + ) { + if (showBack) { + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .size(36.dp), + shape = tokens.shapes.compactCard, + containerColor = tokens.colors.surface, + contentColor = tokens.colors.textPrimary, + buttonSize = NuvioTokens.Space.s36, + iconSize = tokens.icons.md, + ) + } + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, ) } - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - color = tokens.colors.textPrimary, - fontWeight = FontWeight.SemiBold, - ) } } 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 f557f762b..c882944e5 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,8 +21,9 @@ import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.build.AppVersionPolicy import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_about_based_on_version_format import nuvio.composeapp.generated.resources.compose_about_made_with import nuvio.composeapp.generated.resources.compose_about_version_format import nuvio.composeapp.generated.resources.compose_settings_page_account @@ -75,6 +76,8 @@ internal fun LazyListScope.settingsRootContent( onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, + showDownloadsEntry: Boolean = true, + showNotificationsEntry: Boolean = true, showAccountSection: Boolean = true, showGeneralSection: Boolean = true, showAboutSection: Boolean = true, @@ -138,14 +141,16 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onContentDiscoveryClick, ) - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_root_downloads_title), - description = stringResource(Res.string.compose_settings_root_downloads_description), - icon = Icons.Rounded.CloudDownload, - isTablet = isTablet, - onClick = onDownloadsClick, - ) + if (showDownloadsEntry) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_root_downloads_title), + description = stringResource(Res.string.compose_settings_root_downloads_description), + icon = Icons.Rounded.CloudDownload, + isTablet = isTablet, + onClick = onDownloadsClick, + ) + } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_playback), @@ -170,14 +175,16 @@ internal fun LazyListScope.settingsRootContent( isTablet = isTablet, onClick = onIntegrationsClick, ) - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_notifications), - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - isTablet = isTablet, - onClick = onNotificationsClick, - ) + if (showNotificationsEntry) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_notifications), + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + isTablet = isTablet, + onClick = onNotificationsClick, + ) + } } } } @@ -252,14 +259,26 @@ internal fun LazyListScope.settingsRootContent( Text( text = stringResource( Res.string.compose_about_version_format, - AppVersionConfig.VERSION_NAME, - AppVersionConfig.VERSION_CODE, + AppVersionPolicy.displayVersionName, + AppVersionPolicy.displayVersionCode, ), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + AppVersionPolicy.basedOnVersionName?.let { basedOnVersionName -> + Text( + text = stringResource( + Res.string.compose_about_based_on_version_format, + basedOnVersionName, + ), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } } } 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 b356e9864..35566fe48 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,6 +217,12 @@ fun SettingsScreen( val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() + LaunchedEffect(page) { + if (!page.isEnabledByFeaturePolicy()) { + currentPage = SettingsPage.Root.name + } + } + LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { rootActionRequests.collect { if (!rootActionsEnabled) return@collect @@ -234,7 +240,9 @@ fun SettingsScreen( ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } ?: return@LaunchedEffect if (!rootActionsEnabled) return@LaunchedEffect - currentPage = targetPage.name + if (targetPage.isEnabledByFeaturePolicy()) { + currentPage = targetPage.name + } onRequestedPageConsumed() } @@ -432,6 +440,8 @@ private fun MobileSettingsScreen( } val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + downloadsEnabled = AppFeaturePolicy.downloadsEnabled, + notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -454,7 +464,11 @@ private fun MobileSettingsScreen( SettingsPage.MetaScreen -> onMetaScreenClick() else -> onPageChange(target.page) } - SettingsSearchTarget.Downloads -> onDownloadsClick() + SettingsSearchTarget.Downloads -> { + if (AppFeaturePolicy.downloadsEnabled) { + onDownloadsClick() + } + } SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -514,6 +528,8 @@ private fun MobileSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, + showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, + showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, ) } } @@ -564,10 +580,12 @@ private fun MobileSettingsScreen( isTablet = false, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = false, - uiState = episodeReleaseNotificationsUiState, - ) + SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { + notificationsSettingsContent( + isTablet = false, + uiState = episodeReleaseNotificationsUiState, + ) + } SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = false, isVisible = continueWatchingPreferencesUiState.isVisible, @@ -635,6 +653,13 @@ private fun MobileSettingsScreen( } } +private fun SettingsPage.isEnabledByFeaturePolicy(): Boolean = + when (this) { + SettingsPage.Notifications -> AppFeaturePolicy.notificationsEnabled + SettingsPage.Plugins -> AppFeaturePolicy.pluginsEnabled + else -> true + } + @Composable private fun rememberSettingsRootSearchRevealConnection( page: SettingsPage, @@ -795,6 +820,8 @@ private fun TabletSettingsScreen( val hapticScope = rememberCoroutineScope() val searchEntries = settingsSearchEntries( pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + downloadsEnabled = AppFeaturePolicy.downloadsEnabled, + notificationsEnabled = AppFeaturePolicy.notificationsEnabled, liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, switchProfileAvailable = onSwitchProfile != null, checkForUpdatesAvailable = onCheckForUpdatesClick != null, @@ -802,8 +829,16 @@ private fun TabletSettingsScreen( fun openSearchTarget(target: SettingsSearchTarget) { when (target) { - is SettingsSearchTarget.Page -> openInlinePage(target.page) - SettingsSearchTarget.Downloads -> onDownloadsClick() + is SettingsSearchTarget.Page -> { + if (target.page.isEnabledByFeaturePolicy()) { + openInlinePage(target.page) + } + } + SettingsSearchTarget.Downloads -> { + if (AppFeaturePolicy.downloadsEnabled) { + onDownloadsClick() + } + } SettingsSearchTarget.Collections -> onCollectionsClick() SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() @@ -893,6 +928,8 @@ private fun TabletSettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, + showDownloadsEntry = AppFeaturePolicy.downloadsEnabled, + showNotificationsEntry = AppFeaturePolicy.notificationsEnabled, showAccountSection = activeCategory == SettingsCategory.Account, showGeneralSection = activeCategory == SettingsCategory.General, showAboutSection = activeCategory == SettingsCategory.About, @@ -947,10 +984,12 @@ private fun TabletSettingsScreen( isTablet = true, rememberLastProfileEnabled = rememberLastProfileEnabled, ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = true, - uiState = episodeReleaseNotificationsUiState, - ) + SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) { + notificationsSettingsContent( + isTablet = true, + uiState = episodeReleaseNotificationsUiState, + ) + } SettingsPage.ContinueWatching -> continueWatchingSettingsContent( isTablet = true, isVisible = continueWatchingPreferencesUiState.isVisible, 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 d5fb310db..b500ad0df 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,6 +79,8 @@ internal data class SettingsSearchEntry( @Composable internal fun settingsSearchEntries( pluginsEnabled: Boolean, + downloadsEnabled: Boolean, + notificationsEnabled: Boolean, liquidGlassNativeTabBarSupported: Boolean, switchProfileAvailable: Boolean, checkForUpdatesAvailable: Boolean, @@ -225,14 +227,16 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_content_discovery_description), icon = Icons.Rounded.Extension, ) - add( - key = "downloads", - title = downloadsPage, - description = stringResource(Res.string.compose_settings_root_downloads_description), - category = generalCategory, - icon = Icons.Rounded.CloudDownload, - target = SettingsSearchTarget.Downloads, - ) + if (downloadsEnabled) { + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) + } addPage( page = SettingsPage.Playback, key = "playback", @@ -254,13 +258,15 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_integrations_description), icon = Icons.Rounded.Link, ) - addPage( - page = SettingsPage.Notifications, - key = "notifications", - title = notificationsPage, - description = stringResource(Res.string.compose_settings_root_notifications_description), - icon = Icons.Rounded.Notifications, - ) + if (notificationsEnabled) { + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) + } addPage( page = SettingsPage.SupportersContributors, key = "supporters", @@ -463,6 +469,15 @@ internal fun settingsSearchEntries( val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addRow( + page = SettingsPage.Streams, + key = "stream-addon-logo", + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + pageLabel = streamsPage, + section = stringResource(Res.string.settings_stream_display_section), + icon = Icons.Rounded.Style, + ) addRow( page = SettingsPage.Streams, key = "stream-size-badges", @@ -785,24 +800,26 @@ internal fun settingsSearchEntries( ) } - val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) - addRow( - page = SettingsPage.Notifications, - key = "episode-release-alerts", - title = stringResource(Res.string.settings_notifications_episode_release_alerts), - description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), - pageLabel = notificationsPage, - section = notificationsAlerts, - icon = Icons.Rounded.Notifications, - ) - addRow( - page = SettingsPage.Notifications, - key = "notification-test", - title = stringResource(Res.string.settings_notifications_test_title), - pageLabel = notificationsPage, - section = stringResource(Res.string.settings_notifications_section_test), - icon = Icons.Rounded.Notifications, - ) + if (notificationsEnabled) { + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) + } addRow( page = SettingsPage.TraktAuthentication, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index 7075eb1a1..7943b55a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -87,6 +87,9 @@ import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title import nuvio.composeapp.generated.resources.settings_stream_badges_section import nuvio.composeapp.generated.resources.settings_stream_size_badges_description import nuvio.composeapp.generated.resources.settings_stream_size_badges_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_description +import nuvio.composeapp.generated.resources.settings_stream_display_section import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { @@ -129,6 +132,21 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { } } + SettingsSection( + title = stringResource(Res.string.settings_stream_display_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + checked = currentSettings.showAddonLogo, + isTablet = isTablet, + onCheckedChange = StreamBadgeSettingsRepository::setShowAddonLogo, + ) + } + } + if (showBadgeImportDialog) { BadgeUrlManagerDialog( currentRules = currentRules, 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 3b3f80563..664cfd36f 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSurfaceCard 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 431b9d20e..956dc2171 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,6 +17,9 @@ 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() @@ -36,6 +39,7 @@ object ThemeSettingsRepository { _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false _liquidGlassNativeTabBarEnabled.value = false + _desktopNavigationLayout.value = DesktopNavigationLayout.Default NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH @@ -59,6 +63,9 @@ object ThemeSettingsRepository { val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled) + _desktopNavigationLayout.value = DesktopNavigationLayout.fromName( + ThemeSettingsStorage.loadDesktopNavigationLayout(), + ) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage @@ -87,6 +94,13 @@ object ThemeSettingsRepository { NativeTabBridge.publishLiquidGlassEnabled(enabled) } + fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) { + ensureLoaded() + if (_desktopNavigationLayout.value == layout) return + _desktopNavigationLayout.value = layout + ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name) + } + fun setAppLanguage(language: AppLanguage) { ensureLoaded() if (_selectedAppLanguage.value == language) return 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 2a788baf1..5bb6a2840 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,6 +9,8 @@ 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/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 881f90004..2fde7b9a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -198,7 +198,12 @@ object StreamAutoPlaySelector { activeResolverProviderId: String?, ): Boolean = playableDirectUrl != null || - (AppFeaturePolicy.p2pEnabled && needsLocalDebridResolve && p2pInfoHash != null) || + ( + AppFeaturePolicy.p2pEnabled && + needsLocalDebridResolve && + p2pInfoHash != null && + !isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + ) || (debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId)) private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean = 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 f0e9ebd2a..08241f190 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as 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 c48690d4c..397d26adf 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 @@ -15,6 +15,7 @@ import kotlinx.serialization.json.Json data class StreamBadgeSettingsUiState( val rules: StreamBadgeRules = StreamBadgeRules(), val showFileSizeBadges: Boolean = true, + val showAddonLogo: Boolean = false, val badgePlacement: StreamBadgePlacement = StreamBadgePlacement.BOTTOM, ) @@ -36,6 +37,7 @@ object StreamBadgeSettingsRepository { private var hasLoaded = false private var streamBadgeRules = StreamBadgeRules() private var showFileSizeBadges = true + private var showAddonLogo = false private var badgePlacement = StreamBadgePlacement.BOTTOM fun ensureLoaded() { @@ -51,6 +53,7 @@ object StreamBadgeSettingsRepository { hasLoaded = false streamBadgeRules = StreamBadgeRules() showFileSizeBadges = true + showAddonLogo = false badgePlacement = StreamBadgePlacement.BOTTOM _uiState.value = StreamBadgeSettingsUiState() } @@ -133,6 +136,14 @@ object StreamBadgeSettingsRepository { StreamBadgeSettingsStorage.saveShowFileSizeBadges(enabled) } + fun setShowAddonLogo(enabled: Boolean) { + ensureLoaded() + if (showAddonLogo == enabled) return + showAddonLogo = enabled + publish() + StreamBadgeSettingsStorage.saveShowAddonLogo(enabled) + } + fun setBadgePlacement(placement: StreamBadgePlacement) { ensureLoaded() if (badgePlacement == placement) return @@ -151,6 +162,7 @@ object StreamBadgeSettingsRepository { } streamBadgeRules = storedRules ?: legacyRules ?: StreamBadgeRules() showFileSizeBadges = StreamBadgeSettingsStorage.loadShowFileSizeBadges() ?: true + showAddonLogo = StreamBadgeSettingsStorage.loadShowAddonLogo() ?: false badgePlacement = StreamBadgeSettingsStorage.loadStreamBadgePlacement() ?.let { storedPlacement -> StreamBadgePlacement.entries.firstOrNull { placement -> @@ -169,6 +181,7 @@ object StreamBadgeSettingsRepository { _uiState.value = StreamBadgeSettingsUiState( rules = streamBadgeRules, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, badgePlacement = badgePlacement, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt index 5df554535..abd6dffee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt @@ -7,6 +7,8 @@ internal expect object StreamBadgeSettingsStorage { fun saveStreamBadgeRules(rules: String) fun loadShowFileSizeBadges(): Boolean? fun saveShowFileSizeBadges(enabled: Boolean) + fun loadShowAddonLogo(): Boolean? + fun saveShowAddonLogo(enabled: Boolean) fun loadStreamBadgePlacement(): String? fun saveStreamBadgePlacement(placement: String) fun loadLegacyDebridStreamBadgeRules(): String? 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 63f1d4151..f218ae740 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 @@ -16,7 +16,6 @@ data class CachedStreamLink( val videoSize: Long? = null, val infoHash: String? = null, val fileIdx: Int? = null, - val magnetUri: String? = null, val sources: List = emptyList(), val bingeGroup: String? = null, ) @@ -53,7 +52,6 @@ object StreamLinkCacheRepository { videoSize: Long? = null, infoHash: String? = null, fileIdx: Int? = null, - magnetUri: String? = null, sources: List = emptyList(), bingeGroup: String? = null, ) { @@ -74,7 +72,6 @@ object StreamLinkCacheRepository { videoSize = videoSize, infoHash = infoHash, fileIdx = fileIdx, - magnetUri = magnetUri, sources = sources, bingeGroup = bingeGroup, ) @@ -104,7 +101,7 @@ object StreamLinkCacheRepository { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null } - if (entry.url.isBlank() && entry.infoHash.isNullOrBlank() && entry.magnetUri.isNullOrBlank()) { + if (entry.url.isBlank() && entry.infoHash.isNullOrBlank()) { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null } 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 7a8406c33..4719a89db 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 @@ -17,6 +17,7 @@ data class StreamItem( val sourceName: String? = null, val addonName: String, val addonId: String, + val addonLogo: String? = null, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, @@ -36,7 +37,7 @@ data class StreamItem( .firstOrNull { !it.isMagnetLink() } val torrentMagnetUri: String? - get() = listOfNotNull(url, externalUrl, clientResolve?.magnetUri) + get() = listOfNotNull(url, externalUrl) .firstOrNull { it.isMagnetLink() } val isDirectDebridStream: Boolean @@ -61,17 +62,10 @@ data class StreamItem( val p2pInfoHash: String? get() = infoHash.normalizedInfoHash() ?: clientResolve?.infoHash.normalizedInfoHash() - ?: clientResolve?.magnetUri.extractBtihInfoHash() ?: torrentMagnetUri.extractBtihInfoHash() - val p2pFileIdx: Int? - get() = fileIdx ?: clientResolve?.fileIdx - - val p2pFilename: String? - get() = behaviorHints.filename ?: clientResolve?.filename - val p2pTrackers: List - get() = p2pSourceHints + get() = sources .asSequence() .filter { it.startsWith("tracker:") } .map { it.removePrefix("tracker:").trim() } @@ -79,10 +73,6 @@ data class StreamItem( .distinct() .toList() - val p2pSourceHints: List - get() = (sources + clientResolve?.sources.orEmpty()) - .distinct() - val isAddonDebridCandidate: Boolean get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream) 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 9a6aa866d..a27033eda 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 @@ -18,6 +18,7 @@ object StreamParser { payload: String, addonName: String, addonId: String, + addonLogo: String? = null, ): List { val root = json.parseToJsonElement(payload).jsonObject val streamsArray = root["streams"] as? JsonArray ?: return emptyList() @@ -46,6 +47,7 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + addonLogo = addonLogo, 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 bc7bc787d..49fc361a9 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 @@ -456,6 +456,7 @@ object StreamsRepository { payload = payload, addonName = displayName, addonId = addon.addonId, + addonLogo = addon.manifest.logoUrl, ) }.fold( onSuccess = { streams -> 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 0f6281e22..f9138cc11 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 @@ -53,7 +53,6 @@ import androidx.compose.material3.Icon 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.getValue import androidx.compose.runtime.mutableStateOf @@ -78,22 +77,22 @@ 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as 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.p2p.P2pSettingsRepository -import com.nuvio.app.features.p2p.P2pStreamingEngine import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch @@ -142,16 +141,14 @@ fun StreamsScreen( DebridSettingsRepository.ensureLoaded() DebridSettingsRepository.uiState }.collectAsStateWithLifecycle() - val p2pSettings by remember { - P2pSettingsRepository.ensureLoaded() - P2pSettingsRepository.uiState - }.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() remember { - DownloadsRepository.ensureLoaded() + if (AppFeaturePolicy.downloadsEnabled) { + DownloadsRepository.ensureLoaded() + } } val isEpisode = seasonNumber != null && episodeNumber != null val clipboardManager = LocalClipboardManager.current @@ -187,15 +184,6 @@ fun StreamsScreen( } } - DisposableEffect(P2pSettingsRepository.isVisible, p2pSettings.p2pEnabled) { - if (P2pSettingsRepository.isVisible && p2pSettings.p2pEnabled) { - P2pStreamingEngine.warmup() - } - onDispose { - P2pStreamingEngine.cooldownWarmup() - } - } - LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) { StreamsRepository.load( type = type, @@ -359,6 +347,7 @@ fun StreamsScreen( StreamActionsSheet( stream = streamActionsTarget, externalPlayerEnabled = playerSettings.externalPlayerEnabled, + showDownloadAction = AppFeaturePolicy.downloadsEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> val directUrl = stream.playableDirectUrl @@ -833,6 +822,7 @@ internal fun StreamList( debridEnabled = debridEnabled, appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, badgePlacement = streamBadgeSettings.badgePlacement, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, @@ -860,6 +850,7 @@ private fun LazyListScope.streamSection( debridEnabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, badgePlacement: StreamBadgePlacement, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, @@ -907,6 +898,7 @@ private fun LazyListScope.streamSection( enabled = stream.isSelectableForPlayback(debridEnabled), appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, badgePlacement = badgePlacement, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { @@ -1014,6 +1006,7 @@ private fun StreamCard( enabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, badgePlacement: StreamBadgePlacement, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, @@ -1039,8 +1032,9 @@ private fun StreamCard( onClick = onClick, onLongClick = onLongClick, ) + .secondaryClick(if (enabled) onLongClick else null) .padding(14.dp), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { @@ -1079,6 +1073,31 @@ private fun StreamCard( ) } } + + 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, + ) + } + } } } @@ -1163,6 +1182,7 @@ private fun StreamNameWithInstantService( private fun StreamActionsSheet( stream: StreamItem?, externalPlayerEnabled: Boolean, + showDownloadAction: Boolean, onDismiss: () -> Unit, onCopyLink: (StreamItem) -> Unit, onDownload: (StreamItem) -> Unit, @@ -1241,17 +1261,19 @@ private fun StreamActionsSheet( } }, ) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Rounded.Download, - title = stringResource(Res.string.streams_download_file), - onClick = { - onDownload(stream) - coroutineScope.launch { - dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) - } - }, - ) + 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) + } + }, + ) + } } } } 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 5ed13ae77..ff5ab11bf 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 coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.isIos import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index 8ed4dd37d..06512a071 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -54,3 +54,49 @@ internal fun extractTraktYear(value: String?): Int? { internal fun TraktExternalIds.hasAnyId(): Boolean = trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank() + +/** + * Returns true if the given contentId uses a Trakt-compatible prefix + * (IMDB, TMDB, or Trakt numeric). IDs from other sources (kitsu:, mal:, + * anilist:, anidb:, tvdb:, etc.) are NOT Trakt-resolvable and should be + * preserved locally rather than being discarded when absent from Trakt + * remote responses. + */ +internal fun isTraktCompatibleId(contentId: String?): Boolean { + if (contentId.isNullOrBlank()) return false + val raw = contentId.trim() + if (raw.startsWith("tt")) return true + if (raw.startsWith("tmdb:", ignoreCase = true)) return true + if (raw.startsWith("trakt:", ignoreCase = true)) return true + // Pure numeric IDs are treated as Trakt IDs + val beforeColon = raw.substringBefore(':') + if (beforeColon.toIntOrNull() != null) return true + return false +} + +/** + * If [contentId] is not Trakt-resolvable but [videoId] contains a valid + * IMDB or TMDB prefix, extract and return the resolved ID from videoId. + * This handles addons that wrap real IDs in non-standard content IDs + * (e.g. contentId = "tun_tt7821582", videoId = "tt7821582:3:7"). + * + * Returns the original [contentId] if it's already valid or if no + * better ID can be extracted from [videoId]. + */ +internal fun resolveEffectiveContentId(contentId: String, videoId: String?): String { + val parsedContent = parseTraktContentIds(contentId) + if (parsedContent.hasAnyId()) return contentId + + if (videoId.isNullOrBlank() || videoId == contentId) return contentId + + val parsedVideo = parseTraktContentIds(videoId) + if (!parsedVideo.hasAnyId()) return contentId + + // Rebuild a canonical content ID from the resolved video IDs + return when { + !parsedVideo.imdb.isNullOrBlank() -> parsedVideo.imdb + parsedVideo.tmdb != null -> "tmdb:${parsedVideo.tmdb}" + parsedVideo.trakt != null -> parsedVideo.trakt.toString() + else -> contentId + } +} 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 e18f18a1e..c8d365abf 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 @@ -538,10 +538,7 @@ object TraktLibraryRepository { } return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedWith( - compareBy { it.traktRank ?: Int.MAX_VALUE } - .thenByDescending { it.savedAtEpochMs }, - ) + .sortedByDescending { it.savedAtEpochMs } } private suspend fun fetchPersonalListItems( 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 6130d1ae5..5c4613498 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.AppVersionConfig +import com.nuvio.app.core.build.AppVersionPolicy import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.CancellationException @@ -81,7 +81,19 @@ internal object TraktScrobbleRepository { releaseInfo: String? = null, ): TraktScrobbleItem? { val normalizedType = contentType.trim().lowercase() - val ids = parseTraktContentIds(parentMetaId) + var ids = parseTraktContentIds(parentMetaId) + + // Fallback: if parentMetaId doesn't resolve to valid Trakt IDs, try videoId. + // Some addons use non-standard contentId (e.g. "tun_tt7821582") but set a + // valid IMDB/TMDB videoId (e.g. "tt7821582:3:7"). + if (!ids.hasAnyId() && !videoId.isNullOrBlank() && videoId != parentMetaId) { + ids = parseTraktContentIds(videoId) + } + + // Don't send scrobble if we still have no valid Trakt IDs — would cause + // title-based fuzzy match on Trakt API resulting in wrong show matched. + if (!ids.hasAnyId()) return null + val parsedYear = extractTraktYear(releaseInfo) return if ( @@ -243,7 +255,7 @@ internal object TraktScrobbleRepository { ids = item.ids.toRequestBodyOrNull(), ), progress = clampedProgress, - appVersion = AppVersionConfig.VERSION_NAME, + appVersion = AppVersionPolicy.displayVersionName, ) is TraktScrobbleItem.Episode -> TraktScrobbleRequest( @@ -258,7 +270,7 @@ internal object TraktScrobbleRepository { number = item.number, ), progress = clampedProgress, - appVersion = AppVersionConfig.VERSION_NAME, + appVersion = AppVersionPolicy.displayVersionName, ) } } 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 13e2ebd55..b1a681b59 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,7 +8,6 @@ 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( @@ -67,46 +66,32 @@ 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 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 && + val isNewSeasonRelease = 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 = isReleaseAlert, + isReleaseAlert = true, isNewSeasonRelease = isNewSeasonRelease ) } 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 f37b00534..bc695a47f 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 @@ -14,6 +14,8 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.isTraktCompatibleId +import com.nuvio.app.features.trakt.resolveEffectiveContentId import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent @@ -806,9 +808,20 @@ object WatchProgressRepository { return } + val useTraktProgress = shouldUseTraktProgress() + + // If Trakt is the active CW source and parentMetaId is not Trakt-resolvable + // but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid + // duplicate CW entries (one local with garbage ID, one from Trakt with real ID). + val effectiveParentMetaId = if (useTraktProgress) { + resolveEffectiveContentId(session.parentMetaId, session.videoId) + } else { + session.parentMetaId + } + val entry = WatchProgressEntry( contentType = session.contentType, - parentMetaId = session.parentMetaId, + parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, videoId = session.videoId, title = session.title, @@ -835,8 +848,6 @@ object WatchProgressRepository { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } - val useTraktProgress = shouldUseTraktProgress() - entriesByVideoId[session.videoId] = entry if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) @@ -925,7 +936,25 @@ object WatchProgressRepository { private fun currentEntries(): List { return if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.entries + // Merge Trakt remote progress with local-only entries that use + // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). + // Trakt will never return these IDs, so they must come from local storage. + val traktItems = TraktProgressRepository.uiState.value.entries + val localNonTraktItems = entriesByVideoId.values.filter { + !isTraktCompatibleId(it.parentMetaId) + } + if (localNonTraktItems.isEmpty()) { + traktItems + } else { + val traktKeys = traktItems.map { it.videoId }.toSet() + val merged = traktItems.toMutableList() + localNonTraktItems.forEach { localItem -> + if (localItem.videoId !in traktKeys) { + merged.add(localItem) + } + } + merged + } } else { entriesByVideoId.values.toList() } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamItemP2pMetadataTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamItemP2pMetadataTest.kt deleted file mode 100644 index ee8a08254..000000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamItemP2pMetadataTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.nuvio.app.features.streams - -import kotlin.test.Test -import kotlin.test.assertEquals - -class StreamItemP2pMetadataTest { - @Test - fun usesClientResolveP2pMetadataWhenTopLevelFieldsAreMissing() { - val magnet = "magnet:?xt=urn:btih:ABC123&dn=Movie&tr=udp%3A%2F%2Fmagnet.test%3A80%2Fannounce" - val stream = StreamItem( - name = "Resolved torrent", - addonName = "Addon", - addonId = "addon:test", - sources = listOf( - "tracker:udp://base.test:80/announce", - "dht:ABC123", - ), - clientResolve = StreamClientResolve( - infoHash = "ABC123", - fileIdx = 2, - magnetUri = magnet, - filename = "movie.mkv", - sources = listOf( - "tracker:udp://client.test:80/announce", - "tracker:udp://base.test:80/announce", - ), - ), - ) - - assertEquals("ABC123", stream.p2pInfoHash) - assertEquals(2, stream.p2pFileIdx) - assertEquals("movie.mkv", stream.p2pFilename) - assertEquals(magnet, stream.torrentMagnetUri) - assertEquals( - listOf( - "udp://base.test:80/announce", - "udp://client.test:80/announce", - ), - stream.p2pTrackers, - ) - } - - @Test - fun extractsP2pInfoHashFromClientResolveMagnet() { - val stream = StreamItem( - name = "Magnet-only torrent", - addonName = "Addon", - addonId = "addon:test", - clientResolve = StreamClientResolve( - magnetUri = "magnet:?xt=urn:btih:def456&dn=Movie", - ), - ) - - assertEquals("def456", stream.p2pInfoHash) - assertEquals("magnet:?xt=urn:btih:def456&dn=Movie", stream.torrentMagnetUri) - } -} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt new file mode 100644 index 000000000..04cded013 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -0,0 +1,94 @@ +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 new file mode 100644 index 000000000..79fd9ee71 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000..c0dc1da44 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt @@ -0,0 +1,18 @@ +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 2dda9682e..a989c77db 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,7 +1,9 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { - actual val pluginsEnabled: Boolean = false + actual val pluginsEnabled: Boolean = true + actual val downloadsEnabled: Boolean = false + actual val notificationsEnabled: 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 new file mode 100644 index 000000000..88ada6335 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppVersionPolicy.desktop.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..ea0a4eebe --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt @@ -0,0 +1,148 @@ +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 new file mode 100644 index 000000000..b7b0305d7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..e0c469fc6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..86ad57cdf --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..c7c556c50 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..24d3d73da --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt @@ -0,0 +1,281 @@ +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 new file mode 100644 index 000000000..5fdd59604 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..6434bd49e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..d96114b5a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..6734e5fd1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..687ee7986 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..4b20c3068 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.desktop.kt @@ -0,0 +1,24 @@ +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 new file mode 100644 index 000000000..bff3bf30f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt @@ -0,0 +1,108 @@ +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 new file mode 100644 index 000000000..8598fa626 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..3bd8bb6ce --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..d47c69b42 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt @@ -0,0 +1,138 @@ +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 new file mode 100644 index 000000000..5112956ab --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..2c961b328 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..85aeb5016 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..2a9a1c8e4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..5e0e33f5a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..12097a653 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt @@ -0,0 +1,193 @@ +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 new file mode 100644 index 000000000..04ac0d9e8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..2972496e5 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..4b2b4083f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt @@ -0,0 +1,34 @@ +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 new file mode 100644 index 000000000..4869d713b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..659d58156 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..0d95cfeb4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt @@ -0,0 +1,84 @@ +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 new file mode 100644 index 000000000..6d4647381 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..3ee8ee44b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..473aee283 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..e19e8912f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..5eebed0ce --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt @@ -0,0 +1,24 @@ +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 new file mode 100644 index 000000000..280256c7f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..6a9a820f7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt @@ -0,0 +1,68 @@ +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 new file mode 100644 index 000000000..b1c20ca2e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -0,0 +1,243 @@ +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 new file mode 100644 index 000000000..3dd1535b9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..91ad9cef4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..1498a9592 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt @@ -0,0 +1,406 @@ +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 new file mode 100644 index 000000000..d35d9894c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt @@ -0,0 +1,94 @@ +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 new file mode 100644 index 000000000..a47ecaaf0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopAppFullscreen.kt @@ -0,0 +1,60 @@ +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 new file mode 100644 index 000000000..fb63eae77 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..1da376998 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerLaunchShield.kt @@ -0,0 +1,87 @@ +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 new file mode 100644 index 000000000..342962590 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopWindowChrome.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..aea9d4b7e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt @@ -0,0 +1,82 @@ +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 new file mode 100644 index 000000000..149078b77 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -0,0 +1,286 @@ +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 new file mode 100644 index 000000000..9bf4f3cc8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -0,0 +1,953 @@ +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 new file mode 100644 index 000000000..a1675948a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..443eebab8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..0b73ade08 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt @@ -0,0 +1,59 @@ +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 new file mode 100644 index 000000000..1217b4485 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..8c00aedad --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..486f14779 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..bcca6616d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..70cd338b8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 000000000..38b7776dc --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..639c2b8ec --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..e3f4a5f0e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..31fed517e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..0ca7369ea --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt @@ -0,0 +1,83 @@ +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 new file mode 100644 index 000000000..3f83db890 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..1cc7d4699 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..f801c3322 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..cf5b689c9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..349f3c6ab --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt @@ -0,0 +1,114 @@ +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 new file mode 100644 index 000000000..3c05dff11 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..b768fef1e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt @@ -0,0 +1,17 @@ +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 new file mode 100644 index 000000000..bca3536f2 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..6da1187c7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..b4433d6d0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..f94a38b20 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..44da853a8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..ce44c1c17 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..d3eb3d1b1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..ccae7137c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..82b82d1a9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..faead0065 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..cc922a391 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..389f383ab --- /dev/null +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -0,0 +1,2663 @@ +#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 new file mode 100644 index 000000000..249009a5f --- /dev/null +++ b/composeApp/src/desktopMain/native/windows/player_bridge.cpp @@ -0,0 +1,1965 @@ +#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 new file mode 100644 index 000000000..2c68eb9cb Binary files /dev/null and b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.icns differ diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico new file mode 100644 index 000000000..edcfdfcc3 Binary files /dev/null and b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.ico differ diff --git a/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png new file mode 100644 index 000000000..9fc0d4ade Binary files /dev/null and b/composeApp/src/desktopMain/resources/icons/nuvio-app-icon.png differ diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.css b/composeApp/src/desktopMain/resources/player-ui/controls.css new file mode 100644 index 000000000..0f54e0d98 --- /dev/null +++ b/composeApp/src/desktopMain/resources/player-ui/controls.css @@ -0,0 +1,2144 @@ +/* __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 new file mode 100644 index 000000000..877d6658d --- /dev/null +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -0,0 +1,380 @@ + + + + + + + + + + +
+
+
+ +
+
+ +
+ + + + + + + + + + + + + +
+ + +
+ 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 new file mode 100644 index 000000000..2bb636700 --- /dev/null +++ b/composeApp/src/desktopMain/resources/player-ui/controls.js @@ -0,0 +1,2196 @@ +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 new file mode 100644 index 000000000..ecf9739cf --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/plugins/PluginRuntimeDesktopTest.kt @@ -0,0 +1,34 @@ +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 88439c511..da5a0e8fe 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 platform = currentPluginPlatform().lowercase() + val platformTags = currentPluginPlatformTags().map { it.lowercase() }.toSet() val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty() val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty() - if (supported.isNotEmpty() && platform !in supported) return false - if (platform in disabled) return false + if (supported.isNotEmpty() && platformTags.none { it in supported }) return false + if (platformTags.any { it 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 5f7b3114f..70588602e 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,6 +2,8 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = false + actual val downloadsEnabled: Boolean = true + actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = 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 new file mode 100644 index 000000000..1d181ca62 --- /dev/null +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.build + +actual object AppVersionPolicy { + actual val displayVersionName: String = AppVersionConfig.VERSION_NAME + actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE + actual val basedOnVersionName: String? = null +} 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 670f8092a..6f46e9e9f 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,6 +2,8 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { actual val pluginsEnabled: Boolean = true + actual val downloadsEnabled: Boolean = true + actual val notificationsEnabled: Boolean = true actual val p2pEnabled: Boolean = 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 new file mode 100644 index 000000000..1d181ca62 --- /dev/null +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppVersionPolicy.ios.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.build + +actual object AppVersionPolicy { + actual val displayVersionName: String = AppVersionConfig.VERSION_NAME + actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE + actual val basedOnVersionName: String? = null +} 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 a30ad428e..0d304e969 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,5 +19,7 @@ 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 ee348bc59..4f79e7c8e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -8,4 +8,5 @@ class IOSPlatform: Platform { actual fun getPlatform(): Platform = IOSPlatform() -internal actual val isIos: Boolean = true \ No newline at end of file +internal actual val isIos: Boolean = true +internal actual val isDesktop: Boolean = false 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 new file mode 100644 index 000000000..fa48a20ca --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter + +@Composable +internal actual fun NuvioAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier, + placeholder: Painter?, + error: Painter?, + fallback: Painter?, + onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?, + onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?, + onError: ((AsyncImagePainter.State.Error) -> Unit)?, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, + filterQuality: FilterQuality?, + clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, +) { + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = placeholder, + error = error, + fallback = fallback, + onLoading = onLoading, + onSuccess = onSuccess, + onError = onError, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality ?: DefaultFilterQuality, + clipToBounds = clipToBounds, + ) +} 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 new file mode 100644 index 000000000..a01bf27a9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/SecondaryClick.ios.kt @@ -0,0 +1,5 @@ +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/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index d11d9c644..bc68c7e45 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,19 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey( + ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)), + ) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) @@ -232,4 +246,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt index ecca96a16..df670f29c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt @@ -8,14 +8,6 @@ actual object P2pStreamingEngine { private val _state = MutableStateFlow(P2pStreamingState.Idle) actual val state: StateFlow = _state.asStateFlow() - actual fun warmup() { - _state.value = P2pStreamingState.Idle - } - - actual fun cooldownWarmup() { - _state.value = P2pStreamingState.Idle - } - actual suspend fun startStream(request: P2pStreamRequest): String { val message = "P2P streaming is not available on this platform" _state.value = P2pStreamingState.Error(message) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt index 9fe3cb6d5..c579e01b6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt @@ -20,7 +20,11 @@ private val iosExternalPlayerSpecs = listOf( append("infuse://x-callback-url/play?url=") append(request.sourceUrl.urlQueryEncode()) append("&filename=") - append((request.streamTitle ?: request.title).urlQueryEncode()) + append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode()) + request.subtitles?.forEach { subtitle -> + append("&sub=") + append(subtitle.url.urlQueryEncode()) + } } }, ), @@ -29,7 +33,14 @@ private val iosExternalPlayerSpecs = listOf( name = "VLC", scheme = "vlc-x-callback", buildUrl = { request -> - "vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}" + buildString { + append("vlc-x-callback://x-callback-url/stream?url=") + append(request.sourceUrl.urlQueryEncode()) + request.subtitles?.firstOrNull()?.let { subtitle -> + append("&sub=") + append(subtitle.url.urlQueryEncode()) + } + } }, ), IosExternalPlayerSpec( @@ -41,7 +52,7 @@ private val iosExternalPlayerSpecs = listOf( append("outplayer://x-callback-url/play?url=") append(request.sourceUrl.urlQueryEncode()) append("&filename=") - append((request.streamTitle ?: request.title).urlQueryEncode()) + append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode()) } }, ), 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 2cd2657fe..faf7d864c 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 @@ -33,7 +33,13 @@ actual fun PlatformPlayerSurface( modifier: Modifier, playWhenReady: Boolean, resizeMode: PlayerResizeMode, + initialPositionMs: Long, useNativeController: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsEvent: (String, Double) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt new file mode 100644 index 000000000..dc2e47a63 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.player + +/** + * iOS implementation: returns subtitles unchanged (no caching needed). + * iOS players like Infuse accept remote subtitle URLs directly via their URL scheme, + * so we just pass through the original HTTP URLs without downloading. + */ +actual object SubtitleCacheProvider { + actual suspend fun cacheForExternalPlayer(subtitles: List): List? { + return subtitles.ifEmpty { null } + } +} 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 bf14a4528..1737c08e7 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,11 +14,13 @@ actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" + private const val desktopNavigationLayoutKey = "desktop_navigation_layout" private const val selectedAppLanguageKey = "selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + desktopNavigationLayoutKey, ) actual fun loadSelectedTheme(): String? = @@ -59,6 +61,16 @@ 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 @@ -87,6 +99,7 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -97,6 +110,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt index 716304d85..8dbfc035c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt @@ -13,6 +13,7 @@ import platform.Foundation.NSUserDefaults actual object StreamBadgeSettingsStorage { private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) @@ -29,6 +30,12 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) actual fun saveStreamBadgePlacement(placement: String) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39ae4c826..412fcceb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ 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" @@ -53,7 +54,9 @@ 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 af51dfa7d..e9ae08672 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=75 -MARKETING_VERSION=0.2.4 +CURRENT_PROJECT_VERSION=77 +MARKETING_VERSION=0.2.5 diff --git a/scripts/build-torrserver-android.sh b/scripts/build-torrserver-android.sh deleted file mode 100755 index 1ca865170..000000000 --- a/scripts/build-torrserver-android.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TORRSERVER_DIR="${TORRSERVER_DIR:-"${ROOT_DIR}/vendor/TorrServer/server"}" -OUT_DIR="${OUT_DIR:-"${ROOT_DIR}/composeApp/src/androidMain/jniLibs"}" -GO_BIN="${GO_BIN:-go}" -UPX_BIN="${UPX_BIN:-upx}" -SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}}" -NDK_ROOT="${ANDROID_NDK_HOME:-}" -export GOCACHE="${GOCACHE:-"${ROOT_DIR}/build/go-cache"}" -export GOMODCACHE="${GOMODCACHE:-"${ROOT_DIR}/build/go-mod-cache"}" - -if ! command -v "${GO_BIN}" >/dev/null 2>&1; then - echo "Go toolchain not found. Install Go or set GO_BIN=/path/to/go." >&2 - exit 1 -fi - -if [[ ! -d "${TORRSERVER_DIR}" ]]; then - echo "TorrServer source not found at ${TORRSERVER_DIR}" >&2 - exit 1 -fi - -if [[ -z "${NDK_ROOT}" ]]; then - if [[ ! -d "${SDK_ROOT}/ndk" ]]; then - echo "Android NDK not found. Set ANDROID_HOME, ANDROID_SDK_ROOT, or ANDROID_NDK_HOME." >&2 - exit 1 - fi - NDK_VERSION="$(ls -1 "${SDK_ROOT}/ndk" | sort | tail -n 1)" - NDK_ROOT="${SDK_ROOT}/ndk/${NDK_VERSION}" -fi - -PREBUILT_ROOT="${NDK_ROOT}/toolchains/llvm/prebuilt" -HOST_TAG="" -for candidate in darwin-x86_64 linux-x86_64; do - if [[ -d "${PREBUILT_ROOT}/${candidate}" ]]; then - HOST_TAG="${candidate}" - break - fi -done - -if [[ -z "${HOST_TAG}" ]]; then - echo "Could not find an LLVM prebuilt toolchain under ${PREBUILT_ROOT}" >&2 - exit 1 -fi - -TOOLCHAIN="${PREBUILT_ROOT}/${HOST_TAG}" -LDFLAGS="${LDFLAGS:-"-s -w -checklinkname=0"}" -BUILD_FLAGS=(-tags=nosqlite -trimpath -ldflags="${LDFLAGS}") -mkdir -p "${GOCACHE}" "${GOMODCACHE}" - -build_abi() { - local abi="$1" - local goarch="$2" - local goarm="$3" - local triple="$4" - local api_level="$5" - local cc="${TOOLCHAIN}/bin/${triple}${api_level}-clang" - local cxx="${TOOLCHAIN}/bin/${triple}${api_level}-clang++" - local output="${OUT_DIR}/${abi}/libtorrserver.so" - - if [[ ! -x "${cc}" ]]; then - echo "Compiler not found: ${cc}" >&2 - exit 1 - fi - - mkdir -p "$(dirname "${output}")" - echo "Building ${abi} -> ${output}" - - local env_vars=( - GOOS=android - GOARCH="${goarch}" - CGO_ENABLED=1 - CC="${cc}" - CXX="${cxx}" - ) - if [[ -n "${goarm}" ]]; then - env_vars+=(GOARM="${goarm}") - fi - - ( - cd "${TORRSERVER_DIR}" - env "${env_vars[@]}" "${GO_BIN}" build "${BUILD_FLAGS[@]}" -o "${output}" ./cmd - ) - chmod 755 "${output}" - if [[ "${USE_UPX:-0}" == "1" ]] && command -v "${UPX_BIN}" >/dev/null 2>&1; then - "${UPX_BIN}" -q "${output}" || echo "UPX compression failed for ${output}; leaving uncompressed" >&2 - fi -} - -build_abi "arm64-v8a" "arm64" "" "aarch64-linux-android" "21" -build_abi "armeabi-v7a" "arm" "7" "armv7a-linux-androideabi" "21" -build_abi "x86" "386" "" "i686-linux-android" "21" -build_abi "x86_64" "amd64" "" "x86_64-linux-android" "21" - -echo "TorrServer Android binaries updated in ${OUT_DIR}" diff --git a/scripts/run-mobile.sh b/scripts/run-mobile.sh index ccd449987..b6b0e1476 100755 --- a/scripts/run-mobile.sh +++ b/scripts/run-mobile.sh @@ -140,75 +140,46 @@ android_flavor_task_part() { esac } -android_apk_dir() { +android_apk_path() { local flavor="$1" case "$flavor" in full) - echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug" + echo "$ROOT_DIR/composeApp/build/outputs/apk/full/debug/composeApp-full-debug.apk" ;; playstore) - echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug" + echo "$ROOT_DIR/composeApp/build/outputs/apk/playstore/debug/composeApp-playstore-debug.apk" ;; esac } -android_device_primary_abi() { - local serial="$1" - adb -s "$serial" shell getprop ro.product.cpu.abi | tr -d '\r' -} - -android_split_apk_path() { +build_android_apk() { local flavor="$1" - local serial="$2" - local apk_dir - local abi + local flavor_task_part local apk_path - apk_dir="$(android_apk_dir "$flavor")" - abi="$(android_device_primary_abi "$serial")" + flavor_task_part="$(android_flavor_task_part "$flavor")" + apk_path="$(android_apk_path "$flavor")" - apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*-${abi}-debug.apk" | sort | head -n 1)" - if [[ -z "$apk_path" ]]; then - apk_path="$(find "$apk_dir" -maxdepth 1 -type f -name "*${abi}*.apk" | sort | head -n 1)" - fi + echo "Building Android $flavor debug APK..." >&2 + "$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2 - if [[ -z "$apk_path" || ! -f "$apk_path" ]]; then - echo "Expected split APK for ABI '$abi' not found in: $apk_dir" >&2 - find "$apk_dir" -maxdepth 1 -type f -name "*.apk" -print >&2 || true + if [[ ! -f "$apk_path" ]]; then + echo "Expected APK not found at: $apk_path" >&2 exit 1 fi printf '%s\n' "$apk_path" } -build_android_apks() { - local flavor="$1" - local flavor_task_part - local apk_dir - - flavor_task_part="$(android_flavor_task_part "$flavor")" - apk_dir="$(android_apk_dir "$flavor")" - - echo "Building Android $flavor debug split APKs..." >&2 - "$GRADLEW" ":composeApp:assemble${flavor_task_part}Debug" >&2 - - if ! find "$apk_dir" -maxdepth 1 -type f -name "*.apk" | grep -q .; then - echo "Expected split APKs not found in: $apk_dir" >&2 - exit 1 - fi -} - install_and_launch_android() { local device_label="$1" - local flavor="$2" + local apk_path="$2" shift 2 - local apk_path local serial for serial in "$@"; do - apk_path="$(android_split_apk_path "$flavor" "$serial")" - echo "Installing on $device_label $serial: $apk_path" + echo "Installing on $device_label $serial..." adb -s "$serial" install -r "$apk_path" echo "Launching app on $serial..." @@ -277,9 +248,10 @@ run_android_emulator() { wait_for_android_emulator "$serial" done - build_android_apks "$flavor" + local apk_path + apk_path="$(build_android_apk "$flavor")" - install_and_launch_android "emulator" "$flavor" "${booted_serials[@]}" + install_and_launch_android "emulator" "$apk_path" "${booted_serials[@]}" } run_android_physical() { @@ -308,9 +280,10 @@ run_android_physical() { wait_for_android_device "$serial" done - build_android_apks "$flavor" + local apk_path + apk_path="$(build_android_apk "$flavor")" - install_and_launch_android "physical device" "$flavor" "${serials[@]}" + install_and_launch_android "physical device" "$apk_path" "${serials[@]}" } run_ios_simulator() { diff --git a/scripts/set-version.sh b/scripts/set-version.sh new file mode 100755 index 000000000..04a87aadd --- /dev/null +++ b/scripts/set-version.sh @@ -0,0 +1,218 @@ +#!/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