From 9de7f8a34d6583768ed5eaccbfc2e250653818a5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:16:04 +0530 Subject: [PATCH] feat: Implement sync payload encoding and decoding for various settings --- .../mdblist/MdbListSettingsStorage.android.kt | 46 ++ .../player/PlayerSettingsStorage.android.kt | 115 ++++ .../settings/ThemeSettingsStorage.android.kt | 22 + .../tmdb/TmdbSettingsStorage.android.kt | 61 ++ .../trakt/TraktCommentsStorage.android.kt | 18 + .../app/core/sync/ProfileSettingsSync.kt | 575 ++++-------------- .../nuvio/app/core/sync/SyncPreferenceJson.kt | 89 +++ .../mdblist/MdbListSettingsStorage.kt | 4 + .../features/player/PlayerSettingsStorage.kt | 4 + .../features/settings/ThemeSettingsStorage.kt | 4 + .../app/features/tmdb/TmdbSettingsStorage.kt | 4 + .../features/trakt/TraktCommentsStorage.kt | 4 + .../mdblist/MdbListSettingsStorage.ios.kt | 46 ++ .../player/PlayerSettingsStorage.ios.kt | 117 ++++ .../settings/ThemeSettingsStorage.ios.kt | 22 + .../features/tmdb/TmdbSettingsStorage.ios.kt | 61 ++ .../trakt/TraktCommentsStorage.ios.kt | 18 + 17 files changed, 737 insertions(+), 473 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPreferenceJson.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt index ebc4ad16a..45e63d432 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt @@ -2,7 +2,14 @@ package com.nuvio.app.features.mdblist import android.content.Context import android.content.SharedPreferences +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put actual object MdbListSettingsStorage { private const val preferencesName = "nuvio_mdblist_settings" @@ -15,6 +22,17 @@ actual object MdbListSettingsStorage { private const val useTraktKey = "mdblist_use_trakt" private const val useLetterboxdKey = "mdblist_use_letterboxd" private const val useAudienceKey = "mdblist_use_audience" + private val syncKeys = listOf( + enabledKey, + apiKey, + useImdbKey, + useTmdbKey, + useTomatoesKey, + useMetacriticKey, + useTraktKey, + useLetterboxdKey, + useAudienceKey, + ) private var preferences: SharedPreferences? = null @@ -96,4 +114,32 @@ actual object MdbListSettingsStorage { ?.putBoolean(ProfileScopedKey.of(key), enabled) ?.apply() } + + 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) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + 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) + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index e06c58913..71d0531a6 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -2,7 +2,20 @@ package com.nuvio.app.features.player import android.content.Context import android.content.SharedPreferences +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put actual object PlayerSettingsStorage { private const val preferencesName = "nuvio_player_settings" @@ -36,6 +49,38 @@ actual object PlayerSettingsStorage { 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, + 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, + ) private var preferences: SharedPreferences? = null @@ -489,4 +534,74 @@ actual object PlayerSettingsStorage { ?.putString(ProfileScopedKey.of(libassRenderTypeKey), renderType) ?.apply() } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(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) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + 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) + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 6ad2a035d..aa0002f9f 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -2,12 +2,20 @@ package com.nuvio.app.features.settings import android.content.Context import android.content.SharedPreferences +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put 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) private var preferences: SharedPreferences? = null @@ -37,4 +45,18 @@ actual object ThemeSettingsStorage { ?.putBoolean(ProfileScopedKey.of(amoledEnabledKey), enabled) ?.apply() } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } + loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) + payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index 098309e16..92e4b6599 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -2,7 +2,14 @@ package com.nuvio.app.features.tmdb import android.content.Context import android.content.SharedPreferences +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put actual object TmdbSettingsStorage { private const val preferencesName = "nuvio_tmdb_settings" @@ -20,6 +27,22 @@ actual object TmdbSettingsStorage { private const val useSeasonPostersKey = "tmdb_use_season_posters" private const val useMoreLikeThisKey = "tmdb_use_more_like_this" private const val useCollectionsKey = "tmdb_use_collections" + private val syncKeys = listOf( + enabledKey, + apiKeyKey, + languageKey, + useTrailersKey, + useArtworkKey, + useBasicInfoKey, + useDetailsKey, + useCreditsKey, + useProductionsKey, + useNetworksKey, + useEpisodesKey, + useSeasonPostersKey, + useMoreLikeThisKey, + useCollectionsKey, + ) private var preferences: SharedPreferences? = null @@ -135,4 +158,42 @@ actual object TmdbSettingsStorage { ?.putBoolean(ProfileScopedKey.of(key), enabled) ?.apply() } + + 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) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + 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) + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt index 663478679..4856515a1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.android.kt @@ -2,11 +2,17 @@ package com.nuvio.app.features.trakt import android.content.Context import android.content.SharedPreferences +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncBoolean import com.nuvio.app.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put internal actual object TraktCommentsStorage { private const val preferencesName = "nuvio_trakt_comments" private const val enabledKey = "comments_enabled" + private val syncKeys = listOf(enabledKey) private var preferences: SharedPreferences? = null @@ -26,4 +32,16 @@ internal actual object TraktCommentsStorage { ?.putBoolean(ProfileScopedKey.of(enabledKey), enabled) ?.apply() } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 9ae95c46e..aaf9fca02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -1,29 +1,26 @@ package com.nuvio.app.core.sync -import androidx.compose.ui.graphics.Color 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.core.ui.AppTheme -import com.nuvio.app.features.details.MetaScreenSectionItem -import com.nuvio.app.features.details.MetaScreenSectionKey +import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository +import com.nuvio.app.features.mdblist.MdbListMetadataService +import com.nuvio.app.features.mdblist.MdbListSettingsStorage import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository +import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.player.SubtitleStyleState -import com.nuvio.app.features.player.subtitleColorFromStorage -import com.nuvio.app.features.player.toStorageHexString -import com.nuvio.app.features.player.skip.NextEpisodeThresholdMode import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.settings.ThemeSettingsRepository -import com.nuvio.app.features.streams.StreamAutoPlayMode -import com.nuvio.app.features.streams.StreamAutoPlaySource +import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsSettings +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository -import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlin.concurrent.Volatile @@ -103,23 +100,23 @@ object ProfileSettingsSync { return@withLock false } - val remoteBlob = runCatching { - json.decodeFromJsonElement(ProfileSettingsBlob.serializer(), remoteJson) - }.getOrElse { error -> - log.e(error) { "pull(profileId=$profileId) — failed to decode remote settings blob" } - return@withLock false - } - - val remoteSignature = buildSignature(remoteBlob) - if (remoteSignature == localSignature) { - log.d { "pull(profileId=$profileId) — remote matches local" } - return@withLock false - } - isApplyingRemoteBlob = true try { + val remoteBlob = runCatching { + json.decodeFromJsonElement(MobileProfileSettingsBlob.serializer(), remoteJson) + }.getOrElse { error -> + log.e(error) { "pull(profileId=$profileId) — failed to decode remote settings blob" } + return@withLock false + } + + val remoteSignature = buildSignature(remoteBlob) + if (remoteSignature == localSignature) { + log.d { "pull(profileId=$profileId) — remote matches local" } + return@withLock false + } + applyRemoteBlob(remoteBlob) - skipNextPushSignature = remoteSignature + skipNextPushSignature = currentObservedStateSignature() } finally { isApplyingRemoteBlob = false } @@ -149,21 +146,19 @@ object ProfileSettingsSync { @OptIn(FlowPreview::class) private fun observeLocalChangesAndPush() { val signatureFlows = listOf( - ThemeSettingsRepository.selectedTheme.map { "theme=${it.name}" }, - ThemeSettingsRepository.amoledEnabled.map { "amoled=$it" }, - PlayerSettingsRepository.uiState.map { buildPlayerSignature(it) }, - TmdbSettingsRepository.uiState.map { buildTmdbSignature(it) }, - MdbListSettingsRepository.uiState.map { buildMdbListSignature(it) }, - MetaScreenSettingsRepository.uiState.map { buildMetaScreenSignature(it) }, - ContinueWatchingPreferencesRepository.uiState.map { buildContinueWatchingSignature(it) }, - TraktCommentsSettings.enabled.map { "trakt_comments=$it" }, - EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts=${it.isEnabled}" }, + ThemeSettingsRepository.selectedTheme.map { "theme" }, + ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + PlayerSettingsRepository.uiState.map { "player" }, + TmdbSettingsRepository.uiState.map { "tmdb" }, + MdbListSettingsRepository.uiState.map { "mdblist" }, + MetaScreenSettingsRepository.uiState.map { "meta" }, + ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, + TraktCommentsSettings.enabled.map { "trakt_comments" }, + EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) observeJob = scope.launch { - combine(signatureFlows) { parts -> - parts.joinToString(separator = "||") - } + combine(signatureFlows) { currentObservedStateSignature() } .drop(1) .distinctUntilChanged() .debounce(PUSH_DEBOUNCE_MS) @@ -180,243 +175,58 @@ object ProfileSettingsSync { } } - private suspend fun pushToRemoteLocked(profileId: Int, blob: ProfileSettingsBlob) { + private suspend fun pushToRemoteLocked(profileId: Int, blob: MobileProfileSettingsBlob) { val params = buildJsonObject { put("p_profile_id", profileId) put("p_platform", MOBILE_SYNC_PLATFORM) - put("p_settings_json", json.encodeToJsonElement(ProfileSettingsBlob.serializer(), blob)) + put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob)) } SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params) log.d { "pushToRemoteLocked(profileId=$profileId) — success" } } - private fun exportSettingsBlob(): ProfileSettingsBlob { + private fun exportSettingsBlob(): MobileProfileSettingsBlob { ensureRepositoriesLoaded() - - val themePayload = ThemeSettingsPayload( - selectedTheme = ThemeSettingsRepository.selectedTheme.value.name, - amoledEnabled = ThemeSettingsRepository.amoledEnabled.value, - ) - - val playerState = PlayerSettingsRepository.uiState.value - val playerPayload = PlayerSettingsPayload( - showLoadingOverlay = playerState.showLoadingOverlay, - preferredAudioLanguage = playerState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerState.secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = playerState.preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = playerState.secondaryPreferredSubtitleLanguage, - subtitleStyle = SubtitleStylePayload( - textColorHex = playerState.subtitleStyle.textColor.toStorageHexString(), - outlineEnabled = playerState.subtitleStyle.outlineEnabled, - fontSizeSp = playerState.subtitleStyle.fontSizeSp, - bottomOffset = playerState.subtitleStyle.bottomOffset, - ), - streamReuseLastLinkEnabled = playerState.streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = playerState.streamReuseLastLinkCacheHours, - decoderPriority = playerState.decoderPriority, - mapDV7ToHevc = playerState.mapDV7ToHevc, - tunnelingEnabled = playerState.tunnelingEnabled, - streamAutoPlayMode = playerState.streamAutoPlayMode.name, - streamAutoPlaySource = playerState.streamAutoPlaySource.name, - streamAutoPlaySelectedAddons = playerState.streamAutoPlaySelectedAddons.sorted(), - streamAutoPlaySelectedPlugins = playerState.streamAutoPlaySelectedPlugins.sorted(), - streamAutoPlayRegex = playerState.streamAutoPlayRegex, - streamAutoPlayTimeoutSeconds = playerState.streamAutoPlayTimeoutSeconds, - skipIntroEnabled = playerState.skipIntroEnabled, - animeSkipEnabled = playerState.animeSkipEnabled, - animeSkipClientId = playerState.animeSkipClientId, - streamAutoPlayNextEpisodeEnabled = playerState.streamAutoPlayNextEpisodeEnabled, - streamAutoPlayPreferBingeGroup = playerState.streamAutoPlayPreferBingeGroup, - nextEpisodeThresholdMode = playerState.nextEpisodeThresholdMode.name, - nextEpisodeThresholdPercent = playerState.nextEpisodeThresholdPercent, - nextEpisodeThresholdMinutesBeforeEnd = playerState.nextEpisodeThresholdMinutesBeforeEnd, - useLibass = playerState.useLibass, - libassRenderType = playerState.libassRenderType, - ) - - val tmdbState = TmdbSettingsRepository.snapshot() - val tmdbPayload = TmdbSettingsPayload( - enabled = tmdbState.enabled, - apiKey = tmdbState.apiKey, - language = tmdbState.language, - useTrailers = tmdbState.useTrailers, - useArtwork = tmdbState.useArtwork, - useBasicInfo = tmdbState.useBasicInfo, - useDetails = tmdbState.useDetails, - useCredits = tmdbState.useCredits, - useProductions = tmdbState.useProductions, - useNetworks = tmdbState.useNetworks, - useEpisodes = tmdbState.useEpisodes, - useSeasonPosters = tmdbState.useSeasonPosters, - useMoreLikeThis = tmdbState.useMoreLikeThis, - useCollections = tmdbState.useCollections, - ) - - val mdbListState = MdbListSettingsRepository.snapshot() - val mdbListPayload = MdbListSettingsPayload( - enabled = mdbListState.enabled, - apiKey = mdbListState.apiKey, - useImdb = mdbListState.useImdb, - useTmdb = mdbListState.useTmdb, - useTomatoes = mdbListState.useTomatoes, - useMetacritic = mdbListState.useMetacritic, - useTrakt = mdbListState.useTrakt, - useLetterboxd = mdbListState.useLetterboxd, - useAudience = mdbListState.useAudience, - ) - - val metaState = MetaScreenSettingsRepository.uiState.value - val metaPayload = MetaScreenSettingsPayload( - items = metaState.items - .sortedBy { it.order } - .map { item -> - MetaScreenItemPayload( - key = item.key.name, - enabled = item.enabled, - order = item.order, - tabGroup = item.tabGroup, - ) - }, - cinematicBackground = metaState.cinematicBackground, - tabLayout = metaState.tabLayout, - ) - - val continueWatchingState = ContinueWatchingPreferencesRepository.uiState.value - val continueWatchingPayload = ContinueWatchingSettingsPayload( - isVisible = continueWatchingState.isVisible, - style = continueWatchingState.style.name, - upNextFromFurthestEpisode = continueWatchingState.upNextFromFurthestEpisode, - dismissedNextUpKeys = continueWatchingState.dismissedNextUpKeys.sorted(), - ) - - val traktCommentsPayload = TraktCommentsPayload( - enabled = TraktCommentsSettings.enabled.value, - ) - - val notificationsPayload = EpisodeReleaseNotificationsPayload( - alertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, - ) - - return ProfileSettingsBlob( - features = ProfileSettingsFeatures( - theme = themePayload, - player = playerPayload, - tmdb = tmdbPayload, - mdbList = mdbListPayload, - metaScreen = metaPayload, - continueWatching = continueWatchingPayload, - traktComments = traktCommentsPayload, - episodeReleaseNotifications = notificationsPayload, + return MobileProfileSettingsBlob( + features = MobileProfileSettingsFeatures( + themeSettings = ThemeSettingsStorage.exportToSyncPayload(), + playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), + mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), + metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), + continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), + traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), + notificationsSettings = NotificationsSettingsPayload( + episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, + ), ), ) } - private fun applyRemoteBlob(blob: ProfileSettingsBlob) { - val theme = blob.features.theme - ThemeSettingsRepository.setTheme( - runCatching { AppTheme.valueOf(theme.selectedTheme) }.getOrDefault(AppTheme.WHITE), - ) - ThemeSettingsRepository.setAmoled(theme.amoledEnabled) + private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) { + ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings) + ThemeSettingsRepository.onProfileChanged() - val player = blob.features.player - PlayerSettingsRepository.setShowLoadingOverlay(player.showLoadingOverlay) - PlayerSettingsRepository.setPreferredAudioLanguage(player.preferredAudioLanguage) - PlayerSettingsRepository.setSecondaryPreferredAudioLanguage(player.secondaryPreferredAudioLanguage) - PlayerSettingsRepository.setPreferredSubtitleLanguage(player.preferredSubtitleLanguage) - PlayerSettingsRepository.setSecondaryPreferredSubtitleLanguage(player.secondaryPreferredSubtitleLanguage) - PlayerSettingsRepository.setSubtitleStyle( - SubtitleStyleState( - textColor = subtitleColorFromStorage(player.subtitleStyle.textColorHex) ?: Color.White, - outlineEnabled = player.subtitleStyle.outlineEnabled, - fontSizeSp = player.subtitleStyle.fontSizeSp, - bottomOffset = player.subtitleStyle.bottomOffset, - ), - ) - PlayerSettingsRepository.setStreamReuseLastLinkEnabled(player.streamReuseLastLinkEnabled) - PlayerSettingsRepository.setStreamReuseLastLinkCacheHours(player.streamReuseLastLinkCacheHours) - PlayerSettingsRepository.setDecoderPriority(player.decoderPriority) - PlayerSettingsRepository.setMapDV7ToHevc(player.mapDV7ToHevc) - PlayerSettingsRepository.setTunnelingEnabled(player.tunnelingEnabled) - PlayerSettingsRepository.setStreamAutoPlayMode( - runCatching { StreamAutoPlayMode.valueOf(player.streamAutoPlayMode) }.getOrDefault(StreamAutoPlayMode.MANUAL), - ) - PlayerSettingsRepository.setStreamAutoPlaySource( - runCatching { StreamAutoPlaySource.valueOf(player.streamAutoPlaySource) } - .getOrDefault(StreamAutoPlaySource.ALL_SOURCES), - ) - PlayerSettingsRepository.setStreamAutoPlaySelectedAddons(player.streamAutoPlaySelectedAddons.toSet()) - PlayerSettingsRepository.setStreamAutoPlaySelectedPlugins(player.streamAutoPlaySelectedPlugins.toSet()) - PlayerSettingsRepository.setStreamAutoPlayRegex(player.streamAutoPlayRegex) - PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(player.streamAutoPlayTimeoutSeconds) - PlayerSettingsRepository.setSkipIntroEnabled(player.skipIntroEnabled) - PlayerSettingsRepository.setAnimeSkipEnabled(player.animeSkipEnabled) - PlayerSettingsRepository.setAnimeSkipClientId(player.animeSkipClientId) - PlayerSettingsRepository.setStreamAutoPlayNextEpisodeEnabled(player.streamAutoPlayNextEpisodeEnabled) - PlayerSettingsRepository.setStreamAutoPlayPreferBingeGroup(player.streamAutoPlayPreferBingeGroup) - PlayerSettingsRepository.setNextEpisodeThresholdMode( - runCatching { NextEpisodeThresholdMode.valueOf(player.nextEpisodeThresholdMode) } - .getOrDefault(NextEpisodeThresholdMode.PERCENTAGE), - ) - PlayerSettingsRepository.setNextEpisodeThresholdPercent(player.nextEpisodeThresholdPercent) - PlayerSettingsRepository.setNextEpisodeThresholdMinutesBeforeEnd(player.nextEpisodeThresholdMinutesBeforeEnd) - PlayerSettingsRepository.setUseLibass(player.useLibass) - PlayerSettingsRepository.setLibassRenderType(player.libassRenderType) + PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) + PlayerSettingsRepository.onProfileChanged() - val tmdb = blob.features.tmdb - TmdbSettingsRepository.setApiKey(tmdb.apiKey) - TmdbSettingsRepository.setLanguage(tmdb.language) - TmdbSettingsRepository.setUseTrailers(tmdb.useTrailers) - TmdbSettingsRepository.setUseArtwork(tmdb.useArtwork) - TmdbSettingsRepository.setUseBasicInfo(tmdb.useBasicInfo) - TmdbSettingsRepository.setUseDetails(tmdb.useDetails) - TmdbSettingsRepository.setUseCredits(tmdb.useCredits) - TmdbSettingsRepository.setUseProductions(tmdb.useProductions) - TmdbSettingsRepository.setUseNetworks(tmdb.useNetworks) - TmdbSettingsRepository.setUseEpisodes(tmdb.useEpisodes) - TmdbSettingsRepository.setUseSeasonPosters(tmdb.useSeasonPosters) - TmdbSettingsRepository.setUseMoreLikeThis(tmdb.useMoreLikeThis) - TmdbSettingsRepository.setUseCollections(tmdb.useCollections) - TmdbSettingsRepository.setEnabled(tmdb.enabled) + TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings) + TmdbSettingsRepository.onProfileChanged() - val mdbList = blob.features.mdbList - MdbListSettingsRepository.setApiKey(mdbList.apiKey) - MdbListSettingsRepository.setProviderEnabled("imdb", mdbList.useImdb) - MdbListSettingsRepository.setProviderEnabled("tmdb", mdbList.useTmdb) - MdbListSettingsRepository.setProviderEnabled("tomatoes", mdbList.useTomatoes) - MdbListSettingsRepository.setProviderEnabled("metacritic", mdbList.useMetacritic) - MdbListSettingsRepository.setProviderEnabled("trakt", mdbList.useTrakt) - MdbListSettingsRepository.setProviderEnabled("letterboxd", mdbList.useLetterboxd) - MdbListSettingsRepository.setProviderEnabled("audience", mdbList.useAudience) - MdbListSettingsRepository.setEnabled(mdbList.enabled) + MdbListSettingsStorage.replaceFromSyncPayload(blob.features.mdbListSettings) + MdbListMetadataService.clearCache() + MdbListSettingsRepository.onProfileChanged() - val metaScreen = blob.features.metaScreen - MetaScreenSettingsRepository.applyFromSync( - items = metaScreen.items.map { item -> - MetaScreenSectionItem( - key = runCatching { MetaScreenSectionKey.valueOf(item.key) } - .getOrDefault(MetaScreenSectionKey.ACTIONS), - title = "", - description = "", - enabled = item.enabled, - order = item.order, - tabGroup = item.tabGroup, - ) - }, - cinematicBackground = metaScreen.cinematicBackground, - tabLayout = metaScreen.tabLayout, - ) + MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload) + MetaScreenSettingsRepository.onProfileChanged() - val continueWatching = blob.features.continueWatching - ContinueWatchingPreferencesRepository.applyFromSync( - isVisible = continueWatching.isVisible, - style = runCatching { ContinueWatchingSectionStyle.valueOf(continueWatching.style) } - .getOrDefault(ContinueWatchingSectionStyle.Wide), - upNextFromFurthestEpisode = continueWatching.upNextFromFurthestEpisode, - dismissedNextUpKeys = continueWatching.dismissedNextUpKeys.toSet(), - ) + ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) + ContinueWatchingPreferencesRepository.onProfileChanged() - TraktCommentsSettings.setEnabled(blob.features.traktComments.enabled) - EpisodeReleaseNotificationsRepository.applyFromSyncEnabled(blob.features.episodeReleaseNotifications.alertsEnabled) + TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) + TraktCommentsSettings.onProfileChanged() + + EpisodeReleaseNotificationsRepository.applyFromSyncEnabled(blob.features.notificationsSettings.episodeReleaseAlertsEnabled) } private fun ensureRepositoriesLoaded() { @@ -430,232 +240,51 @@ object ProfileSettingsSync { EpisodeReleaseNotificationsRepository.ensureLoaded() } - private fun buildSignature(blob: ProfileSettingsBlob): String = - json.encodeToString(ProfileSettingsBlob.serializer(), blob) + private fun buildSignature(blob: MobileProfileSettingsBlob): String = + json.encodeToString(MobileProfileSettingsBlob.serializer(), blob) private fun defaultSignature(): String = - buildSignature(ProfileSettingsBlob()) + buildSignature(MobileProfileSettingsBlob()) - private fun buildPlayerSignature(state: com.nuvio.app.features.player.PlayerSettingsUiState): String = - json.encodeToString(PlayerSettingsPayload.serializer(), PlayerSettingsPayload( - showLoadingOverlay = state.showLoadingOverlay, - preferredAudioLanguage = state.preferredAudioLanguage, - secondaryPreferredAudioLanguage = state.secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = state.preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = state.secondaryPreferredSubtitleLanguage, - subtitleStyle = SubtitleStylePayload( - textColorHex = state.subtitleStyle.textColor.toStorageHexString(), - outlineEnabled = state.subtitleStyle.outlineEnabled, - fontSizeSp = state.subtitleStyle.fontSizeSp, - bottomOffset = state.subtitleStyle.bottomOffset, - ), - streamReuseLastLinkEnabled = state.streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = state.streamReuseLastLinkCacheHours, - decoderPriority = state.decoderPriority, - mapDV7ToHevc = state.mapDV7ToHevc, - tunnelingEnabled = state.tunnelingEnabled, - streamAutoPlayMode = state.streamAutoPlayMode.name, - streamAutoPlaySource = state.streamAutoPlaySource.name, - streamAutoPlaySelectedAddons = state.streamAutoPlaySelectedAddons.sorted(), - streamAutoPlaySelectedPlugins = state.streamAutoPlaySelectedPlugins.sorted(), - streamAutoPlayRegex = state.streamAutoPlayRegex, - streamAutoPlayTimeoutSeconds = state.streamAutoPlayTimeoutSeconds, - skipIntroEnabled = state.skipIntroEnabled, - animeSkipEnabled = state.animeSkipEnabled, - animeSkipClientId = state.animeSkipClientId, - streamAutoPlayNextEpisodeEnabled = state.streamAutoPlayNextEpisodeEnabled, - streamAutoPlayPreferBingeGroup = state.streamAutoPlayPreferBingeGroup, - nextEpisodeThresholdMode = state.nextEpisodeThresholdMode.name, - nextEpisodeThresholdPercent = state.nextEpisodeThresholdPercent, - nextEpisodeThresholdMinutesBeforeEnd = state.nextEpisodeThresholdMinutesBeforeEnd, - useLibass = state.useLibass, - libassRenderType = state.libassRenderType, - )) - - private fun buildTmdbSignature(state: com.nuvio.app.features.tmdb.TmdbSettings): String = - json.encodeToString(TmdbSettingsPayload.serializer(), TmdbSettingsPayload( - enabled = state.enabled, - apiKey = state.apiKey, - language = state.language, - useTrailers = state.useTrailers, - useArtwork = state.useArtwork, - useBasicInfo = state.useBasicInfo, - useDetails = state.useDetails, - useCredits = state.useCredits, - useProductions = state.useProductions, - useNetworks = state.useNetworks, - useEpisodes = state.useEpisodes, - useSeasonPosters = state.useSeasonPosters, - useMoreLikeThis = state.useMoreLikeThis, - useCollections = state.useCollections, - )) - - private fun buildMdbListSignature(state: com.nuvio.app.features.mdblist.MdbListSettings): String = - json.encodeToString(MdbListSettingsPayload.serializer(), MdbListSettingsPayload( - enabled = state.enabled, - apiKey = state.apiKey, - useImdb = state.useImdb, - useTmdb = state.useTmdb, - useTomatoes = state.useTomatoes, - useMetacritic = state.useMetacritic, - useTrakt = state.useTrakt, - useLetterboxd = state.useLetterboxd, - useAudience = state.useAudience, - )) - - private fun buildMetaScreenSignature(state: com.nuvio.app.features.details.MetaScreenSettingsUiState): String = - json.encodeToString(MetaScreenSettingsPayload.serializer(), MetaScreenSettingsPayload( - items = state.items.sortedBy { it.order }.map { item -> - MetaScreenItemPayload( - key = item.key.name, - enabled = item.enabled, - order = item.order, - tabGroup = item.tabGroup, - ) - }, - cinematicBackground = state.cinematicBackground, - tabLayout = state.tabLayout, - )) - - private fun buildContinueWatchingSignature(state: com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState): String = - json.encodeToString(ContinueWatchingSettingsPayload.serializer(), ContinueWatchingSettingsPayload( - isVisible = state.isVisible, - style = state.style.name, - upNextFromFurthestEpisode = state.upNextFromFurthestEpisode, - dismissedNextUpKeys = state.dismissedNextUpKeys.sorted(), - )) + private fun currentObservedStateSignature(): String = listOf( + "theme=${ThemeSettingsRepository.selectedTheme.value.name}", + "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "player=${PlayerSettingsRepository.uiState.value}", + "tmdb=${TmdbSettingsRepository.uiState.value}", + "mdblist=${MdbListSettingsRepository.uiState.value}", + "meta=${MetaScreenSettingsRepository.uiState.value}", + "continue=${ContinueWatchingPreferencesRepository.uiState.value}", + "trakt_comments=${TraktCommentsSettings.enabled.value}", + "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", + ).joinToString(separator = "||") } +@Serializable +private data class MobileProfileSettingsBlob( + val version: Int = 2, + val features: MobileProfileSettingsFeatures = MobileProfileSettingsFeatures(), +) + +@Serializable +private data class MobileProfileSettingsFeatures( + @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", + @SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "", + @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), +) + +@Serializable +private data class NotificationsSettingsPayload( + @SerialName("episode_release_alerts_enabled") val episodeReleaseAlertsEnabled: Boolean = false, +) + @Serializable private data class SettingsBlobResponse( @SerialName("profile_id") val profileId: Int = 0, @SerialName("settings_json") val settingsJson: JsonObject? = null, @SerialName("updated_at") val updatedAt: String? = null, ) - -@Serializable -private data class ProfileSettingsBlob( - val version: Int = 1, - val features: ProfileSettingsFeatures = ProfileSettingsFeatures(), -) - -@Serializable -private data class ProfileSettingsFeatures( - val theme: ThemeSettingsPayload = ThemeSettingsPayload(), - val player: PlayerSettingsPayload = PlayerSettingsPayload(), - val tmdb: TmdbSettingsPayload = TmdbSettingsPayload(), - @SerialName("mdb_list") val mdbList: MdbListSettingsPayload = MdbListSettingsPayload(), - @SerialName("meta_screen") val metaScreen: MetaScreenSettingsPayload = MetaScreenSettingsPayload(), - @SerialName("continue_watching") val continueWatching: ContinueWatchingSettingsPayload = ContinueWatchingSettingsPayload(), - @SerialName("trakt_comments") val traktComments: TraktCommentsPayload = TraktCommentsPayload(), - @SerialName("episode_release_notifications") val episodeReleaseNotifications: EpisodeReleaseNotificationsPayload = EpisodeReleaseNotificationsPayload(), -) - -@Serializable -private data class ThemeSettingsPayload( - val selectedTheme: String = AppTheme.WHITE.name, - val amoledEnabled: Boolean = false, -) - -@Serializable -private data class SubtitleStylePayload( - val textColorHex: String = Color.White.toStorageHexString(), - val outlineEnabled: Boolean = false, - val fontSizeSp: Int = 18, - val bottomOffset: Int = 20, -) - -@Serializable -private data class PlayerSettingsPayload( - val showLoadingOverlay: Boolean = true, - val preferredAudioLanguage: String = "device", - val secondaryPreferredAudioLanguage: String? = null, - val preferredSubtitleLanguage: String = "none", - val secondaryPreferredSubtitleLanguage: String? = null, - val subtitleStyle: SubtitleStylePayload = SubtitleStylePayload(), - val streamReuseLastLinkEnabled: Boolean = false, - val streamReuseLastLinkCacheHours: Int = 24, - val decoderPriority: Int = 1, - val mapDV7ToHevc: Boolean = false, - val tunnelingEnabled: Boolean = false, - val streamAutoPlayMode: String = StreamAutoPlayMode.MANUAL.name, - val streamAutoPlaySource: String = StreamAutoPlaySource.ALL_SOURCES.name, - val streamAutoPlaySelectedAddons: List = emptyList(), - val streamAutoPlaySelectedPlugins: List = emptyList(), - val streamAutoPlayRegex: String = "", - val streamAutoPlayTimeoutSeconds: Int = 3, - val skipIntroEnabled: Boolean = true, - val animeSkipEnabled: Boolean = false, - val animeSkipClientId: String = "", - val streamAutoPlayNextEpisodeEnabled: Boolean = false, - val streamAutoPlayPreferBingeGroup: Boolean = true, - val nextEpisodeThresholdMode: String = NextEpisodeThresholdMode.PERCENTAGE.name, - val nextEpisodeThresholdPercent: Float = 99f, - val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, - val useLibass: Boolean = false, - val libassRenderType: String = "CUES", -) - -@Serializable -private data class TmdbSettingsPayload( - val enabled: Boolean = false, - val apiKey: String = "", - val language: String = "en", - val useTrailers: Boolean = true, - val useArtwork: Boolean = true, - val useBasicInfo: Boolean = true, - val useDetails: Boolean = true, - val useCredits: Boolean = true, - val useProductions: Boolean = true, - val useNetworks: Boolean = true, - val useEpisodes: Boolean = true, - val useSeasonPosters: Boolean = true, - val useMoreLikeThis: Boolean = true, - val useCollections: Boolean = true, -) - -@Serializable -private data class MdbListSettingsPayload( - val enabled: Boolean = false, - val apiKey: String = "", - val useImdb: Boolean = true, - val useTmdb: Boolean = true, - val useTomatoes: Boolean = true, - val useMetacritic: Boolean = true, - val useTrakt: Boolean = true, - val useLetterboxd: Boolean = true, - val useAudience: Boolean = true, -) - -@Serializable -private data class MetaScreenItemPayload( - val key: String, - val enabled: Boolean = true, - val order: Int = 0, - val tabGroup: Int? = null, -) - -@Serializable -private data class MetaScreenSettingsPayload( - val items: List = emptyList(), - val cinematicBackground: Boolean = false, - val tabLayout: Boolean = false, -) - -@Serializable -private data class ContinueWatchingSettingsPayload( - val isVisible: Boolean = true, - val style: String = ContinueWatchingSectionStyle.Wide.name, - val upNextFromFurthestEpisode: Boolean = true, - val dismissedNextUpKeys: List = emptyList(), -) - -@Serializable -private data class TraktCommentsPayload( - val enabled: Boolean = true, -) - -@Serializable -private data class EpisodeReleaseNotificationsPayload( - val alertsEnabled: Boolean = false, -) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPreferenceJson.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPreferenceJson.kt new file mode 100644 index 000000000..c9b04c262 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPreferenceJson.kt @@ -0,0 +1,89 @@ +package com.nuvio.app.core.sync + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +private const val TYPE_KEY = "type" +private const val VALUE_KEY = "value" +private const val TYPE_STRING = "string" +private const val TYPE_BOOLEAN = "boolean" +private const val TYPE_INT = "int" +private const val TYPE_FLOAT = "float" +private const val TYPE_STRING_SET = "string_set" + +internal fun encodeSyncString(value: String): JsonObject = buildJsonObject { + put(TYPE_KEY, TYPE_STRING) + put(VALUE_KEY, value) +} + +internal fun encodeSyncBoolean(value: Boolean): JsonObject = buildJsonObject { + put(TYPE_KEY, TYPE_BOOLEAN) + put(VALUE_KEY, value) +} + +internal fun encodeSyncInt(value: Int): JsonObject = buildJsonObject { + put(TYPE_KEY, TYPE_INT) + put(VALUE_KEY, value) +} + +internal fun encodeSyncFloat(value: Float): JsonObject = buildJsonObject { + put(TYPE_KEY, TYPE_FLOAT) + put(VALUE_KEY, value) +} + +internal fun encodeSyncStringSet(values: Set): JsonObject = buildJsonObject { + put(TYPE_KEY, TYPE_STRING_SET) + put(VALUE_KEY, JsonArray(values.sorted().map(::JsonPrimitive))) +} + +internal fun JsonObject.decodeSyncString(key: String): String? = + get(key) + ?.jsonObject + ?.takeIf { it[TYPE_KEY]?.jsonPrimitive?.contentOrNull == TYPE_STRING } + ?.get(VALUE_KEY) + ?.jsonPrimitive + ?.contentOrNull + +internal fun JsonObject.decodeSyncBoolean(key: String): Boolean? = + get(key) + ?.jsonObject + ?.takeIf { it[TYPE_KEY]?.jsonPrimitive?.contentOrNull == TYPE_BOOLEAN } + ?.get(VALUE_KEY) + ?.jsonPrimitive + ?.booleanOrNull + +internal fun JsonObject.decodeSyncInt(key: String): Int? = + get(key) + ?.jsonObject + ?.takeIf { it[TYPE_KEY]?.jsonPrimitive?.contentOrNull == TYPE_INT } + ?.get(VALUE_KEY) + ?.jsonPrimitive + ?.intOrNull + +internal fun JsonObject.decodeSyncFloat(key: String): Float? = + get(key) + ?.jsonObject + ?.takeIf { it[TYPE_KEY]?.jsonPrimitive?.contentOrNull == TYPE_FLOAT } + ?.get(VALUE_KEY) + ?.jsonPrimitive + ?.floatOrNull + +internal fun JsonObject.decodeSyncStringSet(key: String): Set? = + get(key) + ?.jsonObject + ?.takeIf { it[TYPE_KEY]?.jsonPrimitive?.contentOrNull == TYPE_STRING_SET } + ?.get(VALUE_KEY) + ?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull?.trim() } + ?.filter(String::isNotBlank) + ?.toSet() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt index 082272f9f..c1a6f51e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.mdblist +import kotlinx.serialization.json.JsonObject + internal expect object MdbListSettingsStorage { fun loadEnabled(): Boolean? fun saveEnabled(enabled: Boolean) @@ -19,4 +21,6 @@ internal expect object MdbListSettingsStorage { fun saveUseLetterboxd(enabled: Boolean) fun loadUseAudience(): Boolean? fun saveUseAudience(enabled: Boolean) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 7ca40fcd4..27909b2aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.player +import kotlinx.serialization.json.JsonObject + internal expect object PlayerSettingsStorage { fun loadShowLoadingOverlay(): Boolean? fun saveShowLoadingOverlay(enabled: Boolean) @@ -61,4 +63,6 @@ internal expect object PlayerSettingsStorage { fun saveUseLibass(enabled: Boolean) fun loadLibassRenderType(): String? fun saveLibassRenderType(renderType: String) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 136168ce4..9de3eb789 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -1,8 +1,12 @@ package com.nuvio.app.features.settings +import kotlinx.serialization.json.JsonObject + internal expect object ThemeSettingsStorage { fun loadSelectedTheme(): String? fun saveSelectedTheme(themeName: String) fun loadAmoledEnabled(): Boolean? fun saveAmoledEnabled(enabled: Boolean) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index 87103e200..f4550bdb8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.tmdb +import kotlinx.serialization.json.JsonObject + internal expect object TmdbSettingsStorage { fun loadEnabled(): Boolean? fun saveEnabled(enabled: Boolean) @@ -29,4 +31,6 @@ internal expect object TmdbSettingsStorage { fun saveUseMoreLikeThis(enabled: Boolean) fun loadUseCollections(): Boolean? fun saveUseCollections(enabled: Boolean) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt index d6df09f07..278142ebe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.kt @@ -1,6 +1,10 @@ package com.nuvio.app.features.trakt +import kotlinx.serialization.json.JsonObject + internal expect object TraktCommentsStorage { fun loadEnabled(): Boolean? fun saveEnabled(enabled: Boolean) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt index 8b06b9ff0..d05e79f14 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt @@ -1,6 +1,13 @@ package com.nuvio.app.features.mdblist +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import platform.Foundation.NSUserDefaults actual object MdbListSettingsStorage { @@ -13,6 +20,17 @@ actual object MdbListSettingsStorage { 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) @@ -82,4 +100,32 @@ actual object MdbListSettingsStorage { private fun saveBoolean(key: String, enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) } + + 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 { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + 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) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 85f759128..cfa20e98d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -1,6 +1,19 @@ package com.nuvio.app.features.player +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import platform.Foundation.NSUserDefaults actual object PlayerSettingsStorage { @@ -32,6 +45,40 @@ actual object PlayerSettingsStorage { 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, + 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? { val defaults = NSUserDefaults.standardUserDefaults @@ -400,4 +447,74 @@ actual object PlayerSettingsStorage { actual fun loadLibassRenderType(): String? = null actual fun saveLibassRenderType(renderType: String) {} + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(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 { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + 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) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index 7c72ebce7..4b2c78dc0 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -1,11 +1,19 @@ package com.nuvio.app.features.settings +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import platform.Foundation.NSUserDefaults actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" + private val syncKeys = listOf(selectedThemeKey, amoledEnabledKey) actual fun loadSelectedTheme(): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedThemeKey)) @@ -27,4 +35,18 @@ actual object ThemeSettingsStorage { actual fun saveAmoledEnabled(enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } + loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) + payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index 3ea24c1d5..bee39ff6c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -1,6 +1,13 @@ package com.nuvio.app.features.tmdb +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.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import platform.Foundation.NSUserDefaults actual object TmdbSettingsStorage { @@ -18,6 +25,22 @@ actual object TmdbSettingsStorage { 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) @@ -118,4 +141,42 @@ actual object TmdbSettingsStorage { private fun saveBoolean(key: String, enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) } + + 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 { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + 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) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt index 36220c19d..80871fd30 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsStorage.ios.kt @@ -1,10 +1,16 @@ package com.nuvio.app.features.trakt +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncBoolean import com.nuvio.app.core.storage.ProfileScopedKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import platform.Foundation.NSUserDefaults internal actual object TraktCommentsStorage { private const val enabledKey = "comments_enabled" + private val syncKeys = listOf(enabledKey) actual fun loadEnabled(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults @@ -19,4 +25,16 @@ internal actual object TraktCommentsStorage { actual fun saveEnabled(enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(enabledKey)) } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + } }