diff --git a/README.md b/README.md
index 7f01b6692..ec96ae4bd 100644
--- a/README.md
+++ b/README.md
@@ -4,63 +4,89 @@
- [![Contributors][contributors-shield]][contributors-url]
- [![Forks][forks-shield]][forks-url]
- [![Stargazers][stars-shield]][stars-url]
- [![Issues][issues-shield]][issues-url]
[![License][license-shield]][license-url]
- A modern media hub for Android and iOS built with Kotlin Multiplatform and Compose Multiplatform.
+ Nuvio Desktop is a desktop media hub built with Kotlin Multiplatform and Compose Multiplatform.
- Stremio addon ecosystem • Cross-platform
+ Desktop app in development
## About
-Nuvio is the current Kotlin Multiplatform rewrite of the original React Native app. It delivers a shared Compose UI for Android and iOS while keeping the playback-focused experience, collection tools, watch progress flows, downloads, and Stremio addon ecosystem integration that shaped the earlier app.
+Nuvio Desktop brings the Nuvio media experience to desktop. It keeps the playback-focused browsing, collection, watch progress, downloads, and Stremio addon ecosystem integration from Nuvio while adapting the app for desktop input, desktop storage, and native desktop playback.
-The mobile app is built from a single shared codebase in [composeApp](./composeApp), with native platform entry points for Android and iOS.
+The desktop app is built from the shared Kotlin Multiplatform codebase in [composeApp](./composeApp), with desktop-specific code in [composeApp/src/desktopMain](./composeApp/src/desktopMain). Desktop packaging is configured through Gradle, with development builds active for desktop hosts and broader platform coverage continuing over time.
+
+## Platform Status
+
+Current desktop builds are actively being developed. Linux support is planned for a later phase, and public release targets are not finalized yet.
## Installation
-### Android
+Public desktop releases are not available yet. Current builds are development builds.
-Download the latest Android build from [GitHub Releases](https://github.com/NuvioMedia/NuvioMobile/releases/latest).
-
-### iOS
-
-- [TestFlight](https://testflight.apple.com/join/u4y7MHK9)
+When releases are ready, desktop builds will be published from the Nuvio Desktop repository.
## Development
+Desktop development checkout:
+
```bash
-git clone https://github.com/NuvioMedia/NuvioMobile.git
-cd NuvioMobile
-./scripts/run-mobile.sh android
-# or
-./scripts/run-mobile.sh ios
+git clone --branch Dev --recurse-submodules https://github.com/NuvioMedia/NuvioDesktop.git
+cd NuvioDesktop
+git submodule update --init --recursive MPVKit
+git -C MPVKit fetch origin Nuvio
+git -C MPVKit switch Nuvio
+git -C MPVKit pull --ff-only
+./gradlew :composeApp:run
```
-### Project Structure
-
-- `composeApp/` contains the shared Kotlin Multiplatform and Compose Multiplatform app code.
-- `composeApp/src/commonMain/` contains shared UI, features, repositories, and platform-agnostic logic.
-- `composeApp/src/androidMain/` contains Android-specific integrations.
-- `composeApp/src/iosMain/` contains iOS-specific integrations.
-- `iosApp/` contains the native Xcode project and iOS entry point.
-
Useful commands:
```bash
-./gradlew :composeApp:assembleDebug
-./gradlew :composeApp:compileKotlinIosSimulatorArm64
-./scripts/build-distribution.sh
+./gradlew :composeApp:compileKotlinDesktop
+./gradlew :composeApp:packageDistributionForCurrentOS
```
-Versioning is driven from `iosApp/Configuration/Version.xcconfig`, which is used as the shared source of truth for both iOS and Android builds.
+On macOS, the native player bridge uses MPVKit libmpv artifacts from the `MPVKit` submodule. For development, the submodule is configured to use [NuvioMedia/MPVKit](https://github.com/NuvioMedia/MPVKit) on the `Nuvio` branch. If you already have a checkout, sync and update it with:
+
+```bash
+git submodule sync MPVKit
+git submodule update --init --recursive MPVKit
+git -C MPVKit remote set-url origin https://github.com/NuvioMedia/MPVKit.git
+git -C MPVKit fetch origin Nuvio
+git -C MPVKit switch Nuvio
+git -C MPVKit pull --ff-only
+```
+
+If Gradle reports missing MPVKit artifacts, build the macOS runtime before running the app:
+
+```bash
+cd MPVKit
+make build platform=macos
+```
+
+You can also point Gradle at a separate MPVKit checkout:
+
+```bash
+./gradlew :composeApp:run -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit
+```
+
+## Project Structure
+
+- `composeApp/` contains the Kotlin Multiplatform and Compose Multiplatform app code.
+- `composeApp/src/commonMain/` contains shared UI, features, repositories, and platform-agnostic logic.
+- `composeApp/src/desktopMain/` contains desktop-specific app code, storage, settings, player integration, and desktop resources.
+- `composeApp/src/desktopMain/native/macos/` and `composeApp/src/desktopMain/native/windows/` contain the native player bridges.
+- `composeApp/src/desktopMain/resources/player-ui/` contains the desktop native player control UI.
+- `composeApp/src/desktopMain/resources/icons/` contains desktop app icons for macOS, Windows, and Linux packaging.
+
+## Desktop Player
+
+Nuvio Desktop uses a native desktop player path with MPVKit/libmpv integration and desktop-owned controls. The desktop player is separate from the mobile Compose player surface so desktop behavior can match desktop input, keyboard, windowing, and playback expectations.
## Legal & DMCA
@@ -68,34 +94,15 @@ Nuvio functions solely as a client-side interface for browsing metadata and play
Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content.
-For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our [Legal & Disclaimer Page](https://nuvioapp.space/legal).
+For comprehensive legal information, including the full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit the [Legal & Disclaimer Page](https://nuvioapp.space/legal).
## Built With
- Kotlin Multiplatform
-- Compose Multiplatform
+- Compose Multiplatform for Desktop
- Kotlin
-- AndroidX Media3
-- AVFoundation and native iOS integrations
-
-## Star History
-
-
-
-
-
-
-
-
+- MPVKit and libmpv
-[contributors-shield]: https://img.shields.io/github/contributors/NuvioMedia/NuvioMobile.svg?style=for-the-badge
-[contributors-url]: https://github.com/NuvioMedia/NuvioMobile/graphs/contributors
-[forks-shield]: https://img.shields.io/github/forks/NuvioMedia/NuvioMobile.svg?style=for-the-badge
-[forks-url]: https://github.com/NuvioMedia/NuvioMobile/network/members
-[stars-shield]: https://img.shields.io/github/stars/NuvioMedia/NuvioMobile.svg?style=for-the-badge
-[stars-url]: https://github.com/NuvioMedia/NuvioMobile/stargazers
-[issues-shield]: https://img.shields.io/github/issues/NuvioMedia/NuvioMobile.svg?style=for-the-badge
-[issues-url]: https://github.com/NuvioMedia/NuvioMobile/issues
-[license-shield]: https://img.shields.io/github/license/NuvioMedia/NuvioMobile.svg?style=for-the-badge
-[license-url]: https://github.com/NuvioMedia/NuvioMobile/blob/main/LICENSE
\ No newline at end of file
+[license-shield]: https://img.shields.io/github/license/NuvioMedia/NuvioDesktop.svg?style=for-the-badge
+[license-url]: https://github.com/NuvioMedia/NuvioDesktop/blob/Dev/LICENSE
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 3b92200fc..9f8a3e9cb 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"))
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/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/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/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt
index c938387f0..e4e833238 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
@@ -79,7 +79,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/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/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/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index be681d8d9..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.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index c8e98bab6..7aa362cd9 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
@@ -348,10 +373,37 @@ 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 +466,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 +482,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 +496,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,18 +507,40 @@ 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 hasCachedProfileAccess =
cachedProfiles.isNotEmpty() &&
@@ -522,10 +598,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
}
@@ -534,10 +610,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,
+ )
}
}
@@ -550,15 +626,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())
@@ -571,11 +641,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
@@ -621,18 +690,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) }
@@ -641,10 +698,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) }
@@ -657,32 +721,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()
@@ -828,6 +874,7 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable,
-> {
offlineLaunchRouteHandled = true
+ if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect
val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
@@ -915,10 +962,7 @@ private fun MainAppContent(
}
}
}
- val continueWatchingPreferencesUiState by remember {
- ContinueWatchingPreferencesRepository.ensureLoaded()
- ContinueWatchingPreferencesRepository.uiState
- }.collectAsStateWithLifecycle()
+ val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
LaunchedEffect(
initialHomeReady,
@@ -945,9 +989,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)
}
@@ -1074,7 +1120,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,
@@ -1329,12 +1375,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(
@@ -1386,8 +1451,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,
@@ -1443,7 +1510,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) {
@@ -1479,7 +1550,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,
@@ -1712,10 +1790,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()}"
@@ -2424,51 +2499,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(
@@ -2735,15 +2812,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,
@@ -2801,6 +2869,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
+ topChromePadding: Dp? = null,
searchFocusRequestCount: Int = 0,
rootActionsEnabled: Boolean = true,
homeScrollToTopRequests: Flow,
@@ -2858,6 +2927,7 @@ private fun AppTabHost(
AppScreenTab.Search -> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
+ topChromePadding = topChromePadding,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
@@ -2868,6 +2938,7 @@ private fun AppTabHost(
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
+ topChromePadding = topChromePadding,
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
@@ -2903,6 +2974,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/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/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/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/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index 86e2cfa10..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
@@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import 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
@@ -781,6 +781,7 @@ fun MetaDetailsScreen(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTablet = maxWidth >= 720.dp
+ val viewportHeight = maxHeight
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed
@@ -815,6 +816,7 @@ fun MetaDetailsScreen(
meta = meta,
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
+ viewportHeight = viewportHeight,
scrollOffset = heroScrollOffset,
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
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/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/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index af8b64104..f5ecf93ce 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -88,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,6 +175,7 @@ fun LibraryScreen(
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
+ topPadding = if (topChromePadding != null) 0.dp else null,
listState = listState,
) {
stickyHeader {
@@ -192,6 +194,7 @@ fun LibraryScreen(
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
+ topPadding = topChromePadding,
)
LibrarySourceSwitch(
selectedMode = sourceMode,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
index ae78fb8d3..cdd15b8b7 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt
@@ -23,6 +23,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()
@@ -62,7 +289,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/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/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt
index c0436c9d4..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
@@ -230,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 5d025f5ed..afabfa862 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
@@ -162,10 +162,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))
@@ -173,6 +196,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) {
@@ -196,7 +230,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 0dbc7a274..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()
@@ -116,6 +168,7 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
activeTorrentFileIdx = stream.fileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentTrackers = stream.p2pTrackers
+ activeSourceIdentityKey = stream.playerSourceIdentityKey()
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
@@ -186,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()
@@ -198,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
@@ -271,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 }
}
@@ -376,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 731451022..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
@@ -66,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()
@@ -100,6 +100,11 @@ internal class PlayerScreenRuntime(
var activeTorrentFilename by mutableStateOf(torrentFilename)
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)
@@ -150,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 fbfc91d02..9c6f79d82 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,42 @@ 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
@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 +102,229 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
(bufferedSeconds / 10f).coerceIn(0f, 1f)
}
}
+ val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
+ val openingOverlayWanted = playerSettingsUiState.showLoadingOverlay &&
+ !initialLoadCompleted &&
+ errorMessage == null
+ 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 -> ""
+ }
+ val playerControlsState = 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,
+ )
val gestureCallbacks = rememberSurfaceGestureCallbacks()
Box(
@@ -113,7 +357,6 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek,
),
) {
- val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
if (playerSurfaceSourceUrl != null) {
PlatformPlayerSurface(
sourceUrl = playerSurfaceSourceUrl,
@@ -123,6 +366,18 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,
+ initialPositionMs = activeInitialPositionMs.takeIf { isDesktop } ?: 0L,
+ playerControlsState = playerControlsState,
+ onPlayerControlsAction = { action -> handlePlayerControlsAction(action) },
+ onPlayerControlsEvent = { type, value -> handlePlayerControlsEvent(type, value) },
+ onPlayerControlsScrubChange = { positionMs ->
+ handlePlayerControlsScrubChange(positionMs)
+ true
+ },
+ onPlayerControlsScrubFinished = { positionMs ->
+ handlePlayerControlsScrubFinished(positionMs)
+ true
+ },
onControllerReady = { controller ->
playerController = controller
playerControllerSourceUrl = activeSourceUrl
@@ -178,6 +433,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
showP2pRebufferStats = showP2pRebufferStats,
p2pRebufferMessage = p2pRebufferMessage,
p2pRebufferProgress = p2pRebufferProgress,
+ suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null,
)
RenderPlayerModals(displayedPositionMs = displayedPositionMs)
}
@@ -287,6 +543,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,
@@ -297,62 +1153,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()
@@ -517,6 +1377,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/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..389aa5ff2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
@@ -83,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,6 +239,7 @@ fun SearchScreen(
NuvioScreen(
horizontalPadding = 0.dp,
+ topPadding = if (topChromePadding != null) 0.dp else null,
listState = listState,
modifier = Modifier.fillMaxSize(),
) {
@@ -251,6 +253,7 @@ fun SearchScreen(
NuvioScreenHeader(
title = headerTitle,
modifier = Modifier.padding(horizontal = 16.dp),
+ topPadding = topChromePadding,
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp))
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt
index 157bed8ea..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
@@ -40,6 +40,8 @@ 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
@@ -57,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
@@ -135,6 +139,11 @@ internal fun LazyListScope.appearanceSettingsContent(
}
item {
var showLanguageSheet by remember { mutableStateOf(false) }
+ var showDesktopNavigationSheet by remember { mutableStateOf(false) }
+ val desktopNavigationLayout by remember {
+ ThemeSettingsRepository.ensureLoaded()
+ ThemeSettingsRepository.desktopNavigationLayout
+ }.collectAsStateWithLifecycle()
SettingsSection(
title = stringResource(Res.string.settings_appearance_section_display),
isTablet = isTablet,
@@ -157,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),
@@ -168,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,
@@ -206,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,
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/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 5d0f8cf39..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",
@@ -794,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/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/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/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
index 3bdaa37ab..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
@@ -77,17 +77,19 @@ 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
@@ -144,7 +146,9 @@ fun StreamsScreen(
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
remember {
- DownloadsRepository.ensureLoaded()
+ if (AppFeaturePolicy.downloadsEnabled) {
+ DownloadsRepository.ensureLoaded()
+ }
}
val isEpisode = seasonNumber != null && episodeNumber != null
val clipboardManager = LocalClipboardManager.current
@@ -343,6 +347,7 @@ fun StreamsScreen(
StreamActionsSheet(
stream = streamActionsTarget,
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
+ showDownloadAction = AppFeaturePolicy.downloadsEnabled,
onDismiss = { streamActionsTarget = null },
onCopyLink = { stream ->
val directUrl = stream.playableDirectUrl
@@ -1027,6 +1032,7 @@ private fun StreamCard(
onClick = onClick,
onLongClick = onLongClick,
)
+ .secondaryClick(if (enabled) onLongClick else null)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -1176,6 +1182,7 @@ private fun StreamNameWithInstantService(
private fun StreamActionsSheet(
stream: StreamItem?,
externalPlayerEnabled: Boolean,
+ showDownloadAction: Boolean,
onDismiss: () -> Unit,
onCopyLink: (StreamItem) -> Unit,
onDownload: (StreamItem) -> Unit,
@@ -1254,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/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt
index 6130d1ae5..998aa559c 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
@@ -243,7 +243,7 @@ internal object TraktScrobbleRepository {
ids = item.ids.toRequestBodyOrNull(),
),
progress = clampedProgress,
- appVersion = AppVersionConfig.VERSION_NAME,
+ appVersion = AppVersionPolicy.displayVersionName,
)
is TraktScrobbleItem.Episode -> TraktScrobbleRequest(
@@ -258,7 +258,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/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