Fix missing Discover catalogs and add dynamic content types (movie/series/anime)

Diagnosed against NuvioMobile's open-source discover implementation
(same addons, more catalogs visible there). Two real gaps:

1. buildDiscoverCatalogOptions() excluded any catalog requiring an
   extra parameter beyond genre. Nuvio's equivalent (supportsDiscover())
   explicitly whitelists "skip" (pagination) as never blocking a
   catalog even when required — Fluxa's filter didn't, silently
   dropping every catalog that declares skip as required, which is a
   common Stremio addon SDK convention. Added "skip" to the allowed set.

2. The Type filter (movie/series) was hardcoded in the shared Discover
   UI instead of derived from what addons actually expose. Nuvio builds
   its type list from distinct catalog types across installed addons,
   which is why "anime" shows up there. Added a new
   buildDiscoverContentTypes() helper (same extras filter, minus the
   "all"/type restriction), threaded contentTypes through the
   readDiscoverCatalogFilters effect, Rust's DiscoverState (new field,
   needed since the effect completion flows back through the engine's
   own state, not directly to Kotlin), HomeViewModel, and the shared
   DiscoverUiState/DiscoverScreen, with a movie/series fallback so the
   dropdown isn't empty before the first fetch completes.
This commit is contained in:
KhooLy 2026-07-13 22:48:20 +03:00
parent 01385e7197
commit 7b8a5c61bf
9 changed files with 54 additions and 12 deletions

View file

@ -46,6 +46,7 @@ import com.fluxa.app.ui.catalog.EpisodeNotificationHelper
import com.fluxa.app.ui.catalog.HomeCategory
import com.fluxa.app.ui.catalog.HomeCatalogSource
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
import com.fluxa.app.domain.discovery.buildDiscoverContentTypes
import com.fluxa.app.domain.discovery.buildMetadataFeedOptions
import com.fluxa.app.domain.discovery.effectiveHomeMetadataFeedSelection
import com.fluxa.app.domain.discovery.isMetadataFeedEnabled
@ -853,6 +854,7 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
val profile = payload.profile()
val addons = addonRepository.getUserAddons(profile?.authKey.orEmpty(), profile?.safeLocalAddons.orEmpty())
val catalogOptions = buildDiscoverCatalogOptions(addons, payload.string("contentType"))
val contentTypes = buildDiscoverContentTypes(addons)
val selectedCatalog = catalogOptions.firstOrNull { it.key == payload.stringOrNull("selectedCatalogKey") }
val selectedGenres = selectedCatalog?.genres.orEmpty()
.distinct()
@ -865,7 +867,7 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
} else {
selectedGenres
}
return ok(effect, mapOf("catalogs" to catalogOptions, "genres" to genres))
return ok(effect, mapOf("catalogs" to catalogOptions, "genres" to genres, "contentTypes" to contentTypes))
}
private suspend fun fetchCatalogPage(effect: NativeHeadlessEffect): HeadlessEffectCompletion {

View file

@ -1,5 +1,6 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.shared.feature.catalog.CatalogItemUiModel
import com.fluxa.app.shared.feature.catalog.CatalogSourceUiModel
@ -22,8 +23,12 @@ class AndroidDiscoverDataSource(
homeViewModel.discoverUiState
) { selectedFilters, state ->
val profile = activeProfile()
val language = profile?.language
DiscoverUiState(
filters = selectedFilters,
typeOptions = state.contentTypes.map { type ->
DiscoverFilterOptionUiModel(type, discoverContentTypeLabel(type, language))
},
catalogOptions = state.catalogs.map { DiscoverFilterOptionUiModel(it.key, it.label) },
genreOptions = state.genres.map { DiscoverFilterOptionUiModel(it.id, it.label) },
results = state.results.map { meta ->
@ -63,3 +68,13 @@ class AndroidDiscoverDataSource(
)
}
}
private fun discoverContentTypeLabel(type: String, language: String?): String {
val key = when (type) {
"movie" -> "auto.movie"
"series" -> "auto.series"
"anime" -> "auto.anime"
else -> null
}
return key?.let { AppStrings.t(language, it) } ?: type.replaceFirstChar { it.uppercase() }
}

View file

@ -52,6 +52,7 @@ data class DiscoverUiState(
val isLoading: Boolean = false,
val genres: List<DiscoverGenreOption> = emptyList(),
val catalogs: List<DiscoverCatalogOption> = emptyList(),
val contentTypes: List<String> = emptyList(),
val resultSources: Map<String, HomeCatalogSource> = emptyMap()
)

View file

@ -76,6 +76,7 @@ class HomeViewModel @Inject constructor(
private val introTimestampsListType = object : TypeToken<List<IntroTimestamps>>() {}.type
private val discoverCatalogListType = object : TypeToken<List<DiscoverCatalogOption>>() {}.type
private val discoverGenreListType = object : TypeToken<List<DiscoverGenreOption>>() {}.type
private val discoverContentTypeListType = object : TypeToken<List<String>>() {}.type
private val addonListType = object : TypeToken<List<AddonDescriptor>>() {}.type
private val headlessRuntime = FluxaHeadlessRuntimeFactory.createUniFfi(headlessEnvironment)
private val initialSearchHistory = searchHistoryStore.load(null)
@ -140,16 +141,20 @@ class HomeViewModel @Inject constructor(
private val _headlessDiscoverLoading = MutableStateFlow(false)
private val _headlessDiscoverGenres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
private val _headlessDiscoverCatalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
private val _headlessDiscoverContentTypes = MutableStateFlow<List<String>>(emptyList())
private val discoverResultSourceMapType = object : TypeToken<Map<String, HomeCatalogSource>>() {}.type
val discoverUiState: StateFlow<DiscoverUiState> = combine(
_headlessDiscoverResults,
_headlessDiscoverResultSources,
_headlessDiscoverLoading,
combine(
_headlessDiscoverResults,
_headlessDiscoverResultSources,
_headlessDiscoverLoading
) { results, resultSources, loading -> Triple(results, resultSources, loading) },
_headlessDiscoverGenres,
_headlessDiscoverCatalogs
) { results, resultSources, loading, genres, catalogs ->
DiscoverUiState(results, loading, genres, catalogs, resultSources)
_headlessDiscoverCatalogs,
_headlessDiscoverContentTypes
) { (results, resultSources, loading), genres, catalogs, contentTypes ->
DiscoverUiState(results, loading, genres, catalogs, contentTypes, resultSources)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DiscoverUiState())
val discoverGenres: StateFlow<List<DiscoverGenreOption>> get() = _headlessDiscoverGenres.asStateFlow()
@ -1065,6 +1070,7 @@ class HomeViewModel @Inject constructor(
val discover = result.state["discover"] as? Map<*, *> ?: return@launch
_headlessDiscoverCatalogs.value = fromStateList(discover["catalogs"], discoverCatalogListType)
_headlessDiscoverGenres.value = fromStateList(discover["genres"], discoverGenreListType)
_headlessDiscoverContentTypes.value = fromStateList(discover["contentTypes"], discoverContentTypeListType)
}
}

View file

@ -151,6 +151,7 @@
"auto.series_02b3bdba": "Series",
"auto.genre": "Genre",
"auto.catalog": "Catalog",
"auto.anime": "Anime",
"auto.platform": "Platform",
"auto.recent_searches": "Recent Searches",
"auto.no_results_yet": "No results yet",

View file

@ -151,6 +151,7 @@
"auto.series_02b3bdba": "Diziler",
"auto.genre": "Kategori",
"auto.catalog": "Katalog",
"auto.anime": "Anime",
"auto.platform": "Platform",
"auto.recent_searches": "Son Aramalar",
"auto.no_results_yet": "Henüz sonuç yok",

View file

@ -38,6 +38,16 @@ fun buildMetadataFeedOptions(addons: List<AddonDescriptor>, language: String? =
return addonFeeds
}
fun buildDiscoverContentTypes(addons: List<AddonDescriptor>): List<String> {
return addons.filter { it.manifest.hasStremioResource("catalog") }.flatMap { addon ->
addon.manifest.catalogs.orEmpty().mapNotNull { catalog ->
val type = catalog.type?.normalizeContentType() ?: return@mapNotNull null
if (catalog.hasRequiredCatalogExtraExcept(setOf("genre", "skip"))) return@mapNotNull null
type
}
}.distinct()
}
fun buildDiscoverCatalogOptions(addons: List<AddonDescriptor>, selectedType: String): List<DiscoverCatalogOption> {
val normalizedType = selectedType.lowercase()
val rawOptions = addons.filter { it.manifest.hasStremioResource("catalog") }.flatMap { addon ->
@ -45,7 +55,7 @@ fun buildDiscoverCatalogOptions(addons: List<AddonDescriptor>, selectedType: Str
val type = catalog.type?.normalizeContentType()?.takeIf { normalizedType == "all" || it == normalizedType }
?: return@mapNotNull null
val id = catalog.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
if (catalog.hasRequiredCatalogExtraExcept(setOf("genre"))) return@mapNotNull null
if (catalog.hasRequiredCatalogExtraExcept(setOf("genre", "skip"))) return@mapNotNull null
val name = discoverCatalogLabel(catalog.name, id)
val genres = (
catalog.genres.orEmpty() +

View file

@ -16,6 +16,7 @@ data class DiscoverFiltersUiModel(
data class DiscoverUiState(
val filters: DiscoverFiltersUiModel = DiscoverFiltersUiModel(),
val typeOptions: List<DiscoverFilterOptionUiModel> = emptyList(),
val catalogOptions: List<DiscoverFilterOptionUiModel> = emptyList(),
val genreOptions: List<DiscoverFilterOptionUiModel> = emptyList(),
val results: List<CatalogItemUiModel> = emptyList(),

View file

@ -103,6 +103,7 @@ fun DiscoverScreen(
} else {
DiscoverFilters(
filters = state.filters,
typeOptions = state.typeOptions,
catalogOptions = state.catalogOptions,
genreOptions = state.genreOptions,
language = language,
@ -194,18 +195,22 @@ private fun DiscoverSkeletonGrid(modifier: Modifier = Modifier) {
@Composable
private fun DiscoverFilters(
filters: DiscoverFiltersUiModel,
typeOptions: List<DiscoverFilterOptionUiModel>,
catalogOptions: List<DiscoverFilterOptionUiModel>,
genreOptions: List<DiscoverFilterOptionUiModel>,
language: String?,
onFiltersChanged: (DiscoverFiltersUiModel) -> Unit
) {
val effectiveTypeOptions = typeOptions.ifEmpty {
listOf(
DiscoverFilterOptionUiModel("movie", AppStrings.t(language, "auto.movie")),
DiscoverFilterOptionUiModel("series", AppStrings.t(language, "auto.series"))
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
DiscoverDropdownFilter(
label = AppStrings.t(language, "auto.type"),
options = listOf(
DiscoverFilterOptionUiModel("movie", AppStrings.t(language, "auto.movie")),
DiscoverFilterOptionUiModel("series", AppStrings.t(language, "auto.series"))
),
options = effectiveTypeOptions,
selectedId = filters.contentType,
onSelected = { value ->
onFiltersChanged(DiscoverFiltersUiModel(contentType = value.orEmpty()))