diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 5ecf9ec9b..d4c7b9ef0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -123,6 +123,7 @@ import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen import com.nuvio.app.features.collection.CollectionEditorRepository import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamContext @@ -394,6 +395,9 @@ private fun MainAppContent( remember { CollectionSyncService.startObserving() } + remember { + HomeCatalogSettingsSyncService.startObserving() + } val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 6e0e79dd9..089e5f2ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionSyncService +import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.profiles.ProfileRepository @@ -61,6 +62,10 @@ object SyncManager { runCatching { CollectionSyncService.pullFromServer(profileId) } .onFailure { log.e(it) { "Collections pull failed" } } } + launch { + runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "HomeCatalogSettings pull failed" } } + } log.i { "pullAllForProfile($profileId) — all pulls launched" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 0767805a0..20e319632 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -378,6 +378,60 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun exportToSyncPayload(): SyncHomeCatalogPayload { + ensureLoaded() + val items = preferences.values.sortedBy { it.order }.map { pref -> + val parts = pref.key.split(":") + val isCollection = pref.key.startsWith("collection_") + if (isCollection) { + SyncCatalogItem( + addonId = "", + type = "", + catalogId = "", + enabled = pref.enabled, + order = pref.order, + customTitle = pref.customTitle, + isCollection = true, + collectionId = pref.key.removePrefix("collection_"), + ) + } else { + SyncCatalogItem( + addonId = parts.getOrElse(0) { "" }, + type = parts.getOrElse(1) { "" }, + catalogId = parts.getOrElse(2) { "" }, + enabled = pref.enabled, + order = pref.order, + customTitle = pref.customTitle, + isCollection = false, + ) + } + } + return SyncHomeCatalogPayload(items = items) + } + + fun applyFromRemote(payload: SyncHomeCatalogPayload) { + ensureLoaded() + val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } + preferences = payload.items.associate { item -> + val key = if (item.isCollection) { + "collection_${item.collectionId}" + } else { + "${item.addonId}:${item.type}:${item.catalogId}" + } + key to StoredHomeCatalogPreference( + key = key, + customTitle = item.customTitle, + enabled = item.enabled, + heroSourceEnabled = existingHeroState[key] ?: true, + order = item.order, + ) + }.toMutableMap() + hasLoaded = true + publish() + persist() + HomeRepository.applyCurrentSettings() + } + private fun allOrderedKeys(): List { val catalogKeys = definitions.map { it.key } val collectionKeys = collectionDefinitions.map { it.key } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt new file mode 100644 index 000000000..4c689bdfd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -0,0 +1,156 @@ +package com.nuvio.app.features.home + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.profiles.ProfileRepository +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlin.concurrent.Volatile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +@Serializable +data class SyncCatalogItem( + @SerialName("addon_id") val addonId: String, + val type: String, + @SerialName("catalog_id") val catalogId: String, + val enabled: Boolean = true, + val order: Int = 0, + @SerialName("custom_title") val customTitle: String = "", + @SerialName("is_collection") val isCollection: Boolean = false, + @SerialName("collection_id") val collectionId: String = "", +) + +@Serializable +data class SyncHomeCatalogPayload( + val items: List = emptyList(), +) + +@Serializable +private data class SupabaseHomeCatalogSettingsBlob( + @SerialName("profile_id") val profileId: Int = 1, + @SerialName("settings_json") val settingsJson: JsonObject = buildJsonObject { }, + @SerialName("updated_at") val updatedAt: String? = null, +) + +object HomeCatalogSettingsSyncService { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val log = Logger.withTag("HomeCatalogSettingsSyncService") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private const val PUSH_DEBOUNCE_MS = 1500L + + @Volatile + var isSyncingFromRemote: Boolean = false + + private var pushJob: Job? = null + private var observeJob: Job? = null + + fun startObserving() { + if (observeJob?.isActive == true) return + observeLocalChangesAndPush() + } + + suspend fun pullFromServer(profileId: Int) { + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) + val blobs = result.decodeList() + val blob = blobs.firstOrNull() + + if (blob == null) { + log.i { "pullFromServer — no remote home catalog settings found" } + return + } + + val remotePayload = runCatching { + json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson) + }.getOrNull() + + if (remotePayload == null) { + log.w { "pullFromServer — failed to parse remote home catalog settings" } + return + } + + if (remotePayload.items.isEmpty()) { + log.i { "pullFromServer — remote has empty items, preserving local" } + return + } + + isSyncingFromRemote = true + HomeCatalogSettingsRepository.applyFromRemote(remotePayload) + isSyncingFromRemote = false + log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } + }.onFailure { e -> + isSyncingFromRemote = false + log.e(e) { "pullFromServer — FAILED" } + } + } + + fun triggerPush() { + pushJob?.cancel() + pushJob = scope.launch { + delay(500) + if (isSyncingFromRemote) return@launch + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch + pushToRemote() + } + } + + private suspend fun pushToRemote() { + runCatching { + val profileId = ProfileRepository.activeProfileId + val payload = HomeCatalogSettingsRepository.exportToSyncPayload() + val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload) + + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_settings_json", jsonElement) + } + SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) + log.d { "pushToRemote — success" } + }.onFailure { e -> + log.e(e) { "pushToRemote — FAILED" } + } + } + + @OptIn(FlowPreview::class) + private fun observeLocalChangesAndPush() { + observeJob = scope.launch { + HomeCatalogSettingsRepository.uiState + .map { it.signature } + .drop(1) + .distinctUntilChanged() + .debounce(PUSH_DEBOUNCE_MS) + .collect { + if (isSyncingFromRemote) return@collect + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect + pushToRemote() + } + } + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 145892afa..00d7b192c 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,2 +1,2 @@ -CURRENT_PROJECT_VERSION=12 -MARKETING_VERSION=0.1.0-alpha12 \ No newline at end of file +CURRENT_PROJECT_VERSION=14 +MARKETING_VERSION=0.1.0-alpha14 \ No newline at end of file