mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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 ->
|
||||
|
||||
Reference in new issue
Block a user