discover screen init

This commit is contained in:
tapframe 2026-03-11 22:15:12 +05:30
parent 242aee0fa5
commit 0b5d4d2816
14 changed files with 1374 additions and 107 deletions

View file

@ -32,8 +32,11 @@ import coil3.compose.setSingletonImageLoaderFactory
import coil3.request.crossfade
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.features.addons.AddonsScreen
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.search.SearchScreen
@ -61,6 +64,17 @@ data class StreamRoute(
val episodeThumbnail: String? = null,
)
@Serializable
data class CatalogRoute(
val title: String,
val subtitle: String,
val manifestUrl: String,
val type: String,
val catalogId: String,
val supportsPagination: Boolean = false,
val genre: String? = null,
)
enum class AppScreenTab {
Home,
Search,
@ -71,11 +85,13 @@ enum class AppScreenTab {
fun AppScreen(
tab: AppScreenTab,
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
when (tab) {
AppScreenTab.Home -> HomeScreen(
modifier = modifier,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
)
AppScreenTab.Search -> SearchScreen(
@ -117,6 +133,19 @@ fun App() {
)
}
val onCatalogClick: (HomeCatalogSection) -> Unit = { section ->
navController.navigate(
CatalogRoute(
title = section.title,
subtitle = section.subtitle,
manifestUrl = section.manifestUrl,
type = section.type,
catalogId = section.catalogId,
supportsPagination = section.supportsPagination,
),
)
}
Box(
modifier = Modifier
.fillMaxSize()
@ -155,6 +184,7 @@ fun App() {
AppScreen(
tab = selectedTab,
modifier = Modifier.padding(innerPadding),
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
@ -202,6 +232,26 @@ fun App() {
modifier = Modifier.fillMaxSize(),
)
}
composable<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
CatalogScreen(
title = route.title,
subtitle = route.subtitle,
manifestUrl = route.manifestUrl,
type = route.type,
catalogId = route.catalogId,
supportsPagination = route.supportsPagination,
genre = route.genre,
onBack = {
CatalogRepository.clear()
navController.popBackStack()
},
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}

View file

@ -71,6 +71,8 @@ internal object AddonManifestParser {
AddonExtraProperty(
name = name,
isRequired = extraElement.jsonObject.boolean("isRequired"),
options = extraElement.jsonObject.stringList("options"),
optionsLimit = extraElement.jsonObject.int("optionsLimit"),
)
}
},
@ -104,4 +106,7 @@ internal object AddonManifestParser {
private fun JsonObject.boolean(name: String): Boolean =
this[name]?.jsonPrimitive?.booleanOrNull == true
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
}

View file

@ -29,6 +29,8 @@ data class AddonCatalog(
data class AddonExtraProperty(
val name: String,
val isRequired: Boolean = false,
val options: List<String> = emptyList(),
val optionsLimit: Int? = null,
)
data class AddonBehaviorHints(

View file

@ -0,0 +1,113 @@
package com.nuvio.app.features.catalog
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogParser
import com.nuvio.app.features.home.MetaPreview
const val CATALOG_PAGE_SIZE = 100
data class CatalogPage(
val items: List<MetaPreview>,
val nextSkip: Int?,
)
suspend fun fetchCatalogPage(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
search: String? = null,
skip: Int? = null,
): CatalogPage {
val payload = httpGetText(
buildCatalogUrl(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
search = search,
skip = skip,
),
)
val items = HomeCatalogParser.parseCatalog(payload)
val nextSkip = if (items.size >= CATALOG_PAGE_SIZE) {
(skip ?: 0) + CATALOG_PAGE_SIZE
} else {
null
}
return CatalogPage(
items = items,
nextSkip = nextSkip,
)
}
fun AddonCatalog.supportsPagination(): Boolean =
extra.any { property -> property.name == "skip" }
fun mergeCatalogItems(
existing: List<MetaPreview>,
incoming: List<MetaPreview>,
): List<MetaPreview> {
if (incoming.isEmpty()) return existing
val seen = existing.mapTo(mutableSetOf()) { item -> "${item.type}:${item.id}" }
return buildList(existing.size + incoming.size) {
addAll(existing)
incoming.forEach { item ->
val key = "${item.type}:${item.id}"
if (seen.add(key)) {
add(item)
}
}
}
}
private fun buildCatalogUrl(
manifestUrl: String,
type: String,
catalogId: String,
genre: String?,
search: String?,
skip: Int?,
): String {
val baseUrl = manifestUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val extraParts = buildList {
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
if (skip != null && skip > 0) add("skip=$skip")
}
return if (extraParts.isEmpty()) {
"$baseUrl/catalog/$type/$catalogId.json"
} else {
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json"
}
}
private fun String.encodeCatalogExtra(): String =
buildString {
encodeToByteArray().forEach { byte ->
val value = byte.toInt() and 0xFF
val char = value.toChar()
if (
char in 'a'..'z' ||
char in 'A'..'Z' ||
char in '0'..'9' ||
char == '-' ||
char == '_' ||
char == '.' ||
char == '~'
) {
append(char)
} else {
append('%')
append(HEX[value shr 4])
append(HEX[value and 0x0F])
}
}
}
private val HEX = "0123456789ABCDEF"

View file

@ -0,0 +1,13 @@
package com.nuvio.app.features.catalog
import com.nuvio.app.features.home.MetaPreview
data class CatalogUiState(
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = false,
val nextSkip: Int? = null,
val errorMessage: String? = null,
) {
val canLoadMore: Boolean
get() = nextSkip != null
}

View file

@ -0,0 +1,116 @@
package com.nuvio.app.features.catalog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
object CatalogRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CatalogUiState())
val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
private var activeJob: Job? = null
private var activeRequest: CatalogRequest? = null
fun load(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
force: Boolean = false,
) {
val request = CatalogRequest(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
)
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
return
}
activeRequest = request
fetchPage(request = request, reset = true)
}
fun loadMore() {
val request = activeRequest ?: return
val current = _uiState.value
if (current.isLoading || current.nextSkip == null) return
fetchPage(request = request, reset = false)
}
fun clear() {
activeJob?.cancel()
activeRequest = null
_uiState.value = CatalogUiState()
}
private fun fetchPage(
request: CatalogRequest,
reset: Boolean,
) {
activeJob?.cancel()
val current = _uiState.value
val requestedSkip = if (reset) 0 else current.nextSkip ?: return
_uiState.value = current.copy(
items = if (reset) emptyList() else current.items,
isLoading = true,
nextSkip = if (reset) null else current.nextSkip,
errorMessage = null,
)
activeJob = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = request.manifestUrl,
type = request.type,
catalogId = request.catalogId,
genre = request.genre,
skip = requestedSkip.takeIf { it > 0 },
)
}.fold(
onSuccess = { page ->
if (activeRequest != request) return@fold
val mergedItems = if (reset) {
page.items
} else {
mergeCatalogItems(_uiState.value.items, page.items)
}
_uiState.value = CatalogUiState(
items = mergedItems,
isLoading = false,
nextSkip = if (request.supportsPagination) page.nextSkip else null,
errorMessage = null,
)
},
onFailure = { error ->
if (activeRequest != request) return@fold
_uiState.value = current.copy(
items = if (reset) emptyList() else current.items,
isLoading = false,
nextSkip = null,
errorMessage = error.message ?: "Unable to load catalog items.",
)
},
)
}
}
}
private data class CatalogRequest(
val manifestUrl: String,
val type: String,
val catalogId: String,
val genre: String?,
val supportsPagination: Boolean,
)

View file

@ -0,0 +1,297 @@
package com.nuvio.app.features.catalog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@Composable
fun CatalogScreen(
title: String,
subtitle: String,
manifestUrl: String,
type: String,
catalogId: String,
supportsPagination: Boolean,
genre: String? = null,
onBack: () -> Unit,
onPosterClick: ((MetaPreview) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
force = true,
)
}
LaunchedEffect(gridState, uiState.canLoadMore, uiState.isLoading) {
snapshotFlow { gridState.layoutInfo }
.map { layoutInfo ->
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= layoutInfo.totalItemsCount - 6
}
.distinctUntilChanged()
.filter { it && uiState.canLoadMore && !uiState.isLoading }
.collect {
CatalogRepository.loadMore()
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
top = with(androidx.compose.ui.platform.LocalDensity.current) { headerHeightPx.toDp() } + 12.dp,
end = 16.dp,
bottom = nuvioPlatformExtraBottomPadding + 28.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (uiState.items.isEmpty() && uiState.isLoading) {
items(9) { CatalogSkeletonTile() }
} else if (uiState.items.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
CatalogEmptyState(errorMessage = uiState.errorMessage)
}
} else {
items(
items = uiState.items,
key = { item -> "${item.type}:${item.id}" },
) { item ->
CatalogPosterTile(
item = item,
onClick = onPosterClick?.let { { it(item) } },
)
}
if (uiState.isLoading) {
item(span = { GridItemSpan(maxLineSpan) }) {
CatalogLoadingFooter()
}
}
}
}
CatalogHeader(
title = title,
subtitle = subtitle,
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
onBack = onBack,
)
}
}
@Composable
private fun CatalogHeader(
title: String,
subtitle: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp)
.padding(top = 52.dp, bottom = 12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onBack),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CatalogPosterTile(
item: MetaPreview,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(item.posterShape.catalogAspectRatio())
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun CatalogSkeletonTile() {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
)
}
@Composable
private fun CatalogEmptyState(errorMessage: String?) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "No titles found",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = errorMessage ?: "This catalog did not return any items.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CatalogLoadingFooter() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp,
)
}
}
private fun PosterShape.catalogAspectRatio(): Float =
when (this) {
PosterShape.Poster -> 0.68f
PosterShape.Square -> 1f
PosterShape.Landscape -> 1.2f
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
data class MetaPreview(
val id: String,
@ -29,8 +30,12 @@ data class HomeCatalogSection(
val manifestUrl: String,
val catalogId: String,
val items: List<MetaPreview>,
val supportsPagination: Boolean = false,
)
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
items.size > previewLimit || (supportsPagination && items.size >= CATALOG_PAGE_SIZE)
data class HomeUiState(
val isLoading: Boolean = false,
val sections: List<HomeCatalogSection> = emptyList(),
@ -42,4 +47,5 @@ internal data class CatalogRequest(
val catalogId: String,
val catalogName: String,
val type: String,
val supportsPagination: Boolean,
)

View file

@ -1,7 +1,8 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.supportsPagination
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -70,19 +71,19 @@ object HomeRepository {
catalogId = catalog.id,
catalogName = catalog.name,
type = catalog.type,
supportsPagination = catalog.supportsPagination(),
)
}
}
private suspend fun CatalogRequest.toSection(): HomeCatalogSection {
val manifest = requireNotNull(addon.manifest)
val catalogUrl = buildCatalogUrl(
val page = fetchCatalogPage(
manifestUrl = manifest.transportUrl,
type = type,
catalogId = catalogId,
)
val payload = httpGetText(catalogUrl)
val items = HomeCatalogParser.parseCatalog(payload)
val items = page.items
require(items.isNotEmpty()) { "No feed items returned for $catalogName." }
return HomeCatalogSection(
@ -94,21 +95,11 @@ object HomeRepository {
manifestUrl = manifest.transportUrl,
catalogId = catalogId,
items = items,
supportsPagination = supportsPagination,
)
}
}
private fun buildCatalogUrl(
manifestUrl: String,
type: String,
catalogId: String,
): String {
val baseUrl = manifestUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
return "$baseUrl/catalog/$type/$catalogId.json"
}
private fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()

View file

@ -17,6 +17,7 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
LaunchedEffect(Unit) {
@ -80,9 +81,16 @@ fun HomeScreen(
count = homeUiState.sections.size,
key = { index -> homeUiState.sections[index].key },
) { index ->
val section = homeUiState.sections[index]
HomeCatalogRowSection(
section = homeUiState.sections[index],
section = section,
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
modifier = Modifier.padding(bottom = 12.dp),
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
onCatalogClick?.let { { it(section) } }
} else {
null
},
onPosterClick = onPosterClick,
)
}
@ -90,3 +98,5 @@ fun HomeScreen(
}
}
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18

View file

@ -12,12 +12,13 @@ import com.nuvio.app.features.home.MetaPreview
fun HomeCatalogRowSection(
section: HomeCatalogSection,
modifier: Modifier = Modifier,
entries: List<MetaPreview> = section.items,
onViewAllClick: (() -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
NuvioShelfSection(
title = section.title,
entries = section.items,
entries = entries,
modifier = modifier,
headerHorizontalPadding = 16.dp,
rowContentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.search
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.HomeCatalogSection
enum class SearchEmptyStateReason {
@ -15,3 +16,44 @@ data class SearchUiState(
val emptyStateReason: SearchEmptyStateReason? = null,
val errorMessage: String? = null,
)
enum class DiscoverEmptyStateReason {
NoActiveAddons,
NoDiscoverCatalogs,
NoResults,
RequestFailed,
}
data class DiscoverCatalogOption(
val key: String,
val addonName: String,
val manifestUrl: String,
val type: String,
val catalogId: String,
val catalogName: String,
val genreOptions: List<String> = emptyList(),
val genreRequired: Boolean = false,
val supportsPagination: Boolean = false,
)
data class DiscoverUiState(
val typeOptions: List<String> = emptyList(),
val selectedType: String? = null,
val catalogOptions: List<DiscoverCatalogOption> = emptyList(),
val selectedCatalogKey: String? = null,
val selectedGenre: String? = null,
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = false,
val nextSkip: Int? = null,
val emptyStateReason: DiscoverEmptyStateReason? = null,
val errorMessage: String? = null,
) {
val selectedCatalog: DiscoverCatalogOption?
get() = catalogOptions.firstOrNull { it.key == selectedCatalogKey }
val genreOptions: List<String>
get() = selectedCatalog?.genreOptions.orEmpty()
val canLoadMore: Boolean
get() = nextSkip != null
}

View file

@ -1,9 +1,11 @@
package com.nuvio.app.features.search
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.httpGetText
import com.nuvio.app.features.home.HomeCatalogParser
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.features.home.HomeCatalogSection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -20,9 +22,13 @@ object SearchRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private val _discoverUiState = MutableStateFlow(DiscoverUiState())
val discoverUiState: StateFlow<DiscoverUiState> = _discoverUiState.asStateFlow()
private var activeJob: Job? = null
private var activeDiscoverJob: Job? = null
private var lastRequestKey: String? = null
private var discoverSources: List<DiscoverCatalogOption> = emptyList()
fun search(query: String, addons: List<ManagedAddon>) {
val normalizedQuery = query.trim()
@ -101,6 +107,126 @@ object SearchRepository {
_uiState.value = SearchUiState()
}
fun refreshDiscover(addons: List<ManagedAddon>) {
val activeAddons = addons.filter { it.manifest != null }
if (activeAddons.isEmpty()) {
activeDiscoverJob?.cancel()
discoverSources = emptyList()
_discoverUiState.value = DiscoverUiState(
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
)
return
}
val sources = buildDiscoverSources(activeAddons)
discoverSources = sources
if (sources.isEmpty()) {
activeDiscoverJob?.cancel()
_discoverUiState.value = DiscoverUiState(
emptyStateReason = DiscoverEmptyStateReason.NoDiscoverCatalogs,
)
return
}
val current = _discoverUiState.value
val typeOptions = sources.map { it.type }.distinct().sortedBy { it.typeSortKey() }
val selectedType = current.selectedType
?.takeIf { type -> typeOptions.contains(type) }
?: typeOptions.first()
val catalogOptions = sources.filter { it.type == selectedType }
val selectedCatalog = catalogOptions.firstOrNull { it.key == current.selectedCatalogKey } ?: catalogOptions.first()
val selectedGenre = selectedCatalog.resolveGenreSelection(current.selectedGenre)
_discoverUiState.value = DiscoverUiState(
typeOptions = typeOptions,
selectedType = selectedType,
catalogOptions = catalogOptions,
selectedCatalogKey = selectedCatalog.key,
selectedGenre = selectedGenre,
items = emptyList(),
isLoading = false,
nextSkip = null,
emptyStateReason = null,
errorMessage = null,
)
loadDiscoverFeed(reset = true)
}
fun selectDiscoverType(type: String) {
val current = _discoverUiState.value
if (current.selectedType == type) return
val catalogOptions = discoverSources.filter { it.type == type }
val selectedCatalog = catalogOptions.firstOrNull() ?: run {
_discoverUiState.value = current.copy(
selectedType = type,
catalogOptions = emptyList(),
selectedCatalogKey = null,
selectedGenre = null,
items = emptyList(),
isLoading = false,
nextSkip = null,
emptyStateReason = DiscoverEmptyStateReason.NoDiscoverCatalogs,
errorMessage = null,
)
return
}
_discoverUiState.value = current.copy(
selectedType = type,
catalogOptions = catalogOptions,
selectedCatalogKey = selectedCatalog.key,
selectedGenre = selectedCatalog.resolveGenreSelection(null),
items = emptyList(),
isLoading = false,
nextSkip = null,
emptyStateReason = null,
errorMessage = null,
)
loadDiscoverFeed(reset = true)
}
fun selectDiscoverCatalog(catalogKey: String) {
val current = _discoverUiState.value
if (current.selectedCatalogKey == catalogKey) return
val selectedCatalog = current.catalogOptions.firstOrNull { it.key == catalogKey } ?: return
_discoverUiState.value = current.copy(
selectedCatalogKey = selectedCatalog.key,
selectedGenre = selectedCatalog.resolveGenreSelection(null),
items = emptyList(),
isLoading = false,
nextSkip = null,
emptyStateReason = null,
errorMessage = null,
)
loadDiscoverFeed(reset = true)
}
fun selectDiscoverGenre(genre: String?) {
val current = _discoverUiState.value
val selectedCatalog = current.selectedCatalog ?: return
val normalizedGenre = selectedCatalog.resolveGenreSelection(genre)
if (current.selectedGenre == normalizedGenre) return
_discoverUiState.value = current.copy(
selectedGenre = normalizedGenre,
items = emptyList(),
isLoading = false,
nextSkip = null,
emptyStateReason = null,
errorMessage = null,
)
loadDiscoverFeed(reset = true)
}
fun loadMoreDiscover() {
val current = _discoverUiState.value
if (current.isLoading || current.nextSkip == null) return
loadDiscoverFeed(reset = false)
}
private fun buildSearchRequests(
addons: List<ManagedAddon>,
query: String,
@ -118,20 +244,45 @@ object SearchRepository {
catalogName = catalog.name,
type = catalog.type,
query = query,
supportsPagination = catalog.supportsPagination(),
)
}
}
private fun buildDiscoverSources(addons: List<ManagedAddon>): List<DiscoverCatalogOption> =
addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->
manifest.catalogs
.filter { catalog -> catalog.supportsDiscover() }
.map { catalog ->
val genreExtra = catalog.genreExtra()
DiscoverCatalogOption(
key = "${manifest.id}:${catalog.type}:${catalog.id}",
addonName = manifest.name,
manifestUrl = addon.manifestUrl,
type = catalog.type,
catalogId = catalog.id,
catalogName = catalog.name,
genreOptions = genreExtra?.options.orEmpty(),
genreRequired = genreExtra?.isRequired == true,
supportsPagination = catalog.supportsPagination(),
)
}
}.sortedWith(
compareBy<DiscoverCatalogOption>({ it.type.typeSortKey() }, { it.addonName.lowercase() }, { it.catalogName.lowercase() }),
)
private suspend fun SearchCatalogRequest.toSection(): HomeCatalogSection {
val manifest = requireNotNull(addon.manifest)
val catalogUrl = buildSearchCatalogUrl(
val page = fetchCatalogPage(
manifestUrl = manifest.transportUrl,
type = type,
catalogId = catalogId,
query = query,
search = query,
)
val payload = httpGetText(catalogUrl)
val items = HomeCatalogParser.parseCatalog(payload)
val items = page.items
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
return HomeCatalogSection(
@ -143,8 +294,68 @@ object SearchRepository {
manifestUrl = manifest.transportUrl,
catalogId = catalogId,
items = items,
supportsPagination = supportsPagination,
)
}
private fun loadDiscoverFeed(reset: Boolean) {
activeDiscoverJob?.cancel()
val current = _discoverUiState.value
val selectedCatalog = current.selectedCatalog ?: return
val requestedSkip = if (reset) 0 else current.nextSkip ?: return
_discoverUiState.value = current.copy(
isLoading = true,
items = if (reset) emptyList() else current.items,
nextSkip = if (reset) null else current.nextSkip,
emptyStateReason = null,
errorMessage = null,
)
activeDiscoverJob = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = selectedCatalog.manifestUrl,
type = selectedCatalog.type,
catalogId = selectedCatalog.catalogId,
genre = current.selectedGenre,
skip = requestedSkip.takeIf { it > 0 },
)
}.fold(
onSuccess = { page ->
val latest = _discoverUiState.value
if (latest.selectedCatalogKey != selectedCatalog.key || latest.selectedGenre != current.selectedGenre) {
return@fold
}
val mergedItems = if (reset) {
page.items
} else {
mergeCatalogItems(latest.items, page.items)
}
_discoverUiState.value = latest.copy(
items = mergedItems,
isLoading = false,
nextSkip = if (selectedCatalog.supportsPagination) page.nextSkip else null,
emptyStateReason = if (mergedItems.isEmpty()) DiscoverEmptyStateReason.NoResults else null,
errorMessage = null,
)
},
onFailure = { error ->
val latest = _discoverUiState.value
if (latest.selectedCatalogKey != selectedCatalog.key || latest.selectedGenre != current.selectedGenre) {
return@fold
}
_discoverUiState.value = latest.copy(
items = if (reset) emptyList() else latest.items,
isLoading = false,
nextSkip = null,
emptyStateReason = DiscoverEmptyStateReason.RequestFailed,
errorMessage = error.message ?: "Unable to load discover items.",
)
},
)
}
}
}
private data class SearchCatalogRequest(
@ -153,50 +364,48 @@ private data class SearchCatalogRequest(
val catalogName: String,
val type: String,
val query: String,
val supportsPagination: Boolean,
)
private fun AddonCatalog.supportsSearch(): Boolean =
extra.any { property -> property.name == "search" } &&
extra.none { property -> property.isRequired && property.name != "search" }
private fun buildSearchCatalogUrl(
manifestUrl: String,
type: String,
catalogId: String,
query: String,
): String {
val baseUrl = manifestUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
return "$baseUrl/catalog/$type/$catalogId/search=${query.encodeForSearchExtra()}.json"
private fun AddonCatalog.supportsDiscover(): Boolean {
if (extra.any { property -> property.name == "search" && property.isRequired }) {
return false
}
return extra.none { property ->
when (property.name) {
"genre" -> property.isRequired && property.options.isEmpty()
"skip" -> false
"search" -> false
else -> property.isRequired
}
}
}
private fun AddonCatalog.genreExtra(): AddonExtraProperty? =
extra.firstOrNull { property -> property.name == "genre" }
private fun DiscoverCatalogOption.resolveGenreSelection(requestedGenre: String?): String? =
when {
genreOptions.isEmpty() -> null
requestedGenre != null && genreOptions.contains(requestedGenre) -> requestedGenre
genreRequired -> genreOptions.firstOrNull()
else -> null
}
private fun String.displayLabel(): String =
replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
private fun String.encodeForSearchExtra(): String =
buildString {
encodeToByteArray().forEach { byte ->
val value = byte.toInt() and 0xFF
val char = value.toChar()
if (
char in 'a'..'z' ||
char in 'A'..'Z' ||
char in '0'..'9' ||
char == '-' ||
char == '_' ||
char == '.' ||
char == '~'
) {
append(char)
} else {
append('%')
append(HEX[value shr 4])
append(HEX[value and 0x0F])
}
}
private fun String.typeSortKey(): String =
when (lowercase()) {
"movie" -> "0_movie"
"series" -> "1_series"
"anime" -> "2_anime"
else -> "9_$this"
}
private val HEX = "0123456789ABCDEF"

View file

@ -1,29 +1,65 @@
package com.nuvio.app.features.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@Composable
fun SearchScreen(
@ -36,7 +72,10 @@ fun SearchScreen(
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { androidx.compose.runtime.mutableStateOf("") }
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var headerHeightPx by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val addonRefreshKey = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon ->
@ -46,7 +85,13 @@ fun SearchScreen(
append(':')
append(manifest.catalogs.joinToString(separator = ",") { catalog ->
val extra = catalog.extra.joinToString(separator = "&") { property ->
"${property.name}:${property.isRequired}"
buildString {
append(property.name)
append(':')
append(property.isRequired)
append(':')
append(property.options.joinToString(separator = "|"))
}
}
"${catalog.type}:${catalog.id}:$extra"
})
@ -54,6 +99,10 @@ fun SearchScreen(
}
}
LaunchedEffect(addonRefreshKey) {
SearchRepository.refreshDiscover(addonsUiState.addons)
}
LaunchedEffect(query, addonRefreshKey) {
val normalizedQuery = query.trim()
if (normalizedQuery.isBlank()) {
@ -67,62 +116,403 @@ fun SearchScreen(
}
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
LaunchedEffect(listState, query, discoverUiState.canLoadMore, discoverUiState.isLoading) {
if (query.isNotBlank()) return@LaunchedEffect
snapshotFlow { listState.layoutInfo }
.map { layoutInfo ->
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= layoutInfo.totalItemsCount - 4
}
.distinctUntilChanged()
.filter { it && discoverUiState.canLoadMore && !discoverUiState.isLoading }
.collect {
SearchRepository.loadMoreDiscover()
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
stickyHeader {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
) {
NuvioScreenHeader(title = "Search")
Spacer(modifier = Modifier.height(12.dp))
NuvioInputField(
value = query,
onValueChange = { query = it },
placeholder = "Search installed addon catalogs",
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 18.dp + nuvioPlatformExtraBottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(modifier = Modifier.height(with(androidx.compose.ui.platform.LocalDensity.current) { headerHeightPx.toDp() }))
}
if (query.isBlank()) {
item {
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
}
item {
DiscoverFilterRow(
state = discoverUiState,
modifier = Modifier.padding(horizontal = 16.dp),
onTypeSelected = SearchRepository::selectDiscoverType,
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
onGenreSelected = SearchRepository::selectDiscoverGenre,
)
}
discoverUiState.selectedCatalog?.let { selectedCatalog ->
item {
Text(
text = "${selectedCatalog.addonName}${selectedCatalog.type.displayTypeLabel()}",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
when {
discoverUiState.isLoading && discoverUiState.items.isEmpty() -> {
items(2) {
DiscoverSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
discoverUiState.items.isEmpty() -> {
item {
DiscoverEmptyStateCard(
reason = discoverUiState.emptyStateReason,
errorMessage = discoverUiState.errorMessage,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
else -> {
items(discoverUiState.items.chunked(3)) { rowItems ->
DiscoverGridRow(
items = rowItems,
modifier = Modifier.padding(horizontal = 16.dp),
onPosterClick = onPosterClick,
)
}
if (discoverUiState.isLoading) {
item {
CatalogLoadingFooter(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
}
} else {
when {
uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
uiState.sections.isEmpty() -> {
item {
SearchEmptyStateCard(
reason = uiState.emptyStateReason,
errorMessage = uiState.errorMessage,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
else -> {
items(
items = uiState.sections,
key = { section -> section.key },
) { section ->
HomeCatalogRowSection(
section = section,
modifier = Modifier.padding(bottom = 12.dp),
onPosterClick = onPosterClick,
)
}
}
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.onSizeChanged { headerHeightPx = it.height },
) {
NuvioScreenHeader(title = "Search")
Spacer(modifier = Modifier.height(12.dp))
NuvioInputField(
value = query,
onValueChange = { query = it },
placeholder = "Search movies, shows...",
)
}
}
}
@Composable
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
Text(
text = "Discover",
modifier = modifier,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
@Composable
private fun DiscoverFilterRow(
state: DiscoverUiState,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
DiscoverDropdownChip(
label = state.selectedType?.displayTypeLabel() ?: "Type",
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
enabled = state.typeOptions.isNotEmpty(),
onSelected = { onTypeSelected(it.key) },
)
DiscoverDropdownChip(
label = state.selectedCatalog?.catalogName ?: "Catalog",
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
enabled = state.catalogOptions.isNotEmpty(),
onSelected = { onCatalogSelected(it.key) },
)
val selectedCatalog = state.selectedCatalog
val genreOptions = buildList {
if (selectedCatalog?.genreRequired != true) {
add(DiscoverOptionItem(key = "", label = "All Genres"))
}
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
}
DiscoverDropdownChip(
label = state.selectedGenre ?: "All Genres",
options = genreOptions,
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
onSelected = { option ->
onGenreSelected(option.key.ifBlank { null })
},
)
}
}
@Composable
private fun DiscoverDropdownChip(
label: String,
options: List<DiscoverOptionItem>,
enabled: Boolean,
onSelected: (DiscoverOptionItem) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surface)
.then(
if (enabled) {
Modifier.clickable { expanded = true }
} else {
Modifier
},
)
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
androidx.compose.material3.Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
when {
query.isBlank() -> Unit
uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
uiState.sections.isEmpty() -> {
item {
SearchEmptyStateCard(
reason = uiState.emptyStateReason,
errorMessage = uiState.errorMessage,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
else -> {
items(
count = uiState.sections.size,
key = { index -> uiState.sections[index].key },
) { index ->
HomeCatalogRowSection(
section = uiState.sections[index],
modifier = Modifier.padding(bottom = 12.dp),
onPosterClick = onPosterClick,
)
}
}
@Composable
private fun DiscoverGridRow(
items: List<MetaPreview>,
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items.forEach { item ->
DiscoverPosterTile(
item = item,
modifier = Modifier.weight(1f),
onClick = onPosterClick?.let { { it(item) } },
)
}
repeat(3 - items.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun DiscoverPosterTile(
item: MetaPreview,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(item.posterShape.discoverAspectRatio())
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun DiscoverSkeletonRow(modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
)
}
}
}
@Composable
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp,
)
}
}
@Composable
private fun DiscoverEmptyStateCard(
reason: DiscoverEmptyStateReason?,
errorMessage: String?,
modifier: Modifier = Modifier,
) {
val title: String
val message: String
when (reason) {
DiscoverEmptyStateReason.NoActiveAddons -> {
title = "No active addons"
message = "Install and validate at least one addon before browsing discover catalogs."
}
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
title = "No discover catalogs"
message = "Installed addons do not expose board-compatible catalogs for discover."
}
DiscoverEmptyStateReason.RequestFailed -> {
title = "Could not load discover"
message = errorMessage ?: "The selected catalog failed to return discover items."
}
DiscoverEmptyStateReason.NoResults, null -> {
title = "No titles found"
message = "The selected catalog and filters did not return any items."
}
}
HomeEmptyStateCard(
modifier = modifier,
title = title,
message = message,
)
}
@Composable
private fun SearchEmptyStateCard(
reason: SearchEmptyStateReason?,
@ -160,3 +550,25 @@ private fun SearchEmptyStateCard(
message = message,
)
}
private data class DiscoverOptionItem(
val key: String,
val label: String,
)
private fun String.displayTypeLabel(): String =
when (lowercase()) {
"movie" -> "Movies"
"series" -> "Series"
"anime" -> "Anime"
"channel" -> "Channels"
"tv" -> "TV"
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
private fun PosterShape.discoverAspectRatio(): Float =
when (this) {
PosterShape.Poster -> 0.68f
PosterShape.Square -> 1f
PosterShape.Landscape -> 1.2f
}