From ba5f4600026ac3786302c3df5ed2db5d63c74d72 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 17:48:48 +0530 Subject: [PATCH] feat: add support for addon enable/disable flag --- .../features/addons/AddonPlatform.android.kt | 29 +++++ .../composeResources/values/strings.xml | 1 + .../commonMain/kotlin/com/nuvio/app/App.kt | 2 + .../nuvio/app/features/addons/AddonModels.kt | 8 +- .../app/features/addons/AddonPlatform.kt | 2 + .../app/features/addons/AddonRepository.kt | 116 ++++++++++++++---- .../nuvio/app/features/addons/AddonsScreen.kt | 18 +++ .../collection/CollectionCatalogResolver.kt | 7 +- .../collection/CollectionRepository.kt | 3 +- .../features/details/MetaDetailsRepository.kt | 2 + .../features/home/HomeCatalogDefinitions.kt | 3 +- .../nuvio/app/features/home/HomeRepository.kt | 10 +- .../com/nuvio/app/features/home/HomeScreen.kt | 22 ++-- .../nuvio/app/features/player/PlayerScreen.kt | 4 +- .../player/PlayerStreamsRepository.kt | 3 +- .../app/features/player/SubtitleRepository.kt | 3 +- .../app/features/search/SearchRepository.kt | 5 +- .../nuvio/app/features/search/SearchScreen.kt | 3 +- .../features/settings/PlaybackSettingsPage.kt | 2 + .../settings/SettingsFullScreenPages.kt | 10 +- .../app/features/settings/SettingsScreen.kt | 10 +- .../streams/AddonStreamWarmupRepository.kt | 2 + .../app/features/streams/StreamsRepository.kt | 3 +- .../app/features/addons/AddonModelsTest.kt | 50 ++++++++ .../app/features/addons/AddonPlatform.ios.kt | 29 +++++ 25 files changed, 285 insertions(+), 62 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index e7fbe541..4549add8 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -19,6 +19,7 @@ import java.util.concurrent.TimeUnit actual object AddonStorage { private const val preferencesName = "nuvio_addons" private const val addonUrlsKey = "installed_manifest_urls" + private const val addonEnabledStatesKey = "installed_manifest_enabled_states" private var preferences: SharedPreferences? = null @@ -41,6 +42,34 @@ actual object AddonStorage { ?.putString("${addonUrlsKey}_$profileId", urls.joinToString(separator = "\n")) ?.apply() } + + actual fun loadAddonEnabledStates(profileId: Int): Map = + preferences + ?.getString("${addonEnabledStatesKey}_$profileId", null) + .orEmpty() + .lineSequence() + .mapNotNull(::parseEnabledStateLine) + .toMap() + + actual fun saveAddonEnabledStates(profileId: Int, states: Map) { + val payload = states.entries.joinToString(separator = "\n") { (url, enabled) -> + "$url\t$enabled" + } + preferences + ?.edit() + ?.putString("${addonEnabledStatesKey}_$profileId", payload) + ?.apply() + } +} + +private fun parseEnabledStateLine(line: String): Pair? { + val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null + val rawEnabled = line.substringAfter("\t", "true").trim().lowercase() + val enabled = when (rawEnabled) { + "false" -> false + else -> true + } + return url to enabled } private val addonHttpClient = OkHttpClient.Builder() diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4d057fd2..b6917cd2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,7 @@ Active %1$d catalogs Configurable + Disabled Refreshing %1$d resources Unavailable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2f33f3dc..1869734b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -103,6 +103,7 @@ import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL @@ -622,6 +623,7 @@ private fun MainAppContent( val addonProbeTargets = remember(addonsUiState.addons) { addonsUiState.addons + .enabledAddons() .mapNotNull { it.manifest?.transportUrl } .distinct() .sorted() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index 6b73ffe6..e20e4486 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -50,11 +50,12 @@ data class ManagedAddon( val manifestUrl: String, val manifest: AddonManifest? = null, val userSetName: String? = null, + val enabled: Boolean = true, val isRefreshing: Boolean = false, val errorMessage: String? = null, ) { val isActive: Boolean - get() = manifest != null + get() = enabled && manifest != null val displayTitle: String get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name } @@ -78,9 +79,12 @@ internal fun List.toOverview(): AddonOverview = AddonOverview( totalAddons = size, activeAddons = count { it.isActive }, - totalCatalogs = sumOf { it.manifest?.catalogs?.size ?: 0 }, + totalCatalogs = filter { it.enabled }.sumOf { it.manifest?.catalogs?.size ?: 0 }, ) +internal fun List.enabledAddons(): List = + filter { it.enabled } + sealed interface AddAddonResult { data class Success(val manifest: AddonManifest) : AddAddonResult data class Error(val message: String) : AddAddonResult diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 36e15e6b..e416e64c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.addons internal expect object AddonStorage { fun loadInstalledAddonUrls(profileId: Int): List fun saveInstalledAddonUrls(profileId: Int, urls: List) + fun loadAddonEnabledStates(profileId: Int): Map + fun saveAddonEnabledStates(profileId: Int, states: Map) } data class RawHttpResponse( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index bf4b1a4c..3e69411d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -62,19 +62,24 @@ object AddonRepository { log.d { "initialize() — loading local addons for profile $currentProfileId" } val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) + val enabledByUrl = loadLocalEnabledStates() log.d { "initialize() — local addon count: ${storedUrls.size}" } if (storedUrls.isEmpty()) return val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) _uiState.value = AddonsUiState( addons = storedUrls.map { manifestUrl -> - existingByUrl[manifestUrl].toPendingAddon(manifestUrl) + existingByUrl[manifestUrl].toPendingAddon( + manifestUrl = manifestUrl, + enabled = enabledByUrl[manifestUrl], + ) }, ) storedUrls.forEach { manifestUrl -> val existing = existingByUrl[manifestUrl] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { + val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == manifestUrl } + if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { refreshAddon(manifestUrl) } } @@ -110,30 +115,35 @@ object AddonRepository { } .decodeList() - val namesByUrl = mutableMapOf() + val rowsByUrl = linkedMapOf() rows.forEach { row -> - if (!row.name.isNullOrBlank()) { - namesByUrl[ensureManifestSuffix(row.url)] = row.name + val manifestUrl = ensureManifestSuffix(row.url) + if (!rowsByUrl.containsKey(manifestUrl)) { + rowsByUrl[manifestUrl] = row.copy(url = manifestUrl) } } - val urls = dedupeManifestUrls(rows.map { it.url }) + val urls = rowsByUrl.keys.toList() log.i { "pullFromServer() — server returned ${rows.size} addons" } urls.forEachIndexed { i, u -> log.d { " server[$i]: $u" } } if (urls.isEmpty() && !pulledFromServer) { - val localUrls = AddonStorage.loadInstalledAddonUrls(currentProfileId) + val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" } if (localUrls.isNotEmpty()) { log.i { "pullFromServer() — migrating local addons to server for profile $currentProfileId" } initialize() pulledFromServer = true + val enabledByUrl = loadLocalEnabledStates() val addons = localUrls.mapIndexed { index, addonUrl -> + val manifestUrl = ensureManifestSuffix(addonUrl) AddonPushItem( - url = addonUrl, + url = manifestUrl, name = _uiState.value.addons - .find { it.manifestUrl == addonUrl }?.manifest?.name ?: "", - enabled = true, + .find { it.manifestUrl == manifestUrl }?.manifest?.name ?: "", + enabled = enabledByUrl[manifestUrl] + ?: _uiState.value.addons.find { it.manifestUrl == manifestUrl }?.enabled + ?: true, sortOrder = index, ) } @@ -151,16 +161,21 @@ object AddonRepository { val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) if (localUrls.isNotEmpty()) { log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } + val enabledByUrl = loadLocalEnabledStates() val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) _uiState.value = AddonsUiState( addons = localUrls.map { url -> - existingByUrl[url].toPendingAddon(url) + existingByUrl[url].toPendingAddon( + manifestUrl = url, + enabled = enabledByUrl[url], + ) }, ) persist() localUrls.forEach { url -> val existing = existingByUrl[url] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { + val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } + if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { refreshAddon(url) } } @@ -173,13 +188,19 @@ object AddonRepository { val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) _uiState.value = AddonsUiState( addons = urls.map { url -> - existingByUrl[url].toPendingAddon(url, namesByUrl[url]) + val row = rowsByUrl[url] + existingByUrl[url].toPendingAddon( + manifestUrl = url, + userSetName = row?.name?.takeIf { it.isNotBlank() }, + enabled = row?.enabled, + ) }, ) persist() urls.forEach { url -> val existing = existingByUrl[url] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { + val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } + if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { refreshAddon(url) } } @@ -194,7 +215,9 @@ object AddonRepository { suspend fun awaitManifestsLoaded() { if (_uiState.value.addons.isEmpty()) return uiState.first { state -> - state.addons.isEmpty() || state.addons.any { it.manifest != null } + state.addons.isEmpty() || + state.addons.any { it.manifest != null } || + state.addons.none { it.isRefreshing } } } @@ -273,8 +296,30 @@ object AddonRepository { pushToServer() } + fun setAddonEnabled(manifestUrl: String, enabled: Boolean) { + if (isUsingPrimaryAddonsFromSecondaryProfile()) return + var shouldRefresh = false + _uiState.update { current -> + current.copy( + addons = current.addons.map { addon -> + if (addon.manifestUrl != manifestUrl || addon.enabled == enabled) { + addon + } else { + shouldRefresh = enabled && addon.manifest == null && !addon.isRefreshing + addon.copy(enabled = enabled) + } + }, + ) + } + persist() + pushToServer() + if (shouldRefresh) { + refreshAddon(manifestUrl) + } + } + fun refreshAll() { - _uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon -> + _uiState.value.addons.filter { it.enabled }.distinctBy { it.manifestUrl }.forEach { addon -> refreshAddon(addon.manifestUrl) } } @@ -339,13 +384,13 @@ object AddonRepository { val addons = _uiState.value.addons .distinctBy { it.manifestUrl } .mapIndexed { index, addon -> - AddonPushItem( - url = addon.manifestUrl, - name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "", - enabled = true, - sortOrder = index, - ) - } + AddonPushItem( + url = addon.manifestUrl, + name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "", + enabled = addon.enabled, + sortOrder = index, + ) + } log.d { "pushToServer() — profileId=$profileId, pushing ${addons.size} addons" } val params = buildJsonObject { put("p_profile_id", profileId) @@ -377,12 +422,21 @@ object AddonRepository { } private fun persist() { + val addons = _uiState.value.addons AddonStorage.saveInstalledAddonUrls( currentProfileId, - dedupeManifestUrls(_uiState.value.addons.map { it.manifestUrl }), + dedupeManifestUrls(addons.map { it.manifestUrl }), + ) + AddonStorage.saveAddonEnabledStates( + currentProfileId, + addons.associate { it.manifestUrl to it.enabled }, ) } + private fun loadLocalEnabledStates(): Map = + AddonStorage.loadAddonEnabledStates(currentProfileId) + .mapKeys { (url, _) -> ensureManifestSuffix(url) } + private fun cancelActiveRefreshes() { activeRefreshJobs.values.forEach(Job::cancel) activeRefreshJobs.clear() @@ -399,27 +453,35 @@ object AddonRepository { } } -private fun ManagedAddon?.toPendingAddon(manifestUrl: String, userSetName: String? = null): ManagedAddon = +private fun ManagedAddon?.toPendingAddon( + manifestUrl: String, + userSetName: String? = null, + enabled: Boolean? = null, +): ManagedAddon = when { this == null -> ManagedAddon( manifestUrl = manifestUrl, - isRefreshing = true, + isRefreshing = enabled ?: true, userSetName = userSetName, + enabled = enabled ?: true, ) manifest != null -> copy( manifestUrl = manifestUrl, isRefreshing = false, userSetName = userSetName ?: this.userSetName, + enabled = enabled ?: this.enabled, ) isRefreshing -> copy( manifestUrl = manifestUrl, userSetName = userSetName ?: this.userSetName, + enabled = enabled ?: this.enabled, ) else -> copy( manifestUrl = manifestUrl, - isRefreshing = true, + isRefreshing = enabled ?: this.enabled, errorMessage = null, userSetName = userSetName ?: this.userSetName, + enabled = enabled ?: this.enabled, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 32c9554e..7e12666d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -157,6 +159,9 @@ internal fun AddonsSettingsPageContent( null }, onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) }, + onEnabledChange = { enabled -> + AddonRepository.setAddonEnabled(addon.manifestUrl, enabled) + }, onConfigureClick = if (showConfigureAction && !configureUrl.isNullOrBlank()) { { runCatching { @@ -347,6 +352,7 @@ private fun InstalledAddonCard( onMoveUpClick: (() -> Unit)?, onMoveDownClick: (() -> Unit)?, onRefreshClick: () -> Unit, + onEnabledChange: (Boolean) -> Unit, onConfigureClick: (() -> Unit)?, onDeleteClick: () -> Unit, ) { @@ -380,6 +386,17 @@ private fun InstalledAddonCard( ) } } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = addon.enabled, + onCheckedChange = onEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) } Spacer(modifier = Modifier.height(12.dp)) @@ -438,6 +455,7 @@ private fun InstalledAddonCard( ) { NuvioInfoBadge( text = when { + !addon.enabled -> stringResource(Res.string.addons_badge_disabled) addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing) manifest != null -> stringResource(Res.string.addons_badge_active) else -> stringResource(Res.string.addons_badge_unavailable) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt index cad93b34..f3334031 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.collection import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons internal data class ResolvedCollectionCatalog( val addon: ManagedAddon, @@ -11,13 +12,14 @@ internal data class ResolvedCollectionCatalog( internal fun List.findCollectionCatalog( source: CollectionCatalogSource, ): ResolvedCollectionCatalog? { - val declaredAddon = firstOrNull { it.manifest?.id == source.addonId } + val activeAddons = enabledAddons() + val declaredAddon = activeAddons.firstOrNull { it.manifest?.id == source.addonId } val declaredCatalog = declaredAddon?.manifest?.catalogs?.findSourceCatalog(source) if (declaredAddon != null && declaredCatalog != null) { return ResolvedCollectionCatalog(addon = declaredAddon, catalog = declaredCatalog) } - return firstNotNullOfOrNull { addon -> + return activeAddons.firstNotNullOfOrNull { addon -> val catalog = addon.manifest?.catalogs?.find { it.id == source.catalogId && it.type == source.type } ?: return@firstNotNullOfOrNull null @@ -40,4 +42,3 @@ private fun List.findSourceCatalog(source: CollectionCatalogSource private fun List.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? = find { it.catalogId == source.catalogId && it.type == source.type } ?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 270e9781..b5baf26e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -240,7 +241,7 @@ object CollectionRepository { fun generateId(): String = Uuid.random().toString() fun getAvailableCatalogs(): List { - val addons = AddonRepository.uiState.value.addons + val addons = AddonRepository.uiState.value.addons.enabledAddons() return addons.mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null addon to manifest diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 06673586..fcb432b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.filterReleasedItems @@ -261,6 +262,7 @@ object MetaDetailsRepository { private fun findMetaManifests(type: String, id: String): List = AddonRepository.uiState.value.addons + .enabledAddons() .mapNotNull { it.manifest } .filter { manifest -> manifest.resources.any { resource -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index 74f54494..853b42a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.supportsPagination import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.Res @@ -19,7 +20,7 @@ data class HomeCatalogDefinition( ) fun buildHomeCatalogDefinitions(addons: List): List = - addons.mapNotNull { addon -> + addons.enabledAddons().mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null addon to manifest }.flatMap { (addon, manifest) -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 083355a5..54b1e30e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionRepository @@ -41,7 +42,8 @@ object HomeRepository { private var lastErrorMessage: String? = null fun refresh(addons: List, force: Boolean = false) { - val requests = buildHomeCatalogDefinitions(addons) + val activeAddons = addons.enabledAddons() + val requests = buildHomeCatalogDefinitions(activeAddons) currentDefinitions = requests val requestKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::key) cachedSections = cachedSections.filterKeys(requestKeys::contains) @@ -71,7 +73,7 @@ object HomeRepository { requestKey = requestKey, ) ensureCollectionHeroFallback( - addons = addons, + addons = activeAddons, force = force, requestKey = requestKey, ) @@ -135,7 +137,7 @@ object HomeRepository { requestKey = requestKey, ) ensureCollectionHeroFallback( - addons = addons, + addons = activeAddons, force = force, requestKey = requestKey, ) @@ -148,7 +150,7 @@ object HomeRepository { requestKey = activeRequestKey ?: lastRequestKey, ) ensureCollectionHeroFallback( - addons = AddonRepository.uiState.value.addons, + addons = AddonRepository.uiState.value.addons.enabledAddons(), force = false, requestKey = activeRequestKey ?: lastRequestKey, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 6cabeec3..1b4c486d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -21,6 +21,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.CloudLibraryUiState @@ -144,7 +145,7 @@ fun HomeScreen( NetworkCondition.Online -> { if (observedOfflineState) { observedOfflineState = false - HomeRepository.refresh(addonsUiState.addons, force = true) + HomeRepository.refresh(addonsUiState.addons.enabledAddons(), force = true) } } @@ -367,8 +368,11 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } - val availableManifests = remember(addonsUiState.addons) { - addonsUiState.addons.mapNotNull { addon -> addon.manifest } + val enabledAddons = remember(addonsUiState.addons) { + addonsUiState.addons.enabledAddons() + } + val availableManifests = remember(enabledAddons) { + enabledAddons.mapNotNull { addon -> addon.manifest } } val metaProviderKey = remember(availableManifests) { @@ -394,8 +398,8 @@ fun HomeScreen( LaunchedEffect(catalogRefreshKey) { if (catalogRefreshKey.isEmpty()) return@LaunchedEffect - HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons) - HomeRepository.refresh(addonsUiState.addons) + HomeCatalogSettingsRepository.syncCatalogs(enabledAddons) + HomeRepository.refresh(enabledAddons) } LaunchedEffect(collections) { @@ -498,9 +502,9 @@ fun HomeScreen( ) } - val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } + val hasActiveAddons = enabledAddons.any { it.manifest != null } val showHeroSlot = homeSettingsUiState.heroEnabled - val isResolvingHeroSources = addonsUiState.addons.any { it.isRefreshing } || homeUiState.isLoading + val isResolvingHeroSources = enabledAddons.any { it.isRefreshing } || homeUiState.isLoading val showHeroSkeleton = showHeroSlot && homeUiState.heroItems.isEmpty() && isResolvingHeroSources @@ -591,7 +595,7 @@ fun HomeScreen( } when { - addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> { + !hasActiveAddons && !hasRenderableCollectionRows -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( @@ -650,7 +654,7 @@ fun HomeScreen( modifier = Modifier.padding(horizontal = 16.dp), onRetry = { NetworkStatusRepository.requestRefresh(force = true) - HomeRepository.refresh(addonsUiState.addons, force = true) + HomeRepository.refresh(addonsUiState.addons.enabledAddons(), force = true) }, ) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 27c0036d..b87bfbf6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -46,6 +46,7 @@ import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaVideo @@ -1166,6 +1167,7 @@ fun PlayerScreen( ) val installedAddonNames = AddonRepository.uiState.value.addons + .enabledAddons() .map { it.displayTitle } .toSet() val debridSettings = DebridSettingsRepository.snapshot() @@ -2289,7 +2291,7 @@ private fun buildAddonSubtitleFetchKey( ): String? { val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null - val compatibleSubtitleAddons = addons.mapNotNull { addon -> + val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null val supportsSubtitles = manifest.resources.any { resource -> resource.isCompatibleSubtitleResource( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 12b59bb4..32eeca28 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DebridStreamPresentation @@ -158,7 +159,7 @@ object PlayerStreamsRepository { return } - val installedAddons = AddonRepository.uiState.value.addons + val installedAddons = AddonRepository.uiState.value.addons.enabledAddons() val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 64d8c879..8df539c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.player import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.addons.httpGetText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -49,7 +50,7 @@ object SubtitleRepository { _error.value = null _addonSubtitles.value = emptyList() - val addons = AddonRepository.uiState.value.addons + val addons = AddonRepository.uiState.value.addons.enabledAddons() val allSubs = mutableListOf() for (addon in addons) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index cee95160..aa0b8e97 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.fetchCatalogPage @@ -50,7 +51,7 @@ object SearchRepository { return } - val activeAddons = addons.filter { it.manifest != null } + val activeAddons = addons.enabledAddons().filter { it.manifest != null } if (activeAddons.isEmpty()) { activeJob?.cancel() lastRequestKey = null @@ -173,7 +174,7 @@ object SearchRepository { } fun refreshDiscover(addons: List) { - val activeAddons = addons.filter { it.manifest != null } + val activeAddons = addons.enabledAddons().filter { it.manifest != null } if (activeAddons.isEmpty()) { activeDiscoverJob?.cancel() discoverSources = emptyList() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 8aba024f..0804d904 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -49,6 +49,7 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomeCatalogRowSection @@ -128,7 +129,7 @@ fun SearchScreen( } val addonRefreshKey = remember(addonsUiState.addons) { - addonsUiState.addons.mapNotNull { addon -> + addonsUiState.addons.enabledAddons().mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null buildString { append(manifest.transportUrl) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 98b1a83b..5f09a12f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp @@ -999,6 +1000,7 @@ private fun PlaybackSettingsSection( if (showAutoPlayAddonSelectionDialog) { val addonNames = addonUiState.addons + .enabledAddons() .mapNotNull { it.manifest } .filter { manifest -> manifest.resources.any { resource -> resource.name == "stream" } } .map { it.name } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 45c6edf3..8b873498 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -31,10 +32,11 @@ fun HomescreenSettingsScreen( ) { val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homescreenCatalogRefreshKey = remember(addonsUiState.addons) { - val allManifestsSettled = addonsUiState.addons.isNotEmpty() && - addonsUiState.addons.none { it.isRefreshing } + val enabledAddons = addonsUiState.addons.enabledAddons() + val allManifestsSettled = enabledAddons.isNotEmpty() && + enabledAddons.none { it.isRefreshing } if (!allManifestsSettled) return@remember emptyList() - addonsUiState.addons.mapNotNull { addon -> + enabledAddons.mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null buildString { append(manifest.transportUrl) @@ -58,7 +60,7 @@ fun HomescreenSettingsScreen( LaunchedEffect(homescreenCatalogRefreshKey) { if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect - HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons) + HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons.enabledAddons()) } LaunchedEffect(collections) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index fb85bc73..66ef8bce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem @@ -157,10 +158,11 @@ fun SettingsScreen( AddonRepository.uiState }.collectAsStateWithLifecycle() val homescreenCatalogRefreshKey = remember(addonsUiState.addons) { - val allManifestsSettled = addonsUiState.addons.isNotEmpty() && - addonsUiState.addons.none { it.isRefreshing } + val enabledAddons = addonsUiState.addons.enabledAddons() + val allManifestsSettled = enabledAddons.isNotEmpty() && + enabledAddons.none { it.isRefreshing } if (!allManifestsSettled) return@remember emptyList() - addonsUiState.addons.mapNotNull { addon -> + enabledAddons.mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null buildString { append(manifest.transportUrl) @@ -195,7 +197,7 @@ fun SettingsScreen( LaunchedEffect(homescreenCatalogRefreshKey) { if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect - HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons) + HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons.enabledAddons()) } LaunchedEffect(Unit) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt index 8c731baf..5b56c1ea 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -213,6 +214,7 @@ object AddonStreamWarmupRepository { AddonRepository.initialize() val addonTargets = AddonRepository.uiState.value.addons + .enabledAddons() .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } if (addonTargets.isEmpty()) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index bb17f2f5..5fe167d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DirectDebridStreamPreparer import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -152,7 +153,7 @@ object StreamsRepository { return } - val installedAddons = AddonRepository.uiState.value.addons + val installedAddons = AddonRepository.uiState.value.addons.enabledAddons() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt new file mode 100644 index 00000000..25c40e49 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.addons + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AddonModelsTest { + + @Test + fun `disabled addon is installed but not active`() { + val addon = ManagedAddon( + manifestUrl = "https://example.test/manifest.json", + manifest = manifest(), + enabled = false, + ) + + assertFalse(addon.isActive) + assertEquals(0, listOf(addon).toOverview().activeAddons) + assertEquals(0, listOf(addon).toOverview().totalCatalogs) + } + + @Test + fun `enabled addons helper filters disabled addons`() { + val enabled = ManagedAddon( + manifestUrl = "https://enabled.example/manifest.json", + manifest = manifest(id = "enabled"), + enabled = true, + ) + val disabled = ManagedAddon( + manifestUrl = "https://disabled.example/manifest.json", + manifest = manifest(id = "disabled"), + enabled = false, + ) + + assertEquals(listOf(enabled), listOf(enabled, disabled).enabledAddons()) + assertTrue(enabled.isActive) + } +} + +private fun manifest(id: String = "addon") = AddonManifest( + id = id, + name = id, + description = "", + version = "1.0.0", + resources = listOf(AddonResource(name = "catalog", types = listOf("movie"))), + types = listOf("movie"), + catalogs = listOf(AddonCatalog(type = "movie", id = "popular", name = "Popular")), + transportUrl = "https://$id.example/manifest.json", +) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 84943920..7dc979e2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -19,6 +19,7 @@ import platform.Foundation.NSUserDefaults actual object AddonStorage { private const val addonUrlsKey = "installed_manifest_urls" + private const val addonEnabledStatesKey = "installed_manifest_enabled_states" actual fun loadInstalledAddonUrls(profileId: Int): List = NSUserDefaults.standardUserDefaults @@ -35,6 +36,34 @@ actual object AddonStorage { forKey = "${addonUrlsKey}_$profileId", ) } + + actual fun loadAddonEnabledStates(profileId: Int): Map = + NSUserDefaults.standardUserDefaults + .stringForKey("${addonEnabledStatesKey}_$profileId") + .orEmpty() + .lineSequence() + .mapNotNull(::parseEnabledStateLine) + .toMap() + + actual fun saveAddonEnabledStates(profileId: Int, states: Map) { + val payload = states.entries.joinToString(separator = "\n") { (url, enabled) -> + "$url\t$enabled" + } + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "${addonEnabledStatesKey}_$profileId", + ) + } +} + +private fun parseEnabledStateLine(line: String): Pair? { + val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null + val rawEnabled = line.substringAfter("\t", "true").trim().lowercase() + val enabled = when (rawEnabled) { + "false" -> false + else -> true + } + return url to enabled } private val addonHttpClient = HttpClient(Darwin) {