feat: inti storage

This commit is contained in:
tapframe 2026-04-16 20:20:49 +05:30
parent 21793b8ac7
commit 9f7f465a25
31 changed files with 1804 additions and 2 deletions

View file

@ -1,4 +1,3 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
@ -164,6 +163,12 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
val iosTargets = listOf(
iosArm64(),
@ -203,6 +208,13 @@ kotlin {
val commonMain by getting {
kotlin.srcDir(generatedRuntimeConfigDir)
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.ktor.client.java)
implementation(libs.kotlinx.coroutines.swing)
}
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
@ -265,6 +277,12 @@ dependencies {
debugImplementation(libs.compose.uiTooling)
}
compose.desktop {
application {
mainClass = "com.nuvio.app.DesktopAppKt"
}
}
configurations.all {
exclude(group = "androidx.media3", module = "media3-exoplayer")
exclude(group = "androidx.media3", module = "media3-ui")

View file

@ -0,0 +1,13 @@
package com.nuvio.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Nuvio",
) {
App()
}
}

View file

@ -0,0 +1,9 @@
package com.nuvio.app
private class DesktopPlatform : Platform {
override val name: String = "Desktop"
}
actual fun getPlatform(): Platform = DesktopPlatform()
internal actual val isIos: Boolean = false

View file

@ -0,0 +1,19 @@
package com.nuvio.app.core.auth
import com.nuvio.app.desktop.DesktopPreferences
internal actual object AuthStorage {
private const val preferencesName = "nuvio_auth"
private const val anonymousUserIdKey = "anonymous_user_id"
actual fun loadAnonymousUserId(): String? =
DesktopPreferences.getString(preferencesName, anonymousUserIdKey)
actual fun saveAnonymousUserId(userId: String) {
DesktopPreferences.putString(preferencesName, anonymousUserIdKey, userId)
}
actual fun clearAnonymousUserId() {
DesktopPreferences.remove(preferencesName, anonymousUserIdKey)
}
}

View file

@ -0,0 +1,37 @@
package com.nuvio.app.core.storage
import com.nuvio.app.desktop.DesktopPreferences
internal actual object PlatformLocalAccountDataCleaner {
private val preferenceNames = listOf(
"nuvio_addons",
"nuvio_library",
"nuvio_home_catalog_settings",
"nuvio_meta_screen_settings",
"nuvio_player_settings",
"nuvio_profile_cache",
"nuvio_search_history",
"nuvio_theme_settings",
"nuvio_poster_card_style",
"nuvio_mdblist_settings",
"nuvio_trakt_auth",
"nuvio_trakt_comments",
"nuvio_trakt_library",
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",
"nuvio_cw_enrichment",
"nuvio_resume_prompt",
"nuvio_episode_release_notifications",
"nuvio_watch_progress",
"nuvio_plugins",
"nuvio_collections",
"nuvio_downloads",
"nuvio_tmdb_settings",
"nuvio_season_view_mode",
)
actual fun wipe() {
preferenceNames.forEach(DesktopPreferences::clearNode)
}
}

View file

@ -0,0 +1,12 @@
package com.nuvio.app.core.sync
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
internal actual object AppForegroundMonitor {
actual fun events(): Flow<Unit> = callbackFlow {
trySend(Unit)
awaitClose {}
}
}

View file

@ -0,0 +1,63 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
import kotlin.system.exitProcess
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.ic_player_aspect_ratio
import nuvio.composeapp.generated.resources.ic_player_audio_filled
import nuvio.composeapp.generated.resources.ic_player_pause
import nuvio.composeapp.generated.resources.ic_player_play
import nuvio.composeapp.generated.resources.ic_player_subtitles
import nuvio.composeapp.generated.resources.library_add_plus
import org.jetbrains.compose.resources.painterResource
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp
@Composable
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0, 0, 0, 0)
@Composable
actual fun PlatformBackHandler(
enabled: Boolean,
onBack: () -> Unit,
) = Unit
@Composable
actual fun appIconPainter(icon: AppIconResource): Painter =
painterResource(
when (icon) {
AppIconResource.PlayerPlay -> Res.drawable.ic_player_play
AppIconResource.PlayerPause -> Res.drawable.ic_player_pause
AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio
AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles
AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled
AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus
}
)
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this
actual fun platformExitApp() {
exitProcess(0)
}
internal actual object PosterCardStyleStorage {
private const val preferencesName = "nuvio_poster_card_style"
private const val payloadKey = "poster_card_style_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}

View file

@ -0,0 +1,124 @@
package com.nuvio.app.desktop
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Base64
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteExisting
import kotlin.io.path.exists
import kotlin.io.path.inputStream
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.outputStream
import kotlin.io.path.readText
import kotlin.io.path.writeText
internal object DesktopPreferences {
private const val setSeparator = "\u001F"
private val keyEncoder = Base64.getUrlEncoder().withoutPadding()
private val rootDir: Path by lazy {
Paths.get(
System.getProperty("user.home"),
"Library",
"Application Support",
"Nuvio",
"preferences",
).apply {
createDirectories()
}
}
private fun namespaceDir(namespace: String): Path =
rootDir.resolve(encodePathPart(namespace)).apply {
createDirectories()
}
private fun keyFile(namespace: String, key: String): Path =
namespaceDir(namespace).resolve(encodePathPart(key))
private fun encodePathPart(value: String): String =
keyEncoder.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
fun contains(namespace: String, key: String): Boolean =
keyFile(namespace, key).exists()
fun getString(namespace: String, key: String): String? =
keyFile(namespace, key)
.takeIf(Path::exists)
?.readText(StandardCharsets.UTF_8)
@Synchronized
fun putString(namespace: String, key: String, value: String) {
keyFile(namespace, key).writeText(value, StandardCharsets.UTF_8)
}
fun putNullableString(namespace: String, key: String, value: String?) {
if (value == null) {
remove(namespace, key)
} else {
putString(namespace, key, value)
}
}
fun getBoolean(namespace: String, key: String): Boolean? =
getString(namespace, key)?.toBooleanStrictOrNull()
@Synchronized
fun putBoolean(namespace: String, key: String, value: Boolean) {
putString(namespace, key, value.toString())
}
fun getInt(namespace: String, key: String): Int? =
getString(namespace, key)?.toIntOrNull()
@Synchronized
fun putInt(namespace: String, key: String, value: Int) {
putString(namespace, key, value.toString())
}
fun getFloat(namespace: String, key: String): Float? =
getString(namespace, key)?.toFloatOrNull()
@Synchronized
fun putFloat(namespace: String, key: String, value: Float) {
putString(namespace, key, value.toString())
}
fun getStringSet(namespace: String, key: String): Set<String>? {
val raw = getString(namespace, key) ?: return null
if (raw.isEmpty()) return emptySet()
return raw.split(setSeparator)
.map(String::trim)
.filter(String::isNotEmpty)
.toSet()
}
fun putStringSet(namespace: String, key: String, values: Set<String>) {
putString(namespace, key, values.toSortedSet().joinToString(setSeparator))
}
@Synchronized
fun remove(namespace: String, key: String) {
runCatching {
keyFile(namespace, key).deleteExisting()
}
}
@Synchronized
fun clearNode(namespace: String) {
runCatching {
deleteRecursively(namespaceDir(namespace))
}
}
private fun deleteRecursively(path: Path) {
if (!path.exists()) return
if (path.isDirectory()) {
path.listDirectoryEntries().forEach(::deleteRecursively)
}
path.deleteExisting()
}
}

View file

@ -0,0 +1,157 @@
package com.nuvio.app.features.addons
import com.nuvio.app.desktop.DesktopPreferences
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal actual object AddonStorage {
private const val preferencesName = "nuvio_addons"
private const val addonUrlsKey = "installed_manifest_urls"
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
DesktopPreferences.getString(preferencesName, "${addonUrlsKey}_$profileId")
.orEmpty()
.lineSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.toList()
actual fun saveInstalledAddonUrls(profileId: Int, urls: List<String>) {
DesktopPreferences.putString(
preferencesName,
"${addonUrlsKey}_$profileId",
urls.joinToString(separator = "\n"),
)
}
}
private val addonHttpClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()
private const val maxRawResponseBodyChars = 1024 * 1024
private const val truncationSuffix = "\n...[truncated]"
private fun requestAllowsBody(method: String): Boolean =
when (method.uppercase()) {
"POST", "PUT", "PATCH", "DELETE" -> true
else -> false
}
private fun Map<String, String>.withoutAcceptEncoding(): Map<String, String> =
entries
.filterNot { (key, _) -> key.equals("Accept-Encoding", ignoreCase = true) }
.associate { (key, value) -> key to value }
private suspend fun executeRequest(
method: String,
url: String,
headers: Map<String, String>,
body: String,
) = withContext(Dispatchers.IO) {
val builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(60))
headers.withoutAcceptEncoding().forEach { (key, value) ->
builder.header(key, value)
}
val request = if (requestAllowsBody(method)) {
builder.method(method.uppercase(), HttpRequest.BodyPublishers.ofString(body))
} else {
builder.method(method.uppercase(), HttpRequest.BodyPublishers.noBody())
}.build()
addonHttpClient.send(request, HttpResponse.BodyHandlers.ofString())
}
private suspend fun executeTextRequest(
method: String,
url: String,
headers: Map<String, String> = emptyMap(),
body: String = "",
): String {
val response = executeRequest(method, url, headers, body)
val payload = response.body()
if (response.statusCode() !in 200..299) {
error("Request failed with HTTP ${response.statusCode()}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
return payload
}
actual suspend fun httpGetText(url: String): String =
executeTextRequest(
method = "GET",
url = url,
headers = mapOf("Accept" to "application/json"),
)
actual suspend fun httpPostJson(url: String, body: String): String =
executeTextRequest(
method = "POST",
url = url,
headers = mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
),
body = body,
)
actual suspend fun httpGetTextWithHeaders(
url: String,
headers: Map<String, String>,
): String =
executeTextRequest(
method = "GET",
url = url,
headers = mapOf("Accept" to "application/json") + headers,
)
actual suspend fun httpPostJsonWithHeaders(
url: String,
body: String,
headers: Map<String, String>,
): String =
executeTextRequest(
method = "POST",
url = url,
headers = mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
) + headers,
body = body,
)
actual suspend fun httpRequestRaw(
method: String,
url: String,
headers: Map<String, String>,
body: String,
): RawHttpResponse {
val response = executeRequest(method, url, headers, body)
val payload = response.body()
val limitedPayload = if (payload.length > maxRawResponseBodyChars) {
payload.take(maxRawResponseBodyChars) + truncationSuffix
} else {
payload
}
return RawHttpResponse(
status = response.statusCode(),
statusText = response.version().toString(),
url = response.uri().toString(),
body = limitedPayload,
headers = response.headers().map().entries.associate { (key, values) ->
key.lowercase() to values.joinToString(",")
},
)
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.collection
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object CollectionStorage {
private const val preferencesName = "nuvio_collections"
private const val payloadKey = "collections_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}

View file

@ -0,0 +1,34 @@
package com.nuvio.app.features.details
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object MetaScreenSettingsStorage {
private const val preferencesName = "nuvio_meta_screen_settings"
private const val payloadKey = "meta_screen_settings_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
internal actual object SeasonViewModeStorage {
private const val preferencesName = "nuvio_season_view_mode"
private const val valueKey = "season_view_mode"
actual fun load(): SeasonViewMode? =
SeasonViewMode.parse(
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(valueKey)),
)
actual fun save(mode: SeasonViewMode) {
DesktopPreferences.putString(
preferencesName,
ProfileScopedKey.of(valueKey),
SeasonViewMode.persist(mode),
)
}
}

View file

@ -0,0 +1,44 @@
package com.nuvio.app.features.downloads
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object DownloadsStorage {
private const val preferencesName = "nuvio_downloads"
private const val payloadKey = "downloads_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
private object NoOpDownloadsTaskHandle : DownloadsTaskHandle {
override fun cancel() = Unit
}
internal actual object DownloadsPlatformDownloader {
actual fun start(
request: DownloadPlatformRequest,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit,
onFailure: (message: String) -> Unit,
): DownloadsTaskHandle {
onFailure("Downloads are not available on desktop yet.")
return NoOpDownloadsTaskHandle
}
actual fun removeFile(localFileUri: String?): Boolean = false
actual fun removePartialFile(destinationFileName: String): Boolean = false
}
internal actual object DownloadsLiveStatusPlatform {
actual fun onItemsChanged(items: List<DownloadItem>) = Unit
}
internal actual object DownloadsClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.home
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object HomeCatalogSettingsStorage {
private const val preferencesName = "nuvio_home_catalog_settings"
private const val payloadKey = "catalog_settings_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}

View file

@ -0,0 +1,22 @@
package com.nuvio.app.features.home.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
@Composable
internal actual fun CollectionCardRemoteImage(
imageUrl: String,
contentDescription: String,
modifier: Modifier,
contentScale: ContentScale,
animateIfPossible: Boolean,
) {
AsyncImage(
model = imageUrl,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.library
import com.nuvio.app.desktop.DesktopPreferences
internal actual object LibraryStorage {
private const val preferencesName = "nuvio_library"
private const val payloadKey = "library_payload"
actual fun loadPayload(profileId: Int): String? =
DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId")
actual fun savePayload(profileId: Int, payload: String) {
DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload)
}
}
internal actual object LibraryClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,131 @@
package com.nuvio.app.features.mdblist
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncString
import com.nuvio.app.desktop.DesktopPreferences
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal actual object MdbListSettingsStorage {
private const val preferencesName = "nuvio_mdblist_settings"
private const val enabledKey = "mdblist_enabled"
private const val apiKey = "mdblist_api_key"
private const val useImdbKey = "mdblist_use_imdb"
private const val useTmdbKey = "mdblist_use_tmdb"
private const val useTomatoesKey = "mdblist_use_tomatoes"
private const val useMetacriticKey = "mdblist_use_metacritic"
private const val useTraktKey = "mdblist_use_trakt"
private const val useLetterboxdKey = "mdblist_use_letterboxd"
private const val useAudienceKey = "mdblist_use_audience"
private val syncKeys = listOf(
enabledKey,
apiKey,
useImdbKey,
useTmdbKey,
useTomatoesKey,
useMetacriticKey,
useTraktKey,
useLetterboxdKey,
useAudienceKey,
)
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadApiKey(): String? = loadString(apiKey)
actual fun saveApiKey(apiKey: String) {
saveString(this.apiKey, apiKey)
}
actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey)
actual fun saveUseImdb(enabled: Boolean) {
saveBoolean(useImdbKey, enabled)
}
actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey)
actual fun saveUseTmdb(enabled: Boolean) {
saveBoolean(useTmdbKey, enabled)
}
actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey)
actual fun saveUseTomatoes(enabled: Boolean) {
saveBoolean(useTomatoesKey, enabled)
}
actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey)
actual fun saveUseMetacritic(enabled: Boolean) {
saveBoolean(useMetacriticKey, enabled)
}
actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey)
actual fun saveUseTrakt(enabled: Boolean) {
saveBoolean(useTraktKey, enabled)
}
actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey)
actual fun saveUseLetterboxd(enabled: Boolean) {
saveBoolean(useLetterboxdKey, enabled)
}
actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey)
actual fun saveUseAudience(enabled: Boolean) {
saveBoolean(useAudienceKey, enabled)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadApiKey()?.let { put(apiKey, encodeSyncString(it)) }
loadUseImdb()?.let { put(useImdbKey, encodeSyncBoolean(it)) }
loadUseTmdb()?.let { put(useTmdbKey, encodeSyncBoolean(it)) }
loadUseTomatoes()?.let { put(useTomatoesKey, encodeSyncBoolean(it)) }
loadUseMetacritic()?.let { put(useMetacriticKey, encodeSyncBoolean(it)) }
loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) }
loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) }
loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) }
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(apiKey)?.let(::saveApiKey)
payload.decodeSyncBoolean(useImdbKey)?.let(::saveUseImdb)
payload.decodeSyncBoolean(useTmdbKey)?.let(::saveUseTmdb)
payload.decodeSyncBoolean(useTomatoesKey)?.let(::saveUseTomatoes)
payload.decodeSyncBoolean(useMetacriticKey)?.let(::saveUseMetacritic)
payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt)
payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd)
payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience)
}
private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey)
private fun loadString(key: String): String? =
DesktopPreferences.getString(preferencesName, scopedKey(key))
private fun saveString(key: String, value: String) {
DesktopPreferences.putString(preferencesName, scopedKey(key), value)
}
private fun loadBoolean(key: String): Boolean? =
DesktopPreferences.getBoolean(preferencesName, scopedKey(key))
private fun saveBoolean(key: String, value: Boolean) {
DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value)
}
}

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.notifications
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
import java.time.Instant
import java.time.ZoneId
internal actual object EpisodeReleaseNotificationsStorage {
private const val preferencesName = "nuvio_episode_release_notifications"
private const val payloadKey = "episode_release_notifications_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
internal actual object EpisodeReleaseNotificationPlatform {
actual suspend fun notificationsAuthorized(): Boolean = true
actual suspend fun requestAuthorization(): Boolean = true
actual suspend fun scheduleEpisodeReleaseNotifications(requests: List<EpisodeReleaseNotificationRequest>) = Unit
actual suspend fun clearScheduledEpisodeReleaseNotifications() = Unit
actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) = Unit
}
internal actual object EpisodeReleaseNotificationsClock {
actual fun isoDateFromEpochMs(epochMs: Long): String =
Instant.ofEpochMilli(epochMs)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toString()
}

