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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -232,14 +232,26 @@ struct RootView: View {
|
||||
)
|
||||
} else {
|
||||
LibraryView(
|
||||
onResume: play,
|
||||
onTitle: open,
|
||||
// A search that led somewhere is one worth offering again.
|
||||
onResume: { title, index in
|
||||
model.rememberSearch()
|
||||
play(title, index)
|
||||
},
|
||||
onTitle: { title in
|
||||
model.rememberSearch()
|
||||
open(title)
|
||||
},
|
||||
// The form as a pasted link opens it, only with nothing in it.
|
||||
onAddShare: {
|
||||
incomingLink = ""
|
||||
destination = .shares
|
||||
},
|
||||
onOpenSettings: { select(.settings) }
|
||||
onOpenSettings: { select(.settings) },
|
||||
onBrowse: { facet in
|
||||
model.rememberSearch()
|
||||
browsing = facet
|
||||
browseFrom = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ struct LibraryView: View {
|
||||
let onTitle: (TitleRecord) -> Void
|
||||
var onAddShare: () -> Void = {}
|
||||
var onOpenSettings: () -> Void = {}
|
||||
/// Open every title sharing a genre, studio or director a search named.
|
||||
var onBrowse: (BrowseFacet) -> Void = { _ in }
|
||||
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
@State private var menuFor: TitleRecord?
|
||||
@@ -178,8 +180,13 @@ struct LibraryView: View {
|
||||
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
|
||||
if model.loading {
|
||||
LibrarySkeleton()
|
||||
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||||
} else if searching && model.query.trimmingCharacters(in: .whitespaces).isEmpty && !model.recentSearches.isEmpty {
|
||||
// Empty and with the field open: the last few searches.
|
||||
recentSearches
|
||||
} else if !model.query.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
model.arrangement?.tiles.isEmpty == true,
|
||||
model.searchExtras?.isEmpty ?? true {
|
||||
EmptyState("Nothing matches", "Nothing in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||||
} else if model.titles.isEmpty, let activity = model.activity {
|
||||
// A share just added: say it is being looked through, rather than
|
||||
// offer to add the one that is already filling the page.
|
||||
@@ -284,6 +291,14 @@ struct LibraryView: View {
|
||||
Text("All titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
// What the search found besides titles, above the titles it
|
||||
// found.
|
||||
if !browsing, let extras = model.searchExtras {
|
||||
searchExtras(extras, byKey: byKey)
|
||||
if !tiles.isEmpty && !extras.isEmpty {
|
||||
Text("Titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
}
|
||||
}
|
||||
if browsing {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -316,6 +331,71 @@ struct LibraryView: View {
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
/// The people, studios and genres a search named as chips, then the
|
||||
/// episodes it found by name. Android's `facetChips` and its episode rows.
|
||||
@ViewBuilder private func searchExtras(_ extras: SearchExtrasRecord, byKey: [String: TitleRecord]) -> some View {
|
||||
ForEach([("People and studios", extras.people), ("Genres", extras.genres)], id: \.0) { heading, facets in
|
||||
if !facets.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(facets, id: \.facet.label) { found in
|
||||
GenreChip(text: "\(found.facet.label) · \(found.count)") { onBrowse(found.facet) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !extras.episodes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
ForEach(extras.episodes, id: \.linkId) { hit in
|
||||
if let title = byKey[hit.titleKey],
|
||||
let index = title.playlist.firstIndex(where: { $0.linkId == hit.linkId }) {
|
||||
Button { onResume(title, index) } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "play.fill").foregroundStyle(scheme.onSurfaceVariant)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(hit.name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||
Text("\(title.displayName) · \(hit.label)")
|
||||
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Play")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last few searches, under an empty search field.
|
||||
private var recentSearches: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Recent searches").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.vertical, 8)
|
||||
ForEach(model.recentSearches, id: \.self) { query in
|
||||
Button { model.search(query) } label: {
|
||||
Label(query, systemImage: "clock.arrow.circlepath")
|
||||
.textStyle(.bodyLarge)
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.frame(maxWidth: .infinity, minHeight: 48, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Button("Clear recent searches") { model.forgetSearches() }
|
||||
.buttonStyle(.quiet)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func longPressed(_ open: () -> Void) {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
open()
|
||||
@@ -846,3 +926,8 @@ extension BrowseFacet {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchExtrasRecord {
|
||||
/// Whether a search found nothing besides titles.
|
||||
var isEmpty: Bool { episodes.isEmpty && people.isEmpty && genres.isEmpty }
|
||||
}
|
||||
Reference in new issue
Block a user