From 64193ea410fde1ee4f3ba24ca657c4518c90ddf8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:38:56 +0530 Subject: [PATCH 1/6] feat: libmpv bridge for windows --- composeApp/build.gradle.kts | 255 +++ .../features/player/PlayerEngine.desktop.kt | 2 +- .../player/desktop/MacosAwtViewResolver.kt | 32 + .../player/desktop/NativePlayerBridge.kt | 52 +- .../native/windows/player_bridge.cpp | 1687 +++++++++++++++++ .../resources/player-ui/controls.html | 7 +- 6 files changed, 2031 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/desktopMain/native/windows/player_bridge.cpp diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0deed6d8..cc3b698a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -5,8 +5,11 @@ 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 @@ -151,6 +154,21 @@ fun readXcconfigValue(file: File, key: String): String? { 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) @@ -211,6 +229,7 @@ val generateRuntimeConfigs = tasks.register("generat } 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") @@ -312,6 +331,204 @@ val buildMacosPlayerBridge = tasks.register("buildMacosPlayerBridge") { 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", + ).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) @@ -319,6 +536,43 @@ tasks.withType().configureEach { 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 { @@ -446,6 +700,7 @@ compose.desktop { "--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" }, ) 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 index 1bce4405..4c751d9f 100644 --- 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 @@ -40,7 +40,7 @@ actual fun PlatformPlayerSurface( onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, ) { - if (DesktopHostOs.current == DesktopHostOs.MACOS) { + if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { NativePlayerSurface( sourceUrl = sourceUrl, sourceHeaders = sourceHeaders, 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 index 9007af2d..aea9d4b7 100644 --- 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 @@ -8,6 +8,7 @@ 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}.") } } @@ -48,3 +49,34 @@ private object MacosAwtViewResolver { 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 index f7586fff..81a62b1b 100644 --- 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 @@ -80,13 +80,14 @@ internal object NativePlayerBridge { private fun loadNativeLibrary() { val platform = DesktopHostOs.current - require(platform == DesktopHostOs.MACOS) { + 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 } @@ -98,12 +99,44 @@ internal object NativePlayerBridge { 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"), @@ -112,6 +145,21 @@ internal object NativePlayerBridge { 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" @@ -163,7 +211,7 @@ internal object NativePlayerBridge { } internal fun preloadNativePlayerBridgeAsync() { - if (DesktopHostOs.current == DesktopHostOs.MACOS) { + if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) { NativePlayerBridge.preloadAsync() } } 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 00000000..ec7c5cf8 --- /dev/null +++ b/composeApp/src/desktopMain/native/windows/player_bridge.cpp @@ -0,0 +1,1687 @@ +#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 + +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"; + +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; +} + +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); + }); +} + +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 &controlsHtml, + 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, controlsHtml, initState]() { + self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsHtml, 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 controlsHtml, + std::shared_ptr initState + ) { + std::string failure; + try { + initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsHtml); + } 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 &controlsHtml + ) { + 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(controlsHtml); + 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 &controlsHtml) { + std::wstring userDataDir = tempUserDataDirectory(); + auto weakSelf = weak_from_this(); + HRESULT result = CreateCoreWebView2EnvironmentWithOptions( + nullptr, + userDataDir.c_str(), + nullptr, + Callback( + [weakSelf, controlsHtml](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, controlsHtml](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { + auto controllerSelf = controllerWeakSelf.lock(); + if (!controllerSelf || controllerSelf->shuttingDown.load()) return S_OK; + if (FAILED(controllerResult) || !createdController) { + return S_OK; + } + controllerSelf->controller = createdController; + createdController->get_CoreWebView2(&controllerSelf->webView); + + ComPtr controller2; + if (SUCCEEDED(createdController->QueryInterface(IID_PPV_ARGS(&controller2))) && controller2) { + COREWEBVIEW2_COLOR transparent = {0, 0, 0, 0}; + controller2->put_DefaultBackgroundColor(transparent); + } + + ComPtr settings; + if (controllerSelf->webView && SUCCEEDED(controllerSelf->webView->get_Settings(&settings)) && settings) { + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_IsStatusBarEnabled(FALSE); + } + + if (controllerSelf->webView) { + auto messageWeakSelf = controllerWeakSelf; + controllerSelf->webView->add_WebMessageReceived( + Callback( + [messageWeakSelf](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { + auto messageSelf = messageWeakSelf.lock(); + if (!messageSelf || messageSelf->shuttingDown.load() || !args) return S_OK; + PWSTR messageJson = nullptr; + if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { + messageSelf->handleWebMessage(std::wstring(messageJson)); + CoTaskMemFree(messageJson); + } + return S_OK; + } + ).Get(), + &controllerSelf->messageToken + ); + controllerSelf->layoutNativeSubviews(); + std::wstring html = toWide(controlsHtml); + controllerSelf->webView->NavigateToString(html.c_str()); + createdController->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); + } + return S_OK; + } + ).Get() + ); + (void)controllerResult; + return S_OK; + } + ).Get() + ); + if (FAILED(result)) { + throw std::runtime_error(hresultMessage("CreateCoreWebView2EnvironmentWithOptions", result)); + } + } + + void startMpv( + const std::string &sourceUrl, + const std::vector &headerLines, + bool playWhenReady, + long long initialPositionMs + ) { + MpvApi &api = mpvApi(); + { + std::lock_guard lock(mpvMutex); + mpv = api.create(); + if (!mpv) { + throw std::runtime_error("mpv_create failed."); + } + initialStartSeconds = initialPositionMs > 0 ? (double)initialPositionMs / 1000.0 : 0.0; + + setMpvOptionStringLocked("config", "no"); + setMpvOptionStringLocked("osc", "no"); + setMpvOptionStringLocked("input-default-bindings", "yes"); + setMpvOptionStringLocked("input-vo-keyboard", "no"); + setMpvOptionStringLocked("keep-open", "yes"); + setMpvOptionStringLocked("vo", "gpu-next"); + setMpvOptionStringLocked("gpu-api", "d3d11"); + setMpvOptionStringLocked("hwdec", "d3d11va"); + setMpvOptionStringLocked("hwdec-codecs", "all"); + setMpvOptionStringLocked("vd-lavc-software-fallback", "no"); + setMpvOptionStringLocked("vd-lavc-threads", "4"); + setMpvOptionStringLocked("target-colorspace-hint", "yes"); + setMpvOptionStringLocked("tone-mapping", "auto"); + setMpvOptionStringLocked("dither-depth", "auto"); + setMpvOptionStringLocked("deband", "yes"); + setMpvOptionStringLocked("scale", "spline36"); + setMpvOptionStringLocked("cscale", "spline36"); + setMpvOptionStringLocked("demuxer-max-bytes", "64MiB"); + setMpvOptionStringLocked("demuxer-max-back-bytes", "16MiB"); + setMpvOptionStringLocked("demuxer-seekable-cache", "no"); + setMpvOptionStringLocked("cache-secs", "30"); + setMpvOptionStringLocked("hr-seek", "no"); + + int64_t wid = (int64_t)(intptr_t)containerHwnd; + int widResult = api.setOption(mpv, "wid", MPV_FORMAT_INT64, &wid); + if (widResult < 0) { + throw std::runtime_error(std::string("mpv wid option failed: ") + api.errorText(widResult)); + } + + if (!headerLines.empty()) { + std::string headers; + for (size_t index = 0; index < headerLines.size(); index++) { + if (index > 0) headers.push_back(','); + headers += headerLines[index]; + } + setMpvOptionStringLocked("http-header-fields", headers.c_str()); + } + + int initResult = api.initialize(mpv); + if (initResult < 0) { + throw std::runtime_error(std::string("mpv_initialize failed: ") + api.errorText(initResult)); + } + + std::vector loadCommand = {"loadfile", sourceUrl.c_str()}; + std::string loadOptions; + if (initialPositionMs > 0) { + char startBuffer[64]; + std::snprintf(startBuffer, sizeof(startBuffer), "start=%.3f", (double)initialPositionMs / 1000.0); + loadOptions = startBuffer; + loadCommand.push_back("replace"); + loadCommand.push_back("-1"); + loadCommand.push_back(loadOptions.c_str()); + } + loadCommand.push_back(nullptr); + + int commandResult = api.command(mpv, loadCommand.data()); + if (commandResult < 0) { + throw std::runtime_error(std::string("mpv loadfile failed: ") + api.errorText(commandResult)); + } + } + + setPaused(!playWhenReady); + auto self = shared_from_this(); + eventThread = std::thread([self]() { self->drainMpvEvents(); }); + } + + void layoutNativeSubviews() { + if (!hostHwnd || !IsWindow(hostHwnd)) { + return; + } + RECT bounds = {}; + GetClientRect(hostHwnd, &bounds); + LONG width = std::max(1, bounds.right - bounds.left); + LONG height = std::max(1, bounds.bottom - bounds.top); + if (containerHwnd) { + SetWindowPos(containerHwnd, HWND_TOP, 0, 0, width, height, SWP_SHOWWINDOW | SWP_NOACTIVATE); + } + if (controller) { + RECT webBounds = {0, 0, width, height}; + controller->put_Bounds(webBounds); + controller->put_IsVisible(TRUE); + } + } + + void flushPendingControlsJsonIfReady() { + if (!webView || !controlsWebReady.load()) { + return; + } + std::string controlsJson; + { + std::lock_guard lock(controlsMutex); + controlsJson = pendingControlsJson; + pendingControlsJson.clear(); + } + if (controlsJson.empty()) return; + std::wstring script = + L"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(" + + javaScriptStringLiteral(controlsJson) + + L"));return 'applied';})()"; + webView->ExecuteScript(script.c_str(), nullptr); + } + + void syncControls() { + if (!webView) return; + double duration = doubleProperty("duration", 0.0); + double position = doubleProperty("time-pos", 0.0); + bool paused = isPaused(); + bool loading = isLoading(); + std::string audioTracks = audioTracksJson(); + std::string subtitleTracks = subtitleTracksJson(); + + std::ostringstream script; + script << "window.playerUpdate({duration:" << duration + << ",position:" << position + << ",paused:" << (paused ? "true" : "false") + << ",loading:" << (loading ? "true" : "false") + << ",audioTracks:" << audioTracks + << ",subtitleTracks:" << subtitleTracks + << "})"; + std::wstring wideScript = toWide(script.str()); + webView->ExecuteScript(wideScript.c_str(), nullptr); + } + + void handleWebMessage(const std::wstring &messageJson) { + std::string type = extractJsonString(messageJson, L"type"); + if (type.empty()) return; + double value = extractJsonNumber(messageJson, L"value", 0.0); + + if (type == "controlsReady") { + controlsWebReady.store(true); + flushPendingControlsJsonIfReady(); + syncControls(); + return; + } + if (type == "selectAudioTrack") { + selectAudioTrackId((int)std::llround(value)); + syncControls(); + return; + } + if (type == "selectSubtitleTrack") { + selectSubtitleTrackId((int)std::llround(value)); + syncControls(); + return; + } + sendPlayerEvent(type, value); + } + + static std::string extractJsonString(const std::wstring &json, const std::wstring &field) { + std::wstring key = L"\"" + field + L"\""; + size_t keyIndex = json.find(key); + if (keyIndex == std::wstring::npos) return std::string(); + size_t colon = json.find(L':', keyIndex + key.size()); + if (colon == std::wstring::npos) return std::string(); + size_t quote = json.find(L'"', colon + 1); + if (quote == std::wstring::npos) return std::string(); + + std::wstring result; + bool escaping = false; + for (size_t index = quote + 1; index < json.size(); index++) { + wchar_t ch = json[index]; + if (escaping) { + switch (ch) { + case L'"': result.push_back(L'"'); break; + case L'\\': result.push_back(L'\\'); break; + case L'/': result.push_back(L'/'); break; + case L'b': result.push_back(L'\b'); break; + case L'f': result.push_back(L'\f'); break; + case L'n': result.push_back(L'\n'); break; + case L'r': result.push_back(L'\r'); break; + case L't': result.push_back(L'\t'); break; + default: result.push_back(ch); break; + } + escaping = false; + continue; + } + if (ch == L'\\') { + escaping = true; + continue; + } + if (ch == L'"') break; + result.push_back(ch); + } + return toUtf8(result); + } + + static double extractJsonNumber(const std::wstring &json, const std::wstring &field, double fallback) { + std::wstring key = L"\"" + field + L"\""; + size_t keyIndex = json.find(key); + if (keyIndex == std::wstring::npos) return fallback; + size_t colon = json.find(L':', keyIndex + key.size()); + if (colon == std::wstring::npos) return fallback; + size_t start = json.find_first_not_of(L" \t\r\n", colon + 1); + if (start == std::wstring::npos) return fallback; + size_t end = start; + while (end < json.size()) { + wchar_t ch = json[end]; + if (!(std::iswdigit(ch) || ch == L'-' || ch == L'+' || ch == L'.' || ch == L'e' || ch == L'E')) { + break; + } + end++; + } + if (end <= start) return fallback; + try { + return std::stod(json.substr(start, end - start)); + } catch (...) { + return fallback; + } + } + + void drainMpvEvents() { + while (!stopping.load()) { + mpv_handle *current = nullptr; + { + std::lock_guard lock(mpvMutex); + current = mpv; + } + if (!current) { + return; + } + + mpv_event *event = mpvApi().waitEvent(current, 0.5); + if (!event) continue; + if (event->event_id == MPV_EVENT_SHUTDOWN) { + return; + } + } + } + + JNIEnv *jniEnvDidAttach(bool *didAttach) { + if (didAttach) *didAttach = false; + if (!javaVm) return nullptr; + JNIEnv *env = nullptr; + jint status = javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); + if (status == JNI_OK) return env; + if (status != JNI_EDETACHED) return nullptr; + if (javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { + return nullptr; + } + if (didAttach) *didAttach = true; + return env; + } + + void sendPlayerEvent(const std::string &type, double value) { + if (!eventSink || !eventMethod) return; + bool didAttach = false; + JNIEnv *env = jniEnvDidAttach(&didAttach); + if (!env) return; + + jstring eventType = newJavaStringUtf8(env, type); + env->CallVoidMethod(eventSink, eventMethod, eventType, (jdouble)value); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + if (eventType) { + env->DeleteLocalRef(eventType); + } + if (didAttach) { + javaVm->DetachCurrentThread(); + } + } + + void setMpvOptionStringLocked(const char *name, const char *value) { + (void)mpvApi().setOptionString(mpv, name, value); + } + + double doubleProperty(const char *name, double fallback) { + std::lock_guard lock(mpvMutex); + if (!mpv) return fallback; + double value = fallback; + int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_DOUBLE, &value); + if (result < 0) { + return fallback; + } + return value; + } + + long long int64Property(const char *name, long long fallback) { + std::lock_guard lock(mpvMutex); + if (!mpv) return fallback; + int64_t value = fallback; + int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_INT64, &value); + if (result < 0) { + return fallback; + } + return value; + } + + bool flagProperty(const char *name, bool fallback) { + std::lock_guard lock(mpvMutex); + if (!mpv) return fallback; + int flag = fallback ? 1 : 0; + int result = mpvApi().getProperty(mpv, name, MPV_FORMAT_FLAG, &flag); + if (result < 0) { + return fallback; + } + return flag != 0; + } + + std::string stringProperty(const char *name, const std::string &fallback) { + std::lock_guard lock(mpvMutex); + if (!mpv) return fallback; + char *value = nullptr; + int propertyResult = mpvApi().getProperty(mpv, name, MPV_FORMAT_STRING, &value); + if (propertyResult < 0 || !value) { + return fallback; + } + std::string result(value); + mpvApi().freeValue(value); + return result; + } + + void setStringProperty(const char *name, const std::string &value) { + std::lock_guard lock(mpvMutex); + if (!mpv) return; + mpvApi().setPropertyString(mpv, name, value.c_str()); + } + + void command(const std::vector &args) { + if (args.empty()) return; + std::lock_guard lock(mpvMutex); + if (!mpv) return; + std::vector cargs; + cargs.reserve(args.size() + 1); + for (const std::string &arg : args) { + cargs.push_back(arg.c_str()); + } + cargs.push_back(nullptr); + mpvApi().command(mpv, cargs.data()); + } + + double rawPositionSeconds() { + double position = doubleProperty("time-pos", 0.0); + return std::isfinite(position) ? std::max(position, 0.0) : 0.0; + } + + double effectiveCachePositionSeconds() { + double position = rawPositionSeconds(); + if (initialStartSeconds > 0.0 && position + 5.0 < initialStartSeconds) { + return initialStartSeconds; + } + return position; + } + + double cacheAheadSeconds() { + double effectivePosition = effectiveCachePositionSeconds(); + double cacheTime = doubleProperty("demuxer-cache-time", 0.0); + if (std::isfinite(cacheTime) && cacheTime > 0.0) { + if (cacheTime >= effectivePosition - 5.0) { + return std::max(cacheTime - effectivePosition, 0.0); + } + return cacheTime; + } + + double cacheDuration = doubleProperty("demuxer-cache-duration", 0.0); + if (std::isfinite(cacheDuration) && cacheDuration > 0.0) { + return cacheDuration; + } + return 0.0; + } + + void removeExternalSubtitleTracks() { + long long count = int64Property("track-list/count", 0); + if (count <= 0) return; + for (long long index = count - 1; index >= 0; index--) { + std::string prefix = "track-list/" + std::to_string(index); + std::string type = stringProperty((prefix + "/type").c_str(), ""); + bool external = flagProperty((prefix + "/external").c_str(), false); + if (type == "sub" && external) { + long long trackId = int64Property((prefix + "/id").c_str(), -1); + if (trackId >= 0) { + command({"sub-remove", std::to_string(trackId)}); + } + } + } + } + + std::string tracksJsonForType(const std::string &wantedType) { + long long count = int64Property("track-list/count", 0); + std::ostringstream json; + json << "["; + int logicalIndex = 0; + bool first = true; + for (long long index = 0; index < count; index++) { + std::string prefix = "track-list/" + std::to_string(index); + std::string type = stringProperty((prefix + "/type").c_str(), ""); + if (type != wantedType) continue; + + long long trackId = int64Property((prefix + "/id").c_str(), logicalIndex + 1); + std::string title = trackStringAtIndex(index, "title"); + std::string language = trackStringAtIndex(index, "lang"); + std::string codec = trackStringAtIndex(index, "codec"); + std::string decoderDescription = trackStringAtIndex(index, "decoder-desc"); + std::string channels = trackStringAtIndex(index, "demux-channels"); + long long channelCount = int64Property((prefix + "/demux-channel-count").c_str(), 0); + bool selected = flagProperty((prefix + "/selected").c_str(), false); + bool forced = flagProperty((prefix + "/forced").c_str(), false); + std::string label = formatTrackTitle(type, logicalIndex, title, language, codec, decoderDescription, channels, (int)channelCount); + + if (!first) json << ","; + first = false; + json << "{" + << "\"index\":" << logicalIndex << "," + << "\"id\":\"" << jsonEscape(std::to_string(trackId)) << "\"," + << "\"label\":\"" << jsonEscape(label) << "\"," + << "\"language\":\"" << jsonEscape(language) << "\"," + << "\"selected\":" << (selected ? "true" : "false") << "," + << "\"forced\":" << (forced ? "true" : "false") + << "}"; + logicalIndex++; + } + json << "]"; + return json.str(); + } + + std::string trackStringAtIndex(long long index, const std::string &field) { + return trim(stringProperty(("track-list/" + std::to_string(index) + "/" + field).c_str(), "")); + } + + std::string formatTrackTitle( + const std::string &type, + int index, + const std::string &title, + const std::string &language, + const std::string &codec, + const std::string &decoderDescription, + const std::string &channels, + int channelCount + ) { + std::string base = !trim(title).empty() + ? trim(title) + : (!trim(language).empty() + ? trim(language) + : (type == "sub" ? "Subtitle " + std::to_string(index + 1) : "Track " + std::to_string(index + 1))); + std::string codecName = codecDisplayName(codec); + if (codecName.empty()) codecName = codecDisplayName(decoderDescription); + std::string channelName = type == "audio" ? channelLayoutName(channels, channelCount) : ""; + + std::vector details; + for (const std::string &detail : {channelName, codecName}) { + if (!detail.empty() && !containsCaseInsensitive(base, detail)) { + details.push_back(detail); + } + } + if (details.empty()) return base; + std::string suffix; + for (size_t detailIndex = 0; detailIndex < details.size(); detailIndex++) { + if (detailIndex > 0) suffix += ", "; + suffix += details[detailIndex]; + } + return base + " (" + suffix + ")"; + } + + std::string channelLayoutName(const std::string &channels, int channelCount) { + std::string normalized = trim(channels); + if (!normalized.empty() && lowerCopy(normalized) != "unknown") { + std::string lower = lowerCopy(normalized); + if (lower == "mono") return "Mono"; + if (lower == "stereo") return "Stereo"; + return normalized; + } + switch (channelCount) { + case 1: return "Mono"; + case 2: return "Stereo"; + case 6: return "5.1"; + case 8: return "7.1"; + default: return channelCount > 0 ? std::to_string(channelCount) + "ch" : ""; + } + } + + std::string codecDisplayName(const std::string &value) { + std::string raw = trim(value); + if (raw.empty()) return ""; + std::string codec = lowerCopy(raw); + if (codec.find("eac3") != std::string::npos || codec.find("e-ac-3") != std::string::npos || codec.find("e ac-3") != std::string::npos) { + return codec.find("joc") != std::string::npos || codec.find("atmos") != std::string::npos ? "E-AC-3-JOC" : "E-AC-3"; + } + if (codec.find("truehd") != std::string::npos || codec.find("true hd") != std::string::npos) return "TrueHD"; + if (codec.find("ac3") != std::string::npos || codec.find("ac-3") != std::string::npos) return "AC-3"; + if (codec.find("dts-hd") != std::string::npos || codec.find("dtshd") != std::string::npos || codec.find("dts hd") != std::string::npos) return "DTS-HD"; + if (codec.find("dts") != std::string::npos || codec == "dca") return "DTS"; + if (codec.find("aac") != std::string::npos) return "AAC"; + if (codec.find("mp3") != std::string::npos || codec.find("mpeg audio") != std::string::npos) return "MP3"; + if (codec.find("mp2") != std::string::npos) return "MP2"; + if (codec.find("opus") != std::string::npos) return "Opus"; + if (codec.find("vorbis") != std::string::npos) return "Vorbis"; + if (codec.find("flac") != std::string::npos) return "FLAC"; + if (codec.find("alac") != std::string::npos) return "ALAC"; + if (codec.find("pcm") != std::string::npos || codec.find("wav") != std::string::npos) return "WAV"; + if (codec.find("pgs") != std::string::npos || codec.find("hdmv") != std::string::npos) return "PGS"; + if (codec.find("subrip") != std::string::npos || codec == "srt") return "SRT"; + if (codec.find("ass") != std::string::npos || codec.find("ssa") != std::string::npos) return "SSA"; + if (codec.find("webvtt") != std::string::npos || codec == "vtt") return "VTT"; + if (codec.find("ttml") != std::string::npos) return "TTML"; + if (codec.find("mov_text") != std::string::npos || codec.find("tx3g") != std::string::npos) return "TX3G"; + if (codec.find("dvb") != std::string::npos) return "DVB"; + return raw; + } +}; + +LRESULT CALLBACK messageWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + auto *create = reinterpret_cast(lParam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); + return TRUE; + } + auto *player = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_NUVIO_TASK: + if (player) player->processUiTasks(); + return 0; + case WM_TIMER: + if (player && wParam == NUVIO_TIMER_ID) player->onTimer(); + return 0; + case WM_NCDESTROY: + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + return 0; + default: + return DefWindowProcW(hwnd, message, wParam, lParam); + } +} + +LRESULT CALLBACK containerWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + auto *create = reinterpret_cast(lParam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); + return TRUE; + } + switch (message) { + case WM_SIZE: + return 0; + case WM_ERASEBKGND: { + RECT rect = {}; + GetClientRect(hwnd, &rect); + FillRect((HDC)wParam, &rect, (HBRUSH)GetStockObject(BLACK_BRUSH)); + return 1; + } + case WM_NCDESTROY: + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + return 0; + default: + return DefWindowProcW(hwnd, message, wParam, lParam); + } +} + +std::shared_ptr playerFromHandle(jlong handle) { + if (handle == 0) return nullptr; + auto *holder = reinterpret_cast *>(handle); + return holder ? *holder : nullptr; +} + +} // namespace + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + gModule = module; + DisableThreadLibraryCalls(module); + } + return TRUE; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( + JNIEnv *env, + jobject, + jlong hostViewPtr, + jstring sourceUrl, + jobjectArray headerLines, + jboolean playWhenReady, + jlong initialPositionMs, + jstring controlsHtml, + jobject eventSink +) { + HWND hostHwnd = (HWND)(intptr_t)hostViewPtr; + std::string sourceUrlText = jstringToUtf8(env, sourceUrl); + std::vector headerLineValues = jstringArrayToVector(env, headerLines); + std::string controlsHtmlText = jstringToUtf8(env, controlsHtml); + JavaVM *javaVm = nullptr; + env->GetJavaVM(&javaVm); + + jobject eventSinkRef = nullptr; + jmethodID eventMethod = nullptr; + if (eventSink) { + eventSinkRef = env->NewGlobalRef(eventSink); + jclass eventSinkClass = env->GetObjectClass(eventSink); + eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); + env->DeleteLocalRef(eventSinkClass); + if (!eventMethod) { + if (eventSinkRef) env->DeleteGlobalRef(eventSinkRef); + throwJavaError(env, "Native player event sink is missing onPlayerEvent(String, Double)."); + return 0; + } + } + + auto player = std::make_shared(); + try { + player->initialize( + hostHwnd, + sourceUrlText, + headerLineValues, + playWhenReady == JNI_TRUE, + initialPositionMs, + controlsHtmlText, + javaVm, + eventSinkRef, + eventMethod + ); + } catch (const std::exception &error) { + player->shutdown(); + throwJavaError(env, error.what()); + return 0; + } + + auto *holder = new std::shared_ptr(player); + jlong handle = (jlong)(intptr_t)holder; + return handle; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose(JNIEnv *, jobject, jlong handle) { + if (handle == 0) return; + auto *holder = reinterpret_cast *>(handle); + std::shared_ptr player = *holder; + delete holder; + if (player) player->shutdown(); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls(JNIEnv *env, jobject, jlong handle, jstring controlsJson) { + auto player = playerFromHandle(handle); + std::string controlsJsonText = jstringToUtf8(env, controlsJson); + if (player) player->updateControlsJson(controlsJsonText); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused(JNIEnv *, jobject, jlong handle, jboolean paused) { + auto player = playerFromHandle(handle); + if (player) player->setPaused(paused == JNI_TRUE); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo(JNIEnv *, jobject, jlong handle, jlong positionMs) { + auto player = playerFromHandle(handle); + if (player) player->seekToMilliseconds(positionMs); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy(JNIEnv *, jobject, jlong handle, jlong offsetMs) { + auto player = playerFromHandle(handle); + if (player) player->seekByMilliseconds(offsetMs); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed(JNIEnv *, jobject, jlong handle, jfloat speed) { + auto player = playerFromHandle(handle); + if (player) player->setSpeed(speed); +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player ? player->durationMs() : 0; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player ? player->positionMs() : 0; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player ? player->bufferedPositionMs() : 0; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player && player->isLoading() ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player && player->isEnded() ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return !player || player->isPaused() ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jfloat JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return player ? (jfloat)player->speed() : 1.0f; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setResizeMode(JNIEnv *, jobject, jlong handle, jint mode) { + auto player = playerFromHandle(handle); + if (player) player->setResizeMode(mode); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_audioTracksJson(JNIEnv *env, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return newJavaStringUtf8(env, player ? player->audioTracksJson() : "[]"); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_subtitleTracksJson(JNIEnv *env, jobject, jlong handle) { + auto player = playerFromHandle(handle); + return newJavaStringUtf8(env, player ? player->subtitleTracksJson() : "[]"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectAudioTrack(JNIEnv *, jobject, jlong handle, jint trackId) { + auto player = playerFromHandle(handle); + if (player) player->selectAudioTrackId(trackId); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_selectSubtitleTrack(JNIEnv *, jobject, jlong handle, jint trackId) { + auto player = playerFromHandle(handle); + if (player) player->selectSubtitleTrackId(trackId); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl(JNIEnv *env, jobject, jlong handle, jstring url) { + auto player = playerFromHandle(handle); + if (player) player->addSubtitleUrl(jstringToUtf8(env, url)); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles(JNIEnv *, jobject, jlong handle) { + auto player = playerFromHandle(handle); + if (player) player->removeExternalSubtitles(); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitlesAndSelect(JNIEnv *, jobject, jlong handle, jint trackId) { + auto player = playerFromHandle(handle); + if (player) player->removeExternalSubtitlesAndSelect(trackId); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSubtitleDelayMs(JNIEnv *, jobject, jlong handle, jint delayMs) { + auto player = playerFromHandle(handle); + if (player) player->setSubtitleDelayMs(delayMs); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_applySubtitleStyle( + JNIEnv *env, + jobject, + jlong handle, + jstring textColor, + jstring backgroundColor, + jstring outlineColor, + jfloat outlineSize, + jboolean bold, + jfloat fontSize, + jint subPos +) { + auto player = playerFromHandle(handle); + if (!player) return; + player->applySubtitleStyle( + jstringToUtf8(env, textColor), + jstringToUtf8(env, backgroundColor), + jstringToUtf8(env, outlineColor), + outlineSize, + bold == JNI_TRUE, + fontSize, + subPos + ); +} diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index 06ca4233..b76a78f7 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -2446,7 +2446,12 @@ const send = (type, value = 0) => { const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.player; - if (bridge) bridge.postMessage({ type, value }); + if (bridge) { + bridge.postMessage({ type, value }); + return; + } + const webViewBridge = window.chrome && window.chrome.webview; + if (webViewBridge) webViewBridge.postMessage({ type, value }); }; const animationDelay = ms => new Promise(resolve => { From 72595d45a2d7c25da97cdbf6394fd960d5d209fc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:45:22 +0530 Subject: [PATCH 2/6] feat: implement drag scroll functionality for desktop shelf --- .../nuvio/app/core/ui/NuvioShelfComponents.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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 5bf613e9..822625a2 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,19 @@ 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 +72,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 +87,8 @@ fun NuvioShelfSection( ) } LazyRow( + state = rowState, + modifier = Modifier.desktopShelfDragScroll(rowState), contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -97,6 +108,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, From ab7fc0d1f9b6c058b7a73f4c68c1d5788e1c9458 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:02:50 +0530 Subject: [PATCH 3/6] feat: Implement NuvioAsyncImage for cross-platform image loading - Added NuvioAsyncImage implementation for Android and iOS platforms. - Refactored existing AsyncImage usages to utilize NuvioAsyncImage across common components. - Introduced a desktop-specific implementation of NuvioAsyncImage with enhanced bitmap handling. - Updated image loading logic to support placeholder, error, and fallback images. - Ensured consistent API across platforms with default parameters for better usability. --- .../app/core/ui/NuvioAsyncImage.android.kt | 49 +++++ .../com/nuvio/app/core/ui/NuvioAsyncImage.kt | 30 +++ .../ui/NuvioContinueWatchingActionSheet.kt | 3 +- .../nuvio/app/core/ui/NuvioFloatingPrompt.kt | 3 +- .../app/core/ui/NuvioPosterActionSheet.kt | 3 +- .../nuvio/app/core/ui/NuvioShelfComponents.kt | 5 +- .../nuvio/app/features/addons/AddonsScreen.kt | 2 +- .../app/features/catalog/CatalogScreen.kt | 2 +- .../features/collection/FolderDetailScreen.kt | 2 +- .../app/features/details/MetaDetailsScreen.kt | 2 +- .../features/details/PersonDetailScreen.kt | 2 +- .../details/TmdbEntityBrowseScreen.kt | 2 +- .../details/components/DetailCastSection.kt | 2 +- .../components/DetailFloatingHeader.kt | 2 +- .../features/details/components/DetailHero.kt | 2 +- .../components/DetailProductionSection.kt | 2 +- .../details/components/DetailSeriesContent.kt | 2 +- .../components/DetailTrailersSection.kt | 2 +- .../components/HomeContinueWatchingSection.kt | 2 +- .../home/components/HomeHeroSection.kt | 2 +- .../features/player/PlayerEpisodesPanel.kt | 2 +- .../app/features/player/PlayerOverlays.kt | 2 +- .../features/player/skip/NextEpisodeCard.kt | 2 +- .../features/profiles/ProfileEditScreen.kt | 2 +- .../profiles/ProfileSelectionScreen.kt | 2 +- .../features/profiles/ProfileSwitcherTab.kt | 2 +- .../features/search/SearchDiscoverContent.kt | 2 +- .../settings/LicensesAttributionsPage.kt | 2 +- .../settings/SupportersContributorsPage.kt | 2 +- .../app/features/streams/StreamBadgeChip.kt | 2 +- .../app/features/streams/StreamsScreen.kt | 2 +- .../features/streams/StreamsTabletLayout.kt | 2 +- .../app/core/ui/NuvioAsyncImage.desktop.kt | 200 ++++++++++++++++++ .../CollectionCardRemoteImage.desktop.kt | 2 +- .../nuvio/app/core/ui/NuvioAsyncImage.ios.kt | 49 +++++ 35 files changed, 360 insertions(+), 36 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt 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 00000000..7e81dd3e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.android.kt @@ -0,0 +1,49 @@ +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, +) { + 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/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 00000000..26606686 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt @@ -0,0 +1,30 @@ +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 + +@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, +) 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 f19d3b21..6e841b46 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 e6214c9e..bf5c3dac 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 6ad66ea4..bf2e6149 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 822625a2..aa3932ac 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 @@ -38,7 +38,6 @@ 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 @@ -187,7 +186,7 @@ fun NuvioPosterCard( contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - AsyncImage( + NuvioAsyncImage( model = imageUrl, contentDescription = title, modifier = Modifier.matchParentSize(), @@ -212,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 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 7e12666d..87abf388 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 e20cbbba..a251166e 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 5881ce2e..008b1cf5 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 1e48bb62..0dfaf1d0 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 @@ -59,7 +59,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 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 f209f7f2..9c968612 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 04abb4ca..0803aacb 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/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 306152a5..a6b498ce 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 88a40c55..700aa41e 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 0471ed11..1b5ce1be 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,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer -import coil3.compose.AsyncImage +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 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 b9308252..bdb1b6e8 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 b10aef83..82740e74 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,7 +58,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 co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.format.formatReleaseDateForDisplay 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 e9ef5fa8..f5226e9b 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/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index df2987c4..9ad94cb4 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 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 d09907ec..a7c9a173 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 @@ -47,7 +47,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.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope 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 f6a32fe1..288fafda 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 00405b6f..eeaa2ef9 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/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index 2de18276..dcb5b496 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 5f00697d..b6a8524b 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/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 195ba674..2dfdb2f4 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 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 5e812b28..baf082f2 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 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 2cf53cba..2df20248 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/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index d86eb0a6..e58a91f7 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/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 3b3f8056..664cfd36 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/streams/StreamBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt index f0e9ebd2..08241f19 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 4e1012d5..30677df0 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 @@ -90,7 +90,7 @@ 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 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 5ed13ae7..ff5ab11b 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/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 00000000..78813b4d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.desktop.kt @@ -0,0 +1,200 @@ +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 coil3.size.Size as CoilSize +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.roundToInt + +@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, +) { + val context = LocalPlatformContext.current + val requestModel = remember(context, model) { + model.withOriginalDesktopSize(context) + } + val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(placeholder, error, fallback) { + { state -> + when (state) { + is AsyncImagePainter.State.Loading -> { + placeholder?.let { state.copy(painter = it) } ?: state + } + is AsyncImagePainter.State.Success -> { + state.result.image.toScaledBitmapPainter() + ?.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?.withOriginalDesktopSize(context: PlatformContext): Any? { + if (this == null) return null + + return if (this is ImageRequest) { + newBuilder() + .size(CoilSize.ORIGINAL) + .build() + } else { + ImageRequest.Builder(context) + .data(this) + .size(CoilSize.ORIGINAL) + .build() + } +} + +private fun Image.toScaledBitmapPainter(): Painter? = + (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), + ) + val bitmap = scaledBitmap(drawSize) + + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = drawSize, + dstOffset = IntOffset.Zero, + dstSize = drawSize, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = FilterQuality.None, + ) + } + + 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 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/features/home/components/CollectionCardRemoteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt index 1a679191..4b2b4083 100644 --- 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 @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt new file mode 100644 index 00000000..7e81dd3e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt @@ -0,0 +1,49 @@ +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, +) { + 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, + ) +} From 8f7552fa610e3bb2afbaec32e267a3b049365b38 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:18:30 +0530 Subject: [PATCH 4/6] feat: Add desktop image scaling support to NuvioAsyncImage --- .../app/core/ui/NuvioAsyncImage.android.kt | 1 + .../com/nuvio/app/core/ui/NuvioAsyncImage.kt | 6 ++ .../features/details/components/DetailHero.kt | 2 + .../home/components/HomeHeroSection.kt | 2 + .../app/core/ui/NuvioAsyncImage.desktop.kt | 99 ++++++++++++++++--- .../nuvio/app/core/ui/NuvioAsyncImage.ios.kt | 1 + 6 files changed, 98 insertions(+), 13 deletions(-) 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 index 7e81dd3e..fa48a20c 100644 --- 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 @@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage( colorFilter: ColorFilter?, filterQuality: FilterQuality?, clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, ) { AsyncImage( model = model, 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 index 26606686..c773a3a7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.kt @@ -10,6 +10,11 @@ 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?, @@ -27,4 +32,5 @@ internal expect fun NuvioAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality? = null, clipToBounds: Boolean = true, + desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto, ) 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 1b5ce1be..99bf121c 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,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer +import com.nuvio.app.core.ui.NuvioDesktopImageScaling import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* @@ -108,6 +109,7 @@ fun DetailHero( }, alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, + desktopImageScaling = NuvioDesktopImageScaling.Disabled, ) } else { Box( 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 a7c9a173..6b8a5a36 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 @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioDesktopImageScaling import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview @@ -181,6 +182,7 @@ fun HomeHeroSection( }, alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, + desktopImageScaling = NuvioDesktopImageScaling.Disabled, ) } 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 index 78813b4d..83e76808 100644 --- 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 @@ -24,14 +24,18 @@ import coil3.compose.AsyncImagePainter import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.NullRequestDataException -import coil3.size.Size as CoilSize 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 + @Composable internal actual fun NuvioAsyncImage( model: Any?, @@ -49,19 +53,29 @@ internal actual fun NuvioAsyncImage( colorFilter: ColorFilter?, filterQuality: FilterQuality?, clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, ) { val context = LocalPlatformContext.current - val requestModel = remember(context, model) { - model.withOriginalDesktopSize(context) + val requestModel = remember(context, model, desktopImageScaling) { + if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) { + model + } else { + model.withDesktopHighQualitySize(context) + } } - val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(placeholder, error, fallback) { + val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember( + placeholder, + error, + fallback, + desktopImageScaling, + ) { { state -> when (state) { is AsyncImagePainter.State.Loading -> { placeholder?.let { state.copy(painter = it) } ?: state } is AsyncImagePainter.State.Success -> { - state.result.image.toScaledBitmapPainter() + state.result.image.toScaledBitmapPainter(desktopImageScaling) ?.let { state.copy(painter = it) } ?: state } @@ -107,26 +121,29 @@ internal actual fun NuvioAsyncImage( ) } -private fun Any?.withOriginalDesktopSize(context: PlatformContext): Any? { +private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? { if (this == null) return null return if (this is ImageRequest) { newBuilder() - .size(CoilSize.ORIGINAL) + .size(MaxDesktopSourceSizePx) .build() } else { ImageRequest.Builder(context) .data(this) - .size(CoilSize.ORIGINAL) + .size(MaxDesktopSourceSizePx) .build() } } -private fun Image.toScaledBitmapPainter(): Painter? = - (this as? BitmapImage) +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, @@ -144,17 +161,28 @@ private class ScaledBitmapPainter( width = size.width.roundToInt().coerceAtLeast(1), height = size.height.roundToInt().coerceAtLeast(1), ) - val bitmap = scaledBitmap(drawSize) + 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 = drawSize, + srcSize = cacheSize, dstOffset = IntOffset.Zero, dstSize = drawSize, alpha = alpha, colorFilter = colorFilter, - filterQuality = FilterQuality.None, + filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium, ) } @@ -178,6 +206,51 @@ private class ScaledBitmapPainter( } } + 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 { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt index 7e81dd3e..fa48a20c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioAsyncImage.ios.kt @@ -28,6 +28,7 @@ internal actual fun NuvioAsyncImage( colorFilter: ColorFilter?, filterQuality: FilterQuality?, clipToBounds: Boolean, + desktopImageScaling: NuvioDesktopImageScaling, ) { AsyncImage( model = model, From f6f427b170da21d2bc71a62b0a431dcc1fdbe8f6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:20:14 +0530 Subject: [PATCH 5/6] ref: image scal;ing gate to windows --- .../nuvio/app/core/ui/NuvioAsyncImage.desktop.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 index 83e76808..24d3d73d 100644 --- 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 @@ -36,6 +36,11 @@ 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?, @@ -56,8 +61,11 @@ internal actual fun NuvioAsyncImage( desktopImageScaling: NuvioDesktopImageScaling, ) { val context = LocalPlatformContext.current - val requestModel = remember(context, model, desktopImageScaling) { - if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) { + 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) @@ -67,7 +75,7 @@ internal actual fun NuvioAsyncImage( placeholder, error, fallback, - desktopImageScaling, + effectiveDesktopImageScaling, ) { { state -> when (state) { @@ -75,7 +83,7 @@ internal actual fun NuvioAsyncImage( placeholder?.let { state.copy(painter = it) } ?: state } is AsyncImagePainter.State.Success -> { - state.result.image.toScaledBitmapPainter(desktopImageScaling) + state.result.image.toScaledBitmapPainter(effectiveDesktopImageScaling) ?.let { state.copy(painter = it) } ?: state } From 6d2abeab84170178314dcd767d6374dc5cde02c2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:58:09 +0530 Subject: [PATCH 6/6] cleanup logs --- .../commonMain/kotlin/com/nuvio/app/App.kt | 195 ++++++++++-------- .../com/nuvio/app/features/home/HomeScreen.kt | 30 ++- .../features/profiles/ProfileRepository.kt | 14 +- .../profiles/ProfileSelectionScreen.kt | 3 - .../features/watchprogress/AirDateUtils.kt | 39 ++-- .../desktopMain/kotlin/com/nuvio/app/Main.kt | 8 +- 6 files changed, 145 insertions(+), 144 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 22ff2f30..6784c276 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -176,7 +176,9 @@ 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 @@ -194,6 +196,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 @@ -206,10 +209,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 @@ -350,10 +356,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 @@ -416,6 +449,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 @@ -431,6 +465,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 @@ -439,12 +479,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 } @@ -455,18 +490,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 allowOfflineProfileAccess = cachedProfiles.isNotEmpty() && @@ -519,10 +576,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 } @@ -531,10 +588,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, + ) } } @@ -547,15 +604,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()) @@ -568,11 +619,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 @@ -618,18 +668,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) } @@ -638,6 +676,10 @@ private fun MainAppContent( val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + + LaunchedEffect(Unit) { + warmProfileBoundRepositories() + } val currentBackStackEntry by navController.currentBackStackEntryAsState() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -654,32 +696,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() @@ -913,10 +937,7 @@ private fun MainAppContent( } } } - val continueWatchingPreferencesUiState by remember { - ContinueWatchingPreferencesRepository.ensureLoaded() - ContinueWatchingPreferencesRepository.uiState - }.collectAsStateWithLifecycle() + val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() LaunchedEffect( initialHomeReady, @@ -1339,8 +1360,16 @@ private fun MainAppContent( 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( @@ -1723,10 +1752,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()}" @@ -2751,15 +2777,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, 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 0d90dfad..5668d2cc 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 @@ -104,19 +104,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 +126,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) { 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 ab6b5b2b..c2033993 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 2dfdb2f4..da6c96b9 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 @@ -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/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..b1a681b5 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 index 6d99bed3..413e7441 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -2,10 +2,6 @@ package com.nuvio.app import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.Window @@ -48,15 +44,13 @@ fun main() { if (smokePlayerUrl == null) { App() } else { - var error by remember { mutableStateOf(null) } PlatformPlayerSurface( sourceUrl = smokePlayerUrl, modifier = Modifier.fillMaxSize(), onControllerReady = {}, onSnapshot = {}, - onError = { error = it }, + onError = {}, ) - error?.let { println("Nuvio desktop player smoke error: $it") } } } }