From 6222c10ee91fd52b8edc0807d89b93bec3cf79f6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:12:43 +0530 Subject: [PATCH] feat: init desktop storafe --- composeApp/build.gradle.kts | 25 ++ .../desktopMain/kotlin/com/nuvio/app/Main.kt | 16 + .../kotlin/com/nuvio/app/Platform.desktop.kt | 9 + .../app/core/auth/AuthStorage.desktop.kt | 18 + .../nuvio/app/core/storage/DesktopStorage.kt | 148 +++++++ ...PlatformLocalAccountDataCleaner.desktop.kt | 7 + .../core/sync/AppForegroundMonitor.desktop.kt | 8 + .../app/core/ui/AppIconPainter.desktop.kt | 25 ++ .../app/core/ui/NativeTabBridge.desktop.kt | 18 + .../core/ui/NuvioPlatformInsets.desktop.kt | 13 + .../core/ui/PlatformBackHandler.desktop.kt | 9 + .../app/core/ui/PlatformExitApp.desktop.kt | 7 + .../core/ui/PlatformImageLoader.desktop.kt | 5 + .../core/ui/PosterCardStyleStorage.desktop.kt | 15 + .../features/addons/AddonPlatform.desktop.kt | 108 +++++ ...CollectionMobileSettingsStorage.desktop.kt | 15 + .../collection/CollectionStorage.desktop.kt | 15 + .../debrid/DebridSettingsStorage.desktop.kt | 138 ++++++ .../MetaScreenSettingsStorage.desktop.kt | 15 + .../details/SeasonViewModeStorage.desktop.kt | 15 + .../HeroTrailerPlayerSurface.desktop.kt | 21 + .../downloads/DownloadsClock.desktop.kt | 5 + .../DownloadsLiveStatusPlatform.desktop.kt | 5 + .../DownloadsPlatformDownloader.desktop.kt | 193 +++++++++ .../downloads/DownloadsStorage.desktop.kt | 15 + .../HomeCatalogSettingsStorage.desktop.kt | 15 + .../CollectionCardRemoteImage.desktop.kt | 34 ++ .../features/library/LibraryClock.desktop.kt | 5 + .../library/LibraryStorage.desktop.kt | 14 + .../mdblist/MdbListSettingsStorage.desktop.kt | 84 ++++ ...sodeReleaseNotificationPlatform.desktop.kt | 15 + ...pisodeReleaseNotificationsClock.desktop.kt | 15 + ...sodeReleaseNotificationsStorage.desktop.kt | 15 + .../p2p/P2pSettingsStorage.desktop.kt | 21 + .../p2p/P2pStreamingEngine.desktop.kt | 28 ++ .../ExternalPlayerLauncherEffect.desktop.kt | 15 + .../player/ExternalPlayerPlatform.desktop.kt | 68 +++ .../features/player/PlayerEngine.desktop.kt | 65 +++ .../PlayerLanguagePreferences.desktop.kt | 12 + .../player/PlayerPlatformEffects.desktop.kt | 19 + .../player/PlayerSettingsStorage.desktop.kt | 406 ++++++++++++++++++ .../PlayerTrackPreferenceStorage.desktop.kt | 94 ++++ .../player/skip/DateComponents.desktop.kt | 12 + .../plugins/PluginRepository.desktop.kt | 42 ++ .../profiles/AvatarStorage.desktop.kt | 13 + .../ProfileHoverHapticFeedback.desktop.kt | 7 + .../ProfilePinCacheStorage.desktop.kt | 20 + .../profiles/ProfilePinCrypto.desktop.kt | 11 + .../profiles/ProfileStorage.desktop.kt | 13 + .../search/SearchHistoryStorage.desktop.kt | 15 + .../IntegrationLogoPainter.desktop.kt | 21 + .../settings/PluginsSettingsPage.desktop.kt | 5 + .../settings/ThemeSettingsStorage.desktop.kt | 74 ++++ .../streams/BingeGroupCacheStorage.desktop.kt | 19 + .../app/features/streams/EpochMs.desktop.kt | 3 + .../StreamBadgeSettingsStorage.desktop.kt | 53 +++ .../streams/StreamLinkCacheStorage.desktop.kt | 19 + .../tmdb/TmdbSettingsStorage.desktop.kt | 114 +++++ .../TrailerPlaybackResolver.desktop.kt | 5 + .../trakt/TraktBrandPainter.desktop.kt | 17 + .../trakt/TraktCommentsStorage.desktop.kt | 30 ++ .../trakt/TraktPlatformClock.desktop.kt | 18 + .../features/trakt/TraktStorage.desktop.kt | 36 ++ .../features/watched/WatchedClock.desktop.kt | 5 + .../watched/WatchedStorage.desktop.kt | 14 + ...ntinueWatchingEnrichmentStorage.desktop.kt | 21 + ...tinueWatchingPreferencesStorage.desktop.kt | 15 + .../CurrentDateProvider.desktop.kt | 7 + .../ResumePromptStorage.desktop.kt | 21 + .../WatchProgressClock.desktop.kt | 5 + .../WatchProgressStorage.desktop.kt | 14 + gradle/libs.versions.toml | 3 + 72 files changed, 2445 insertions(+) create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ff4be0f3d..8e975de0e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -217,6 +217,12 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } + + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } val iosTargets = listOf( iosArm64(), @@ -281,6 +287,13 @@ kotlin { implementation(libs.androidx.media3.extractor) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) } + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.ktor.client.cio) + } + } commonMain.dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -309,6 +322,18 @@ kotlin { } } +compose.desktop { + application { + mainClass = "com.nuvio.app.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "Nuvio" + packageVersion = releaseAppVersionName + } + } +} + afterEvaluate { dependencies { add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt new file mode 100644 index 000000000..d5d106ca6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -0,0 +1,16 @@ +package com.nuvio.app + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.unit.dp + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Nuvio", + state = WindowState(width = 1280.dp, height = 820.dp), + ) { + App() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt new file mode 100644 index 000000000..37753bf91 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt @@ -0,0 +1,9 @@ +package com.nuvio.app + +class DesktopPlatform : Platform { + override val name: String = "Desktop ${System.getProperty("os.name").orEmpty()}".trim() +} + +actual fun getPlatform(): Platform = DesktopPlatform() + +internal actual val isIos: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt new file mode 100644 index 000000000..c0dc1da44 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.core.auth + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object AuthStorage { + private val store = DesktopStorage.store("nuvio_auth") + + actual fun loadAnonymousUserId(): String? = + store.getString("anonymous_user_id") + + actual fun saveAnonymousUserId(userId: String) { + store.putString("anonymous_user_id", userId) + } + + actual fun clearAnonymousUserId() { + store.remove("anonymous_user_id") + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt new file mode 100644 index 000000000..ea0a4eebe --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/DesktopStorage.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.core.storage + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Comparator +import java.util.Locale +import java.util.Properties +import kotlin.io.path.exists + +internal object DesktopStorage { + private val json = Json { ignoreUnknownKeys = true } + private val stores = mutableMapOf() + + val rootDir: Path by lazy { + resolveAppDataDir().also { Files.createDirectories(it) } + } + + fun store(name: String): Store = synchronized(stores) { + stores.getOrPut(name) { Store(rootDir.resolve("$name.properties")) } + } + + fun wipe() { + synchronized(stores) { + stores.values.forEach(Store::clearInMemory) + stores.clear() + } + if (!rootDir.exists()) return + Files.walk(rootDir).use { stream -> + stream + .sorted(Comparator.reverseOrder()) + .filter { it != rootDir } + .forEach { path -> runCatching { Files.deleteIfExists(path) } } + } + } + + private fun resolveAppDataDir(): Path { + val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT) + val userHome = Paths.get(System.getProperty("user.home").orEmpty()) + return when { + osName.contains("mac") -> userHome.resolve("Library/Application Support/Nuvio") + osName.contains("win") -> { + val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() } + (appData?.let(Paths::get) ?: userHome.resolve("AppData/Roaming")).resolve("Nuvio") + } + else -> { + val xdgConfig = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() } + (xdgConfig?.let(Paths::get) ?: userHome.resolve(".config")).resolve("nuvio") + } + } + } + + internal class Store( + private val file: Path, + ) { + private val lock = Any() + private val properties = Properties() + private var loaded = false + + fun contains(key: String): Boolean = synchronized(lock) { + ensureLoaded() + properties.containsKey(key) + } + + fun getString(key: String): String? = synchronized(lock) { + ensureLoaded() + properties.getProperty(key) + } + + fun putString(key: String, value: String?) = synchronized(lock) { + ensureLoaded() + if (value == null) { + properties.remove(key) + } else { + properties.setProperty(key, value) + } + persist() + } + + fun getBoolean(key: String): Boolean? = + getString(key)?.toBooleanStrictOrNull() + + fun putBoolean(key: String, value: Boolean) { + putString(key, value.toString()) + } + + fun getInt(key: String): Int? = + getString(key)?.toIntOrNull() + + fun putInt(key: String, value: Int) { + putString(key, value.toString()) + } + + fun getFloat(key: String): Float? = + getString(key)?.toFloatOrNull() + + fun putFloat(key: String, value: Float) { + putString(key, value.toString()) + } + + fun getStringSet(key: String): Set? = + getString(key)?.let { payload -> + runCatching { json.decodeFromString>(payload).toSet() }.getOrNull() + } + + fun putStringSet(key: String, values: Set) { + putString(key, json.encodeToString(values.toList())) + } + + fun remove(key: String) = synchronized(lock) { + ensureLoaded() + properties.remove(key) + persist() + } + + fun removeAll(keys: Iterable) = synchronized(lock) { + ensureLoaded() + keys.forEach(properties::remove) + persist() + } + + fun clearInMemory() = synchronized(lock) { + properties.clear() + loaded = false + } + + private fun ensureLoaded() { + if (loaded) return + loaded = true + properties.clear() + if (!file.exists()) return + runCatching { + Files.newInputStream(file).use { input -> + properties.load(input) + } + } + } + + private fun persist() { + Files.createDirectories(file.parent) + Files.newOutputStream(file).use { output -> + properties.store(output, "Nuvio desktop preferences") + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt new file mode 100644 index 000000000..b7b0305d7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.storage + +internal actual object PlatformLocalAccountDataCleaner { + actual fun wipe() { + DesktopStorage.wipe() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt new file mode 100644 index 000000000..e0c469fc6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.core.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +internal actual object AppForegroundMonitor { + actual fun events(): Flow = emptyFlow() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt new file mode 100644 index 000000000..86ad57cdf --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.desktop.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.ic_player_aspect_ratio +import nuvio.composeapp.generated.resources.ic_player_audio_filled +import nuvio.composeapp.generated.resources.ic_player_pause +import nuvio.composeapp.generated.resources.ic_player_play +import nuvio.composeapp.generated.resources.ic_player_subtitles +import nuvio.composeapp.generated.resources.library_add_plus +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun appIconPainter(icon: AppIconResource): Painter = + painterResource( + when (icon) { + AppIconResource.PlayerPlay -> Res.drawable.ic_player_play + AppIconResource.PlayerPause -> Res.drawable.ic_player_pause + AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio + AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles + AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled + AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus + }, + ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt new file mode 100644 index 000000000..c7c556c50 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.core.ui + +internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false + +internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit + +internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit + +internal actual fun publishNativeSelectedTab(tabName: String) = Unit + +internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit + +internal actual fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt new file mode 100644 index 000000000..5fdd59604 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.desktop.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp +internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp +internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp + +@Composable +internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0.dp) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt new file mode 100644 index 000000000..6434bd49e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformBackHandler.desktop.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt new file mode 100644 index 000000000..d96114b5a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformExitApp.desktop.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.ui + +import kotlin.system.exitProcess + +actual fun platformExitApp() { + exitProcess(0) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt new file mode 100644 index 000000000..6734e5fd1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.core.ui + +import coil3.ImageLoader + +internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt new file mode 100644 index 000000000..687ee7986 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.core.ui + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object PosterCardStyleStorage { + private val store = DesktopStorage.store("nuvio_poster_card_style") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("poster_card_style")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("poster_card_style"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt new file mode 100644 index 000000000..bff3bf30f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt @@ -0,0 +1,108 @@ +package com.nuvio.app.features.addons + +import com.nuvio.app.core.storage.DesktopStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +internal actual object AddonStorage { + private val store = DesktopStorage.store("nuvio_addons") + private val json = Json { ignoreUnknownKeys = true } + + actual fun loadInstalledAddonUrls(profileId: Int): List = + store.getString("installed_addon_urls_$profileId") + ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } + ?: emptyList() + + actual fun saveInstalledAddonUrls(profileId: Int, urls: List) { + store.putString("installed_addon_urls_$profileId", json.encodeToString(urls)) + } + + actual fun loadAddonEnabledStates(profileId: Int): Map = + store.getString("addon_enabled_states_$profileId") + ?.let { payload -> runCatching { json.decodeFromString>(payload) }.getOrNull() } + ?: emptyMap() + + actual fun saveAddonEnabledStates(profileId: Int, states: Map) { + store.putString("addon_enabled_states_$profileId", json.encodeToString(states)) + } +} + +private val desktopHttpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + +actual suspend fun httpGetText(url: String): String = + httpGetTextWithHeaders(url, emptyMap()) + +actual suspend fun httpPostJson(url: String, body: String): String = + httpPostJsonWithHeaders(url, body, emptyMap()) + +actual suspend fun httpGetTextWithHeaders( + url: String, + headers: Map, +): String = + httpRequestRaw("GET", url, headers, body = "").body + +actual suspend fun httpPostJsonWithHeaders( + url: String, + body: String, + headers: Map, +): String = + httpRequestRaw( + method = "POST", + url = url, + headers = mapOf("Content-Type" to "application/json") + headers, + body = body, + ).body + +actual suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, + followRedirects: Boolean, +): RawHttpResponse = withContext(Dispatchers.IO) { + val client = if (followRedirects) { + desktopHttpClient + } else { + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + } + val normalizedMethod = method.trim().uppercase().ifBlank { "GET" } + val requestBuilder = HttpRequest.newBuilder() + .uri(URI(url)) + .timeout(Duration.ofSeconds(60)) + .method( + normalizedMethod, + if (normalizedMethod == "GET" || normalizedMethod == "HEAD") { + HttpRequest.BodyPublishers.noBody() + } else { + HttpRequest.BodyPublishers.ofString(body) + }, + ) + + headers.forEach { (key, value) -> + if (key.isNotBlank() && value.isNotBlank()) { + requestBuilder.header(key, value) + } + } + + val response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + RawHttpResponse( + status = response.statusCode(), + statusText = "HTTP ${response.statusCode()}", + url = response.uri().toString(), + body = response.body(), + headers = response.headers().map().mapValues { (_, values) -> values.joinToString(",") }, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt new file mode 100644 index 000000000..8598fa626 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object CollectionMobileSettingsStorage { + private val store = DesktopStorage.store("nuvio_collection_mobile_settings") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("collection_mobile_settings")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("collection_mobile_settings"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt new file mode 100644 index 000000000..3bd8bb6ce --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object CollectionStorage { + private val store = DesktopStorage.store("nuvio_collections") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("collections")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("collections"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt new file mode 100644 index 000000000..d47c69b42 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt @@ -0,0 +1,138 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object DebridSettingsStorage { + private const val enabledKey = "debrid_enabled" + private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled" + private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val store = DesktopStorage.store("nuvio_debrid_settings") + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) + actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) + actual fun saveCloudLibraryEnabled(enabled: Boolean) = saveBoolean(cloudLibraryEnabledKey, enabled) + actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) + actual fun savePreferredResolverProviderId(providerId: String) = saveString(preferredResolverProviderIdKey, providerId) + actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) + actual fun saveProviderApiKey(providerId: String, apiKey: String) = saveString(providerApiKeyKey(providerId), apiKey) + actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) + actual fun saveTorboxApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) + actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) + actual fun saveRealDebridApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey) + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + actual fun saveInstantPlaybackPreparationLimit(limit: Int) = saveInt(instantPlaybackPreparationLimitKey, limit) + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + actual fun saveStreamMaxResults(maxResults: Int) = saveInt(streamMaxResultsKey, maxResults) + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + actual fun saveStreamSortMode(mode: String) = saveString(streamSortModeKey, mode) + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + actual fun saveStreamMinimumQuality(quality: String) = saveString(streamMinimumQualityKey, quality) + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + actual fun saveStreamDolbyVisionFilter(filter: String) = saveString(streamDolbyVisionFilterKey, filter) + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + actual fun saveStreamHdrFilter(filter: String) = saveString(streamHdrFilterKey, filter) + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + actual fun saveStreamCodecFilter(filter: String) = saveString(streamCodecFilterKey, filter) + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + actual fun saveStreamPreferences(preferences: String) = saveString(streamPreferencesKey, preferences) + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + actual fun saveStreamNameTemplate(template: String) = saveString(streamNameTemplateKey, template) + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + actual fun saveStreamDescriptionTemplate(template: String) = saveString(streamDescriptionTemplateKey, template) + + private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) + private fun loadInt(key: String): Int? = store.getInt(ProfileScopedKey.of(key)) + private fun saveInt(key: String, value: Int) = store.putInt(ProfileScopedKey.of(key), value) + private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) + private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } + loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } + DebridProviders.all().forEach { provider -> + loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) } + } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(syncKeys().map(ProfileScopedKey::of)) + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) + payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) + DebridProviders.all().forEach { provider -> + payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { saveProviderApiKey(provider.id, it) } + } + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } + + private fun syncKeys(): List = + listOf( + enabledKey, + cloudLibraryEnabledKey, + preferredResolverProviderIdKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + DebridProviders.all().map { providerApiKeyKey(it.id) } + + private fun providerApiKeyKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return when (normalized) { + DebridProviders.TORBOX_ID -> torboxApiKeyKey + DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey + else -> "debrid_${normalized}_api_key" + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt new file mode 100644 index 000000000..5112956ab --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object MetaScreenSettingsStorage { + private val store = DesktopStorage.store("nuvio_meta_screen_settings") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("meta_screen_settings")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("meta_screen_settings"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt new file mode 100644 index 000000000..2c961b328 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/SeasonViewModeStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object SeasonViewModeStorage { + private val store = DesktopStorage.store("nuvio_season_view_mode") + + actual fun load(): SeasonViewMode? = + SeasonViewMode.parse(store.getString(ProfileScopedKey.of("season_view_mode"))) + + actual fun save(mode: SeasonViewMode) { + store.putString(ProfileScopedKey.of("season_view_mode"), SeasonViewMode.persist(mode)) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt new file mode 100644 index 000000000..85aeb5016 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/components/HeroTrailerPlayerSurface.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier + +@Composable +actual fun HeroTrailerPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + playWhenReady: Boolean, + muted: Boolean, + modifier: Modifier, + onReady: () -> Unit, + onEnded: () -> Unit, + onError: () -> Unit, +) { + LaunchedEffect(sourceUrl) { + onError() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt new file mode 100644 index 000000000..2a9a1c8e4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.downloads + +internal actual object DownloadsClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt new file mode 100644 index 000000000..5e0e33f5a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.downloads + +internal actual object DownloadsLiveStatusPlatform { + actual fun onItemsChanged(items: List) = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt new file mode 100644 index 000000000..12097a653 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.desktop.kt @@ -0,0 +1,193 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.core.storage.DesktopStorage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import kotlin.io.path.createDirectories + +private val desktopDownloadHttpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + +internal actual object DownloadsPlatformDownloader { + private val downloadsDir: File + get() = File(DesktopStorage.rootDir.resolve("downloads").also { it.createDirectories() }.toUri()) + + actual fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle { + val job = SupervisorJob() + val scope = CoroutineScope(job + Dispatchers.IO) + + scope.launch { + val destination = File(downloadsDir, request.destinationFileName) + val tempFile = File(downloadsDir, "${request.destinationFileName}.part") + + try { + var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L + var attemptedRangeRequest = resumeFromBytes > 0L + var response = sendDownloadRequest(request, if (attemptedRangeRequest) resumeFromBytes else null) + + if (attemptedRangeRequest && response.statusCode() == 416) { + tempFile.delete() + resumeFromBytes = 0L + attemptedRangeRequest = false + response = sendDownloadRequest(request, null) + } + + if (response.statusCode() !in 200..299) { + error("Download failed with HTTP ${response.statusCode()}") + } + + val isPartialResume = attemptedRangeRequest && response.statusCode() == 206 && resumeFromBytes > 0L + val appendToTemp = isPartialResume + val startingBytes = if (appendToTemp) resumeFromBytes else 0L + if (!appendToTemp && tempFile.exists()) { + tempFile.delete() + } + + val totalBytes = resolveTotalBytes( + startingBytes = startingBytes, + isPartialResume = isPartialResume, + contentRangeHeader = response.headers().firstValue("Content-Range").orElse(null), + contentLength = response.headers().firstValue("Content-Length").orElse(null)?.toLongOrNull(), + ) + var downloadedBytes = startingBytes + onProgress(downloadedBytes, totalBytes) + + response.body().use { input -> + FileOutputStream(tempFile, appendToTemp).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + ensureActive() + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + downloadedBytes += read.toLong() + onProgress(downloadedBytes, totalBytes) + } + output.flush() + } + } + + if (destination.exists()) { + destination.delete() + } + if (!tempFile.renameTo(destination)) { + tempFile.copyTo(destination, overwrite = true) + tempFile.delete() + } + + val finalSize = destination.length() + onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onFailure(error.message ?: "Download failed") + } + } + + return DesktopDownloadsTaskHandle(job) + } + + actual fun removeFile(localFileUri: String?): Boolean { + if (localFileUri.isNullOrBlank()) return false + val file = localFileUri.toLocalFileOrNull() ?: return false + return runCatching { file.delete() }.getOrDefault(false) + } + + actual fun removePartialFile(destinationFileName: String): Boolean { + val tempFile = File(downloadsDir, "$destinationFileName.part") + if (!tempFile.exists()) return true + return runCatching { tempFile.delete() }.getOrDefault(false) + } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri + ?.toLocalFileOrNull() + ?.takeIf { it.exists() } + ?.let { return it.toURI().toString() } + + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri?.toLocalFileOrNull()?.name?.takeIf { it.isNotBlank() } + ?: return null + return File(downloadsDir, fileName).takeIf { it.exists() }?.toURI()?.toString() + } + + private fun sendDownloadRequest( + request: DownloadPlatformRequest, + rangeStart: Long?, + ): HttpResponse { + val builder = HttpRequest.newBuilder() + .uri(URI(request.sourceUrl)) + .timeout(Duration.ofSeconds(60)) + .GET() + request.sourceHeaders.forEach { (key, value) -> + if (key.isNotBlank() && value.isNotBlank()) { + builder.header(key, value) + } + } + if (rangeStart != null && rangeStart > 0L) { + builder.header("Range", "bytes=$rangeStart-") + } + return desktopDownloadHttpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()) + } +} + +private class DesktopDownloadsTaskHandle( + private val job: Job, +) : DownloadsTaskHandle { + override fun cancel() { + job.cancel() + } +} + +private fun String.toLocalFileOrNull(): File? = + runCatching { + if (startsWith("file:")) { + File(URI(this)) + } else { + File(this) + } + }.getOrNull() + +private fun resolveTotalBytes( + startingBytes: Long, + isPartialResume: Boolean, + contentRangeHeader: String?, + contentLength: Long?, +): Long? { + parseContentRangeTotal(contentRangeHeader)?.let { return it } + val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null + return if (isPartialResume && startingBytes > 0L) { + startingBytes + normalizedLength + } else { + normalizedLength + } +} + +private fun parseContentRangeTotal(headerValue: String?): Long? { + val value = headerValue?.trim().orEmpty() + if (value.isBlank()) return null + val slashIndex = value.lastIndexOf('/') + if (slashIndex == -1 || slashIndex == value.lastIndex) return null + val totalPart = value.substring(slashIndex + 1).trim() + if (totalPart == "*") return null + return totalPart.toLongOrNull()?.takeIf { it > 0L } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt new file mode 100644 index 000000000..04ac0d9e8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object DownloadsStorage { + private val store = DesktopStorage.store("nuvio_downloads") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("downloads")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("downloads"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt new file mode 100644 index 000000000..2972496e5 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object HomeCatalogSettingsStorage { + private val store = DesktopStorage.store("nuvio_home_catalog_settings") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("home_catalog_settings")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("home_catalog_settings"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt new file mode 100644 index 000000000..1a6791919 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest + +@Composable +internal actual fun CollectionCardRemoteImage( + imageUrl: String, + contentDescription: String, + modifier: Modifier, + contentScale: ContentScale, + animateIfPossible: Boolean, +) { + val context = LocalPlatformContext.current + val request = remember(context, imageUrl) { + ImageRequest.Builder(context) + .data(imageUrl) + .memoryCacheKey("home-collection:$imageUrl") + .diskCacheKey(imageUrl) + .build() + } + + AsyncImage( + model = request, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt new file mode 100644 index 000000000..4869d713b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryClock.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.library + +internal actual object LibraryClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt new file mode 100644 index 000000000..659d58156 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryStorage.desktop.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object LibraryStorage { + private val store = DesktopStorage.store("nuvio_library") + + actual fun loadPayload(profileId: Int): String? = + store.getString("library_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + store.putString("library_$profileId", payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt new file mode 100644 index 000000000..0d95cfeb4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt @@ -0,0 +1,84 @@ +package com.nuvio.app.features.mdblist + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object MdbListSettingsStorage { + private const val enabledKey = "mdblist_enabled" + private const val apiKey = "mdblist_api_key" + private const val useImdbKey = "mdblist_use_imdb" + private const val useTmdbKey = "mdblist_use_tmdb" + private const val useTomatoesKey = "mdblist_use_tomatoes" + private const val useMetacriticKey = "mdblist_use_metacritic" + private const val useTraktKey = "mdblist_use_trakt" + private const val useLetterboxdKey = "mdblist_use_letterboxd" + private const val useAudienceKey = "mdblist_use_audience" + private val syncKeys = listOf( + enabledKey, + apiKey, + useImdbKey, + useTmdbKey, + useTomatoesKey, + useMetacriticKey, + useTraktKey, + useLetterboxdKey, + useAudienceKey, + ) + private val store = DesktopStorage.store("nuvio_mdblist_settings") + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) + actual fun loadApiKey(): String? = loadString(apiKey) + actual fun saveApiKey(apiKey: String) = saveString(this.apiKey, apiKey) + actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) + actual fun saveUseImdb(enabled: Boolean) = saveBoolean(useImdbKey, enabled) + actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) + actual fun saveUseTmdb(enabled: Boolean) = saveBoolean(useTmdbKey, enabled) + actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) + actual fun saveUseTomatoes(enabled: Boolean) = saveBoolean(useTomatoesKey, enabled) + actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) + actual fun saveUseMetacritic(enabled: Boolean) = saveBoolean(useMetacriticKey, enabled) + actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) + actual fun saveUseTrakt(enabled: Boolean) = saveBoolean(useTraktKey, enabled) + actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) + actual fun saveUseLetterboxd(enabled: Boolean) = saveBoolean(useLetterboxdKey, enabled) + actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) + actual fun saveUseAudience(enabled: Boolean) = saveBoolean(useAudienceKey, enabled) + + private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) + private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) + private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadApiKey()?.let { put(apiKey, encodeSyncString(it)) } + loadUseImdb()?.let { put(useImdbKey, encodeSyncBoolean(it)) } + loadUseTmdb()?.let { put(useTmdbKey, encodeSyncBoolean(it)) } + loadUseTomatoes()?.let { put(useTomatoesKey, encodeSyncBoolean(it)) } + loadUseMetacritic()?.let { put(useMetacriticKey, encodeSyncBoolean(it)) } + loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } + loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } + loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(syncKeys.map(ProfileScopedKey::of)) + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(apiKey)?.let(::saveApiKey) + payload.decodeSyncBoolean(useImdbKey)?.let(::saveUseImdb) + payload.decodeSyncBoolean(useTmdbKey)?.let(::saveUseTmdb) + payload.decodeSyncBoolean(useTomatoesKey)?.let(::saveUseTomatoes) + payload.decodeSyncBoolean(useMetacriticKey)?.let(::saveUseMetacritic) + payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) + payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) + payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt new file mode 100644 index 000000000..6d4647381 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.notifications + +internal actual object EpisodeReleaseNotificationPlatform { + actual suspend fun notificationsAuthorized(): Boolean = false + + actual suspend fun requestAuthorization(): Boolean = false + + actual suspend fun scheduleEpisodeReleaseNotifications( + requests: List, + ) = Unit + + actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit + + actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt new file mode 100644 index 000000000..3ee8ee44b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.notifications + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal actual object EpisodeReleaseNotificationsClock { + private val formatter = DateTimeFormatter.ISO_LOCAL_DATE + + actual fun isoDateFromEpochMs(epochMs: Long): String = + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt new file mode 100644 index 000000000..473aee283 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.notifications + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object EpisodeReleaseNotificationsStorage { + private val store = DesktopStorage.store("nuvio_episode_release_notifications") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("episode_release_notifications")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("episode_release_notifications"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt new file mode 100644 index 000000000..e19e8912f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.p2p + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object P2pSettingsStorage { + private const val p2pEnabledKey = "p2p_enabled" + private const val enableUploadKey = "enable_upload" + private const val hideTorrentStatsKey = "hide_torrent_stats" + private val store = DesktopStorage.store("torrent_settings") + + actual fun loadP2pEnabled(): Boolean? = loadBoolean(p2pEnabledKey) + actual fun saveP2pEnabled(enabled: Boolean) = saveBoolean(p2pEnabledKey, enabled) + actual fun loadEnableUpload(): Boolean? = loadBoolean(enableUploadKey) + actual fun saveEnableUpload(enabled: Boolean) = saveBoolean(enableUploadKey, enabled) + actual fun loadHideTorrentStats(): Boolean? = loadBoolean(hideTorrentStatsKey) + actual fun saveHideTorrentStats(enabled: Boolean) = saveBoolean(hideTorrentStatsKey, enabled) + + private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt new file mode 100644 index 000000000..87bed9f18 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.desktop.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.p2p + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +actual object P2pStreamingEngine { + private val _state = MutableStateFlow(P2pStreamingState.Idle) + + actual val state: StateFlow = _state.asStateFlow() + + actual fun warmup() = Unit + + actual fun cooldownWarmup() = Unit + + actual suspend fun startStream(request: P2pStreamRequest): String { + _state.value = P2pStreamingState.Error("P2P streaming is not available on desktop yet.") + throw P2pStreamingException("P2P streaming is not available on desktop yet.") + } + + actual fun stopStream() { + _state.value = P2pStreamingState.Idle + } + + actual fun shutdown() { + _state.value = P2pStreamingState.Idle + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt new file mode 100644 index 000000000..280256c7f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLauncherEffect.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberExternalPlayerLauncher( + onResult: (ExternalPlaybackResult?) -> Unit, +): (ExternalPlayerIntentResult.Success) -> Boolean = + { intentResult -> + val launched = ExternalPlayerPlatform.launch(intentResult.intent) + if (launched) { + onResult(null) + } + launched + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt new file mode 100644 index 000000000..6a9a820f7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt @@ -0,0 +1,68 @@ +package com.nuvio.app.features.player + +import java.awt.Desktop +import java.io.File +import java.net.URI + +private data class DesktopExternalPlayerIntent( + val request: ExternalPlayerPlaybackRequest, + val playerId: String?, +) + +internal actual object ExternalPlayerPlatform { + private const val systemPlayerId = "system" + + actual fun defaultPlayerId(): String? = systemPlayerId + + actual fun availablePlayers(): List = + listOf(ExternalPlayerApp(systemPlayerId, "System default")) + + actual fun open( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerOpenResult = + if (openUri(request.sourceUrl)) { + ExternalPlayerOpenResult.Opened + } else { + ExternalPlayerOpenResult.Failed + } + + actual fun buildIntent( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerIntentResult = + ExternalPlayerIntentResult.Success(DesktopExternalPlayerIntent(request, playerId)) + + internal fun launch(intent: Any): Boolean { + val desktopIntent = intent as? DesktopExternalPlayerIntent ?: return false + return open(desktopIntent.request, desktopIntent.playerId) == ExternalPlayerOpenResult.Opened + } + + private fun openUri(rawUri: String): Boolean { + val uri = runCatching { URI(rawUri) }.getOrNull() ?: return false + val desktop = runCatching { Desktop.getDesktop() }.getOrNull() + + if (desktop != null && Desktop.isDesktopSupported()) { + val opened = runCatching { + if (uri.scheme.equals("file", ignoreCase = true)) { + desktop.open(File(uri)) + } else { + desktop.browse(uri) + } + }.isSuccess + if (opened) return true + } + + return openWithPlatformCommand(rawUri) + } + + private fun openWithPlatformCommand(rawUri: String): Boolean { + val osName = System.getProperty("os.name").orEmpty().lowercase() + val command = when { + osName.contains("mac") -> listOf("open", rawUri) + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", rawUri) + else -> listOf("xdg-open", rawUri) + } + return runCatching { ProcessBuilder(command).start() }.isSuccess + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt new file mode 100644 index 000000000..7a6ed4806 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +actual fun PlatformPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + sourceResponseHeaders: Map, + useYoutubeChunkedPlayback: Boolean, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + useNativeController: Boolean, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val controller = remember { DesktopStubPlayerController() } + + LaunchedEffect(controller) { + onControllerReady(controller) + onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Desktop in-app playback is not available yet.", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +private class DesktopStubPlayerController : PlayerEngineController { + override fun play() = Unit + override fun pause() = Unit + override fun seekTo(positionMs: Long) = Unit + override fun seekBy(offsetMs: Long) = Unit + override fun retry() = Unit + override fun setPlaybackSpeed(speed: Float) = Unit + override fun getAudioTracks(): List = emptyList() + override fun getSubtitleTracks(): List = emptyList() + override fun selectAudioTrack(index: Int) = Unit + override fun selectSubtitleTrack(index: Int) = Unit + override fun setSubtitleUri(url: String) = Unit + override fun clearExternalSubtitle() = Unit + override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt new file mode 100644 index 000000000..3dd1535b9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.desktop.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.player + +import java.util.Locale + +internal actual object DeviceLanguagePreferences { + actual fun preferredLanguageCodes(): List = + Locale.getDefault() + .toLanguageTag() + .takeIf { it.isNotBlank() } + ?.let(::listOf) + ?: emptyList() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt new file mode 100644 index 000000000..91ad9cef4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntSize + +@Composable +actual fun LockPlayerToLandscape() = Unit + +@Composable +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) = Unit + +@Composable +actual fun ManagePlayerPictureInPicture( + isPlaying: Boolean, + playerSize: IntSize, +) = Unit + +@Composable +actual fun rememberPlayerGestureController(): PlayerGestureController? = null diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt new file mode 100644 index 000000000..1498a9592 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt @@ -0,0 +1,406 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncFloat +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.decodeSyncStringSet +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncFloat +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.core.sync.encodeSyncStringSet +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object PlayerSettingsStorage { + private const val showLoadingOverlayKey = "show_loading_overlay" + private const val resizeModeKey = "resize_mode" + private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" + private const val holdToSpeedValueKey = "hold_to_speed_value" + private const val externalPlayerEnabledKey = "external_player_enabled" + private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" + private const val externalPlayerIdKey = "external_player_id" + private const val preferredAudioLanguageKey = "preferred_audio_language" + private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" + private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" + private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" + private const val subtitleTextColorKey = "subtitle_text_color" + private const val subtitleBackgroundColorKey = "subtitle_background_color" + private const val subtitleOutlineColorKey = "subtitle_outline_color" + private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled" + private const val subtitleOutlineWidthKey = "subtitle_outline_width" + private const val subtitleBoldKey = "subtitle_bold" + private const val subtitleFontSizeSpKey = "subtitle_font_size_sp" + private const val subtitleBottomOffsetKey = "subtitle_bottom_offset" + private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles" + private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages" + private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode" + private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" + private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" + private const val decoderPriorityKey = "decoder_priority" + private const val mapDV7ToHevcKey = "map_dv7_to_hevc" + private const val tunnelingEnabledKey = "tunneling_enabled" + private const val streamAutoPlayModeKey = "stream_auto_play_mode" + private const val streamAutoPlaySourceKey = "stream_auto_play_source" + private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" + private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" + private const val streamAutoPlayRegexKey = "stream_auto_play_regex" + private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" + private const val skipIntroEnabledKey = "skip_intro_enabled" + private const val animeSkipEnabledKey = "animeskip_enabled" + private const val animeSkipClientIdKey = "animeskip_client_id" + private const val introDbApiKeyKey = "introdb_api_key" + private const val introSubmitEnabledKey = "intro_submit_enabled" + private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" + private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" + private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" + private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" + private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" + private const val useLibassKey = "use_libass" + private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosAudioOutputModeKey = "ios_audio_output_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" + private val syncKeys = listOf( + showLoadingOverlayKey, + resizeModeKey, + holdToSpeedEnabledKey, + holdToSpeedValueKey, + externalPlayerEnabledKey, + externalPlayerForwardSubtitlesKey, + externalPlayerIdKey, + preferredAudioLanguageKey, + secondaryPreferredAudioLanguageKey, + preferredSubtitleLanguageKey, + secondaryPreferredSubtitleLanguageKey, + subtitleTextColorKey, + subtitleBackgroundColorKey, + subtitleOutlineColorKey, + subtitleOutlineEnabledKey, + subtitleOutlineWidthKey, + subtitleBoldKey, + subtitleFontSizeSpKey, + subtitleBottomOffsetKey, + subtitleUseForcedSubtitlesKey, + subtitleShowOnlyPreferredLanguagesKey, + addonSubtitleStartupModeKey, + streamReuseLastLinkEnabledKey, + streamReuseLastLinkCacheHoursKey, + decoderPriorityKey, + mapDV7ToHevcKey, + tunnelingEnabledKey, + streamAutoPlayModeKey, + streamAutoPlaySourceKey, + streamAutoPlaySelectedAddonsKey, + streamAutoPlaySelectedPluginsKey, + streamAutoPlayRegexKey, + streamAutoPlayTimeoutSecondsKey, + skipIntroEnabledKey, + animeSkipEnabledKey, + animeSkipClientIdKey, + streamAutoPlayNextEpisodeEnabledKey, + streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, + nextEpisodeThresholdModeKey, + nextEpisodeThresholdPercentKey, + nextEpisodeThresholdMinutesBeforeEndKey, + useLibassKey, + libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosAudioOutputModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, + ) + private val store = DesktopStorage.store("nuvio_player_settings") + + actual fun loadShowLoadingOverlay(): Boolean? = loadBoolean(showLoadingOverlayKey) + actual fun saveShowLoadingOverlay(enabled: Boolean) = saveBoolean(showLoadingOverlayKey, enabled) + actual fun loadResizeMode(): String? = loadString(resizeModeKey) + actual fun saveResizeMode(mode: String) = saveString(resizeModeKey, mode) + actual fun loadHoldToSpeedEnabled(): Boolean? = loadBoolean(holdToSpeedEnabledKey) + actual fun saveHoldToSpeedEnabled(enabled: Boolean) = saveBoolean(holdToSpeedEnabledKey, enabled) + actual fun loadHoldToSpeedValue(): Float? = loadFloat(holdToSpeedValueKey) + actual fun saveHoldToSpeedValue(speed: Float) = saveFloat(holdToSpeedValueKey, speed) + actual fun loadExternalPlayerEnabled(): Boolean? = loadBoolean(externalPlayerEnabledKey) + actual fun saveExternalPlayerEnabled(enabled: Boolean) = saveBoolean(externalPlayerEnabledKey, enabled) + actual fun loadExternalPlayerForwardSubtitles(): Boolean? = loadBoolean(externalPlayerForwardSubtitlesKey) + actual fun saveExternalPlayerForwardSubtitles(enabled: Boolean) = saveBoolean(externalPlayerForwardSubtitlesKey, enabled) + actual fun loadExternalPlayerId(): String? = loadString(externalPlayerIdKey) + actual fun saveExternalPlayerId(playerId: String?) = saveOptionalString(externalPlayerIdKey, playerId) + actual fun loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey) + actual fun savePreferredAudioLanguage(language: String) = saveString(preferredAudioLanguageKey, language) + actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey) + actual fun saveSecondaryPreferredAudioLanguage(language: String?) = saveOptionalString(secondaryPreferredAudioLanguageKey, language) + actual fun loadPreferredSubtitleLanguage(): String? = loadString(preferredSubtitleLanguageKey) + actual fun savePreferredSubtitleLanguage(language: String) = saveString(preferredSubtitleLanguageKey, language) + actual fun loadSecondaryPreferredSubtitleLanguage(): String? = loadString(secondaryPreferredSubtitleLanguageKey) + actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) = saveOptionalString(secondaryPreferredSubtitleLanguageKey, language) + actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey) + actual fun saveSubtitleTextColor(colorHex: String) = saveString(subtitleTextColorKey, colorHex) + actual fun loadSubtitleBackgroundColor(): String? = loadString(subtitleBackgroundColorKey) + actual fun saveSubtitleBackgroundColor(colorHex: String) = saveString(subtitleBackgroundColorKey, colorHex) + actual fun loadSubtitleOutlineColor(): String? = loadString(subtitleOutlineColorKey) + actual fun saveSubtitleOutlineColor(colorHex: String) = saveString(subtitleOutlineColorKey, colorHex) + actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey) + actual fun saveSubtitleOutlineEnabled(enabled: Boolean) = saveBoolean(subtitleOutlineEnabledKey, enabled) + actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey) + actual fun saveSubtitleOutlineWidth(width: Int) = saveInt(subtitleOutlineWidthKey, width) + actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey) + actual fun saveSubtitleBold(enabled: Boolean) = saveBoolean(subtitleBoldKey, enabled) + actual fun loadSubtitleFontSizeSp(): Int? = loadInt(subtitleFontSizeSpKey) + actual fun saveSubtitleFontSizeSp(fontSizeSp: Int) = saveInt(subtitleFontSizeSpKey, fontSizeSp) + actual fun loadSubtitleBottomOffset(): Int? = loadInt(subtitleBottomOffsetKey) + actual fun saveSubtitleBottomOffset(bottomOffset: Int) = saveInt(subtitleBottomOffsetKey, bottomOffset) + actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey) + actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) = saveBoolean(subtitleUseForcedSubtitlesKey, enabled) + actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey) + actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) = saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled) + actual fun loadAddonSubtitleStartupMode(): String? = loadString(addonSubtitleStartupModeKey) + actual fun saveAddonSubtitleStartupMode(mode: String) = saveString(addonSubtitleStartupModeKey, mode) + actual fun loadStreamReuseLastLinkEnabled(): Boolean? = loadBoolean(streamReuseLastLinkEnabledKey) + actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) = saveBoolean(streamReuseLastLinkEnabledKey, enabled) + actual fun loadStreamReuseLastLinkCacheHours(): Int? = loadInt(streamReuseLastLinkCacheHoursKey) + actual fun saveStreamReuseLastLinkCacheHours(hours: Int) = saveInt(streamReuseLastLinkCacheHoursKey, hours) + actual fun loadDecoderPriority(): Int? = loadInt(decoderPriorityKey) + actual fun saveDecoderPriority(priority: Int) = saveInt(decoderPriorityKey, priority) + actual fun loadMapDV7ToHevc(): Boolean? = loadBoolean(mapDV7ToHevcKey) + actual fun saveMapDV7ToHevc(enabled: Boolean) = saveBoolean(mapDV7ToHevcKey, enabled) + actual fun loadTunnelingEnabled(): Boolean? = loadBoolean(tunnelingEnabledKey) + actual fun saveTunnelingEnabled(enabled: Boolean) = saveBoolean(tunnelingEnabledKey, enabled) + actual fun loadStreamAutoPlayMode(): String? = loadString(streamAutoPlayModeKey) + actual fun saveStreamAutoPlayMode(mode: String) = saveString(streamAutoPlayModeKey, mode) + actual fun loadStreamAutoPlaySource(): String? = loadString(streamAutoPlaySourceKey) + actual fun saveStreamAutoPlaySource(source: String) = saveString(streamAutoPlaySourceKey, source) + actual fun loadStreamAutoPlaySelectedAddons(): Set? = loadStringSet(streamAutoPlaySelectedAddonsKey) + actual fun saveStreamAutoPlaySelectedAddons(addons: Set) = saveStringSet(streamAutoPlaySelectedAddonsKey, addons) + actual fun loadStreamAutoPlaySelectedPlugins(): Set? = loadStringSet(streamAutoPlaySelectedPluginsKey) + actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) = saveStringSet(streamAutoPlaySelectedPluginsKey, plugins) + actual fun loadStreamAutoPlayRegex(): String? = loadString(streamAutoPlayRegexKey) + actual fun saveStreamAutoPlayRegex(regex: String) = saveString(streamAutoPlayRegexKey, regex) + actual fun loadStreamAutoPlayTimeoutSeconds(): Int? = loadInt(streamAutoPlayTimeoutSecondsKey) + actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) = saveInt(streamAutoPlayTimeoutSecondsKey, seconds) + actual fun loadSkipIntroEnabled(): Boolean? = loadBoolean(skipIntroEnabledKey) + actual fun saveSkipIntroEnabled(enabled: Boolean) = saveBoolean(skipIntroEnabledKey, enabled) + actual fun loadAnimeSkipEnabled(): Boolean? = loadBoolean(animeSkipEnabledKey) + actual fun saveAnimeSkipEnabled(enabled: Boolean) = saveBoolean(animeSkipEnabledKey, enabled) + actual fun loadAnimeSkipClientId(): String? = loadString(animeSkipClientIdKey) + actual fun saveAnimeSkipClientId(clientId: String) = saveString(animeSkipClientIdKey, clientId) + actual fun loadIntroDbApiKey(): String? = loadString(introDbApiKeyKey) + actual fun saveIntroDbApiKey(apiKey: String) = saveString(introDbApiKeyKey, apiKey) + actual fun loadIntroSubmitEnabled(): Boolean? = loadBoolean(introSubmitEnabledKey) + actual fun saveIntroSubmitEnabled(enabled: Boolean) = saveBoolean(introSubmitEnabledKey, enabled) + actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? = loadBoolean(streamAutoPlayNextEpisodeEnabledKey) + actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) = saveBoolean(streamAutoPlayNextEpisodeEnabledKey, enabled) + actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? = loadBoolean(streamAutoPlayPreferBingeGroupKey) + actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayPreferBingeGroupKey, enabled) + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = loadBoolean(streamAutoPlayReuseBingeGroupKey) + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) = saveBoolean(streamAutoPlayReuseBingeGroupKey, enabled) + actual fun loadNextEpisodeThresholdMode(): String? = loadString(nextEpisodeThresholdModeKey) + actual fun saveNextEpisodeThresholdMode(mode: String) = saveString(nextEpisodeThresholdModeKey, mode) + actual fun loadNextEpisodeThresholdPercent(): Float? = loadFloat(nextEpisodeThresholdPercentKey) + actual fun saveNextEpisodeThresholdPercent(percent: Float) = saveFloat(nextEpisodeThresholdPercentKey, percent) + actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? = loadFloat(nextEpisodeThresholdMinutesBeforeEndKey) + actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) = saveFloat(nextEpisodeThresholdMinutesBeforeEndKey, minutes) + actual fun loadUseLibass(): Boolean? = loadBoolean(useLibassKey) + actual fun saveUseLibass(enabled: Boolean) = saveBoolean(useLibassKey, enabled) + actual fun loadLibassRenderType(): String? = loadString(libassRenderTypeKey) + actual fun saveLibassRenderType(renderType: String) = saveString(libassRenderTypeKey, renderType) + actual fun loadIosVideoOutputPreset(): String? = loadString(iosVideoOutputPresetKey) + actual fun saveIosVideoOutputPreset(preset: String) = saveString(iosVideoOutputPresetKey, preset) + actual fun loadIosToneMappingMode(): String? = loadString(iosToneMappingModeKey) + actual fun saveIosToneMappingMode(mode: String) = saveString(iosToneMappingModeKey, mode) + actual fun loadIosTargetPrimaries(): String? = loadString(iosTargetPrimariesKey) + actual fun saveIosTargetPrimaries(primaries: String) = saveString(iosTargetPrimariesKey, primaries) + actual fun loadIosTargetTransfer(): String? = loadString(iosTargetTransferKey) + actual fun saveIosTargetTransfer(transfer: String) = saveString(iosTargetTransferKey, transfer) + actual fun loadIosHardwareDecoderMode(): String? = loadString(iosHardwareDecoderModeKey) + actual fun saveIosHardwareDecoderMode(mode: String) = saveString(iosHardwareDecoderModeKey, mode) + actual fun loadIosAudioOutputMode(): String? = loadString(iosAudioOutputModeKey) + actual fun saveIosAudioOutputMode(mode: String) = saveString(iosAudioOutputModeKey, mode) + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = loadBoolean(iosExtendedDynamicRangeEnabledKey) + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) = saveBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = loadBoolean(iosTargetColorspaceHintEnabledKey) + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) = saveBoolean(iosTargetColorspaceHintEnabledKey, enabled) + actual fun loadIosHdrComputePeakEnabled(): Boolean? = loadBoolean(iosHdrComputePeakEnabledKey) + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) = saveBoolean(iosHdrComputePeakEnabledKey, enabled) + actual fun loadIosDebandEnabled(): Boolean? = loadBoolean(iosDebandEnabledKey) + actual fun saveIosDebandEnabled(enabled: Boolean) = saveBoolean(iosDebandEnabledKey, enabled) + actual fun loadIosInterpolationEnabled(): Boolean? = loadBoolean(iosInterpolationEnabledKey) + actual fun saveIosInterpolationEnabled(enabled: Boolean) = saveBoolean(iosInterpolationEnabledKey, enabled) + actual fun loadIosBrightness(): Int? = loadInt(iosBrightnessKey) + actual fun saveIosBrightness(value: Int) = saveInt(iosBrightnessKey, value) + actual fun loadIosContrast(): Int? = loadInt(iosContrastKey) + actual fun saveIosContrast(value: Int) = saveInt(iosContrastKey, value) + actual fun loadIosSaturation(): Int? = loadInt(iosSaturationKey) + actual fun saveIosSaturation(value: Int) = saveInt(iosSaturationKey, value) + actual fun loadIosGamma(): Int? = loadInt(iosGammaKey) + actual fun saveIosGamma(value: Int) = saveInt(iosGammaKey, value) + + private fun scoped(key: String): String = ProfileScopedKey.of(key) + private fun loadString(key: String): String? = store.getString(scoped(key)) + private fun saveString(key: String, value: String) = store.putString(scoped(key), value) + private fun saveOptionalString(key: String, value: String?) = store.putString(scoped(key), value?.takeIf { it.isNotBlank() }) + private fun loadBoolean(key: String): Boolean? = store.getBoolean(scoped(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(scoped(key), value) + private fun loadInt(key: String): Int? = store.getInt(scoped(key)) + private fun saveInt(key: String, value: Int) = store.putInt(scoped(key), value) + private fun loadFloat(key: String): Float? = store.getFloat(scoped(key)) + private fun saveFloat(key: String, value: Float) = store.putFloat(scoped(key), value) + private fun loadStringSet(key: String): Set? = store.getStringSet(scoped(key)) + private fun saveStringSet(key: String, values: Set) = store.putStringSet(scoped(key), values) + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } + loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } + loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } + loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } + loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } + loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } + loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } + loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } + loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } + loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) } + loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) } + loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) } + loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) } + loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) } + loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) } + loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) } + loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) } + loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) } + loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) } + loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) } + loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) } + loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } + loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } + loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } + loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } + loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayMode()?.let { put(streamAutoPlayModeKey, encodeSyncString(it)) } + loadStreamAutoPlaySource()?.let { put(streamAutoPlaySourceKey, encodeSyncString(it)) } + loadStreamAutoPlaySelectedAddons()?.let { put(streamAutoPlaySelectedAddonsKey, encodeSyncStringSet(it)) } + loadStreamAutoPlaySelectedPlugins()?.let { put(streamAutoPlaySelectedPluginsKey, encodeSyncStringSet(it)) } + loadStreamAutoPlayRegex()?.let { put(streamAutoPlayRegexKey, encodeSyncString(it)) } + loadStreamAutoPlayTimeoutSeconds()?.let { put(streamAutoPlayTimeoutSecondsKey, encodeSyncInt(it)) } + loadSkipIntroEnabled()?.let { put(skipIntroEnabledKey, encodeSyncBoolean(it)) } + loadAnimeSkipEnabled()?.let { put(animeSkipEnabledKey, encodeSyncBoolean(it)) } + loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } + loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } + loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } + loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } + loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } + loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } + loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(syncKeys.map(::scoped)) + payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) + payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) + payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) + payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) + payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) + payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) + payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) + payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) + payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) + payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage) + payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor) + payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor) + payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor) + payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled) + payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth) + payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold) + payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp) + payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset) + payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles) + payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages) + payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode) + payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) + payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) + payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) + payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) + payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) + payload.decodeSyncString(streamAutoPlayModeKey)?.let(::saveStreamAutoPlayMode) + payload.decodeSyncString(streamAutoPlaySourceKey)?.let(::saveStreamAutoPlaySource) + payload.decodeSyncStringSet(streamAutoPlaySelectedAddonsKey)?.let(::saveStreamAutoPlaySelectedAddons) + payload.decodeSyncStringSet(streamAutoPlaySelectedPluginsKey)?.let(::saveStreamAutoPlaySelectedPlugins) + payload.decodeSyncString(streamAutoPlayRegexKey)?.let(::saveStreamAutoPlayRegex) + payload.decodeSyncInt(streamAutoPlayTimeoutSecondsKey)?.let(::saveStreamAutoPlayTimeoutSeconds) + payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) + payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) + payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) + payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) + payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) + payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) + payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) + payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) + payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) + payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) + payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) + payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt new file mode 100644 index 000000000..d35d9894c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.desktop.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object PlayerTrackPreferenceStorage { + private const val subtitleTypeKey = "subtitle_type" + private const val subtitleLanguageKey = "subtitle_language" + private const val subtitleNameKey = "subtitle_name" + private const val subtitleTrackIdKey = "subtitle_track_id" + private const val addonSubtitleIdKey = "addon_subtitle_id" + private const val addonSubtitleUrlKey = "addon_subtitle_url" + private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name" + private const val audioLanguageKey = "audio_language" + private const val audioNameKey = "audio_name" + private const val audioTrackIdKey = "audio_track_id" + private const val subtitleDelayMsKey = "subtitle_delay_ms" + private val store = DesktopStorage.store("nuvio_player_track_preferences") + + actual fun load(contentId: String): PersistedPlayerTrackPreference? { + val id = contentId.normalizedStorageId() ?: return null + val preference = PersistedPlayerTrackPreference( + subtitleType = loadString(subtitleTypeKey, id), + subtitleLanguage = loadString(subtitleLanguageKey, id), + subtitleName = loadString(subtitleNameKey, id), + subtitleTrackId = loadString(subtitleTrackIdKey, id), + addonSubtitleId = loadString(addonSubtitleIdKey, id), + addonSubtitleUrl = loadString(addonSubtitleUrlKey, id), + addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id), + audioLanguage = loadString(audioLanguageKey, id), + audioName = loadString(audioNameKey, id), + audioTrackId = loadString(audioTrackIdKey, id), + ) + return preference.takeIf { + listOf( + it.subtitleType, + it.subtitleLanguage, + it.subtitleName, + it.subtitleTrackId, + it.addonSubtitleId, + it.addonSubtitleUrl, + it.addonSubtitleAddonName, + it.audioLanguage, + it.audioName, + it.audioTrackId, + ).any { value -> !value.isNullOrBlank() } + } + } + + actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) { + val id = contentId.normalizedStorageId() ?: return + putOptionalString(subtitleTypeKey, id, preference.subtitleType) + putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage) + putOptionalString(subtitleNameKey, id, preference.subtitleName) + putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId) + putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId) + putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl) + putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName) + putOptionalString(audioLanguageKey, id, preference.audioLanguage) + putOptionalString(audioNameKey, id, preference.audioName) + putOptionalString(audioTrackIdKey, id, preference.audioTrackId) + } + + actual fun loadSubtitleDelayMs(videoId: String): Int? { + val id = videoId.normalizedStorageId() ?: return null + return store.getInt(scopedKey(subtitleDelayMsKey, id)) + } + + actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) { + val id = videoId.normalizedStorageId() ?: return + store.putInt( + scopedKey(subtitleDelayMsKey, id), + delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS), + ) + } + + private fun loadString(field: String, contentId: String): String? = + store.getString(scopedKey(field, contentId))?.takeIf { it.isNotBlank() } + + private fun putOptionalString(field: String, contentId: String, value: String?) { + val key = scopedKey(field, contentId) + if (value.isNullOrBlank()) { + store.remove(key) + } else { + store.putString(key, value) + } + } + + private fun scopedKey(field: String, contentId: String): String = + ProfileScopedKey.of("$field|$contentId") + + private fun String.normalizedStorageId(): String? = + trim().takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt new file mode 100644 index 000000000..443eebab8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.player.skip + +import java.time.LocalDate + +internal actual fun currentDateComponents(): DateComponents { + val today = LocalDate.now() + return DateComponents( + year = today.year, + month = today.monthValue, + day = today.dayOfMonth, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt new file mode 100644 index 000000000..c6af9b68a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt @@ -0,0 +1,42 @@ +package com.nuvio.app.features.plugins + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_error_unavailable_build +import org.jetbrains.compose.resources.getString + +actual object PluginRepository { + private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false)) + + actual val uiState: StateFlow = disabledState.asStateFlow() + + actual fun initialize() = Unit + actual fun onProfileChanged(profileId: Int) = Unit + actual fun clearLocalState() = Unit + actual suspend fun pullFromServer(profileId: Int) = Unit + + actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult = + AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build)) + + actual fun removeRepository(manifestUrl: String) = Unit + actual fun refreshAll() = Unit + actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) = Unit + actual fun toggleScraper(scraperId: String, enabled: Boolean) = Unit + actual fun setPluginsEnabled(enabled: Boolean) = Unit + actual fun setGroupStreamsByRepository(enabled: Boolean) = Unit + actual fun getEnabledScrapersForType(type: String): List = emptyList() + + actual suspend fun testScraper(scraperId: String): Result> = + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) + + actual suspend fun executeScraper( + scraper: PluginScraper, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + ): Result> = + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt new file mode 100644 index 000000000..8c00aedad --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.desktop.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object AvatarStorage { + private val store = DesktopStorage.store("nuvio_avatars") + + actual fun loadPayload(): String? = store.getString("avatars") + + actual fun savePayload(payload: String) { + store.putString("avatars", payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt new file mode 100644 index 000000000..486f14779 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal actual object ProfileHoverHapticFeedback { + actual fun prepare() = Unit + actual fun perform() = Unit + actual fun release() = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt new file mode 100644 index 000000000..bcca6616d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.desktop.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object ProfilePinCacheStorage { + private val store = DesktopStorage.store("nuvio_profile_pin_cache") + + actual fun loadPayload(profileIndex: Int): String? = + store.getString(key(profileIndex)) + + actual fun savePayload(profileIndex: Int, payload: String) { + store.putString(key(profileIndex), payload) + } + + actual fun removePayload(profileIndex: Int) { + store.remove(key(profileIndex)) + } + + private fun key(profileIndex: Int): String = "profile_pin_$profileIndex" +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt new file mode 100644 index 000000000..70cd338b8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.desktop.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.features.profiles + +import java.security.MessageDigest + +internal actual object ProfilePinCrypto { + actual fun sha256Hex(value: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt new file mode 100644 index 000000000..38b7776dc --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object ProfileStorage { + private val store = DesktopStorage.store("nuvio_profiles") + + actual fun loadPayload(): String? = store.getString("profiles") + + actual fun savePayload(payload: String) { + store.putString("profiles", payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt new file mode 100644 index 000000000..639c2b8ec --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.search + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object SearchHistoryStorage { + private val store = DesktopStorage.store("nuvio_search_history") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("search_history")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("search_history"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt new file mode 100644 index 000000000..e3f4a5f0e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.introdb_favicon +import nuvio.composeapp.generated.resources.mdblist_logo +import nuvio.composeapp.generated.resources.rating_tmdb +import nuvio.composeapp.generated.resources.trakt_tv_favicon +import org.jetbrains.compose.resources.painterResource + +@Composable +internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = + painterResource( + when (logo) { + IntegrationLogo.Tmdb -> Res.drawable.rating_tmdb + IntegrationLogo.Trakt -> Res.drawable.trakt_tv_favicon + IntegrationLogo.MdbList -> Res.drawable.mdblist_logo + IntegrationLogo.IntroDb -> Res.drawable.introdb_favicon + }, + ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt new file mode 100644 index 000000000..99a36f15e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope + +internal actual fun LazyListScope.pluginsSettingsContent() = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt new file mode 100644 index 000000000..77d3d4ec4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.desktop.kt @@ -0,0 +1,74 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.Locale + +internal actual object ThemeSettingsStorage { + private const val selectedThemeKey = "selected_theme" + private const val amoledEnabledKey = "amoled_enabled" + private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" + private const val selectedAppLanguageKey = "selected_app_language" + private val profileScopedSyncKeys = listOf( + selectedThemeKey, + amoledEnabledKey, + liquidGlassNativeTabBarEnabledKey, + ) + private val store = DesktopStorage.store("nuvio_theme_settings") + + actual fun loadSelectedTheme(): String? = + store.getString(ProfileScopedKey.of(selectedThemeKey)) + + actual fun saveSelectedTheme(themeName: String) { + store.putString(ProfileScopedKey.of(selectedThemeKey), themeName) + } + + actual fun loadAmoledEnabled(): Boolean? = + store.getBoolean(ProfileScopedKey.of(amoledEnabledKey)) + + actual fun saveAmoledEnabled(enabled: Boolean) { + store.putBoolean(ProfileScopedKey.of(amoledEnabledKey), enabled) + } + + actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? = + store.getBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)) + + actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + store.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled) + } + + actual fun loadSelectedAppLanguage(): String? = + store.getString(selectedAppLanguageKey) + ?: Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } + + actual fun saveSelectedAppLanguage(languageCode: String) { + store.putString(selectedAppLanguageKey, languageCode) + } + + actual fun applySelectedAppLanguage(languageCode: String) { + Locale.setDefault(Locale.forLanguageTag(languageCode)) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } + loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(profileScopedSyncKeys.map(ProfileScopedKey::of) + selectedAppLanguageKey) + payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) + payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt new file mode 100644 index 000000000..3f83db890 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object BingeGroupCacheStorage { + private val store = DesktopStorage.store("nuvio_binge_group_cache") + + actual fun load(hashedKey: String): String? = + store.getString(ProfileScopedKey.of(hashedKey)) + + actual fun save(hashedKey: String, value: String) { + store.putString(ProfileScopedKey.of(hashedKey), value) + } + + actual fun remove(hashedKey: String) { + store.remove(ProfileScopedKey.of(hashedKey)) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt new file mode 100644 index 000000000..1cc7d4699 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/EpochMs.desktop.kt @@ -0,0 +1,3 @@ +package com.nuvio.app.features.streams + +internal actual fun epochMs(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt new file mode 100644 index 000000000..f801c3322 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.desktop.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object StreamBadgeSettingsStorage { + private const val streamBadgeRulesKey = "stream_badge_rules" + private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val streamBadgePlacementKey = "stream_badge_placement" + private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" + private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) + private val store = DesktopStorage.store("nuvio_stream_badge_settings") + private val legacyDebridStore = DesktopStorage.store("nuvio_debrid_settings") + + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + actual fun saveStreamBadgeRules(rules: String) = saveString(streamBadgeRulesKey, rules) + actual fun loadShowFileSizeBadges(): Boolean? = loadBoolean(showFileSizeBadgesKey) + actual fun saveShowFileSizeBadges(enabled: Boolean) = saveBoolean(showFileSizeBadgesKey, enabled) + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) + actual fun saveStreamBadgePlacement(placement: String) = saveString(streamBadgePlacementKey, placement) + + actual fun loadLegacyDebridStreamBadgeRules(): String? = + legacyDebridStore.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) + + actual fun clearLegacyDebridStreamBadgeRules() { + legacyDebridStore.remove(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) + } + + private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) + private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) + private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } + loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) } + loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(syncKeys.map(ProfileScopedKey::of)) + payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) + payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges) + payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt new file mode 100644 index 000000000..cf5b689c9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheStorage.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object StreamLinkCacheStorage { + private val store = DesktopStorage.store("nuvio_stream_link_cache") + + actual fun loadEntry(hashedKey: String): String? = + store.getString(ProfileScopedKey.of(hashedKey)) + + actual fun saveEntry(hashedKey: String, payload: String) { + store.putString(ProfileScopedKey.of(hashedKey), payload) + } + + actual fun removeEntry(hashedKey: String) { + store.remove(ProfileScopedKey.of(hashedKey)) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt new file mode 100644 index 000000000..349f3c6ab --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt @@ -0,0 +1,114 @@ +package com.nuvio.app.features.tmdb + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object TmdbSettingsStorage { + private const val enabledKey = "tmdb_enabled" + private const val apiKeyKey = "tmdb_api_key" + private const val languageKey = "tmdb_language" + private const val useTrailersKey = "tmdb_use_trailers" + private const val useArtworkKey = "tmdb_use_artwork" + private const val useBasicInfoKey = "tmdb_use_basic_info" + private const val useDetailsKey = "tmdb_use_details" + private const val useCreditsKey = "tmdb_use_credits" + private const val useProductionsKey = "tmdb_use_productions" + private const val useNetworksKey = "tmdb_use_networks" + private const val useEpisodesKey = "tmdb_use_episodes" + private const val useSeasonPostersKey = "tmdb_use_season_posters" + private const val useMoreLikeThisKey = "tmdb_use_more_like_this" + private const val useCollectionsKey = "tmdb_use_collections" + private val syncKeys = listOf( + enabledKey, + apiKeyKey, + languageKey, + useTrailersKey, + useArtworkKey, + useBasicInfoKey, + useDetailsKey, + useCreditsKey, + useProductionsKey, + useNetworksKey, + useEpisodesKey, + useSeasonPostersKey, + useMoreLikeThisKey, + useCollectionsKey, + ) + private val store = DesktopStorage.store("nuvio_tmdb_settings") + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled) + actual fun loadApiKey(): String? = loadString(apiKeyKey) + actual fun saveApiKey(apiKey: String) = saveString(apiKeyKey, apiKey) + actual fun loadLanguage(): String? = loadString(languageKey) + actual fun saveLanguage(language: String) = saveString(languageKey, language) + actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey) + actual fun saveUseTrailers(enabled: Boolean) = saveBoolean(useTrailersKey, enabled) + actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey) + actual fun saveUseArtwork(enabled: Boolean) = saveBoolean(useArtworkKey, enabled) + actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey) + actual fun saveUseBasicInfo(enabled: Boolean) = saveBoolean(useBasicInfoKey, enabled) + actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey) + actual fun saveUseDetails(enabled: Boolean) = saveBoolean(useDetailsKey, enabled) + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) + actual fun saveUseCredits(enabled: Boolean) = saveBoolean(useCreditsKey, enabled) + actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey) + actual fun saveUseProductions(enabled: Boolean) = saveBoolean(useProductionsKey, enabled) + actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey) + actual fun saveUseNetworks(enabled: Boolean) = saveBoolean(useNetworksKey, enabled) + actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey) + actual fun saveUseEpisodes(enabled: Boolean) = saveBoolean(useEpisodesKey, enabled) + actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey) + actual fun saveUseSeasonPosters(enabled: Boolean) = saveBoolean(useSeasonPostersKey, enabled) + actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey) + actual fun saveUseMoreLikeThis(enabled: Boolean) = saveBoolean(useMoreLikeThisKey, enabled) + actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey) + actual fun saveUseCollections(enabled: Boolean) = saveBoolean(useCollectionsKey, enabled) + + private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key)) + private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value) + private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key)) + private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value) + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadApiKey()?.let { put(apiKeyKey, encodeSyncString(it)) } + loadLanguage()?.let { put(languageKey, encodeSyncString(it)) } + loadUseTrailers()?.let { put(useTrailersKey, encodeSyncBoolean(it)) } + loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } + loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } + loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } + loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } + loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } + loadUseEpisodes()?.let { put(useEpisodesKey, encodeSyncBoolean(it)) } + loadUseSeasonPosters()?.let { put(useSeasonPostersKey, encodeSyncBoolean(it)) } + loadUseMoreLikeThis()?.let { put(useMoreLikeThisKey, encodeSyncBoolean(it)) } + loadUseCollections()?.let { put(useCollectionsKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.removeAll(syncKeys.map(ProfileScopedKey::of)) + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(apiKeyKey)?.let(::saveApiKey) + payload.decodeSyncString(languageKey)?.let(::saveLanguage) + payload.decodeSyncBoolean(useTrailersKey)?.let(::saveUseTrailers) + payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) + payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) + payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) + payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) + payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) + payload.decodeSyncBoolean(useEpisodesKey)?.let(::saveUseEpisodes) + payload.decodeSyncBoolean(useSeasonPostersKey)?.let(::saveUseSeasonPosters) + payload.decodeSyncBoolean(useMoreLikeThisKey)?.let(::saveUseMoreLikeThis) + payload.decodeSyncBoolean(useCollectionsKey)?.let(::saveUseCollections) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt new file mode 100644 index 000000000..3c05dff11 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerPlaybackResolver.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.trailer + +actual object TrailerPlaybackResolver { + actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? = null +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt new file mode 100644 index 000000000..b768fef1e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktBrandPainter.desktop.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.trakt + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.trakt_logo_wordmark +import nuvio.composeapp.generated.resources.trakt_tv_favicon +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun traktBrandPainter(asset: TraktBrandAsset): Painter = + painterResource( + when (asset) { + TraktBrandAsset.Glyph -> Res.drawable.trakt_tv_favicon + TraktBrandAsset.Wordmark -> Res.drawable.trakt_logo_wordmark + }, + ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt new file mode 100644 index 000000000..bca3536f2 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.desktop.kt @@ -0,0 +1,30 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncBoolean +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object TraktCommentsStorage { + private const val enabledKey = "trakt_comments_enabled" + private val store = DesktopStorage.store("nuvio_trakt_comments") + + actual fun loadEnabled(): Boolean? = + store.getBoolean(ProfileScopedKey.of(enabledKey)) + + actual fun saveEnabled(enabled: Boolean) { + store.putBoolean(ProfileScopedKey.of(enabledKey), enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + store.remove(ProfileScopedKey.of(enabledKey)) + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt new file mode 100644 index 000000000..6da1187c7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.desktop.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.features.trakt + +import java.time.Instant +import java.time.format.DateTimeParseException + +internal actual object TraktPlatformClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun parseIsoDateTimeToEpochMs(value: String): Long? = + try { + Instant.parse(value).toEpochMilli() + } catch (_: DateTimeParseException) { + null + } + + actual fun availableProcessors(): Int = + Runtime.getRuntime().availableProcessors().coerceAtLeast(1) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt new file mode 100644 index 000000000..b4433d6d0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktStorage.desktop.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object TraktAuthStorage { + private val store = DesktopStorage.store("nuvio_trakt_auth") + + actual fun loadPayload(): String? = store.getString("trakt_auth") + + actual fun savePayload(payload: String) { + store.putString("trakt_auth", payload) + } +} + +internal actual object TraktLibraryStorage { + private val store = DesktopStorage.store("nuvio_trakt_library") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("trakt_library")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("trakt_library"), payload) + } +} + +internal actual object TraktSettingsStorage { + private val store = DesktopStorage.store("nuvio_trakt_settings") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("trakt_settings")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("trakt_settings"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt new file mode 100644 index 000000000..f94a38b20 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedClock.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.watched + +actual object WatchedClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt new file mode 100644 index 000000000..44da853a8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedStorage.desktop.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watched + +import com.nuvio.app.core.storage.DesktopStorage + +actual object WatchedStorage { + private val store = DesktopStorage.store("nuvio_watched") + + actual fun loadPayload(profileId: Int): String? = + store.getString("watched_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + store.putString("watched_$profileId", payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt new file mode 100644 index 000000000..ce44c1c17 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object ContinueWatchingEnrichmentStorage { + private val store = DesktopStorage.store("nuvio_continue_watching_enrichment") + + actual fun loadPayload(key: String): String? = + store.getString(ProfileScopedKey.of(key.scopedKey())) + + actual fun savePayload(key: String, payload: String) { + store.putString(ProfileScopedKey.of(key.scopedKey()), payload) + } + + actual fun removePayload(key: String) { + store.remove(ProfileScopedKey.of(key.scopedKey())) + } + + private fun String.scopedKey(): String = "continue_watching_enrichment_$this" +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt new file mode 100644 index 000000000..d3eb3d1b1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.DesktopStorage +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object ContinueWatchingPreferencesStorage { + private val store = DesktopStorage.store("nuvio_continue_watching_preferences") + + actual fun loadPayload(): String? = + store.getString(ProfileScopedKey.of("continue_watching_preferences")) + + actual fun savePayload(payload: String) { + store.putString(ProfileScopedKey.of("continue_watching_preferences"), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt new file mode 100644 index 000000000..ccae7137c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.desktop.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.watchprogress + +import java.time.LocalDate + +actual object CurrentDateProvider { + actual fun todayIsoDate(): String = LocalDate.now().toString() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt new file mode 100644 index 000000000..82b82d1a9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/ResumePromptStorage.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object ResumePromptStorage { + private val store = DesktopStorage.store("nuvio_resume_prompt") + + actual fun loadWasInPlayer(): Boolean = + store.getBoolean("was_in_player") ?: false + + actual fun saveWasInPlayer(value: Boolean) { + store.putBoolean("was_in_player", value) + } + + actual fun loadLastPlayerVideoId(): String? = + store.getString("last_player_video_id") + + actual fun saveLastPlayerVideoId(videoId: String?) { + store.putString("last_player_video_id", videoId?.takeIf { it.isNotBlank() }) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt new file mode 100644 index 000000000..faead0065 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.watchprogress + +internal actual object WatchProgressClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt new file mode 100644 index 000000000..cc922a391 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.desktop.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.DesktopStorage + +internal actual object WatchProgressStorage { + private val store = DesktopStorage.store("nuvio_watch_progress") + + actual fun loadPayload(profileId: Int): String? = + store.getString("watch_progress_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + store.putString("watch_progress_$profileId", payload) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39ae4c826..412fcceb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" ktor = "3.4.1" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" @@ -53,7 +54,9 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }