mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: collection preserving to prevent overwriting unsupported fields in blobs
This commit is contained in:
parent
23080c4344
commit
84a4771f67
4 changed files with 215 additions and 41 deletions
|
|
@ -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<Collection>,
|
||||
): 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<String, JsonElement> = emptyMap(),
|
||||
): JsonObject = buildJsonObject {
|
||||
raw?.forEach { (key, value) -> put(key, value) }
|
||||
encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) }
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectArrayById(): Map<String, JsonObject> =
|
||||
asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull }
|
||||
|
||||
private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map<String, JsonObject> =
|
||||
(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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<Collection>>(emptyList())
|
||||
val collections: StateFlow<List<Collection>> = _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<List<Collection>>(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<Collection>) {
|
||||
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<List<Collection>> {
|
||||
return runCatching {
|
||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||
_collections.value = imported
|
||||
persist()
|
||||
|
|
@ -228,7 +237,8 @@ object CollectionRepository {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun applyFromRemote(collections: List<Collection>) {
|
||||
internal fun applyFromRemote(collections: List<Collection>, 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue