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 1/3] 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) { From 96010101b8e6b2f487f01be0be331805b6eb5113 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 18:39:29 +0530 Subject: [PATCH 2/3] Fix stale series watched markers --- .../app/features/details/MetaDetailsScreen.kt | 16 +++++++++++++ .../app/features/watched/WatchedRepository.kt | 10 ++++++-- .../watching/application/WatchingActions.kt | 24 +++++++++++++++---- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index a9ea7373..797a168b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -344,6 +344,22 @@ fun MetaDetailsScreen( val progressByVideoId = remember(watchProgressUiState.entries) { watchProgressUiState.byVideoId } + LaunchedEffect( + meta.id, + meta.type, + todayIsoDate, + watchedUiState.isLoaded, + watchProgressUiState.hasLoadedRemoteProgress, + watchedUiState.watchedKeys, + watchProgressUiState.entries, + ) { + if (watchedUiState.isLoaded && watchProgressUiState.hasLoadedRemoteProgress) { + WatchingActions.reconcileSeriesWatchedState( + meta = meta, + todayIsoDate = todayIsoDate, + ) + } + } val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index a208fe94..3735763f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -201,6 +201,8 @@ object WatchedRepository { todayIsoDate: String, isEpisodeCompleted: (com.nuvio.app.features.details.MetaVideo) -> Boolean = { false }, ) { + if (!meta.type.isSeriesLikeWatchedType()) return + ensureLoaded() val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> isWatched( @@ -211,11 +213,12 @@ object WatchedRepository { ) || isEpisodeCompleted(episode) } val seriesWatchedItem = meta.toSeriesWatchedItem() + val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type) if (shouldMarkSeriesWatched) { - if (!isWatched(id = meta.id, type = meta.type)) { + if (!hasSeriesWatchedMarker) { markWatched(seriesWatchedItem) } - } else if (isWatched(id = meta.id, type = meta.type)) { + } else if (hasSeriesWatchedMarker) { unmarkWatched(seriesWatchedItem) } } @@ -355,3 +358,6 @@ internal fun shouldUseTraktWatchedSync( isAuthenticated = isAuthenticated, source = source, ) + +private fun String.isSeriesLikeWatchedType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index 4a79954e..4d207422 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -7,7 +7,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.episodePlaybackId -import com.nuvio.app.features.watched.releasedPlayableEpisodes +import com.nuvio.app.features.watched.releasedMainSeasonEpisodes import com.nuvio.app.features.watched.toEpisodeWatchedItem import com.nuvio.app.features.watched.toSeriesWatchedItem import com.nuvio.app.features.watched.toWatchedItem @@ -23,7 +23,7 @@ object WatchingActions { private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) suspend fun togglePosterWatched(preview: MetaPreview) { - if (preview.type != "series") { + if (!preview.type.isSeriesLikeType()) { WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L)) return } @@ -34,14 +34,23 @@ object WatchingActions { ) val meta = MetaDetailsRepository.fetch(type = preview.type, id = preview.id) if (meta == null) { - WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L)) + if (isCurrentlyWatched) { + WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L)) + } return } val todayIsoDate = CurrentDateProvider.todayIsoDate() + val releasedMainEpisodes = meta.releasedMainSeasonEpisodes(todayIsoDate) + if (releasedMainEpisodes.isEmpty()) { + if (isCurrentlyWatched) { + WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem()) + } + return + } val seriesItems = buildList { add(meta.toSeriesWatchedItem()) - addAll(meta.releasedPlayableEpisodes(todayIsoDate).map(meta::toEpisodeWatchedItem)) + addAll(releasedMainEpisodes.map(meta::toEpisodeWatchedItem)) } if (isCurrentlyWatched) { @@ -49,7 +58,7 @@ object WatchingActions { } else { WatchedRepository.markWatched(seriesItems) WatchProgressRepository.clearProgress( - meta.releasedPlayableEpisodes(todayIsoDate).map(meta::episodePlaybackId), + releasedMainEpisodes.map(meta::episodePlaybackId), ) } } @@ -97,6 +106,8 @@ object WatchingActions { meta: MetaDetails, todayIsoDate: String = CurrentDateProvider.todayIsoDate(), ) { + if (!meta.type.isSeriesLikeType()) return + WatchedRepository.reconcileSeriesWatchedState( meta = meta, todayIsoDate = todayIsoDate, @@ -149,3 +160,6 @@ object WatchingActions { reconcileSeriesWatchedState(meta) } } + +private fun String.isSeriesLikeType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow") From a789d7b45595eb6a0b1ac6203387b32403a10963 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 20:03:00 +0530 Subject: [PATCH 3/3] feat: optimize cw enrichmnet logic --- .../com/nuvio/app/features/home/HomeScreen.kt | 140 +++++++++++++----- .../ContinueWatchingEnrichmentCache.kt | 8 +- .../watchprogress/WatchProgressRepository.kt | 100 ++++++++++--- scripts/nuvio_debug_logs.sh | 11 +- 4 files changed, 200 insertions(+), 59 deletions(-) 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 1b4c486d..efb9d71b 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 @@ -48,6 +48,7 @@ import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache +import com.nuvio.app.features.watchprogress.ContinueWatchingLimit import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -81,6 +82,7 @@ import kotlinx.coroutines.sync.withPermit import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout +import kotlinx.coroutines.CancellationException import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -371,6 +373,9 @@ fun HomeScreen( val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } + val isRefreshingEnabledAddons = remember(enabledAddons) { + enabledAddons.any { addon -> addon.isRefreshing } + } val availableManifests = remember(enabledAddons) { enabledAddons.mapNotNull { addon -> addon.manifest } } @@ -413,8 +418,10 @@ fun HomeScreen( metaProviderKey, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, + isRefreshingEnabledAddons, watchProgressUiState.entries, watchedUiState.items, + watchedUiState.isLoaded, ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() @@ -422,6 +429,14 @@ fun HomeScreen( return@LaunchedEffect } + if (!isTraktProgressActive && !watchedUiState.isLoaded) { + return@LaunchedEffect + } + + if (isRefreshingEnabledAddons) { + return@LaunchedEffect + } + val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null val item = cached.second @@ -436,6 +451,7 @@ fun HomeScreen( val candidatesToResolve = completedSeriesCandidates.filter { candidate -> candidate.content.id !in cachedResolvedNextUpItems } + val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT) if (candidatesToResolve.isEmpty()) { nextUpItemsBySeries = cachedResolvedNextUpItems processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> @@ -454,46 +470,54 @@ fun HomeScreen( } val todayIsoDate = CurrentDateProvider.todayIsoDate() - val semaphore = Semaphore(4) - val freshResults = candidatesToResolve.map { completedEntry -> - async { - semaphore.withPermit { - val meta = MetaDetailsRepository.fetch( - type = completedEntry.content.type, - id = completedEntry.content.id, - ) - if (meta == null) { - return@withPermit null + val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) + val freshResults = mutableMapOf>() + val processedFreshContentIds = mutableSetOf() + val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) + + for (batch in candidateBatches) { + val batchResults = batch.map { completedEntry -> + async { + semaphore.withPermit { + resolveHomeNextUpCandidate( + completedEntry = completedEntry, + watchProgressEntries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, + dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, + ) } - val action = meta.seriesPrimaryAction( - content = completedEntry.content, - entries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, - todayIsoDate = todayIsoDate, - preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, - showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - ) - if (action?.resumePositionMs != null) { - return@withPermit null - } - val nextEpisode = action?.let { meta.videoForSeriesAction(it) } - if (nextEpisode == null) { - return@withPermit null - } - val item = completedEntry.toContinueWatchingSeed(meta) - .toUpNextContinueWatchingItem(nextEpisode) - if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) { - return@withPermit null - } - completedEntry.content.id to (completedEntry.markedAtEpochMs to item) } + }.awaitAll() + batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } + + val resolvedBeforeBatch = freshResults.size + batchResults.filterNotNull().forEach { (contentId, item) -> + freshResults[contentId] = item } - }.awaitAll().filterNotNull().toMap() + val batchResolvedCount = freshResults.size - resolvedBeforeBatch + if (batchResolvedCount > 0) { + val progressiveResults = cachedResolvedNextUpItems + freshResults + nextUpItemsBySeries = progressiveResults + processedNextUpContentIds = ( + cachedResolvedNextUpItems.keys + + processedFreshContentIds + ).toSet() + } + + if (cachedResolvedNextUpItems.size + freshResults.size >= ContinueWatchingLimit) { + break + } + } + val results = cachedResolvedNextUpItems + freshResults nextUpItemsBySeries = results - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } + processedNextUpContentIds = ( + cachedResolvedNextUpItems.keys + + processedFreshContentIds + ).toSet() saveContinueWatchingSnapshots( nextUpItemsBySeries = results, @@ -730,6 +754,9 @@ fun HomeScreen( private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L +private const val NEXT_UP_INITIAL_RESOLUTION_LIMIT = ContinueWatchingLimit * 2 +private const val NEXT_UP_RESOLUTION_CONCURRENCY = 8 +private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, @@ -824,6 +851,49 @@ internal fun filterNextUpItemsByCurrentSeeds( item.nextUpSeedEpisodeNumber == currentSeed.second } +private suspend fun resolveHomeNextUpCandidate( + completedEntry: CompletedSeriesCandidate, + watchProgressEntries: List, + watchedItems: List, + todayIsoDate: String, + preferFurthestEpisode: Boolean, + showUnairedNextUp: Boolean, + dismissedNextUpKeys: Set, +): Pair>? { + val contentId = completedEntry.content.id + val meta = try { + MetaDetailsRepository.fetch( + type = completedEntry.content.type, + id = contentId, + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + null + } + if (meta == null) return null + + val action = meta.seriesPrimaryAction( + content = completedEntry.content, + entries = watchProgressEntries, + watchedItems = watchedItems, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, + ) + if (action == null) return null + if (action.resumePositionMs != null) return null + + val nextEpisode = meta.videoForSeriesAction(action) + if (nextEpisode == null) return null + val item = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode) + if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) { + return null + } + + return contentId to (completedEntry.markedAtEpochMs to item) +} + private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { if (action.seasonNumber != null && action.episodeNumber != null) { videos.firstOrNull { video -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 19d6c046..c247eeda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -70,7 +70,9 @@ internal object ContinueWatchingEnrichmentCache { fun getSnapshots(): Pair, List> { val payload = loadPayload() - return (payload?.nextUp ?: emptyList()) to (payload?.inProgress ?: emptyList()) + val nextUp = payload?.nextUp ?: emptyList() + val inProgress = payload?.inProgress ?: emptyList() + return nextUp to inProgress } fun saveSnapshots( @@ -80,7 +82,9 @@ internal object ContinueWatchingEnrichmentCache { ) { val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress) val payloadHash = payload.hashCode() - if (!force && lastPayloadHash == payloadHash) return + if (!force && lastPayloadHash == payloadHash) { + return + } val encoded = runCatching { json.encodeToString(payload) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 8f4569b3..96e5bf6d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository @@ -20,15 +21,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L +private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 + +private data class RemoteMetadataResolutionResult( + val key: Pair, + val entries: List, + val meta: MetaDetails?, +) object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -189,6 +203,11 @@ object WatchProgressRepository { sinceLastWatched = sinceLastWatched, ) val isIncrementalPull = sinceLastWatched != null + if (isIncrementalPull && serverEntries.isEmpty()) { + hasLoaded = true + hasCompletedInitialNuvioSyncPull = true + return@runCatching + } val oldLocal = entriesByVideoId.toMap() val newMap = if (isIncrementalPull) { entriesByVideoId.toMutableMap() @@ -245,8 +264,10 @@ object WatchProgressRepository { ) private fun resolveRemoteMetadata() { - val needsResolution = entriesByVideoId.values + val missingMetadataEntries = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } + val entriesToResolve = missingMetadataEntries.continueWatchingEntries(limit = ContinueWatchingLimit) + val needsResolution = entriesToResolve .groupBy { it.parentMetaId to it.contentType } if (needsResolution.isEmpty()) { @@ -262,41 +283,79 @@ object WatchProgressRepository { return@launch } - for ((key, entries) in needsResolution) { - val (metaId, metaType) = key - val meta = runCatching { - MetaDetailsRepository.fetch(metaType, metaId) - }.getOrNull() + var resolvedEntries = 0 + val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) + val resolutionResults = coroutineScope { + needsResolution.map { (key, entries) -> + async { + semaphore.withPermit { + fetchRemoteMetadataGroup(key = key, entries = entries) + } + } + }.awaitAll() + } + + for (result in resolutionResults) { + ensureActive() + val meta = result.meta if (meta == null) { continue } - for (entry in entries) { - val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) { + var appliedEntries = 0 + for (entry in result.entries) { + val current = entriesByVideoId[entry.videoId] ?: continue + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { meta.videos.find { v -> - v.season == entry.seasonNumber && v.episode == entry.episodeNumber + v.season == current.seasonNumber && v.episode == current.episodeNumber } } else null - entriesByVideoId[entry.videoId] = entry.copy( + entriesByVideoId[current.videoId] = current.copy( title = meta.name, poster = meta.poster, background = meta.background, logo = meta.logo, - episodeTitle = episodeVideo?.title ?: entry.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: entry.episodeThumbnail, + episodeTitle = episodeVideo?.title ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, pauseDescription = episodeVideo?.overview ?: meta.description - ?: entry.pauseDescription, + ?: current.pauseDescription, ) + appliedEntries += 1 + } + if (appliedEntries == 0) { + continue } - publish() + resolvedEntries += appliedEntries + } + if (resolvedEntries > 0) { + publish() + persist() } - persist() } } + private suspend fun fetchRemoteMetadataGroup( + key: Pair, + entries: List, + ): RemoteMetadataResolutionResult { + val (metaId, metaType) = key + val meta = try { + MetaDetailsRepository.fetch(metaType, metaId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } + return RemoteMetadataResolutionResult( + key = key, + entries = entries, + meta = meta, + ) + } + fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, @@ -494,13 +553,14 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } + val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { + TraktProgressRepository.uiState.value.hasLoadedRemoteProgress + } else { + hasLoaded + } _uiState.value = WatchProgressUiState( entries = sortedEntries, - hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.hasLoadedRemoteProgress - } else { - hasLoaded - }, + hasLoadedRemoteProgress = hasLoadedRemoteProgress, ) } diff --git a/scripts/nuvio_debug_logs.sh b/scripts/nuvio_debug_logs.sh index 8ae32969..020e7bec 100755 --- a/scripts/nuvio_debug_logs.sh +++ b/scripts/nuvio_debug_logs.sh @@ -128,7 +128,14 @@ fi # ── Colour-coding function ────────────────────────────────────────────────── colorize_line() { local line="$1" - local level="${line:0:1}" + local level="" + if [[ "$line" =~ ^[[:space:]]*[0-9]{2}-[0-9]{2}[[:space:]]+[0-9:.]+[[:space:]]+([VDIWEF])/ ]]; then + level="${BASH_REMATCH[1]}" + elif [[ "$line" =~ ^([VDIWEF])/ ]]; then + level="${BASH_REMATCH[1]}" + else + level="${line:0:1}" + fi local clr="" case "$level" in V) clr="$CLR_V" ;; @@ -160,7 +167,7 @@ stream_logcat_colored() { local value="$2" local noise_re="$NOISE_TAGS" - "${ADB[@]}" logcat -v brief "$mode" "$value" 2>/dev/null \ + "${ADB[@]}" logcat -v time "$mode" "$value" 2>/dev/null \ | while IFS= read -r raw_line; do if [[ "$raw_line" =~ $noise_re ]]; then continue