From 242aee0fa5f89d526c0292483f1cfa027d8e452d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:10:12 +0530 Subject: [PATCH] initsearchs screen --- .../commonMain/kotlin/com/nuvio/app/App.kt | 13 ++ .../nuvio/app/core/ui/NuvioShelfComponents.kt | 12 +- .../nuvio/app/features/search/SearchModels.kt | 17 ++ .../app/features/search/SearchRepository.kt | 202 ++++++++++++++++++ .../nuvio/app/features/search/SearchScreen.kt | 162 ++++++++++++++ 5 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 259cb3de2..835894e3e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -35,6 +36,7 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.home.HomeScreen import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import kotlinx.serialization.Serializable @@ -61,6 +63,7 @@ data class StreamRoute( enum class AppScreenTab { Home, + Search, Addons, } @@ -75,6 +78,10 @@ fun AppScreen( modifier = modifier, onPosterClick = onPosterClick, ) + AppScreenTab.Search -> SearchScreen( + modifier = modifier, + onPosterClick = onPosterClick, + ) AppScreenTab.Addons -> AddonsScreen(modifier = modifier) } } @@ -130,6 +137,12 @@ fun App() { icon = { Icon(Icons.Rounded.Home, contentDescription = null) }, label = { Text("Home") }, ) + NavigationBarItem( + selected = selectedTab == AppScreenTab.Search, + onClick = { selectedTab = AppScreenTab.Search }, + icon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + label = { Text("Search") }, + ) NavigationBarItem( selected = selectedTab == AppScreenTab.Addons, onClick = { selectedTab = AppScreenTab.Addons }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index 4a0bd01af..9f8b6f990 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -177,13 +177,15 @@ private fun NuvioShelfSectionHeader( .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(999.dp), - ), + ), + ) + } + if (onViewAllClick != null) { + NuvioViewAllPill( + onClick = onViewAllClick, + size = viewAllPillSize, ) } - NuvioViewAllPill( - onClick = onViewAllClick, - size = viewAllPillSize, - ) } } 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 new file mode 100644 index 000000000..0ae15e6b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.search + +import com.nuvio.app.features.home.HomeCatalogSection + +enum class SearchEmptyStateReason { + NoActiveAddons, + NoSearchCatalogs, + NoResults, + RequestFailed, +} + +data class SearchUiState( + val isLoading: Boolean = false, + val sections: List = emptyList(), + val emptyStateReason: SearchEmptyStateReason? = null, + val errorMessage: String? = 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 new file mode 100644 index 000000000..bc949052d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -0,0 +1,202 @@ +package com.nuvio.app.features.search + +import com.nuvio.app.features.addons.AddonCatalog +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.home.HomeCatalogSection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +object SearchRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var activeJob: Job? = null + private var lastRequestKey: String? = null + + fun search(query: String, addons: List) { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) { + clear() + return + } + + val activeAddons = addons.filter { it.manifest != null } + if (activeAddons.isEmpty()) { + activeJob?.cancel() + lastRequestKey = null + _uiState.value = SearchUiState( + emptyStateReason = SearchEmptyStateReason.NoActiveAddons, + ) + return + } + + val requests = buildSearchRequests( + addons = activeAddons, + query = normalizedQuery, + ) + if (requests.isEmpty()) { + activeJob?.cancel() + lastRequestKey = null + _uiState.value = SearchUiState( + emptyStateReason = SearchEmptyStateReason.NoSearchCatalogs, + ) + return + } + + val requestKey = buildString { + append(normalizedQuery.lowercase()) + append('|') + append( + requests.joinToString(separator = "|") { request -> + "${request.addon.manifestUrl}:${request.type}:${request.catalogId}" + }, + ) + } + if (requestKey == lastRequestKey) return + lastRequestKey = requestKey + + activeJob?.cancel() + _uiState.value = SearchUiState(isLoading = true) + + activeJob = scope.launch { + val results = requests.map { request -> + async { + runCatching { request.toSection() } + } + }.awaitAll() + + val sections = results + .mapNotNull { it.getOrNull() } + .sortedBy { it.title.lowercase() } + val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } + val allFailed = results.isNotEmpty() && results.all { it.isFailure } + + _uiState.value = SearchUiState( + isLoading = false, + sections = sections, + emptyStateReason = when { + sections.isNotEmpty() -> null + allFailed -> SearchEmptyStateReason.RequestFailed + else -> SearchEmptyStateReason.NoResults + }, + errorMessage = if (allFailed) firstFailure else null, + ) + } + } + + fun clear() { + activeJob?.cancel() + lastRequestKey = null + _uiState.value = SearchUiState() + } + + private fun buildSearchRequests( + addons: List, + query: String, + ): List = + addons.mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + addon to manifest + }.flatMap { (addon, manifest) -> + manifest.catalogs + .filter { catalog -> catalog.supportsSearch() } + .map { catalog -> + SearchCatalogRequest( + addon = addon, + catalogId = catalog.id, + catalogName = catalog.name, + type = catalog.type, + query = query, + ) + } + } + + private suspend fun SearchCatalogRequest.toSection(): HomeCatalogSection { + val manifest = requireNotNull(addon.manifest) + val catalogUrl = buildSearchCatalogUrl( + manifestUrl = manifest.transportUrl, + type = type, + catalogId = catalogId, + query = query, + ) + val payload = httpGetText(catalogUrl) + val items = HomeCatalogParser.parseCatalog(payload) + require(items.isNotEmpty()) { "No search results returned for $catalogName." } + + return HomeCatalogSection( + key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}", + title = "$catalogName - ${type.displayLabel()}", + subtitle = manifest.name, + addonName = manifest.name, + type = type, + manifestUrl = manifest.transportUrl, + catalogId = catalogId, + items = items, + ) + } +} + +private data class SearchCatalogRequest( + val addon: ManagedAddon, + val catalogId: String, + val catalogName: String, + val type: String, + val query: String, +) + +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 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 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 new file mode 100644 index 000000000..b0071de4f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -0,0 +1,162 @@ +package com.nuvio.app.features.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.features.addons.AddonRepository +import com.nuvio.app.features.home.MetaPreview +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 + +@Composable +fun SearchScreen( + modifier: Modifier = Modifier, + onPosterClick: ((MetaPreview) -> Unit)? = null, +) { + LaunchedEffect(Unit) { + AddonRepository.initialize() + } + + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() + var query by rememberSaveable { androidx.compose.runtime.mutableStateOf("") } + + val addonRefreshKey = remember(addonsUiState.addons) { + addonsUiState.addons.mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + buildString { + append(manifest.transportUrl) + append(':') + append(manifest.catalogs.joinToString(separator = ",") { catalog -> + val extra = catalog.extra.joinToString(separator = "&") { property -> + "${property.name}:${property.isRequired}" + } + "${catalog.type}:${catalog.id}:$extra" + }) + } + } + } + + LaunchedEffect(query, addonRefreshKey) { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) { + SearchRepository.clear() + } else { + delay(350) + SearchRepository.search( + query = normalizedQuery, + addons = addonsUiState.addons, + ) + } + } + + NuvioScreen( + modifier = modifier, + horizontalPadding = 0.dp, + ) { + 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", + ) + } + } + + 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 SearchEmptyStateCard( + reason: SearchEmptyStateReason?, + errorMessage: String?, + modifier: Modifier = Modifier, +) { + val title: String + val message: String + + when (reason) { + SearchEmptyStateReason.NoActiveAddons -> { + title = "No active addons" + message = "Install and validate at least one addon before searching." + } + + SearchEmptyStateReason.NoSearchCatalogs -> { + title = "No searchable catalogs" + message = "Your installed addons do not expose catalog search." + } + + SearchEmptyStateReason.RequestFailed -> { + title = "Search failed" + message = errorMessage ?: "Installed addons failed to return valid search results." + } + + SearchEmptyStateReason.NoResults, null -> { + title = "No results found" + message = "Installed searchable catalogs did not return any matches for this query." + } + } + + HomeEmptyStateCard( + modifier = modifier, + title = title, + message = message, + ) +}