mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
Merge branch 'addon' into cmp-rewrite
This commit is contained in:
commit
9d41785887
31 changed files with 528 additions and 128 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -47,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
|
||||
|
|
@ -80,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
|
||||
|
||||
|
|
@ -144,7 +147,7 @@ fun HomeScreen(
|
|||
NetworkCondition.Online -> {
|
||||
if (observedOfflineState) {
|
||||
observedOfflineState = false
|
||||
HomeRepository.refresh(addonsUiState.addons, force = true)
|
||||
HomeRepository.refresh(addonsUiState.addons.enabledAddons(), force = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,8 +370,14 @@ fun HomeScreen(
|
|||
cloudLibraryUiState = cloudLibraryUiState,
|
||||
)
|
||||
}
|
||||
val availableManifests = remember(addonsUiState.addons) {
|
||||
addonsUiState.addons.mapNotNull { addon -> addon.manifest }
|
||||
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 }
|
||||
}
|
||||
|
||||
val metaProviderKey = remember(availableManifests) {
|
||||
|
|
@ -394,8 +403,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) {
|
||||
|
|
@ -409,8 +418,10 @@ fun HomeScreen(
|
|||
metaProviderKey,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
continueWatchingPreferences.upNextFromFurthestEpisode,
|
||||
isRefreshingEnabledAddons,
|
||||
watchProgressUiState.entries,
|
||||
watchedUiState.items,
|
||||
watchedUiState.isLoaded,
|
||||
) {
|
||||
if (completedSeriesCandidates.isEmpty()) {
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
|
|
@ -418,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
|
||||
|
|
@ -432,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 ->
|
||||
|
|
@ -450,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<String, Pair<Long, ContinueWatchingItem>>()
|
||||
val processedFreshContentIds = mutableSetOf<String>()
|
||||
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,
|
||||
|
|
@ -498,9 +526,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 +619,7 @@ fun HomeScreen(
|
|||
}
|
||||
|
||||
when {
|
||||
addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> {
|
||||
!hasActiveAddons && !hasRenderableCollectionRows -> {
|
||||
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
|
||||
item {
|
||||
HomeContinueWatchingSection(
|
||||
|
|
@ -650,7 +678,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 {
|
||||
|
|
@ -726,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<WatchProgressEntry>,
|
||||
|
|
@ -820,6 +851,49 @@ internal fun filterNextUpItemsByCurrentSeeds(
|
|||
item.nextUpSeedEpisodeNumber == currentSeed.second
|
||||
}
|
||||
|
||||
private suspend fun resolveHomeNextUpCandidate(
|
||||
completedEntry: CompletedSeriesCandidate,
|
||||
watchProgressEntries: List<WatchProgressEntry>,
|
||||
watchedItems: List<WatchedItem>,
|
||||
todayIsoDate: String,
|
||||
preferFurthestEpisode: Boolean,
|
||||
showUnairedNextUp: Boolean,
|
||||
dismissedNextUpKeys: Set<String>,
|
||||
): Pair<String, Pair<Long, ContinueWatchingItem>>? {
|
||||
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 ->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ internal object ContinueWatchingEnrichmentCache {
|
|||
|
||||
fun getSnapshots(): Pair<List<CachedNextUpItem>, List<CachedInProgressItem>> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
val entries: List<WatchProgressEntry>,
|
||||
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<String, String>,
|
||||
entries: List<WatchProgressEntry>,
|
||||
): 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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue