initsearchs screen

This commit is contained in:
tapframe 2026-03-11 22:10:12 +05:30
parent 36cae2f3d3
commit 242aee0fa5
5 changed files with 401 additions and 5 deletions

View file

@ -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 },

View file

@ -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,
)
}
}

View file

@ -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<HomeCatalogSection> = emptyList(),
val emptyStateReason: SearchEmptyStateReason? = null,
val errorMessage: String? = null,
)

View file

@ -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<SearchUiState> = _uiState.asStateFlow()
private var activeJob: Job? = null
private var lastRequestKey: String? = null
fun search(query: String, addons: List<ManagedAddon>) {
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<ManagedAddon>,
query: String,
): List<SearchCatalogRequest> =
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"

View file

@ -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,
)
}