View file

@ -0,0 +1,499 @@
package com.nuvio.app.features.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntSize
import androidx.compose.material3.Text
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncFloat
import com.nuvio.app.core.sync.decodeSyncInt
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.decodeSyncStringSet
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncFloat
import com.nuvio.app.core.sync.encodeSyncInt
import com.nuvio.app.core.sync.encodeSyncString
import com.nuvio.app.core.sync.encodeSyncStringSet
import com.nuvio.app.desktop.DesktopPreferences
import java.util.Locale
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@Composable
actual fun PlatformPlayerSurface(
sourceUrl: String,
sourceAudioUrl: String?,
sourceHeaders: Map<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 {
object : PlayerEngineController {
override fun play() = Unit
override fun pause() = Unit
override fun seekTo(positionMs: Long) = Unit
override fun seekBy(offsetMs: Long) = Unit
override fun retry() = Unit
override fun setPlaybackSpeed(speed: Float) = Unit
override fun getAudioTracks(): List<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
}
}
LaunchedEffect(controller) {
onControllerReady(controller)
onSnapshot(PlayerPlaybackSnapshot(isLoading = false))
onError(null)
}
Box(
modifier = modifier.background(Color.Black),
contentAlignment = Alignment.Center,
) {
Text(
text = "Desktop playback is not implemented yet.",
color = Color.White,
)
}
}
internal actual object DeviceLanguagePreferences {
actual fun preferredLanguageCodes(): List<String> =
listOfNotNull(Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() })
}
internal actual object PlayerSettingsStorage {
private const val preferencesName = "nuvio_player_settings"
private const val showLoadingOverlayKey = "show_loading_overlay"
private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
private const val subtitleTextColorKey = "subtitle_text_color"
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
private const val decoderPriorityKey = "decoder_priority"
private const val mapDV7ToHevcKey = "map_dv7_to_hevc"
private const val tunnelingEnabledKey = "tunneling_enabled"
private const val streamAutoPlayModeKey = "stream_auto_play_mode"
private const val streamAutoPlaySourceKey = "stream_auto_play_source"
private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons"
private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins"
private const val streamAutoPlayRegexKey = "stream_auto_play_regex"
private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds"
private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id"
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2"
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type"
private val syncKeys = listOf(
showLoadingOverlayKey,
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey,
secondaryPreferredSubtitleLanguageKey,
subtitleTextColorKey,
subtitleOutlineEnabledKey,
subtitleFontSizeSpKey,
subtitleBottomOffsetKey,
streamReuseLastLinkEnabledKey,
streamReuseLastLinkCacheHoursKey,
decoderPriorityKey,
mapDV7ToHevcKey,
tunnelingEnabledKey,
streamAutoPlayModeKey,
streamAutoPlaySourceKey,
streamAutoPlaySelectedAddonsKey,
streamAutoPlaySelectedPluginsKey,
streamAutoPlayRegexKey,
streamAutoPlayTimeoutSecondsKey,
skipIntroEnabledKey,
animeSkipEnabledKey,
animeSkipClientIdKey,
streamAutoPlayNextEpisodeEnabledKey,
streamAutoPlayPreferBingeGroupKey,
nextEpisodeThresholdModeKey,
nextEpisodeThresholdPercentKey,
nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey,
libassRenderTypeKey,
)
actual fun loadShowLoadingOverlay(): Boolean? = loadBoolean(showLoadingOverlayKey)
actual fun saveShowLoadingOverlay(enabled: Boolean) {
saveBoolean(showLoadingOverlayKey, enabled)
}
actual fun loadResizeMode(): String? = loadString(resizeModeKey)
actual fun saveResizeMode(mode: String) {
saveString(resizeModeKey, mode)
}
actual fun loadHoldToSpeedEnabled(): Boolean? = loadBoolean(holdToSpeedEnabledKey)
actual fun saveHoldToSpeedEnabled(enabled: Boolean) {
saveBoolean(holdToSpeedEnabledKey, enabled)
}
actual fun loadHoldToSpeedValue(): Float? = loadFloat(holdToSpeedValueKey)
actual fun saveHoldToSpeedValue(speed: Float) {
saveFloat(holdToSpeedValueKey, speed)
}
actual fun loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey)
actual fun savePreferredAudioLanguage(language: String) {
saveString(preferredAudioLanguageKey, language)
}
actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey)
actual fun saveSecondaryPreferredAudioLanguage(language: String?) {
saveNullableString(secondaryPreferredAudioLanguageKey, language)
}
actual fun loadPreferredSubtitleLanguage(): String? = loadString(preferredSubtitleLanguageKey)
actual fun savePreferredSubtitleLanguage(language: String) {
saveString(preferredSubtitleLanguageKey, language)
}
actual fun loadSecondaryPreferredSubtitleLanguage(): String? = loadString(secondaryPreferredSubtitleLanguageKey)
actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) {
saveNullableString(secondaryPreferredSubtitleLanguageKey, language)
}
actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey)
actual fun saveSubtitleTextColor(colorHex: String) {
saveString(subtitleTextColorKey, colorHex)
}
actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey)
actual fun saveSubtitleOutlineEnabled(enabled: Boolean) {
saveBoolean(subtitleOutlineEnabledKey, enabled)
}
actual fun loadSubtitleFontSizeSp(): Int? = loadInt(subtitleFontSizeSpKey)
actual fun saveSubtitleFontSizeSp(fontSizeSp: Int) {
saveInt(subtitleFontSizeSpKey, fontSizeSp)
}
actual fun loadSubtitleBottomOffset(): Int? = loadInt(subtitleBottomOffsetKey)
actual fun saveSubtitleBottomOffset(bottomOffset: Int) {
saveInt(subtitleBottomOffsetKey, bottomOffset)
}
actual fun loadStreamReuseLastLinkEnabled(): Boolean? = loadBoolean(streamReuseLastLinkEnabledKey)
actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) {
saveBoolean(streamReuseLastLinkEnabledKey, enabled)
}
actual fun loadStreamReuseLastLinkCacheHours(): Int? = loadInt(streamReuseLastLinkCacheHoursKey)
actual fun saveStreamReuseLastLinkCacheHours(hours: Int) {
saveInt(streamReuseLastLinkCacheHoursKey, hours)
}
actual fun loadDecoderPriority(): Int? = loadInt(decoderPriorityKey)
actual fun saveDecoderPriority(priority: Int) {
saveInt(decoderPriorityKey, priority)
}
actual fun loadMapDV7ToHevc(): Boolean? = loadBoolean(mapDV7ToHevcKey)
actual fun saveMapDV7ToHevc(enabled: Boolean) {
saveBoolean(mapDV7ToHevcKey, enabled)
}
actual fun loadTunnelingEnabled(): Boolean? = loadBoolean(tunnelingEnabledKey)
actual fun saveTunnelingEnabled(enabled: Boolean) {
saveBoolean(tunnelingEnabledKey, enabled)
}
actual fun loadStreamAutoPlayMode(): String? = loadString(streamAutoPlayModeKey)
actual fun saveStreamAutoPlayMode(mode: String) {
saveString(streamAutoPlayModeKey, mode)
}
actual fun loadStreamAutoPlaySource(): String? = loadString(streamAutoPlaySourceKey)
actual fun saveStreamAutoPlaySource(source: String) {
saveString(streamAutoPlaySourceKey, source)
}
actual fun loadStreamAutoPlaySelectedAddons(): Set<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 loadStreamAutoPlayNextEpisodeEnabled(): Boolean? = loadBoolean(streamAutoPlayNextEpisodeEnabledKey)
actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
saveBoolean(streamAutoPlayNextEpisodeEnabledKey, enabled)
}
actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? = loadBoolean(streamAutoPlayPreferBingeGroupKey)
actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) {
saveBoolean(streamAutoPlayPreferBingeGroupKey, enabled)
}
actual fun loadNextEpisodeThresholdMode(): String? = loadString(nextEpisodeThresholdModeKey)
actual fun saveNextEpisodeThresholdMode(mode: String) {
saveString(nextEpisodeThresholdModeKey, mode)
}
actual fun loadNextEpisodeThresholdPercent(): Float? = loadFloat(nextEpisodeThresholdPercentKey)
actual fun saveNextEpisodeThresholdPercent(percent: Float) {
saveFloat(nextEpisodeThresholdPercentKey, percent)
}
actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? = loadFloat(nextEpisodeThresholdMinutesBeforeEndKey)
actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) {
saveFloat(nextEpisodeThresholdMinutesBeforeEndKey, minutes)
}
actual fun loadUseLibass(): Boolean? = loadBoolean(useLibassKey)
actual fun saveUseLibass(enabled: Boolean) {
saveBoolean(useLibassKey, enabled)
}
actual fun loadLibassRenderType(): String? = loadString(libassRenderTypeKey)
actual fun saveLibassRenderType(renderType: String) {
saveString(libassRenderTypeKey, renderType)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) }
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) }
loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) }
loadStreamAutoPlayMode()?.let { put(streamAutoPlayModeKey, encodeSyncString(it)) }
loadStreamAutoPlaySource()?.let { put(streamAutoPlaySourceKey, encodeSyncString(it)) }
loadStreamAutoPlaySelectedAddons()?.let { put(streamAutoPlaySelectedAddonsKey, encodeSyncStringSet(it)) }
loadStreamAutoPlaySelectedPlugins()?.let { put(streamAutoPlaySelectedPluginsKey, encodeSyncStringSet(it)) }
loadStreamAutoPlayRegex()?.let { put(streamAutoPlayRegexKey, encodeSyncString(it)) }
loadStreamAutoPlayTimeoutSeconds()?.let { put(streamAutoPlayTimeoutSecondsKey, encodeSyncInt(it)) }
loadSkipIntroEnabled()?.let { put(skipIntroEnabledKey, encodeSyncBoolean(it)) }
loadAnimeSkipEnabled()?.let { put(animeSkipEnabledKey, encodeSyncBoolean(it)) }
loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) }
loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) }
loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) }
loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) }
loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) }
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) }
payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay)
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)
payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc)
payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled)
payload.decodeSyncString(streamAutoPlayModeKey)?.let(::saveStreamAutoPlayMode)
payload.decodeSyncString(streamAutoPlaySourceKey)?.let(::saveStreamAutoPlaySource)
payload.decodeSyncStringSet(streamAutoPlaySelectedAddonsKey)?.let(::saveStreamAutoPlaySelectedAddons)
payload.decodeSyncStringSet(streamAutoPlaySelectedPluginsKey)?.let(::saveStreamAutoPlaySelectedPlugins)
payload.decodeSyncString(streamAutoPlayRegexKey)?.let(::saveStreamAutoPlayRegex)
payload.decodeSyncInt(streamAutoPlayTimeoutSecondsKey)?.let(::saveStreamAutoPlayTimeoutSeconds)
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent)
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
}
private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey)
private fun loadString(key: String): String? =
DesktopPreferences.getString(preferencesName, scopedKey(key))
private fun saveString(key: String, value: String) {
DesktopPreferences.putString(preferencesName, scopedKey(key), value)
}
private fun saveNullableString(key: String, value: String?) {
if (value.isNullOrBlank()) {
DesktopPreferences.remove(preferencesName, scopedKey(key))
} else {
DesktopPreferences.putString(preferencesName, scopedKey(key), value)
}
}
private fun loadBoolean(key: String): Boolean? =
DesktopPreferences.getBoolean(preferencesName, scopedKey(key))
private fun saveBoolean(key: String, value: Boolean) {
DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value)
}
private fun loadInt(key: String): Int? =
DesktopPreferences.getInt(preferencesName, scopedKey(key))
private fun saveInt(key: String, value: Int) {
DesktopPreferences.putInt(preferencesName, scopedKey(key), value)
}
private fun loadFloat(key: String): Float? =
DesktopPreferences.getFloat(preferencesName, scopedKey(key))
private fun saveFloat(key: String, value: Float) {
DesktopPreferences.putFloat(preferencesName, scopedKey(key), value)
}
private fun loadStringSet(key: String): Set<String>? =
DesktopPreferences.getStringSet(preferencesName, scopedKey(key))
private fun saveStringSet(key: String, values: Set<String>) {
DesktopPreferences.putStringSet(preferencesName, scopedKey(key), values)
}
}
@Composable
actual fun LockPlayerToLandscape() = Unit
@Composable
actual fun EnterImmersivePlayerMode() = Unit
@Composable
actual fun ManagePlayerPictureInPicture(
isPlaying: Boolean,
playerSize: IntSize,
) = Unit
@Composable
actual fun rememberPlayerGestureController(): PlayerGestureController? = null

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,48 @@
package com.nuvio.app.features.plugins
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
actual object PluginRepository {
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
actual val uiState: StateFlow<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("Plugins are not available in this build.")
actual fun removeRepository(manifestUrl: String) = Unit
actual fun refreshAll() = Unit
actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) = Unit
actual fun toggleScraper(scraperId: String, enabled: Boolean) = Unit
actual fun setPluginsEnabled(enabled: Boolean) = Unit
actual fun setGroupStreamsByRepository(enabled: Boolean) = Unit
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
actual suspend fun executeScraper(
scraper: PluginScraper,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
): Result<List<PluginRuntimeResult>> =
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.profiles
import com.nuvio.app.desktop.DesktopPreferences
internal actual object ProfileStorage {
private const val preferencesName = "nuvio_profile_cache"
private const val payloadKey = "profile_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, payloadKey)
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, payloadKey, payload)
}
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.search
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object SearchHistoryStorage {
private const val preferencesName = "nuvio_search_history"
private const val payloadKey = "search_history_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}

View file

@ -0,0 +1,62 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncString
import com.nuvio.app.desktop.DesktopPreferences
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.mdblist_logo
import nuvio.composeapp.generated.resources.rating_tmdb
import nuvio.composeapp.generated.resources.trakt_tv_favicon
import org.jetbrains.compose.resources.painterResource
internal actual object ThemeSettingsStorage {
private const val preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled"
private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey)
actual fun loadSelectedTheme(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(selectedThemeKey))
actual fun saveSelectedTheme(themeName: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(selectedThemeKey), themeName)
}
actual fun loadAmoledEnabled(): Boolean? =
DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey))
actual fun saveAmoledEnabled(enabled: Boolean) {
DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey), enabled)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) }
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
}
}
internal actual fun LazyListScope.pluginsSettingsContent() = Unit
@Composable
internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
when (logo) {
IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb)
IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon)
IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo)
}

