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

+42 -3
View File
@@ -41,7 +41,14 @@ final class AppModel {
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = ""
/// The whole library, whatever is searched for: the grid narrows to the
/// search through `arrangement`, and a title opened from a search stays one
/// the app can show. Android's `AppUiState.titles`.
var titles: [TitleRecord] = []
/// What the search found besides titles; nil with no search.
private(set) var searchExtras: SearchExtrasRecord?
/// The last few searches on this device, newest first.
private(set) var recentSearches: [String] = []
/// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are.
@@ -85,6 +92,7 @@ final class AppModel {
started = true
reload()
enteredForeground()
Task { recentSearches = (try? await run { try $0.recentSearches() }) ?? [] }
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued()
Task {
@@ -136,6 +144,32 @@ final class AppModel {
}
}
/// Keep the search being shown as one to offer again: called when a viewer
/// opens something it found. Nothing for an empty one.
func rememberSearch() {
let query = query.trimmingCharacters(in: .whitespaces)
if query.isEmpty { return }
Task {
if let recent = try? await run({ engine in
try engine.rememberSearch(query: query)
return try engine.recentSearches()
}) {
recentSearches = recent
}
}
}
func forgetSearches() {
Task {
do {
try await run { try $0.forgetSearches() }
recentSearches = []
} catch {
reportError(error)
}
}
}
func refresh() {
crawl(nil)
}
@@ -840,7 +874,7 @@ final class AppModel {
_ = try engine.pruneOfflineFiles()
return Reloaded(
shares: try engine.shares(),
titles: try engine.library(search: query),
titles: try engine.library(search: nil),
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(),
@@ -866,11 +900,16 @@ final class AppModel {
let sort = sort
let grouped = grouped
do {
let (titles, arrangement) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
let (titles, arrangement, extras) = try await run { engine in
(
try engine.library(search: nil),
try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped),
try search.map { try engine.searchExtras(search: $0) }
)
}
self.titles = titles
self.arrangement = arrangement
self.searchExtras = extras
} catch {
message = errorMessage(error)
}