Merge branch 'windows' into dev

This commit is contained in:
tapframe 2026-06-09 17:05:08 +05:30
commit 128f7322d0
46 changed files with 2681 additions and 184 deletions

View file

@ -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<GenerateRuntimeConfigsTask>("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<Exec>("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<Exec>("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<Sync>("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<GenerateNativeRuntimeIndexTask>("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<Jar>().configureEach {
if (isMacHost && name == "desktopJar") {
dependsOn(buildMacosPlayerBridge)
@ -319,6 +536,43 @@ tasks.withType<Jar>().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<KotlinCompilationTask<*>>().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" },
)

View file

@ -0,0 +1,50 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
@Composable
internal actual fun NuvioAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier,
placeholder: Painter?,
error: Painter?,
fallback: Painter?,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?,
onError: ((AsyncImagePainter.State.Error) -> Unit)?,
alignment: Alignment,
contentScale: ContentScale,
alpha: Float,
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality ?: DefaultFilterQuality,
clipToBounds = clipToBounds,
)
}

View file

@ -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<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
var pendingProfileSwitch by remember { mutableStateOf<PendingProfileSwitch?>(null) }
fun rememberedStartupProfile(profiles: List<NuvioProfile>): 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<NuvioProfile>, 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<Unit>(extraBufferCapacity = 1) }
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(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<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(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,

View file

@ -0,0 +1,36 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
internal enum class NuvioDesktopImageScaling {
Auto,
Disabled,
}
@Composable
internal expect fun NuvioAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
error: Painter? = null,
fallback: Painter? = error,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality? = null,
clipToBounds: Boolean = true,
desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto,
)

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -15,8 +15,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@ -28,15 +32,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.isDesktop
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.home_view_all
import nuvio.composeapp.generated.resources.poster_logo_content_description
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
enum class NuvioPosterShape {
Poster,
@ -64,6 +71,7 @@ fun <T> NuvioShelfSection(
itemContent: @Composable (T) -> Unit,
) {
val tokens = MaterialTheme.nuvio
val rowState = rememberLazyListState()
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
@ -78,6 +86,8 @@ fun <T> NuvioShelfSection(
)
}
LazyRow(
state = rowState,
modifier = Modifier.desktopShelfDragScroll(rowState),
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
) {
@ -97,6 +107,47 @@ fun <T> NuvioShelfSection(
}
}
private fun Modifier.desktopShelfDragScroll(
state: LazyListState,
): Modifier {
if (!isDesktop) return this
return pointerInput(state) {
awaitEachGesture {
val down = awaitFirstDown(pass = PointerEventPass.Initial)
var totalDx = 0f
var totalDy = 0f
var dragging = false
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
val delta = change.position - change.previousPosition
totalDx += delta.x
totalDy += delta.y
if (!dragging) {
val horizontalDrag =
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
val verticalDrag =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
when {
verticalDrag -> break
horizontalDrag -> dragging = true
else -> continue
}
}
state.dispatchRawDelta(-delta.x)
change.consume()
}
}
}
}
@Composable
fun NuvioPosterCard(
title: String,
@ -135,7 +186,7 @@ fun NuvioPosterCard(
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
AsyncImage(
NuvioAsyncImage(
model = imageUrl,
contentDescription = title,
modifier = Modifier.matchParentSize(),
@ -160,7 +211,7 @@ fun NuvioPosterCard(
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10),
) {
if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage(
NuvioAsyncImage(
model = bottomLeftLogoUrl,
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
modifier = Modifier

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -43,7 +43,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -108,6 +109,7 @@ fun DetailHero(
},
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
)
} else {
Box(

View file

@ -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.*

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -47,7 +47,8 @@ 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.NuvioDesktopImageScaling
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.CoroutineScope
@ -181,6 +182,7 @@ fun HomeHeroSection(
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import kotlinx.coroutines.delay
@ -176,7 +176,6 @@ fun ProfileSelectionScreen(
} else if (profile.pinEnabled) {
pinDialogProfile = profile
} else {
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
}
},
@ -217,7 +216,6 @@ fun ProfileSelectionScreen(
} else if (profile.pinEnabled) {
pinDialogProfile = profile
} else {
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
}
},
@ -282,7 +280,6 @@ fun ProfileSelectionScreen(
onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) },
onVerified = {
pinDialogProfile = null
ProfileRepository.selectProfile(profile.profileIndex)
onProfileSelected(profile)
},
onDismiss = { pinDialogProfile = null },

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)
}

View file

@ -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<String?>(null) }
PlatformPlayerSurface(
sourceUrl = smokePlayerUrl,
modifier = Modifier.fillMaxSize(),
onControllerReady = {},
onSnapshot = {},
onError = { error = it },
onError = {},
)
error?.let { println("Nuvio desktop player smoke error: $it") }
}
}
}