View file

@ -0,0 +1,21 @@
package com.nuvio.app.features.streams
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
internal actual object StreamLinkCacheStorage {
private const val preferencesName = "nuvio_stream_link_cache"
actual fun loadEntry(hashedKey: String): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(hashedKey))
actual fun saveEntry(hashedKey: String, payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(hashedKey), payload)
}
actual fun removeEntry(hashedKey: String) {
DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(hashedKey))
}
}
internal actual fun epochMs(): Long = System.currentTimeMillis()

View file

@ -0,0 +1,181 @@
package com.nuvio.app.features.tmdb
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncString
import com.nuvio.app.desktop.DesktopPreferences
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal actual object TmdbSettingsStorage {
private const val preferencesName = "nuvio_tmdb_settings"
private const val enabledKey = "tmdb_enabled"
private const val apiKeyKey = "tmdb_api_key"
private const val languageKey = "tmdb_language"
private const val useTrailersKey = "tmdb_use_trailers"
private const val useArtworkKey = "tmdb_use_artwork"
private const val useBasicInfoKey = "tmdb_use_basic_info"
private const val useDetailsKey = "tmdb_use_details"
private const val useCreditsKey = "tmdb_use_credits"
private const val useProductionsKey = "tmdb_use_productions"
private const val useNetworksKey = "tmdb_use_networks"
private const val useEpisodesKey = "tmdb_use_episodes"
private const val useSeasonPostersKey = "tmdb_use_season_posters"
private const val useMoreLikeThisKey = "tmdb_use_more_like_this"
private const val useCollectionsKey = "tmdb_use_collections"
private val syncKeys = listOf(
enabledKey,
apiKeyKey,
languageKey,
useTrailersKey,
useArtworkKey,
useBasicInfoKey,
useDetailsKey,
useCreditsKey,
useProductionsKey,
useNetworksKey,
useEpisodesKey,
useSeasonPostersKey,
useMoreLikeThisKey,
useCollectionsKey,
)
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadApiKey(): String? = loadString(apiKeyKey)
actual fun saveApiKey(apiKey: String) {
saveString(apiKeyKey, apiKey)
}
actual fun loadLanguage(): String? = loadString(languageKey)
actual fun saveLanguage(language: String) {
saveString(languageKey, language)
}
actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey)
actual fun saveUseTrailers(enabled: Boolean) {
saveBoolean(useTrailersKey, enabled)
}
actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey)
actual fun saveUseArtwork(enabled: Boolean) {
saveBoolean(useArtworkKey, enabled)
}
actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey)
actual fun saveUseBasicInfo(enabled: Boolean) {
saveBoolean(useBasicInfoKey, enabled)
}
actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey)
actual fun saveUseDetails(enabled: Boolean) {
saveBoolean(useDetailsKey, enabled)
}
actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey)
actual fun saveUseCredits(enabled: Boolean) {
saveBoolean(useCreditsKey, enabled)
}
actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey)
actual fun saveUseProductions(enabled: Boolean) {
saveBoolean(useProductionsKey, enabled)
}
actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey)
actual fun saveUseNetworks(enabled: Boolean) {
saveBoolean(useNetworksKey, enabled)
}
actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey)
actual fun saveUseEpisodes(enabled: Boolean) {
saveBoolean(useEpisodesKey, enabled)
}
actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey)
actual fun saveUseSeasonPosters(enabled: Boolean) {
saveBoolean(useSeasonPostersKey, enabled)
}
actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey)
actual fun saveUseMoreLikeThis(enabled: Boolean) {
saveBoolean(useMoreLikeThisKey, enabled)
}
actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey)
actual fun saveUseCollections(enabled: Boolean) {
saveBoolean(useCollectionsKey, enabled)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadApiKey()?.let { put(apiKeyKey, encodeSyncString(it)) }
loadLanguage()?.let { put(languageKey, encodeSyncString(it)) }
loadUseTrailers()?.let { put(useTrailersKey, encodeSyncBoolean(it)) }
loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) }
loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) }
loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) }
loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) }
loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) }
loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) }
loadUseEpisodes()?.let { put(useEpisodesKey, encodeSyncBoolean(it)) }
loadUseSeasonPosters()?.let { put(useSeasonPostersKey, encodeSyncBoolean(it)) }
loadUseMoreLikeThis()?.let { put(useMoreLikeThisKey, encodeSyncBoolean(it)) }
loadUseCollections()?.let { put(useCollectionsKey, encodeSyncBoolean(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) }
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(apiKeyKey)?.let(::saveApiKey)
payload.decodeSyncString(languageKey)?.let(::saveLanguage)
payload.decodeSyncBoolean(useTrailersKey)?.let(::saveUseTrailers)
payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork)
payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo)
payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails)
payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits)
payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions)
payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks)
payload.decodeSyncBoolean(useEpisodesKey)?.let(::saveUseEpisodes)
payload.decodeSyncBoolean(useSeasonPostersKey)?.let(::saveUseSeasonPosters)
payload.decodeSyncBoolean(useMoreLikeThisKey)?.let(::saveUseMoreLikeThis)
payload.decodeSyncBoolean(useCollectionsKey)?.let(::saveUseCollections)
}
private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey)
private fun loadString(key: String): String? =
DesktopPreferences.getString(preferencesName, scopedKey(key))
private fun saveString(key: String, value: String) {
DesktopPreferences.putString(preferencesName, scopedKey(key), value)
}
private fun loadBoolean(key: String): Boolean? =
DesktopPreferences.getBoolean(preferencesName, scopedKey(key))
private fun saveBoolean(key: String, value: Boolean) {
DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value)
}
}

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,77 @@
package com.nuvio.app.features.trakt
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.desktop.DesktopPreferences
import java.time.Instant
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.trakt_logo_wordmark
import nuvio.composeapp.generated.resources.trakt_tv_favicon
import org.jetbrains.compose.resources.painterResource
internal actual object TraktAuthStorage {
private const val preferencesName = "nuvio_trakt_auth"
private const val payloadKey = "trakt_auth_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
internal actual object TraktCommentsStorage {
private const val preferencesName = "nuvio_trakt_comments"
private const val enabledKey = "comments_enabled"
private val syncKeys = listOf(enabledKey)
actual fun loadEnabled(): Boolean? =
DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(enabledKey))
actual fun saveEnabled(enabled: Boolean) {
DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(enabledKey), enabled)
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) }
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
}
}
internal actual object TraktLibraryStorage {
private const val preferencesName = "nuvio_trakt_library"
private const val payloadKey = "trakt_library_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
@Composable
actual fun traktBrandPainter(asset: TraktBrandAsset): Painter =
when (asset) {
TraktBrandAsset.Glyph -> painterResource(Res.drawable.trakt_tv_favicon)
TraktBrandAsset.Wordmark -> painterResource(Res.drawable.trakt_logo_wordmark)
}
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
Instant.parse(value).toEpochMilli()
}.getOrNull()
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.watched
import com.nuvio.app.desktop.DesktopPreferences
actual object WatchedStorage {
private const val preferencesName = "nuvio_watched"
private const val payloadKey = "watched_payload"
actual fun loadPayload(profileId: Int): String? =
DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId")
actual fun savePayload(profileId: Int, payload: String) {
DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload)
}
}
actual object WatchedClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.desktop.DesktopPreferences
import java.time.LocalDate
actual object CurrentDateProvider {
actual fun todayIsoDate(): String = LocalDate.now().toString()
}
internal actual object WatchProgressClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}
internal actual object ContinueWatchingPreferencesStorage {
private const val preferencesName = "nuvio_continue_watching_preferences"
private const val payloadKey = "continue_watching_preferences_payload"
actual fun loadPayload(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload)
}
}
actual object ContinueWatchingEnrichmentStorage {
private const val preferencesName = "nuvio_cw_enrichment"
actual fun loadPayload(key: String): String? =
DesktopPreferences.getString(preferencesName, key)
actual fun savePayload(key: String, payload: String) {
DesktopPreferences.putString(preferencesName, key, payload)
}
}
actual object ResumePromptStorage {
private const val preferencesName = "nuvio_resume_prompt"
private const val wasInPlayerKey = "was_in_player"
private const val lastPlayerVideoIdKey = "last_player_video_id"
actual fun loadWasInPlayer(): Boolean =
DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey)) ?: false
actual fun saveWasInPlayer(value: Boolean) {
DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey), value)
}
actual fun loadLastPlayerVideoId(): String? =
DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(lastPlayerVideoIdKey))
actual fun saveLastPlayerVideoId(videoId: String?) {
DesktopPreferences.putNullableString(
preferencesName,
ProfileScopedKey.of(lastPlayerVideoIdKey),
videoId,
)
}
}
internal actual object WatchProgressStorage {
private const val preferencesName = "nuvio_watch_progress"
private const val payloadKey = "watch_progress_payload"
actual fun loadPayload(profileId: Int): String? =
DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId")
actual fun savePayload(profileId: Int, payload: String) {
DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload)
}
}

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.10.0-alpha05"
androidx-media3 = "1.8.0"
@ -51,9 +52,11 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=25
CURRENT_PROJECT_VERSION=26
MARKETING_VERSION=0.1.0