diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 835894e3e..98d3b5ffc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + 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(), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt index 3ecf520ec..6bb064946 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index 758a2883e..5ac995149 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -29,6 +29,8 @@ data class AddonCatalog( data class AddonExtraProperty( val name: String, val isRequired: Boolean = false, + val options: List = emptyList(), + val optionsLimit: Int? = null, ) data class AddonBehaviorHints( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt new file mode 100644 index 000000000..5109d75e6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -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, + 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, + incoming: List, +): List { + 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" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt new file mode 100644 index 000000000..c136a0956 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.catalog + +import com.nuvio.app.features.home.MetaPreview + +data class CatalogUiState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val nextSkip: Int? = null, + val errorMessage: String? = null, +) { + val canLoadMore: Boolean + get() = nextSkip != null +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt new file mode 100644 index 000000000..92982067d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -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 = _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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt new file mode 100644 index 000000000..85b902dd1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -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 + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 7c74fe1bd..9d87d2c0d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -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, + 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 = emptyList(), @@ -42,4 +47,5 @@ internal data class CatalogRequest( val catalogId: String, val catalogName: String, val type: String, + val supportsPagination: Boolean, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 616ce74b6..89deb1c05 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 101d4b466..3445a9a7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index 4908a2dbc..2ac167f5a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -12,12 +12,13 @@ import com.nuvio.app.features.home.MetaPreview fun HomeCatalogRowSection( section: HomeCatalogSection, modifier: Modifier = Modifier, + entries: List = 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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt index 0ae15e6b0..43a243b19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt @@ -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 = emptyList(), + val genreRequired: Boolean = false, + val supportsPagination: Boolean = false, +) + +data class DiscoverUiState( + val typeOptions: List = emptyList(), + val selectedType: String? = null, + val catalogOptions: List = emptyList(), + val selectedCatalogKey: String? = null, + val selectedGenre: String? = null, + val items: List = 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 + get() = selectedCatalog?.genreOptions.orEmpty() + + val canLoadMore: Boolean + get() = nextSkip != null +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index bc949052d..d97aa9e32 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -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 = _uiState.asStateFlow() + private val _discoverUiState = MutableStateFlow(DiscoverUiState()) + val discoverUiState: StateFlow = _discoverUiState.asStateFlow() private var activeJob: Job? = null + private var activeDiscoverJob: Job? = null private var lastRequestKey: String? = null + private var discoverSources: List = emptyList() fun search(query: String, addons: List) { val normalizedQuery = query.trim() @@ -101,6 +107,126 @@ object SearchRepository { _uiState.value = SearchUiState() } + fun refreshDiscover(addons: List) { + 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, query: String, @@ -118,20 +244,45 @@ object SearchRepository { catalogName = catalog.name, type = catalog.type, query = query, + supportsPagination = catalog.supportsPagination(), ) } } + private fun buildDiscoverSources(addons: List): List = + 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({ 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" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index b0071de4f..768b5edc9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -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, + 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, + 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 + }