feat: init desktop storafe

This commit is contained in:
tapframe 2026-06-08 03:12:43 +05:30
parent 5dbf522b7a
commit 6222c10ee9
72 changed files with 2445 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, Store>()
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<String>? =
getString(key)?.let { payload ->
runCatching { json.decodeFromString<List<String>>(payload).toSet() }.getOrNull()
}
fun putStringSet(key: String, values: Set<String>) {
putString(key, json.encodeToString(values.toList()))
}
fun remove(key: String) = synchronized(lock) {
ensureLoaded()
properties.remove(key)
persist()
}
fun removeAll(keys: Iterable<String>) = 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")
}
}
}
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.core.storage
internal actual object PlatformLocalAccountDataCleaner {
actual fun wipe() {
DesktopStorage.wipe()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(
enabled: Boolean,
onBack: () -> Unit,
) = Unit

View file

@ -0,0 +1,7 @@
package com.nuvio.app.core.ui
import kotlin.system.exitProcess
actual fun platformExitApp() {
exitProcess(0)
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this

View file

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

View file

@ -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<String> =
store.getString("installed_addon_urls_$profileId")
?.let { payload -> runCatching { json.decodeFromString<List<String>>(payload) }.getOrNull() }
?: emptyList()
actual fun saveInstalledAddonUrls(profileId: Int, urls: List<String>) {
store.putString("installed_addon_urls_$profileId", json.encodeToString(urls))
}
actual fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean> =
store.getString("addon_enabled_states_$profileId")
?.let { payload -> runCatching { json.decodeFromString<Map<String, Boolean>>(payload) }.getOrNull() }
?: emptyMap()
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
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, String>,
): String =
httpRequestRaw("GET", url, headers, body = "").body
actual suspend fun httpPostJsonWithHeaders(
url: String,
body: String,
headers: Map<String, String>,
): 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<String, String>,
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(",") },
)
}

View file

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

View file

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

View file

@ -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<String> =
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"
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal actual object DownloadsClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal actual object DownloadsLiveStatusPlatform {
actual fun onItemsChanged(items: List<DownloadItem>) = Unit
}

View file

@ -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<java.io.InputStream> {
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 }
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.library
internal actual object LibraryClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

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

View file

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

View file

@ -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<EpisodeReleaseNotificationRequest>,
) = Unit
actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit
actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit
}

View file

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

View file

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

View file

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

View file

@ -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>(P2pStreamingState.Idle)
actual val state: StateFlow<P2pStreamingState> = _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
}
}

View file

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

View file

@ -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<ExternalPlayerApp> =
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
}
}

View file

@ -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<String, String>,
sourceResponseHeaders: Map<String, String>,
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<AudioTrack> = emptyList()
override fun getSubtitleTracks(): List<SubtitleTrack> = 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
}

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.player
import java.util.Locale
internal actual object DeviceLanguagePreferences {
actual fun preferredLanguageCodes(): List<String> =
Locale.getDefault()
.toLanguageTag()
.takeIf { it.isNotBlank() }
?.let(::listOf)
?: emptyList()
}

View file

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

View file

@ -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<String>? = loadStringSet(streamAutoPlaySelectedAddonsKey)
actual fun saveStreamAutoPlaySelectedAddons(addons: Set<String>) = saveStringSet(streamAutoPlaySelectedAddonsKey, addons)
actual fun loadStreamAutoPlaySelectedPlugins(): Set<String>? = loadStringSet(streamAutoPlaySelectedPluginsKey)
actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set<String>) = 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<String>? = store.getStringSet(scoped(key))
private fun saveStringSet(key: String, values: Set<String>) = 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)
}
}

View file

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

View file

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

View file

@ -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<PluginsUiState> = 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<PluginScraper> = emptyList()
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
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<List<PluginRuntimeResult>> =
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
internal actual fun LazyListScope.pluginsSettingsContent() = Unit

View file

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

View file

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

View file

@ -0,0 +1,3 @@
package com.nuvio.app.features.streams
internal actual fun epochMs(): Long = System.currentTimeMillis()

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.trailer
actual object TrailerPlaybackResolver {
actual suspend fun resolveFromYouTubeUrl(youtubeUrl: String): TrailerPlaybackSource? = null
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.watched
actual object WatchedClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.watchprogress
import java.time.LocalDate
actual object CurrentDateProvider {
actual fun todayIsoDate(): String = LocalDate.now().toString()
}

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.watchprogress
internal actual object WatchProgressClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

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

View file

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