diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index f2a6d8f..5ef36c3 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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 = 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 = 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) } } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index b19be3e..61fc256 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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 = 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, 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, 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. diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index b648ab6..4ee79ec 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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 -> diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index dd992da..3192e7b 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -365,6 +365,44 @@ pub enum BrowseFacet { Person { name: String }, } +/// One episode a search found by its provider name. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct EpisodeMatchRecord { + pub title_key: String, + pub link_id: String, + /// `S01E05`. + pub label: String, + pub name: String, +} + +/// A genre, studio or person a search named, with how many titles carry it. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct FacetCountRecord { + pub facet: BrowseFacet, + pub count: u32, +} + +/// What a search found besides titles; the shared +/// `pstr_core::discover::Found`. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct SearchExtrasRecord { + pub episodes: Vec, + /// Directors and studios. + pub people: Vec, + pub genres: Vec, +} + +impl From for BrowseFacet { + fn from(facet: pstr_core::discover::Facet) -> Self { + use pstr_core::discover::Facet; + match facet { + Facet::Genre(name) => Self::Genre { name }, + Facet::Studio(name) => Self::Studio { name }, + Facet::Person(name) => Self::Person { name }, + } + } +} + impl BrowseFacet { fn core(&self) -> pstr_core::discover::Facet { use pstr_core::discover::Facet; @@ -1764,6 +1802,65 @@ impl AndroidEngine { }) } + /// What `search` finds besides titles: episodes by name, and the people, + /// studios and genres it names. + pub fn search_extras(&self, search: String) -> Result { + self.with_library(|cached| { + let found = pstr_core::discover::find( + &cached.library, + &cached.metadata, + &cached.guides, + &search, + ); + let facets = |facets: Vec<(pstr_core::discover::Facet, usize)>| { + facets + .into_iter() + .map(|(facet, count)| FacetCountRecord { + facet: BrowseFacet::from(facet), + count: count as u32, + }) + .collect() + }; + SearchExtrasRecord { + episodes: found + .episodes + .into_iter() + .map(|hit| EpisodeMatchRecord { + title_key: hit.title_key, + link_id: hit.link_id, + label: hit.label, + name: hit.name, + }) + .collect(), + people: facets(found.people), + genres: facets(found.genres), + } + }) + } + + /// The last few searches on this device, newest first. + pub fn recent_searches(&self) -> Result, BridgeError> { + pstr_core::recent::load(&self.dirs) + .map(|recent| recent.queries) + .map_err(BridgeError::from_display) + } + + /// Put `query` at the front of the recent searches. Refused when the + /// file would not parse, rather than writing a new list over it. + pub fn remember_search(&self, query: String) -> Result<(), BridgeError> { + let mut recent = pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?; + if recent.remember(&query) { + pstr_core::recent::save(&self.dirs, &recent).map_err(BridgeError::from_display)?; + } + Ok(()) + } + + /// Forget every recent search. + pub fn forget_searches(&self) -> Result<(), BridgeError> { + pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?; + pstr_core::recent::save(&self.dirs, &Default::default()).map_err(BridgeError::from_display) + } + /// The keys of every title `facet` stands for, in library order. pub fn browse(&self, facet: BrowseFacet) -> Result, BridgeError> { let facet = facet.core(); @@ -2645,6 +2742,7 @@ impl AndroidEngine { titles, offline: offline.into_keys().collect(), first_seen, + guides, }; let answer = read(&cached); *self.library_cache.lock() = Some(cached); @@ -3527,6 +3625,9 @@ struct CachedLibrary { offline: HashSet<(String, String)>, /// When each file first appeared in a crawl; see [`Catalog::first_seen`]. first_seen: HashMap<(String, String), i64>, + /// What the providers said about each title's episodes, for searching + /// them by name. + guides: HashMap, } /// The titles whose files first appeared at or after `since`, with how many @@ -4487,6 +4588,23 @@ mod tests { assert!(engine.browse(genre.clone()).expect("browse").is_empty()); assert_eq!(super::facet_kind(genre), "Genre"); + // Recent searches: newest first, and forgotten on asking. + engine.remember_search("frieren".into()).expect("remember"); + engine.remember_search("abyss".into()).expect("remember"); + assert_eq!( + engine.recent_searches().expect("recent"), + ["abyss", "frieren"] + ); + engine.forget_searches().expect("forget"); + assert!(engine.recent_searches().expect("recent").is_empty()); + assert!( + engine + .search_extras("frieren".into()) + .expect("extras") + .episodes + .is_empty() + ); + let _ = std::fs::remove_dir_all(root); } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 61c3ddc..4ddaa51 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -427,6 +427,8 @@ pub struct LibraryView { pub badges: HashMap, /// The keys on My List. pub listed: std::collections::HashSet, + /// What the search found besides titles. + pub found: pstr_core::discover::Found, /// "More like this" for the title page open, worked out once per title: /// its key, and the keys of the titles like it. pub similar: Option<(String, Vec)>, @@ -468,6 +470,7 @@ impl Default for LibraryView { badges: HashMap::new(), listed: std::collections::HashSet::new(), similar: None, + found: pstr_core::discover::Found::default(), stale: false, progressed: false, } @@ -488,6 +491,9 @@ pub struct ViewInputs<'a> { pub list: &'a MyList, /// When each file first appeared in a crawl, for what is new. pub first_seen: &'a HashMap<(String, String), i64>, + /// What the providers said about each title's episodes, for searching + /// them by name. + pub guides: &'a HashMap, pub now: i64, } @@ -615,6 +621,9 @@ impl LibraryView { self.resumable = Self::resumable(library); self.badges = Self::badges(library, &inputs); } + if self.query != inputs.query || self.stale { + self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query); + } self.query = inputs.query.to_owned(); self.stale = false; self.progressed = false; @@ -1599,6 +1608,15 @@ impl App { } fn apply(&mut self, ctx: &egui::Context, action: Action) { + // A search that led somewhere is one worth offering again. + if self.page == Page::Library + && matches!( + action, + Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_) + ) + { + self.engine.remember_search(Some(&self.search)); + } match action { Action::Goto(page) => self.page = page, Action::Play(mut target) => { @@ -2221,7 +2239,9 @@ impl App { if on_library && !self.library.is_empty() { ui.add_space(theme::space::M); - ui::search_field(ui, search, focus_search); + if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) { + self.engine.remember_search(None); + } } }); }); @@ -2474,6 +2494,7 @@ impl eframe::App for App { offline: &self.offline_files, list: &self.my_list, first_seen: &self.first_seen, + guides: &self.episodes, now: crate::engine::unix_now(), }, ); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 4755163..e0b2ee0 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -425,6 +425,9 @@ pub struct Engine { /// My List, read once at launch. `None` when the file would not parse: it /// is then never written over, and changing the list is refused. my_list: Arc>>, + /// The last few searches, newest first. `None`, and never written, when + /// the file would not parse. + recent: Arc>>, /// Preloaded so clicking Play never runs SQLite on egui's thread. track_prefs: Arc>>, downloads: Arc>, @@ -535,6 +538,9 @@ impl Engine { let my_list = pstr_core::mylist::load(&dirs) .inspect_err(|error| tracing::warn!("read My List: {error}")) .ok(); + let recent = pstr_core::recent::load(&dirs) + .inspect_err(|error| tracing::warn!("read recent searches: {error}")) + .ok(); // The TMDB key, which every provider looks for, comes out of the OS // credential store, and that can be a D-Bus round trip to a keyring @@ -575,6 +581,7 @@ impl Engine { prefs: Arc::new(Mutex::new(prefs)), appearance: Arc::new(Mutex::new(appearance)), my_list: Arc::new(Mutex::new(my_list)), + recent: Arc::new(Mutex::new(recent)), track_prefs: Arc::new(Mutex::new(track_prefs)), downloads: Arc::new(Mutex::new(Downloads::default())), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), @@ -676,6 +683,38 @@ impl Engine { saved } + /// The last few searches, newest first. + pub fn recent_searches(&self) -> Vec { + self.recent + .lock() + .as_ref() + .map(|recent| recent.queries.clone()) + .unwrap_or_default() + } + + /// Put `query` at the front of the recent searches, or with `None` + /// forget them all, and write the list off the UI thread. + pub fn remember_search(&self, query: Option<&str>) { + let changed = match (self.recent.lock().as_mut(), query) { + (Some(recent), Some(query)) => recent.remember(query), + (Some(recent), None) => !std::mem::take(&mut recent.queries).is_empty(), + (None, _) => false, + }; + if !changed { + return; + } + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + let guard = engine.recent.lock(); + if let Some(recent) = guard.as_ref() + && let Err(error) = pstr_core::recent::save(&engine.dirs, recent) + { + drop(guard); + engine.fail("save recent searches", error); + } + }); + } + /// Change the theme and write it. /// /// Always committed, unlike the volume: a theme is picked with a click and diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 92ead1a..24f65cc 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -94,6 +94,15 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: } } + if searching && !view.found.is_empty() { + found(ui, library, &view.found, art, actions); + // Found as an episode or a person but not by name: no empty + // heading over no titles. + if matches.is_empty() { + return; + } + } + ui.horizontal(|ui| { ui::section( ui, @@ -127,6 +136,76 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: }); } +/// What a search found besides titles, above the titles it found: the +/// people, studios and genres it names as chips, then episodes by name. +fn found( + ui: &mut egui::Ui, + library: &Library, + found: &pstr_core::discover::Found, + art: &Art<'_>, + actions: &mut Vec, +) { + let chips = |ui: &mut egui::Ui, + heading: &str, + facets: &[(pstr_core::discover::Facet, usize)], + actions: &mut Vec| { + if facets.is_empty() { + return; + } + ui::section(ui, heading); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for (facet, count) in facets { + if ui::widgets::link_chip(ui, &format!("{} · {count}", facet.name())) + .on_hover_text(format!("{}: {}", facet.kind(), plural(*count, "title"))) + .clicked() + { + actions.push(Action::Goto(Page::Browse { + facet: facet.clone(), + from: None, + })); + } + } + }); + ui.add_space(theme::space::XL); + }; + chips(ui, "People and studios", &found.people, actions); + chips(ui, "Genres", &found.genres, actions); + + if found.episodes.is_empty() { + return; + } + ui::section(ui, "Episodes"); + for hit in &found.episodes { + let Some(title) = library.get(&hit.title_key) else { + continue; + }; + let Some(episode) = title.episodes().find(|episode| { + episode.node.link_id == hit.link_id && episode.node.share_id == hit.share_id + }) else { + continue; + }; + let text = format!( + "{} {} · {} · {}", + egui_phosphor::regular::PLAY, + art.name(title), + hit.label, + hit.name + ); + let response = ui + .add(egui::Button::selectable(false, text).frame_when_inactive(false)) + .on_hover_text("Play this episode"); + if response.clicked() { + actions.push(Action::Play(PlaybackTarget::from_node( + title, + &episode.node, + None, + ))); + } + } + ui.add_space(theme::space::XL); +} + /// What a chip on a title page opened. pub struct Browsing<'a> { pub facet: &'a pstr_core::discover::Facet, diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index de8a54c..10a8fbc 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -407,7 +407,15 @@ pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response { /// /// Escape inside it clears it. `focus` puts the caret in it this frame — which /// is what `Ctrl+F` and `/` ask for from anywhere in the library. -pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { +/// +/// Empty and with the caret in it, it offers the `recent` searches under it. +/// Picking one fills the box; the return says the viewer asked to forget them. +pub fn search_field( + ui: &mut egui::Ui, + search: &mut String, + focus: bool, + recent: &[String], +) -> bool { let id = egui::Id::new("library-search"); let width = 240.0; let height = ui.spacing().interact_size.y; @@ -446,7 +454,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { .id(id) .frame(egui::Frame::NONE) .hint_text(if has_focus { - "Titles and filenames" + "Titles, episodes, people" } else { "Search Ctrl+F" }) @@ -490,6 +498,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { search.clear(); } } + + // Kept up while the pointer is over it as well as while the box has the + // caret: pressing on it takes the caret away before the click lands. + let popup = id.with("recent"); + let over = ui + .data(|data| data.get_temp::(popup)) + .unwrap_or(false); + let mut forget = false; + if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) { + let mut picked = None; + let shown = egui::Area::new(popup) + .order(egui::Order::Foreground) + .fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(width - 12.0); + ui.label( + theme::Role::Caption + .rich("Recent searches") + .color(theme::muted()), + ); + for query in recent { + if ui + .add( + egui::Button::selectable(false, query.as_str()) + .frame_when_inactive(false), + ) + .clicked() + { + picked = Some(query.clone()); + } + } + ui.separator(); + forget = ui + .add( + egui::Button::selectable(false, muted("Clear recent searches")) + .frame_when_inactive(false), + ) + .clicked(); + }); + }); + let over = shown.response.contains_pointer(); + ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none())); + if let Some(query) = picked { + *search = query; + } + } else { + ui.data_mut(|data| data.remove::(popup)); + } + forget } /// A thin bar in the accent, for how far something has got. diff --git a/crates/pstr-core/src/discover.rs b/crates/pstr-core/src/discover.rs index 7c272cd..7f0ff0d 100644 --- a/crates/pstr-core/src/discover.rs +++ b/crates/pstr-core/src/discover.rs @@ -70,6 +70,125 @@ impl Facet { } } +/// One episode a search found by the name the provider gave it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpisodeMatch { + pub title_key: String, + pub share_id: String, + pub link_id: String, + /// `S01E05`, as the row everywhere else says it. + pub label: String, + /// The provider's name for it. + pub name: String, +} + +/// What a search found besides titles: episodes by name, and the people, +/// studios and genres whose names hold the query, each with how many titles +/// in the library carry it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Found { + pub episodes: Vec, + /// Directors and studios. + pub people: Vec<(Facet, usize)>, + pub genres: Vec<(Facet, usize)>, +} + +impl Found { + pub fn is_empty(&self) -> bool { + self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty() + } +} + +/// How many episodes a search lists: past this the query is too loose for a +/// list of episodes to help. +const EPISODES: usize = 12; + +/// Everything but titles that `query` finds. Nothing for a query under two +/// letters, which would hold half of every name. +pub fn find( + library: &crate::library::Library, + metadata: &HashMap, + guides: &HashMap, + query: &str, +) -> Found { + let needle = query.trim().to_lowercase(); + if needle.chars().count() < 2 { + return Found::default(); + } + let holds = |name: &str| name.to_lowercase().contains(&needle); + + let mut episodes = Vec::new(); + 'titles: for title in &library.titles { + let Some(guide) = guides.get(&title.key) else { + continue; + }; + for episode in title.episodes() { + let node = &episode.node; + let Some(name) = node + .parsed + .episode + .and_then(|number| guide.get(node.parsed.season, number)) + .and_then(|found| found.name.as_deref()) + .filter(|name| holds(name)) + else { + continue; + }; + episodes.push(EpisodeMatch { + title_key: title.key.clone(), + share_id: node.share_id.clone(), + link_id: node.link_id.clone(), + label: episode.numbering().unwrap_or_else(|| episode.label()), + name: name.to_owned(), + }); + if episodes.len() == EPISODES { + break 'titles; + } + } + } + + let mut people: HashMap = HashMap::new(); + let mut genres: HashMap = HashMap::new(); + for title in &library.titles { + let Some(found) = metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + else { + continue; + }; + for genre in found.genres.iter().filter(|genre| holds(genre)) { + *genres.entry(Facet::Genre(genre.clone())).or_default() += 1; + } + let Some(details) = &found.details else { + continue; + }; + let made = details + .directors + .iter() + .map(|name| Facet::Person(name.clone())) + .chain( + details + .studios + .iter() + .map(|name| Facet::Studio(name.clone())), + ); + for facet in made.filter(|facet| holds(facet.name())) { + *people.entry(facet).or_default() += 1; + } + } + // Most titles first, then by name, so the order does not change from one + // keystroke to the next. + let ranked = |found: HashMap| { + let mut found: Vec<(Facet, usize)> = found.into_iter().collect(); + found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name()))); + found + }; + Found { + episodes, + people: ranked(people), + genres: ranked(genres), + } +} + /// How much one shared trait says two titles are alike. A director is close /// to a signature; a genre is one of three or four on every entry. const DIRECTOR: u32 = 4; @@ -410,6 +529,38 @@ mod tests { ); } + #[test] + fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() { + let (library, metadata) = library(&[]); + let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata { + season: None, + number: 1, + absolute: None, + entry_season: None, + entry_number: None, + name: Some("The Journey's End".into()), + overview: None, + still_url: None, + air_date: None, + runtime: None, + }]); + let guides = HashMap::from([("frieren".to_owned(), guide)]); + + let found = find(&library, &metadata, &guides, "journey"); + assert_eq!(found.episodes.len(), 1); + assert_eq!(found.episodes[0].title_key, "frieren"); + assert_eq!(found.episodes[0].name, "The Journey's End"); + + let found = find(&library, &metadata, &guides, "saitou"); + assert_eq!( + found.people, + [(Facet::Person("Keiichirou Saitou".into()), 3)] + ); + let found = find(&library, &metadata, &guides, "com"); + assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]); + assert!(find(&library, &metadata, &guides, "c").is_empty()); + } + #[test] fn a_facet_page_lists_the_titles_that_carry_it() { let (library, metadata) = library(&[]); diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 4d59235..a4ca80b 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod naming; pub mod news; pub mod prefs; pub mod profile; +pub mod recent; pub mod shares; pub mod shelves; pub mod subtitles; diff --git a/crates/pstr-core/src/recent.rs b/crates/pstr-core/src/recent.rs new file mode 100644 index 0000000..2871f88 --- /dev/null +++ b/crates/pstr-core/src/recent.rs @@ -0,0 +1,81 @@ +//! The last few searches, offered again under an empty search box. +//! +//! Kept on this device only, in `recent-searches.json` beside the other +//! settings, and never synced: what someone typed is not a setting, and +//! another device's list is not what they were just looking for. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::config::{AppDirs, read_json, write_json}; +use crate::error::Result; + +/// How many searches are kept. +pub const KEEP: usize = 8; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentSearches { + /// Newest first. + #[serde(default)] + pub queries: Vec, +} + +impl RecentSearches { + /// Put `query` first, saying whether the list changed. The same search + /// in other letter case moves rather than repeats, and one under two + /// letters is not kept. + pub fn remember(&mut self, query: &str) -> bool { + let query = query.trim(); + if query.chars().count() < 2 { + return false; + } + if self.queries.first().map(String::as_str) == Some(query) { + return false; + } + let lower = query.to_lowercase(); + self.queries.retain(|kept| kept.to_lowercase() != lower); + self.queries.insert(0, query.to_owned()); + self.queries.truncate(KEEP); + true + } +} + +fn file(dirs: &AppDirs) -> PathBuf { + dirs.config.join("recent-searches.json") +} + +/// The list, empty when there is no file yet. +pub fn load(dirs: &AppDirs) -> Result { + Ok(read_json::(&file(dirs))?.unwrap_or_default()) +} + +pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> { + write_json(&file(dirs), recent) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() { + let mut recent = RecentSearches::default(); + assert!(recent.remember("frieren")); + assert!(recent.remember("kon")); + assert!(recent.remember("Frieren ")); + assert_eq!(recent.queries, ["Frieren", "kon"]); + assert!(!recent.remember("Frieren")); + assert!(!recent.remember("x")); + } + + #[test] + fn only_the_newest_searches_are_kept() { + let mut recent = RecentSearches::default(); + for at in 0..KEEP + 3 { + recent.remember(&format!("search {at}")); + } + assert_eq!(recent.queries.len(), KEEP); + assert_eq!(recent.queries[0], format!("search {}", KEEP + 2)); + } +} diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e4e9d61..2d9d334 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -47,7 +47,7 @@ player is handed an opaque token, not a link, so no secret ends up in its log or | What | Where on Linux | |---|---| -| Settings and the share list (without secrets) | `~/.config/proton-stream/` | +| Settings, the share list (without secrets), My List and your last eight searches (`recent-searches.json`) | `~/.config/proton-stream/` | | The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` | | Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` | @@ -62,6 +62,9 @@ can play them. Empty the cache in **Settings → Storage**, and delete downloads The library database holds file names and your watch history, in plain text. It holds no secrets. +Recent searches are saved only when you open something a search found, are never synced, and +**Clear recent searches** under the empty search box deletes them. + ## Other people on the same computer - A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 84874b4..f5bad4b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -50,7 +50,16 @@ devices ([Account and sync](ACCOUNT_AND_SYNC.md)). It is on by default, and needs posters and descriptions to know what belongs together. **Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the -romaji one and the name of its folder. +romaji one and the name of its folder. With posters and descriptions on, it also finds: + +- **People and studios**: directors and studios whose names match. Choose one to see all their + titles. +- **Genres** whose names match. +- **Episodes** whose names match. Choose one to play it. + +The titles it finds come after these. Click the empty search box to see your last eight +searches. A search is kept only when you open something it found. **Clear recent searches** +removes the list. The list stays on this device and does not sync. **Refresh** (or **F5**) reads every share again. The app also does this by itself when a share has not been read for 6 hours. diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 041ce94..f33c1d4 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -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) } diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 7cf1233..91065a9 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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 + } ) } } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 9a51b7e..83f3b37 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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 } +}