feat(app,android,ios): search across episodes and people

A search now also finds, with posters and descriptions on, the directors
and studios and the genres whose names hold it, as chips that open their
page, and the episodes whose provider names hold it, which play from the
result (pstr_core::discover::find). The titles it found follow under
their own heading.

The last eight searches are kept in recent-searches.json on this device
only (pstr_core::recent), written only when a search led somewhere, and
offered under the empty search box with a way to clear them. The bridge
exposes search_extras, recent_searches, remember_search and
forget_searches.

On Android and iOS the library's titles are now the whole library
whatever is searched for; the grid narrows through the arrangement, as
it already did. A title opened from an episode result can then always be
shown, and History and Downloads no longer shrink to the search.
This commit is contained in:
nvrl committed 2026-10-30 23:07:51 +01:00
1 parent fafe0c3087
commit 87ccaf26e1
16 files changed
+869 -20

No files matched your search

@@ -29,6 +29,7 @@ import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
@@ -52,7 +53,16 @@ data class AppUiState(
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "",
/**
* The whole library, whatever is searched for: the grid narrows to the
* search through [arrangement], and a title opened from a search, or from
* an episode it found, stays one the app can show.
*/
val titles: List<TitleRecord> = emptyList(),
/** What the search found besides titles; null with no search. */
val searchExtras: SearchExtrasRecord? = null,
/** The last few searches on this device, newest first. */
val recentSearches: List<String> = emptyList(),
/**
* The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered,
@@ -169,6 +179,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
}
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
viewModelScope.launch {
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
@@ -206,6 +220,32 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
searchQuery.value = query
}
/**
* Keep the search being shown as one to offer again: called when a viewer
* opens something it found. Nothing for an empty one.
*/
fun rememberSearch() {
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().run {
rememberSearch(query)
recentSearches()
}
}
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
}
fun forgetSearches() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
.onFailure(::reportError)
}
}
fun refresh() {
viewModelScope.launch {
busy("Refreshing the library…") {
@@ -889,7 +929,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(query),
engine.library(null),
engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
@@ -918,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
Triple(
engine.library(null),
engine.arrangement(search, current.filter, current.sort, current.grouped),
search?.let(engine::searchExtras),
)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onSuccess { (titles, arrangement, extras) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
@@ -112,6 +112,12 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.BrowseFacet
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.filled.History
import uniffi.pstr_android.FacetCountRecord
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
@@ -196,6 +202,9 @@ internal fun LibraryScreen(
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** Open every title sharing a genre, studio or director a search named. */
onBrowse: (BrowseFacet) -> Unit = {},
onForgetSearches: () -> Unit = {},
) {
val download = rememberDownload()
val context = LocalContext.current
@@ -266,9 +275,17 @@ internal fun LibraryScreen(
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
// Empty and with the field open: the last few searches.
searching && state.query.isBlank() && state.recentSearches.isNotEmpty() -> RecentSearches(
state.recentSearches,
onPick = onSearch,
onForget = onForgetSearches,
)
state.query.isNotBlank() &&
state.arrangement?.tiles?.isEmpty() == true &&
state.searchExtras?.isEmpty() != false -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
"Nothing in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
@@ -300,6 +317,7 @@ internal fun LibraryScreen(
featured = if (overBanner) featured else emptyList(),
grid = grid,
onSetOnList = onSetOnList,
onBrowse = onBrowse,
)
}
}
@@ -505,6 +523,7 @@ private fun LibraryGrid(
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
onBrowse: (BrowseFacet) -> Unit = {},
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -622,6 +641,38 @@ private fun LibraryGrid(
}
}
}
// What the search found besides titles, above the titles it found.
state.searchExtras?.takeIf { state.query.isNotBlank() }?.let { extras ->
facetChips("People and studios", extras.people, onBrowse)
facetChips("Genres", extras.genres, onBrowse)
if (extras.episodes.isNotEmpty()) {
item(key = "search-episodes", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Episodes", style = MaterialTheme.typography.titleMedium)
extras.episodes.forEach { hit ->
val title = byKey[hit.titleKey] ?: return@forEach
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val index = playlist.indexOfFirst { it.linkId == hit.linkId }
if (index < 0) return@forEach
ListItem(
headlineContent = { Text(hit.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Text("${title.displayName} · ${hit.label}", maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClickLabel = "Play") { onResume(title, index) },
)
}
}
}
}
if (tiles.isNotEmpty() && !extras.isEmpty()) {
item(key = "search-titles", span = { GridItemSpan(maxLineSpan) }) {
Text("Titles", style = MaterialTheme.typography.titleMedium)
}
}
}
items(tiles, key = { it.title.key }) { tile ->
PosterTile(
tile.title,
@@ -633,6 +684,55 @@ private fun LibraryGrid(
}
}
/** Whether a search found nothing besides titles. */
internal fun SearchExtrasRecord.isEmpty(): Boolean = episodes.isEmpty() && people.isEmpty() && genres.isEmpty()
/** A row of the genres, studios or people a search named, each opening its page. */
private fun LazyGridScope.facetChips(heading: String, facets: List<FacetCountRecord>, onBrowse: (BrowseFacet) -> Unit) {
if (facets.isEmpty()) return
item(key = "search-$heading", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(heading, style = MaterialTheme.typography.titleMedium)
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
facets.forEach { found ->
GenreChip("${found.facet.label} · ${found.count}") { onBrowse(found.facet) }
}
}
}
}
}
/** The last few searches, under an empty search field. */
@Composable
private fun RecentSearches(recent: List<String>, onPick: (String) -> Unit, onForget: () -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
item(key = "heading") {
Text(
"Recent searches",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(recent, key = { it }) { query ->
ListItem(
headlineContent = { Text(query) },
leadingContent = { Icon(Icons.Default.History, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { onPick(query) },
)
}
item(key = "forget") {
QuietButton(onClick = onForget, modifier = Modifier.padding(horizontal = 8.dp)) {
Text("Clear recent searches")
}
}
}
}
/**
* The grid's shape before the catalog has answered: posters and their two
* lines of text as blocks, so the page does not jump when the titles land.
@@ -255,11 +255,14 @@ fun ProtonStreamApp(
state,
model::search,
{ title, index ->
// A search that led somewhere is one
// worth offering again.
model.rememberSearch()
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
{ model.rememberSearch(); selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
@@ -274,6 +277,12 @@ fun ProtonStreamApp(
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
onSetOnList = model::setOnList,
onBrowse = { facet ->
model.rememberSearch()
browse(facet)
browseFrom = null
},
onForgetSearches = model::forgetSearches,
)
}
val titlePane = @Composable { selected: TitleRecord ->