mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
initsearchs screen
This commit is contained in:
parent
36cae2f3d3
commit
242aee0fa5
5 changed files with 401 additions and 5 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue