diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt new file mode 100644 index 00000000..f7da122a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -0,0 +1,114 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal object CollectionJsonPreserver { + fun merge( + json: Json, + rawCollectionsJson: JsonElement, + collections: List, + ): JsonArray { + val rawById = rawCollectionsJson.asObjectArrayById() + return buildJsonArray { + collections.forEach { collection -> + add( + mergeCollection( + json = json, + raw = rawById[collection.id], + collection = collection, + ), + ) + } + } + } + + private fun mergeCollection( + json: Json, + raw: JsonObject?, + collection: Collection, + ): JsonObject { + val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject + val rawFoldersById = raw?.get("folders").asObjectArrayById() + val mergedFolders = buildJsonArray { + collection.folders.forEach { folder -> + add( + mergeFolder( + json = json, + raw = rawFoldersById[folder.id], + folder = folder, + ), + ) + } + } + return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders)) + } + + private fun mergeFolder( + json: Json, + raw: JsonObject?, + folder: CollectionFolder, + ): JsonObject { + val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject + val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey) + val mergedSources = buildJsonArray { + folder.catalogSources.forEach { source -> + val sourceElement = + json.encodeToJsonElement(CollectionCatalogSource.serializer(), source) + add( + mergeSource( + json = json, + raw = rawSourcesByKey[sourceKey(sourceElement)], + source = source, + ), + ) + } + } + return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources)) + } + + private fun mergeSource( + json: Json, + raw: JsonObject?, + source: CollectionCatalogSource, + ): JsonObject { + val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject + return mergeObjects(raw, encoded) + } + + private fun mergeObjects( + raw: JsonObject?, + encoded: JsonObject, + overrides: Map = emptyMap(), + ): JsonObject = buildJsonObject { + raw?.forEach { (key, value) -> put(key, value) } + encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) } + } + + private fun JsonElement?.asObjectArrayById(): Map = + asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull } + + private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map = + (this as? JsonArray) + ?.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + keySelector(obj)?.let { key -> key to obj } + } + ?.toMap() + .orEmpty() + + private fun sourceKey(element: JsonElement): String? { + val obj = element as? JsonObject ?: return null + val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null + val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null + val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null + return "$addonId|$type|$catalogId" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 7d9f5abd..860f97d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title @@ -31,6 +33,7 @@ object CollectionRepository { private val _collections = MutableStateFlow>(emptyList()) val collections: StateFlow> = _collections.asStateFlow() + private var rawCollectionsJson: JsonElement = JsonArray(emptyList()) private var hasLoaded = false @@ -41,6 +44,8 @@ object CollectionRepository { if (payload.isNullOrBlank()) return runCatching { + val parsed = json.parseToJsonElement(payload) + rawCollectionsJson = parsed _collections.value = json.decodeFromString>(payload) }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } @@ -50,11 +55,13 @@ object CollectionRepository { fun onProfileChanged() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun clearLocalState() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun getCollection(id: String): Collection? = @@ -81,6 +88,7 @@ object CollectionRepository { } fun setCollections(collections: List) { + ensureLoaded() _collections.value = collections persist() } @@ -106,11 +114,12 @@ object CollectionRepository { fun exportToJson(): String { ensureLoaded() - return json.encodeToString(_collections.value) + return mergedCollectionsJson().toString() } fun importFromJson(jsonString: String): Result> { return runCatching { + rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) _collections.value = imported persist() @@ -228,7 +237,8 @@ object CollectionRepository { } } - internal fun applyFromRemote(collections: List) { + internal fun applyFromRemote(collections: List, rawJson: JsonElement) { + rawCollectionsJson = rawJson _collections.value = collections persist() } @@ -239,9 +249,14 @@ object CollectionRepository { private fun persist() { runCatching { - CollectionStorage.savePayload(json.encodeToString(_collections.value)) + CollectionStorage.savePayload(mergedCollectionsJson().toString()) }.onFailure { e -> log.e(e) { "Failed to persist collections" } } } + + private fun mergedCollectionsJson(): JsonArray = + CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also { + rawCollectionsJson = it + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index aced9be6..1ec14547 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -80,7 +80,7 @@ object CollectionSyncService { if (remoteCollections != null) { isSyncingFromRemote = true - CollectionRepository.applyFromRemote(remoteCollections) + CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson) isSyncingFromRemote = false log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index 9d54dfd1..9e64a911 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -40,14 +40,17 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import kotlin.math.round import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -230,24 +233,32 @@ private fun SourceStreamRow( onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val cardShape = RoundedCornerShape(12.dp) Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .heightIn(min = 68.dp) + .shadow( + elevation = 2.dp, + shape = cardShape, + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f), + ) + .clip(cardShape) .background( - if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent, + if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f), ) .then( if (isCurrent) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape) } else { Modifier }, ) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(14.dp), + verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(modifier = Modifier.weight(1f)) { @@ -258,11 +269,13 @@ private fun SourceStreamRow( Text( text = stream.streamLabel, color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + modifier = Modifier.weight(1f), ) if (isCurrent) { Box( @@ -280,34 +293,66 @@ private fun SourceStreamRow( } } } - stream.streamSubtitle?.let { subtitle -> - if (subtitle != stream.streamLabel) { - Text( - text = subtitle, - color = colorScheme.onSurfaceVariant, + + val subtitle = stream.streamSubtitle + if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + lineHeight = 18.sp, + ), + color = colorScheme.onSurfaceVariant, + ) } - Text( - text = stream.addonName, - color = colorScheme.onSurfaceVariant, + + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PlayerStreamFileSizeBadge(stream = stream) + Text( + text = stream.addonName, + color = colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontStyle = FontStyle.Italic, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun PlayerStreamFileSizeBadge(stream: StreamItem) { + val bytes = stream.behaviorHints.videoSize ?: return + val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) + val sizeLabel = if (gib >= 1.0) { + val roundedGiB = round(gib * 10.0) / 10.0 + "$roundedGiB ${localizedByteUnit("GB")}" + } else { + val mib = bytes.toDouble() / (1024.0 * 1024.0) + "${round(mib).toInt()} ${localizedByteUnit("MB")}" + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF0A0C0C)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = stringResource(Res.string.streams_size, sizeLabel), + style = MaterialTheme.typography.labelSmall.copy( fontSize = 11.sp, - fontStyle = FontStyle.Italic, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (isCurrent) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = stringResource(Res.string.compose_player_currently_playing), - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + ), + color = Color.White, + ) } }