feat: adding seperate preference key for collections

This commit is contained in:
tapframe 2026-05-09 00:46:55 +05:30
parent 0ce89650c2
commit c16711ebb8
16 changed files with 314 additions and 12 deletions

View file

@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)

View file

@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
"nuvio_collection_mobile_settings",
"nuvio_collections",
"nuvio_plugins",
)

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.collection
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object CollectionMobileSettingsStorage {
private const val preferencesName = "nuvio_collection_mobile_settings"
private const val payloadKey = "collection_mobile_settings_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState()
CollectionMobileSettingsRepository.clearLocalState()
CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()

View file

@ -4,6 +4,8 @@ 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.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -158,6 +160,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
@ -202,6 +205,7 @@ object ProfileSettingsSync {
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
@ -232,6 +236,9 @@ object ProfileSettingsSync {
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
MetaScreenSettingsRepository.onProfileChanged()
CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
CollectionMobileSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
@ -251,6 +258,7 @@ object ProfileSettingsSync {
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
CollectionMobileSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
@ -272,6 +280,7 @@ object ProfileSettingsSync {
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
@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("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),

View file

@ -195,10 +195,10 @@ object CollectionEditorRepository {
)
}
fun updateFolderFocusGifEnabled(enabled: Boolean) {
fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(focusGifEnabled = enabled),
editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
)
}
@ -808,6 +808,8 @@ object CollectionEditorRepository {
folders = state.folders,
)
CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
if (state.isNew) {
CollectionRepository.addCollection(collection)
} else {

View file

@ -702,8 +702,8 @@ private fun FolderEditorPage(
FolderEditorToggleRow(
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
checked = folder.focusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
checked = folder.mobileFocusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
)
FolderEditorToggleRow(

View file

@ -0,0 +1,155 @@
package com.nuvio.app.features.collection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class CollectionMobileSettingsUiState(
val folderGifOverrides: Map<String, Boolean> = emptyMap(),
)
object CollectionMobileSettingsRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
val uiState: StateFlow<CollectionMobileSettingsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
CollectionRepository.onMobileSettingsChanged()
}
fun clearLocalState() {
hasLoaded = false
_uiState.value = CollectionMobileSettingsUiState()
}
fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean {
ensureLoaded()
return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true
}
fun applyToCollections(collections: List<Collection>): List<Collection> {
ensureLoaded()
return collections.map(::applyToCollection)
}
fun applyToCollection(collection: Collection): Collection {
ensureLoaded()
return collection.copy(
folders = collection.folders.map { folder ->
folder.copy(
mobileFocusGifEnabled = isFolderGifEnabled(
collectionId = collection.id,
folderId = folder.id,
),
)
},
)
}
fun replaceCollectionFolderGifSettings(collectionId: String, folders: List<CollectionFolder>) {
ensureLoaded()
val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator"
val next = _uiState.value.folderGifOverrides
.filterKeys { key -> !key.startsWith(collectionPrefix) }
.toMutableMap()
folders.forEach { folder ->
val key = folderKey(collectionId, folder.id)
if (folder.mobileFocusGifEnabled) {
next.remove(key)
} else {
next[key] = false
}
}
_uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next)
persist()
CollectionRepository.onMobileSettingsChanged()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = CollectionMobileSettingsUiState()
return
}
val stored = runCatching {
json.decodeFromString<StoredCollectionMobileSettingsPayload>(payload)
}.getOrNull()
_uiState.value = CollectionMobileSettingsUiState(
folderGifOverrides = stored
?.folderGifOverrides
.orEmpty()
.mapNotNull { item ->
if (item.collectionId.isBlank() || item.folderId.isBlank()) {
null
} else {
folderKey(item.collectionId, item.folderId) to item.enabled
}
}
.toMap(),
)
}
private fun persist() {
if (_uiState.value.folderGifOverrides.isEmpty()) {
CollectionMobileSettingsStorage.savePayload("")
return
}
val payload = StoredCollectionMobileSettingsPayload(
folderGifOverrides = _uiState.value.folderGifOverrides
.mapNotNull { (key, enabled) ->
val parts = key.split(FolderKeySeparator, limit = 2)
val collectionId = parts.getOrNull(0).orEmpty()
val folderId = parts.getOrNull(1).orEmpty()
if (collectionId.isBlank() || folderId.isBlank()) {
null
} else {
StoredFolderGifOverride(
collectionId = collectionId,
folderId = folderId,
enabled = enabled,
)
}
}
.sortedWith(compareBy<StoredFolderGifOverride> { it.collectionId }.thenBy { it.folderId }),
)
CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload))
}
private fun folderKey(collectionId: String, folderId: String): String =
"${collectionId.trim()}$FolderKeySeparator${folderId.trim()}"
}
private const val FolderKeySeparator = "\u001F"
@Serializable
private data class StoredCollectionMobileSettingsPayload(
@SerialName("folder_gif_overrides") val folderGifOverrides: List<StoredFolderGifOverride> = emptyList(),
)
@Serializable
private data class StoredFolderGifOverride(
@SerialName("collection_id") val collectionId: String,
@SerialName("folder_id") val folderId: String,
val enabled: Boolean = true,
)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.collection
internal expect object CollectionMobileSettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
enum class FolderViewMode {
TABBED_GRID,
@ -168,6 +169,8 @@ data class CollectionFolder(
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,
val focusGifEnabled: Boolean = true,
@Transient
val mobileFocusGifEnabled: Boolean = true,
val coverEmoji: String? = null,
val tileShape: String = "poster",
val hideTitle: Boolean = false,

View file

@ -52,7 +52,8 @@ object CollectionRepository {
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload)
val decoded = json.decodeFromString<List<Collection>>(payload)
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
}
@ -75,14 +76,15 @@ object CollectionRepository {
fun addCollection(collection: Collection) {
ensureLoaded()
_collections.value = _collections.value + collection
_collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
persist()
}
fun updateCollection(collection: Collection) {
ensureLoaded()
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
_collections.value = _collections.value.map {
if (it.id == collection.id) collection else it
if (it.id == collection.id) decorated else it
}
persist()
}
@ -95,7 +97,7 @@ object CollectionRepository {
fun setCollections(collections: List<Collection>) {
ensureLoaded()
_collections.value = collections
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist()
}
@ -127,7 +129,7 @@ object CollectionRepository {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
persist()
imported
}
@ -262,10 +264,15 @@ object CollectionRepository {
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
rawCollectionsJson = rawJson
_collections.value = collections
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
persist(sync = false)
}
internal fun onMobileSettingsChanged() {
if (!hasLoaded) return
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
}
private fun ensureLoaded() {
if (!hasLoaded) initialize()
}

View file

@ -186,7 +186,7 @@ private fun CollectionFolderCard(
}
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
return if (folder.focusGifEnabled) {
return if (folder.mobileFocusGifEnabled) {
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
} else {
firstNonBlank(folder.coverImageUrl)
@ -202,5 +202,5 @@ private fun isAnimatedCollectionFolderImage(
imageUrl: String,
): Boolean {
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
return folder.focusGifEnabled && imageUrl == gifUrl
return folder.mobileFocusGifEnabled && imageUrl == gifUrl
}

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -156,6 +157,7 @@ object ProfileRepository {
TraktAuthRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
CollectionRepository.onProfileChanged()
CollectionMobileSettingsRepository.onProfileChanged()
DownloadsRepository.onProfileChanged()
}

View file

@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
assertTrue(merged.contains(""""customField":"keep-me""""))
assertTrue(merged.contains(""""traktListId":123456"""))
}
@Test
fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() {
val raw = json.parseToJsonElement(
"""
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"coverImageUrl": "https://example.com/poster.jpg",
"focusGifUrl": "https://example.com/focus.gif",
"focusGifEnabled": true
}
]
}
]
""".trimIndent(),
)
val collection = json.decodeFromString<List<Collection>>(raw.toString()).single()
val mobileDisabled = collection.copy(
folders = collection.folders.map { folder ->
folder.copy(mobileFocusGifEnabled = false)
},
)
val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
val mergedFolder = merged
.single()
.jsonObject["folders"]!!
.jsonArray
.single()
.jsonObject
assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
}
@Test
fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
val payload = """
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"focusGifUrl": "https://example.com/focus.gif",
"focusGifEnabled": false
}
]
}
]
""".trimIndent()
val folder = json.decodeFromString<List<Collection>>(payload).single().folders.single()
assertFalse(folder.focusGifEnabled)
assertTrue(folder.mobileFocusGifEnabled)
}
}

View file

@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload",
"trakt_library_payload",
"trakt_settings_payload",
"collection_mobile_settings_payload",
"collections_payload",
)

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.collection
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object CollectionMobileSettingsStorage {
private const val payloadKey = "collection_mobile_settings_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
}