diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 15e9453c..c4f12ba8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty @@ -164,6 +163,12 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } + + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } val iosTargets = listOf( iosArm64(), @@ -203,6 +208,13 @@ kotlin { val commonMain by getting { kotlin.srcDir(generatedRuntimeConfigDir) } + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.ktor.client.java) + implementation(libs.kotlinx.coroutines.swing) + } + } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) @@ -265,6 +277,12 @@ dependencies { debugImplementation(libs.compose.uiTooling) } +compose.desktop { + application { + mainClass = "com.nuvio.app.DesktopAppKt" + } +} + configurations.all { exclude(group = "androidx.media3", module = "media3-exoplayer") exclude(group = "androidx.media3", module = "media3-ui") diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt new file mode 100644 index 00000000..f9c397b3 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt @@ -0,0 +1,13 @@ +package com.nuvio.app + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Nuvio", + ) { + App() + } +} \ No newline at end of file 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 00000000..a7cd2187 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt @@ -0,0 +1,9 @@ +package com.nuvio.app + +private class DesktopPlatform : Platform { + override val name: String = "Desktop" +} + +actual fun getPlatform(): Platform = DesktopPlatform() + +internal actual val isIos: Boolean = false \ No newline at end of file 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 00000000..9f39ae79 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.core.auth + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object AuthStorage { + private const val preferencesName = "nuvio_auth" + private const val anonymousUserIdKey = "anonymous_user_id" + + actual fun loadAnonymousUserId(): String? = + DesktopPreferences.getString(preferencesName, anonymousUserIdKey) + + actual fun saveAnonymousUserId(userId: String) { + DesktopPreferences.putString(preferencesName, anonymousUserIdKey, userId) + } + + actual fun clearAnonymousUserId() { + DesktopPreferences.remove(preferencesName, anonymousUserIdKey) + } +} \ No newline at end of file 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 00000000..726cace5 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.core.storage + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object PlatformLocalAccountDataCleaner { + private val preferenceNames = listOf( + "nuvio_addons", + "nuvio_library", + "nuvio_home_catalog_settings", + "nuvio_meta_screen_settings", + "nuvio_player_settings", + "nuvio_profile_cache", + "nuvio_search_history", + "nuvio_theme_settings", + "nuvio_poster_card_style", + "nuvio_mdblist_settings", + "nuvio_trakt_auth", + "nuvio_trakt_comments", + "nuvio_trakt_library", + "nuvio_watched", + "nuvio_stream_link_cache", + "nuvio_continue_watching_preferences", + "nuvio_cw_enrichment", + "nuvio_resume_prompt", + "nuvio_episode_release_notifications", + "nuvio_watch_progress", + "nuvio_plugins", + "nuvio_collections", + "nuvio_downloads", + "nuvio_tmdb_settings", + "nuvio_season_view_mode", + ) + + actual fun wipe() { + preferenceNames.forEach(DesktopPreferences::clearNode) + } +} \ No newline at end of file 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 00000000..398cc605 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.core.sync + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +internal actual object AppForegroundMonitor { + actual fun events(): Flow = callbackFlow { + trySend(Unit) + awaitClose {} + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt new file mode 100644 index 00000000..f6c7cbbe --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt @@ -0,0 +1,63 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import kotlin.system.exitProcess +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 + +internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp +internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp +internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp + +@Composable +internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0, 0, 0, 0) + +@Composable +actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) = Unit + +@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 + } + ) + +internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this + +actual fun platformExitApp() { + exitProcess(0) +} + +internal actual object PosterCardStyleStorage { + private const val preferencesName = "nuvio_poster_card_style" + private const val payloadKey = "poster_card_style_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt new file mode 100644 index 00000000..27043744 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt @@ -0,0 +1,124 @@ +package com.nuvio.app.desktop + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Base64 +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteExisting +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.outputStream +import kotlin.io.path.readText +import kotlin.io.path.writeText + +internal object DesktopPreferences { + private const val setSeparator = "\u001F" + private val keyEncoder = Base64.getUrlEncoder().withoutPadding() + + private val rootDir: Path by lazy { + Paths.get( + System.getProperty("user.home"), + "Library", + "Application Support", + "Nuvio", + "preferences", + ).apply { + createDirectories() + } + } + + private fun namespaceDir(namespace: String): Path = + rootDir.resolve(encodePathPart(namespace)).apply { + createDirectories() + } + + private fun keyFile(namespace: String, key: String): Path = + namespaceDir(namespace).resolve(encodePathPart(key)) + + private fun encodePathPart(value: String): String = + keyEncoder.encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + + fun contains(namespace: String, key: String): Boolean = + keyFile(namespace, key).exists() + + fun getString(namespace: String, key: String): String? = + keyFile(namespace, key) + .takeIf(Path::exists) + ?.readText(StandardCharsets.UTF_8) + + @Synchronized + fun putString(namespace: String, key: String, value: String) { + keyFile(namespace, key).writeText(value, StandardCharsets.UTF_8) + } + + fun putNullableString(namespace: String, key: String, value: String?) { + if (value == null) { + remove(namespace, key) + } else { + putString(namespace, key, value) + } + } + + fun getBoolean(namespace: String, key: String): Boolean? = + getString(namespace, key)?.toBooleanStrictOrNull() + + @Synchronized + fun putBoolean(namespace: String, key: String, value: Boolean) { + putString(namespace, key, value.toString()) + } + + fun getInt(namespace: String, key: String): Int? = + getString(namespace, key)?.toIntOrNull() + + @Synchronized + fun putInt(namespace: String, key: String, value: Int) { + putString(namespace, key, value.toString()) + } + + fun getFloat(namespace: String, key: String): Float? = + getString(namespace, key)?.toFloatOrNull() + + @Synchronized + fun putFloat(namespace: String, key: String, value: Float) { + putString(namespace, key, value.toString()) + } + + fun getStringSet(namespace: String, key: String): Set? { + val raw = getString(namespace, key) ?: return null + if (raw.isEmpty()) return emptySet() + return raw.split(setSeparator) + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + } + + fun putStringSet(namespace: String, key: String, values: Set) { + putString(namespace, key, values.toSortedSet().joinToString(setSeparator)) + } + + @Synchronized + fun remove(namespace: String, key: String) { + runCatching { + keyFile(namespace, key).deleteExisting() + } + } + + @Synchronized + fun clearNode(namespace: String) { + runCatching { + deleteRecursively(namespaceDir(namespace)) + } + } + + private fun deleteRecursively(path: Path) { + if (!path.exists()) return + if (path.isDirectory()) { + path.listDirectoryEntries().forEach(::deleteRecursively) + } + path.deleteExisting() + } +} \ No newline at end of file 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 00000000..7dc3401b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt @@ -0,0 +1,157 @@ +package com.nuvio.app.features.addons + +import com.nuvio.app.desktop.DesktopPreferences +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal actual object AddonStorage { + private const val preferencesName = "nuvio_addons" + private const val addonUrlsKey = "installed_manifest_urls" + + actual fun loadInstalledAddonUrls(profileId: Int): List = + DesktopPreferences.getString(preferencesName, "${addonUrlsKey}_$profileId") + .orEmpty() + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + + actual fun saveInstalledAddonUrls(profileId: Int, urls: List) { + DesktopPreferences.putString( + preferencesName, + "${addonUrlsKey}_$profileId", + urls.joinToString(separator = "\n"), + ) + } +} + +private val addonHttpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + +private const val maxRawResponseBodyChars = 1024 * 1024 +private const val truncationSuffix = "\n...[truncated]" + +private fun requestAllowsBody(method: String): Boolean = + when (method.uppercase()) { + "POST", "PUT", "PATCH", "DELETE" -> true + else -> false + } + +private fun Map.withoutAcceptEncoding(): Map = + entries + .filterNot { (key, _) -> key.equals("Accept-Encoding", ignoreCase = true) } + .associate { (key, value) -> key to value } + +private suspend fun executeRequest( + method: String, + url: String, + headers: Map, + body: String, +) = withContext(Dispatchers.IO) { + val builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(60)) + + headers.withoutAcceptEncoding().forEach { (key, value) -> + builder.header(key, value) + } + + val request = if (requestAllowsBody(method)) { + builder.method(method.uppercase(), HttpRequest.BodyPublishers.ofString(body)) + } else { + builder.method(method.uppercase(), HttpRequest.BodyPublishers.noBody()) + }.build() + + addonHttpClient.send(request, HttpResponse.BodyHandlers.ofString()) +} + +private suspend fun executeTextRequest( + method: String, + url: String, + headers: Map = emptyMap(), + body: String = "", +): String { + val response = executeRequest(method, url, headers, body) + val payload = response.body() + if (response.statusCode() !in 200..299) { + error("Request failed with HTTP ${response.statusCode()}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + return payload +} + +actual suspend fun httpGetText(url: String): String = + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json"), + ) + +actual suspend fun httpPostJson(url: String, body: String): String = + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), + body = body, + ) + +actual suspend fun httpGetTextWithHeaders( + url: String, + headers: Map, +): String = + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json") + headers, + ) + +actual suspend fun httpPostJsonWithHeaders( + url: String, + body: String, + headers: Map, +): String = + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers, + body = body, + ) + +actual suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, +): RawHttpResponse { + val response = executeRequest(method, url, headers, body) + val payload = response.body() + val limitedPayload = if (payload.length > maxRawResponseBodyChars) { + payload.take(maxRawResponseBodyChars) + truncationSuffix + } else { + payload + } + return RawHttpResponse( + status = response.statusCode(), + statusText = response.version().toString(), + url = response.uri().toString(), + body = limitedPayload, + headers = response.headers().map().entries.associate { (key, values) -> + key.lowercase() to values.joinToString(",") + }, + ) +} \ No newline at end of file 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 00000000..b37e9880 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object CollectionStorage { + private const val preferencesName = "nuvio_collections" + private const val payloadKey = "collections_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt new file mode 100644 index 00000000..23e09533 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object MetaScreenSettingsStorage { + private const val preferencesName = "nuvio_meta_screen_settings" + private const val payloadKey = "meta_screen_settings_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object SeasonViewModeStorage { + private const val preferencesName = "nuvio_season_view_mode" + private const val valueKey = "season_view_mode" + + actual fun load(): SeasonViewMode? = + SeasonViewMode.parse( + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(valueKey)), + ) + + actual fun save(mode: SeasonViewMode) { + DesktopPreferences.putString( + preferencesName, + ProfileScopedKey.of(valueKey), + SeasonViewMode.persist(mode), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt new file mode 100644 index 00000000..b74671b8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object DownloadsStorage { + private const val preferencesName = "nuvio_downloads" + private const val payloadKey = "downloads_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +private object NoOpDownloadsTaskHandle : DownloadsTaskHandle { + override fun cancel() = Unit +} + +internal actual object DownloadsPlatformDownloader { + actual fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle { + onFailure("Downloads are not available on desktop yet.") + return NoOpDownloadsTaskHandle + } + + actual fun removeFile(localFileUri: String?): Boolean = false + + actual fun removePartialFile(destinationFileName: String): Boolean = false +} + +internal actual object DownloadsLiveStatusPlatform { + actual fun onItemsChanged(items: List) = Unit +} + +internal actual object DownloadsClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} \ No newline at end of file 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 00000000..21a200c6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object HomeCatalogSettingsStorage { + private const val preferencesName = "nuvio_home_catalog_settings" + private const val payloadKey = "catalog_settings_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file 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 00000000..729c71d8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +@Composable +internal actual fun CollectionCardRemoteImage( + imageUrl: String, + contentDescription: String, + modifier: Modifier, + contentScale: ContentScale, + animateIfPossible: Boolean, +) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt new file mode 100644 index 00000000..38ef5dc8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object LibraryStorage { + private const val preferencesName = "nuvio_library" + private const val payloadKey = "library_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} + +internal actual object LibraryClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} \ No newline at end of file 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 00000000..8399f38d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt @@ -0,0 +1,131 @@ +package com.nuvio.app.features.mdblist + +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 com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object MdbListSettingsStorage { + private const val preferencesName = "nuvio_mdblist_settings" + 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, + ) + + 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) + } + + 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) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + 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) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt new file mode 100644 index 00000000..a75961bb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.notifications + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import java.time.Instant +import java.time.ZoneId + +internal actual object EpisodeReleaseNotificationsStorage { + private const val preferencesName = "nuvio_episode_release_notifications" + private const val payloadKey = "episode_release_notifications_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object EpisodeReleaseNotificationPlatform { + actual suspend fun notificationsAuthorized(): Boolean = true + + actual suspend fun requestAuthorization(): Boolean = true + + actual suspend fun scheduleEpisodeReleaseNotifications(requests: List) = Unit + + actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit + + actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit +} + +internal actual object EpisodeReleaseNotificationsClock { + actual fun isoDateFromEpochMs(epochMs: Long): String = + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt new file mode 100644 index 00000000..2c58e9c8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt @@ -0,0 +1,499 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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 +import androidx.compose.ui.unit.IntSize +import androidx.compose.material3.Text +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 com.nuvio.app.desktop.DesktopPreferences +import java.util.Locale +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +@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 { + object : 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 + } + } + + LaunchedEffect(controller) { + onControllerReady(controller) + onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) + onError(null) + } + + Box( + modifier = modifier.background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Desktop playback is not implemented yet.", + color = Color.White, + ) + } +} + +internal actual object DeviceLanguagePreferences { + actual fun preferredLanguageCodes(): List = + listOfNotNull(Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() }) +} + +internal actual object PlayerSettingsStorage { + private const val preferencesName = "nuvio_player_settings" + 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 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 subtitleOutlineEnabledKey = "subtitle_outline_enabled" + private const val subtitleFontSizeSpKey = "subtitle_font_size_sp" + private const val subtitleBottomOffsetKey = "subtitle_bottom_offset" + 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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" + private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_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 val syncKeys = listOf( + showLoadingOverlayKey, + resizeModeKey, + holdToSpeedEnabledKey, + holdToSpeedValueKey, + preferredAudioLanguageKey, + secondaryPreferredAudioLanguageKey, + preferredSubtitleLanguageKey, + secondaryPreferredSubtitleLanguageKey, + subtitleTextColorKey, + subtitleOutlineEnabledKey, + subtitleFontSizeSpKey, + subtitleBottomOffsetKey, + streamReuseLastLinkEnabledKey, + streamReuseLastLinkCacheHoursKey, + decoderPriorityKey, + mapDV7ToHevcKey, + tunnelingEnabledKey, + streamAutoPlayModeKey, + streamAutoPlaySourceKey, + streamAutoPlaySelectedAddonsKey, + streamAutoPlaySelectedPluginsKey, + streamAutoPlayRegexKey, + streamAutoPlayTimeoutSecondsKey, + skipIntroEnabledKey, + animeSkipEnabledKey, + animeSkipClientIdKey, + streamAutoPlayNextEpisodeEnabledKey, + streamAutoPlayPreferBingeGroupKey, + nextEpisodeThresholdModeKey, + nextEpisodeThresholdPercentKey, + nextEpisodeThresholdMinutesBeforeEndKey, + useLibassKey, + libassRenderTypeKey, + ) + + 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 loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey) + + actual fun savePreferredAudioLanguage(language: String) { + saveString(preferredAudioLanguageKey, language) + } + + actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey) + + actual fun saveSecondaryPreferredAudioLanguage(language: String?) { + saveNullableString(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?) { + saveNullableString(secondaryPreferredSubtitleLanguageKey, language) + } + + actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey) + + actual fun saveSubtitleTextColor(colorHex: String) { + saveString(subtitleTextColorKey, colorHex) + } + + actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey) + + actual fun saveSubtitleOutlineEnabled(enabled: Boolean) { + saveBoolean(subtitleOutlineEnabledKey, 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 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 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 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 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)) } + 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)) } + loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) } + loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) } + loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(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)) } + 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)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) + payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) + payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) + 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.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled) + payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp) + payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset) + 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.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) + payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + 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) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun saveNullableString(key: String, value: String?) { + if (value.isNullOrBlank()) { + DesktopPreferences.remove(preferencesName, scopedKey(key)) + } else { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } + + private fun loadInt(key: String): Int? = + DesktopPreferences.getInt(preferencesName, scopedKey(key)) + + private fun saveInt(key: String, value: Int) { + DesktopPreferences.putInt(preferencesName, scopedKey(key), value) + } + + private fun loadFloat(key: String): Float? = + DesktopPreferences.getFloat(preferencesName, scopedKey(key)) + + private fun saveFloat(key: String, value: Float) { + DesktopPreferences.putFloat(preferencesName, scopedKey(key), value) + } + + private fun loadStringSet(key: String): Set? = + DesktopPreferences.getStringSet(preferencesName, scopedKey(key)) + + private fun saveStringSet(key: String, values: Set) { + DesktopPreferences.putStringSet(preferencesName, scopedKey(key), values) + } +} + +@Composable +actual fun LockPlayerToLandscape() = Unit + +@Composable +actual fun EnterImmersivePlayerMode() = Unit + +@Composable +actual fun ManagePlayerPictureInPicture( + isPlaying: Boolean, + playerSize: IntSize, +) = Unit + +@Composable +actual fun rememberPlayerGestureController(): PlayerGestureController? = null \ No newline at end of file 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 00000000..ecd87c3c --- /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, + ) +} \ No newline at end of file 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 00000000..6139ed02 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.desktop.kt @@ -0,0 +1,48 @@ +package com.nuvio.app.features.plugins + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +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("Plugins are not available in this 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("Plugins are not available in this build.")) + + actual suspend fun executeScraper( + scraper: PluginScraper, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + ): Result> = + Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) +} \ No newline at end of file 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 00000000..be251ed8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object ProfileStorage { + private const val preferencesName = "nuvio_profile_cache" + private const val payloadKey = "profile_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, payloadKey) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, payloadKey, payload) + } +} \ No newline at end of file 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 00000000..7ef545aa --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.search + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object SearchHistoryStorage { + private const val preferencesName = "nuvio_search_history" + private const val payloadKey = "search_history_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt new file mode 100644 index 00000000..3101c166 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt @@ -0,0 +1,62 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +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 com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +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 + +internal actual object ThemeSettingsStorage { + private const val preferencesName = "nuvio_theme_settings" + private const val selectedThemeKey = "selected_theme" + private const val amoledEnabledKey = "amoled_enabled" + private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey) + + actual fun loadSelectedTheme(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(selectedThemeKey)) + + actual fun saveSelectedTheme(themeName: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(selectedThemeKey), themeName) + } + + actual fun loadAmoledEnabled(): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey)) + + actual fun saveAmoledEnabled(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey), enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } + loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) + payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + } +} + +internal actual fun LazyListScope.pluginsSettingsContent() = Unit + +@Composable +internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = + when (logo) { + IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb) + IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon) + IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo) + } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt new file mode 100644 index 00000000..9854f62a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object StreamLinkCacheStorage { + private const val preferencesName = "nuvio_stream_link_cache" + + actual fun loadEntry(hashedKey: String): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(hashedKey)) + + actual fun saveEntry(hashedKey: String, payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(hashedKey), payload) + } + + actual fun removeEntry(hashedKey: String) { + DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(hashedKey)) + } +} + +internal actual fun epochMs(): Long = System.currentTimeMillis() \ No newline at end of file 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 00000000..3ae6703c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt @@ -0,0 +1,181 @@ +package com.nuvio.app.features.tmdb + +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 com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object TmdbSettingsStorage { + private const val preferencesName = "nuvio_tmdb_settings" + 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, + ) + + 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) + } + + 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) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + 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) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } +} \ No newline at end of file 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 00000000..33c4aa29 --- /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 +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt new file mode 100644 index 00000000..1687a75f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt @@ -0,0 +1,77 @@ +package com.nuvio.app.features.trakt + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.desktop.DesktopPreferences +import java.time.Instant +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +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 + +internal actual object TraktAuthStorage { + private const val preferencesName = "nuvio_trakt_auth" + private const val payloadKey = "trakt_auth_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object TraktCommentsStorage { + private const val preferencesName = "nuvio_trakt_comments" + private const val enabledKey = "comments_enabled" + private val syncKeys = listOf(enabledKey) + + actual fun loadEnabled(): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(enabledKey)) + + actual fun saveEnabled(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(enabledKey), enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + } +} + +internal actual object TraktLibraryStorage { + private const val preferencesName = "nuvio_trakt_library" + private const val payloadKey = "trakt_library_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +@Composable +actual fun traktBrandPainter(asset: TraktBrandAsset): Painter = + when (asset) { + TraktBrandAsset.Glyph -> painterResource(Res.drawable.trakt_tv_favicon) + TraktBrandAsset.Wordmark -> painterResource(Res.drawable.trakt_logo_wordmark) + } + +internal actual object TraktPlatformClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { + Instant.parse(value).toEpochMilli() + }.getOrNull() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt new file mode 100644 index 00000000..b2359a16 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.watched + +import com.nuvio.app.desktop.DesktopPreferences + +actual object WatchedStorage { + private const val preferencesName = "nuvio_watched" + private const val payloadKey = "watched_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} + +actual object WatchedClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt new file mode 100644 index 00000000..0c38e7ed --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import java.time.LocalDate + +actual object CurrentDateProvider { + actual fun todayIsoDate(): String = LocalDate.now().toString() +} + +internal actual object WatchProgressClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} + +internal actual object ContinueWatchingPreferencesStorage { + private const val preferencesName = "nuvio_continue_watching_preferences" + private const val payloadKey = "continue_watching_preferences_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +actual object ContinueWatchingEnrichmentStorage { + private const val preferencesName = "nuvio_cw_enrichment" + + actual fun loadPayload(key: String): String? = + DesktopPreferences.getString(preferencesName, key) + + actual fun savePayload(key: String, payload: String) { + DesktopPreferences.putString(preferencesName, key, payload) + } +} + +actual object ResumePromptStorage { + private const val preferencesName = "nuvio_resume_prompt" + private const val wasInPlayerKey = "was_in_player" + private const val lastPlayerVideoIdKey = "last_player_video_id" + + actual fun loadWasInPlayer(): Boolean = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey)) ?: false + + actual fun saveWasInPlayer(value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey), value) + } + + actual fun loadLastPlayerVideoId(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(lastPlayerVideoIdKey)) + + actual fun saveLastPlayerVideoId(videoId: String?) { + DesktopPreferences.putNullableString( + preferencesName, + ProfileScopedKey.of(lastPlayerVideoIdKey), + videoId, + ) + } +} + +internal actual object WatchProgressStorage { + private const val preferencesName = "nuvio_watch_progress" + private const val payloadKey = "watch_progress_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a8920f1..04e2c315 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.10.0-alpha05" androidx-media3 = "1.8.0" @@ -51,9 +52,11 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" 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" } 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" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index eedc6d2f..e961c555 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=25 +CURRENT_PROJECT_VERSION=26 MARKETING_VERSION=0.1.0