feat: add support for addon enable/disable flag

This commit is contained in:
tapframe 2026-05-22 17:48:48 +05:30
parent ada9bc00f7
commit ba5f460002
25 changed files with 285 additions and 62 deletions

View file

@ -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<String, Boolean> =
preferences
?.getString("${addonEnabledStatesKey}_$profileId", null)
.orEmpty()
.lineSequence()
.mapNotNull(::parseEnabledStateLine)
.toMap()
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
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<String, Boolean>? {
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()

View file

@ -25,6 +25,7 @@
<string name="addons_badge_active">Active</string>
<string name="addons_badge_catalogs">%1$d catalogs</string>
<string name="addons_badge_configurable">Configurable</string>
<string name="addons_badge_disabled">Disabled</string>
<string name="addons_badge_refreshing">Refreshing</string>
<string name="addons_badge_resources">%1$d resources</string>
<string name="addons_badge_unavailable">Unavailable</string>

View file

@ -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()

View file

@ -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<ManagedAddon>.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<ManagedAddon>.enabledAddons(): List<ManagedAddon> =
filter { it.enabled }
sealed interface AddAddonResult {
data class Success(val manifest: AddonManifest) : AddAddonResult
data class Error(val message: String) : AddAddonResult

View file

@ -3,6 +3,8 @@ package com.nuvio.app.features.addons
internal expect object AddonStorage {
fun loadInstalledAddonUrls(profileId: Int): List<String>
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean>
fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>)
}
data class RawHttpResponse(

View file

@ -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<AddonRow>()
val namesByUrl = mutableMapOf<String, String>()
val rowsByUrl = linkedMapOf<String, AddonRow>()
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<String, Boolean> =
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,
)
}

View file

@ -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)

View file

@ -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<ManagedAddon>.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<AddonCatalog>.findSourceCatalog(source: CollectionCatalogSource
private fun List<AvailableCatalog>.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? =
find { it.catalogId == source.catalogId && it.type == source.type }
?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type }

View file

@ -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<AvailableCatalog> {
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

View file

@ -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<AddonManifest> =
AddonRepository.uiState.value.addons
.enabledAddons()
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->

View file

@ -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<ManagedAddon>): List<HomeCatalogDefinition> =
addons.mapNotNull { addon ->
addons.enabledAddons().mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->

View file

@ -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<ManagedAddon>, 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,
)

View file

@ -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 {

View file

@ -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(

View file

@ -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

View file

@ -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<AddonSubtitle>()
for (addon in addons) {

View file

@ -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<ManagedAddon>) {
val activeAddons = addons.filter { it.manifest != null }
val activeAddons = addons.enabledAddons().filter { it.manifest != null }
if (activeAddons.isEmpty()) {
activeDiscoverJob?.cancel()
discoverSources = emptyList()

View file

@ -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)

View file

@ -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 }

View file

@ -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<String>()
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) {

View file

@ -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<String>()
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) {

View file

@ -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

View file

@ -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 {

View file

@ -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",
)

View file

@ -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<String> =
NSUserDefaults.standardUserDefaults
@ -35,6 +36,34 @@ actual object AddonStorage {
forKey = "${addonUrlsKey}_$profileId",
)
}
actual fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean> =
NSUserDefaults.standardUserDefaults
.stringForKey("${addonEnabledStatesKey}_$profileId")
.orEmpty()
.lineSequence()
.mapNotNull(::parseEnabledStateLine)
.toMap()
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
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<String, Boolean>? {
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) {