View file

@ -0,0 +1,281 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import coil3.BitmapImage
import coil3.Image
import coil3.PlatformContext
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.NullRequestDataException
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.FilterMipmap
import org.jetbrains.skia.FilterMode
import org.jetbrains.skia.Image as SkiaImage
import org.jetbrains.skia.MipmapMode
import kotlin.math.max
import kotlin.math.roundToInt
private const val MinCustomDownscaleRatio = 1.08f
private const val MaxDesktopSourceSizePx = 1536
private const val MaxScaledBitmapPixels = 1_250_000L
private val IsWindowsDesktop: Boolean =
System.getProperty("os.name")
?.startsWith("Windows", ignoreCase = true)
?: false
@Composable
internal actual fun NuvioAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier,
placeholder: Painter?,
error: Painter?,
fallback: Painter?,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?,
onError: ((AsyncImagePainter.State.Error) -> Unit)?,
alignment: Alignment,
contentScale: ContentScale,
alpha: Float,
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
val context = LocalPlatformContext.current
val effectiveDesktopImageScaling = remember(desktopImageScaling) {
if (IsWindowsDesktop) desktopImageScaling else NuvioDesktopImageScaling.Disabled
}
val requestModel = remember(context, model, effectiveDesktopImageScaling) {
if (effectiveDesktopImageScaling == NuvioDesktopImageScaling.Disabled) {
model
} else {
model.withDesktopHighQualitySize(context)
}
}
val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(
placeholder,
error,
fallback,
effectiveDesktopImageScaling,
) {
{ state ->
when (state) {
is AsyncImagePainter.State.Loading -> {
placeholder?.let { state.copy(painter = it) } ?: state
}
is AsyncImagePainter.State.Success -> {
state.result.image.toScaledBitmapPainter(effectiveDesktopImageScaling)
?.let { state.copy(painter = it) }
?: state
}
is AsyncImagePainter.State.Error -> {
val fallbackPainter = if (state.result.throwable is NullRequestDataException) {
fallback ?: error
} else {
error
}
fallbackPainter?.let { state.copy(painter = it) } ?: state
}
AsyncImagePainter.State.Empty -> state
}
}
}
val onState: ((AsyncImagePainter.State) -> Unit)? = remember(onLoading, onSuccess, onError) {
if (onLoading == null && onSuccess == null && onError == null) {
null
} else {
{ state ->
when (state) {
is AsyncImagePainter.State.Loading -> onLoading?.invoke(state)
is AsyncImagePainter.State.Success -> onSuccess?.invoke(state)
is AsyncImagePainter.State.Error -> onError?.invoke(state)
AsyncImagePainter.State.Empty -> Unit
}
}
}
}
AsyncImage(
model = requestModel,
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality ?: FilterQuality.High,
clipToBounds = clipToBounds,
)
}
private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? {
if (this == null) return null
return if (this is ImageRequest) {
newBuilder()
.size(MaxDesktopSourceSizePx)
.build()
} else {
ImageRequest.Builder(context)
.data(this)
.size(MaxDesktopSourceSizePx)
.build()
}
}
private fun Image.toScaledBitmapPainter(desktopImageScaling: NuvioDesktopImageScaling): Painter? {
if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) return null
return (this as? BitmapImage)
?.bitmap
?.asComposeImageBitmap()
?.let { imageBitmap -> ScaledBitmapPainter(imageBitmap) }
}
private class ScaledBitmapPainter(
private val image: ImageBitmap,
) : Painter() {
private var cachedSize: IntSize? = null
private var cachedBitmap: ImageBitmap? = null
private var alpha: Float = DefaultAlpha
private var colorFilter: ColorFilter? = null
override val intrinsicSize: Size =
Size(image.width.toFloat(), image.height.toFloat())
override fun DrawScope.onDraw() {
val drawSize = IntSize(
width = size.width.roundToInt().coerceAtLeast(1),
height = size.height.roundToInt().coerceAtLeast(1),
)
if (!shouldUseScaledBitmap(drawSize)) {
drawSource(drawSize)
return
}
val cacheSize = drawSize.cacheSize()
if (cacheSize.pixelCount() > MaxScaledBitmapPixels) {
drawSource(drawSize)
return
}
val bitmap = scaledBitmap(cacheSize)
drawImage(
image = bitmap,
srcOffset = IntOffset.Zero,
srcSize = cacheSize,
dstOffset = IntOffset.Zero,
dstSize = drawSize,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium,
)
}
override fun applyAlpha(alpha: Float): Boolean {
this.alpha = alpha
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
this.colorFilter = colorFilter
return true
}
private fun scaledBitmap(size: IntSize): ImageBitmap {
cachedBitmap?.let { bitmap ->
if (cachedSize == size) return bitmap
}
return image.scale(size.width, size.height).also { bitmap ->
cachedSize = size
cachedBitmap = bitmap
}
}
private fun DrawScope.drawSource(drawSize: IntSize) {
drawImage(
image = image,
srcOffset = IntOffset.Zero,
srcSize = IntSize(image.width, image.height),
dstOffset = IntOffset.Zero,
dstSize = drawSize,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = FilterQuality.High,
)
}
private fun shouldUseScaledBitmap(drawSize: IntSize): Boolean {
if (drawSize.pixelCount() > MaxScaledBitmapPixels) return false
val widthScale = image.width.toFloat() / drawSize.width.toFloat()
val heightScale = image.height.toFloat() / drawSize.height.toFloat()
return max(widthScale, heightScale) >= MinCustomDownscaleRatio
}
private fun IntSize.cacheSize(): IntSize {
val quantum = cacheQuantum()
return IntSize(
width = width.roundUpTo(quantum),
height = height.roundUpTo(quantum),
)
}
private fun IntSize.cacheQuantum(): Int {
val longestSide = max(width, height)
return when {
longestSide >= 1200 -> 16
longestSide >= 600 -> 8
longestSide >= 200 -> 4
else -> 2
}
}
private fun Int.roundUpTo(quantum: Int): Int =
((this + quantum - 1) / quantum) * quantum
private fun IntSize.pixelCount(): Long =
width.toLong() * height.toLong()
private fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap {
val image = SkiaImage.makeFromBitmap(asSkiaBitmap())
return try {
image.scale(width, height)
} finally {
image.close()
}
}
private fun SkiaImage.scale(width: Int, height: Int): ImageBitmap {
val bitmap = Bitmap()
bitmap.allocN32Pixels(width, height)
scalePixels(
bitmap.peekPixels()!!,
FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR),
false,
)
return bitmap.asComposeImageBitmap()
}
}

View file

@ -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

View file

@ -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,

View file

@ -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()
}

View file

@ -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<String> {
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()
}
}

File diff suppressed because it is too large Load diff

View file

@ -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 => {

View file

@ -0,0 +1,50 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
@Composable
internal actual fun NuvioAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier,
placeholder: Painter?,
error: Painter?,
fallback: Painter?,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?,
onError: ((AsyncImagePainter.State.Error) -> Unit)?,
alignment: Alignment,
contentScale: ContentScale,
alpha: Float,
colorFilter: ColorFilter?,
filterQuality: FilterQuality?,
clipToBounds: Boolean,
desktopImageScaling: NuvioDesktopImageScaling,
) {
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality ?: DefaultFilterQuality,
clipToBounds = clipToBounds,
)
}