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 io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import uniffi.pstr_android.ArrangementRecord
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
import uniffi.pstr_android.BrowseFacet
|
import uniffi.pstr_android.BrowseFacet
|
||||||
|
import uniffi.pstr_android.SearchExtrasRecord
|
||||||
import uniffi.pstr_android.LibraryFilter
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
@@ -52,7 +53,16 @@ data class AppUiState(
|
|||||||
/** A crawl is running, which the banner offers to stop. */
|
/** A crawl is running, which the banner offers to stop. */
|
||||||
val crawling: Boolean = false,
|
val crawling: Boolean = false,
|
||||||
val query: String = "",
|
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(),
|
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
|
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||||
* [grouped], and the shelves above it. Null until the bridge has answered,
|
* [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 {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
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 {
|
viewModelScope.launch {
|
||||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
.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
|
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() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
busy("Refreshing the library…") {
|
busy("Refreshing the library…") {
|
||||||
@@ -889,7 +929,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
val query = current.query.takeIf(String::isNotBlank)
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
Reloaded(
|
Reloaded(
|
||||||
engine.shares(),
|
engine.shares(),
|
||||||
engine.library(query),
|
engine.library(null),
|
||||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||||
engine.offlineFiles(),
|
engine.offlineFiles(),
|
||||||
engine.metadataSettings(),
|
engine.metadataSettings(),
|
||||||
@@ -918,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
val search = query.takeIf(String::isNotBlank)
|
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) ->
|
}.onSuccess { (titles, arrangement, extras) ->
|
||||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
|
||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}.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.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import uniffi.pstr_android.BrowseFacet
|
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.EpisodeRecord
|
||||||
import uniffi.pstr_android.LibraryFilter
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
@@ -196,6 +202,9 @@ internal fun LibraryScreen(
|
|||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
onFilter: (LibraryFilter) -> Unit = {},
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
onSetOnList: (TitleRecord, Boolean) -> 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 download = rememberDownload()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -266,9 +275,17 @@ internal fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> LibrarySkeleton()
|
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",
|
"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
|
// A share just added: say it is being looked through, rather
|
||||||
// than offer to add the one that is already filling the page.
|
// 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(),
|
featured = if (overBanner) featured else emptyList(),
|
||||||
grid = grid,
|
grid = grid,
|
||||||
onSetOnList = onSetOnList,
|
onSetOnList = onSetOnList,
|
||||||
|
onBrowse = onBrowse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,6 +523,7 @@ private fun LibraryGrid(
|
|||||||
featured: List<TitleRecord> = emptyList(),
|
featured: List<TitleRecord> = emptyList(),
|
||||||
grid: LazyGridState = rememberLazyGridState(),
|
grid: LazyGridState = rememberLazyGridState(),
|
||||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onBrowse: (BrowseFacet) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// The arrangement names titles by key; before it has arrived, the titles
|
// The arrangement names titles by key; before it has arrived, the titles
|
||||||
// as they came are the grid.
|
// 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 ->
|
items(tiles, key = { it.title.key }) { tile ->
|
||||||
PosterTile(
|
PosterTile(
|
||||||
tile.title,
|
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
|
* 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.
|
* lines of text as blocks, so the page does not jump when the titles land.
|
||||||
|
|||||||
@@ -255,11 +255,14 @@ fun ProtonStreamApp(
|
|||||||
state,
|
state,
|
||||||
model::search,
|
model::search,
|
||||||
{ title, index ->
|
{ title, index ->
|
||||||
|
// A search that led somewhere is one
|
||||||
|
// worth offering again.
|
||||||
|
model.rememberSearch()
|
||||||
playingTitleKey = title.key
|
playingTitleKey = title.key
|
||||||
playingIndex = index
|
playingIndex = index
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
},
|
},
|
||||||
{ selectedTitleKey = it.key },
|
{ model.rememberSearch(); selectedTitleKey = it.key },
|
||||||
model::refresh,
|
model::refresh,
|
||||||
model::setTitleWatched,
|
model::setTitleWatched,
|
||||||
model::forgetPosition,
|
model::forgetPosition,
|
||||||
@@ -274,6 +277,12 @@ fun ProtonStreamApp(
|
|||||||
onOpenSettings = { destination = Destination.Settings },
|
onOpenSettings = { destination = Destination.Settings },
|
||||||
onFilter = model::filter,
|
onFilter = model::filter,
|
||||||
onSetOnList = model::setOnList,
|
onSetOnList = model::setOnList,
|
||||||
|
onBrowse = { facet ->
|
||||||
|
model.rememberSearch()
|
||||||
|
browse(facet)
|
||||||
|
browseFrom = null
|
||||||
|
},
|
||||||
|
onForgetSearches = model::forgetSearches,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val titlePane = @Composable { selected: TitleRecord ->
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
|
|||||||
@@ -365,6 +365,44 @@ pub enum BrowseFacet {
|
|||||||
Person { name: String },
|
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<EpisodeMatchRecord>,
|
||||||
|
/// Directors and studios.
|
||||||
|
pub people: Vec<FacetCountRecord>,
|
||||||
|
pub genres: Vec<FacetCountRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pstr_core::discover::Facet> 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 {
|
impl BrowseFacet {
|
||||||
fn core(&self) -> pstr_core::discover::Facet {
|
fn core(&self) -> pstr_core::discover::Facet {
|
||||||
use 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<SearchExtrasRecord, BridgeError> {
|
||||||
|
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<Vec<String>, 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.
|
/// The keys of every title `facet` stands for, in library order.
|
||||||
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
|
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
|
||||||
let facet = facet.core();
|
let facet = facet.core();
|
||||||
@@ -2645,6 +2742,7 @@ impl AndroidEngine {
|
|||||||
titles,
|
titles,
|
||||||
offline: offline.into_keys().collect(),
|
offline: offline.into_keys().collect(),
|
||||||
first_seen,
|
first_seen,
|
||||||
|
guides,
|
||||||
};
|
};
|
||||||
let answer = read(&cached);
|
let answer = read(&cached);
|
||||||
*self.library_cache.lock() = Some(cached);
|
*self.library_cache.lock() = Some(cached);
|
||||||
@@ -3527,6 +3625,9 @@ struct CachedLibrary {
|
|||||||
offline: HashSet<(String, String)>,
|
offline: HashSet<(String, String)>,
|
||||||
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
||||||
first_seen: HashMap<(String, String), i64>,
|
first_seen: HashMap<(String, String), i64>,
|
||||||
|
/// What the providers said about each title's episodes, for searching
|
||||||
|
/// them by name.
|
||||||
|
guides: HashMap<String, EpisodeGuide>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The titles whose files first appeared at or after `since`, with how many
|
/// 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!(engine.browse(genre.clone()).expect("browse").is_empty());
|
||||||
assert_eq!(super::facet_kind(genre), "Genre");
|
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);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ pub struct LibraryView {
|
|||||||
pub badges: HashMap<String, String>,
|
pub badges: HashMap<String, String>,
|
||||||
/// The keys on My List.
|
/// The keys on My List.
|
||||||
pub listed: std::collections::HashSet<String>,
|
pub listed: std::collections::HashSet<String>,
|
||||||
|
/// 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:
|
/// "More like this" for the title page open, worked out once per title:
|
||||||
/// its key, and the keys of the titles like it.
|
/// its key, and the keys of the titles like it.
|
||||||
pub similar: Option<(String, Vec<String>)>,
|
pub similar: Option<(String, Vec<String>)>,
|
||||||
@@ -468,6 +470,7 @@ impl Default for LibraryView {
|
|||||||
badges: HashMap::new(),
|
badges: HashMap::new(),
|
||||||
listed: std::collections::HashSet::new(),
|
listed: std::collections::HashSet::new(),
|
||||||
similar: None,
|
similar: None,
|
||||||
|
found: pstr_core::discover::Found::default(),
|
||||||
stale: false,
|
stale: false,
|
||||||
progressed: false,
|
progressed: false,
|
||||||
}
|
}
|
||||||
@@ -488,6 +491,9 @@ pub struct ViewInputs<'a> {
|
|||||||
pub list: &'a MyList,
|
pub list: &'a MyList,
|
||||||
/// When each file first appeared in a crawl, for what is new.
|
/// When each file first appeared in a crawl, for what is new.
|
||||||
pub first_seen: &'a HashMap<(String, String), i64>,
|
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<String, EpisodeGuide>,
|
||||||
pub now: i64,
|
pub now: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +621,9 @@ impl LibraryView {
|
|||||||
self.resumable = Self::resumable(library);
|
self.resumable = Self::resumable(library);
|
||||||
self.badges = Self::badges(library, &inputs);
|
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.query = inputs.query.to_owned();
|
||||||
self.stale = false;
|
self.stale = false;
|
||||||
self.progressed = false;
|
self.progressed = false;
|
||||||
@@ -1599,6 +1608,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
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 {
|
match action {
|
||||||
Action::Goto(page) => self.page = page,
|
Action::Goto(page) => self.page = page,
|
||||||
Action::Play(mut target) => {
|
Action::Play(mut target) => {
|
||||||
@@ -2221,7 +2239,9 @@ impl App {
|
|||||||
|
|
||||||
if on_library && !self.library.is_empty() {
|
if on_library && !self.library.is_empty() {
|
||||||
ui.add_space(theme::space::M);
|
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,
|
offline: &self.offline_files,
|
||||||
list: &self.my_list,
|
list: &self.my_list,
|
||||||
first_seen: &self.first_seen,
|
first_seen: &self.first_seen,
|
||||||
|
guides: &self.episodes,
|
||||||
now: crate::engine::unix_now(),
|
now: crate::engine::unix_now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ pub struct Engine {
|
|||||||
/// My List, read once at launch. `None` when the file would not parse: it
|
/// 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.
|
/// is then never written over, and changing the list is refused.
|
||||||
my_list: Arc<Mutex<Option<MyList>>>,
|
my_list: Arc<Mutex<Option<MyList>>>,
|
||||||
|
/// The last few searches, newest first. `None`, and never written, when
|
||||||
|
/// the file would not parse.
|
||||||
|
recent: Arc<Mutex<Option<pstr_core::recent::RecentSearches>>>,
|
||||||
/// Preloaded so clicking Play never runs SQLite on egui's thread.
|
/// Preloaded so clicking Play never runs SQLite on egui's thread.
|
||||||
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
||||||
downloads: Arc<Mutex<Downloads>>,
|
downloads: Arc<Mutex<Downloads>>,
|
||||||
@@ -535,6 +538,9 @@ impl Engine {
|
|||||||
let my_list = pstr_core::mylist::load(&dirs)
|
let my_list = pstr_core::mylist::load(&dirs)
|
||||||
.inspect_err(|error| tracing::warn!("read My List: {error}"))
|
.inspect_err(|error| tracing::warn!("read My List: {error}"))
|
||||||
.ok();
|
.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
|
// 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
|
// 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)),
|
prefs: Arc::new(Mutex::new(prefs)),
|
||||||
appearance: Arc::new(Mutex::new(appearance)),
|
appearance: Arc::new(Mutex::new(appearance)),
|
||||||
my_list: Arc::new(Mutex::new(my_list)),
|
my_list: Arc::new(Mutex::new(my_list)),
|
||||||
|
recent: Arc::new(Mutex::new(recent)),
|
||||||
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
||||||
downloads: Arc::new(Mutex::new(Downloads::default())),
|
downloads: Arc::new(Mutex::new(Downloads::default())),
|
||||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||||
@@ -676,6 +683,38 @@ impl Engine {
|
|||||||
saved
|
saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last few searches, newest first.
|
||||||
|
pub fn recent_searches(&self) -> Vec<String> {
|
||||||
|
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.
|
/// Change the theme and write it.
|
||||||
///
|
///
|
||||||
/// Always committed, unlike the volume: a theme is picked with a click and
|
/// Always committed, unlike the volume: a theme is picked with a click and
|
||||||
|
|||||||
@@ -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.horizontal(|ui| {
|
||||||
ui::section(
|
ui::section(
|
||||||
ui,
|
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<Action>,
|
||||||
|
) {
|
||||||
|
let chips = |ui: &mut egui::Ui,
|
||||||
|
heading: &str,
|
||||||
|
facets: &[(pstr_core::discover::Facet, usize)],
|
||||||
|
actions: &mut Vec<Action>| {
|
||||||
|
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.
|
/// What a chip on a title page opened.
|
||||||
pub struct Browsing<'a> {
|
pub struct Browsing<'a> {
|
||||||
pub facet: &'a pstr_core::discover::Facet,
|
pub facet: &'a pstr_core::discover::Facet,
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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 id = egui::Id::new("library-search");
|
||||||
let width = 240.0;
|
let width = 240.0;
|
||||||
let height = ui.spacing().interact_size.y;
|
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)
|
.id(id)
|
||||||
.frame(egui::Frame::NONE)
|
.frame(egui::Frame::NONE)
|
||||||
.hint_text(if has_focus {
|
.hint_text(if has_focus {
|
||||||
"Titles and filenames"
|
"Titles, episodes, people"
|
||||||
} else {
|
} else {
|
||||||
"Search Ctrl+F"
|
"Search Ctrl+F"
|
||||||
})
|
})
|
||||||
@@ -490,6 +498,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
search.clear();
|
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::<bool>(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::<bool>(popup));
|
||||||
|
}
|
||||||
|
forget
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A thin bar in the accent, for how far something has got.
|
/// A thin bar in the accent, for how far something has got.
|
||||||
|
|||||||
@@ -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<EpisodeMatch>,
|
||||||
|
/// 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<String, MetadataRecord>,
|
||||||
|
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
|
||||||
|
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<Facet, usize> = HashMap::new();
|
||||||
|
let mut genres: HashMap<Facet, usize> = 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<Facet, usize>| {
|
||||||
|
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
|
/// 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.
|
/// to a signature; a genre is one of three or four on every entry.
|
||||||
const DIRECTOR: u32 = 4;
|
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]
|
#[test]
|
||||||
fn a_facet_page_lists_the_titles_that_carry_it() {
|
fn a_facet_page_lists_the_titles_that_carry_it() {
|
||||||
let (library, metadata) = library(&[]);
|
let (library, metadata) = library(&[]);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod naming;
|
|||||||
pub mod news;
|
pub mod news;
|
||||||
pub mod prefs;
|
pub mod prefs;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod recent;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod shelves;
|
pub mod shelves;
|
||||||
pub mod subtitles;
|
pub mod subtitles;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RecentSearches> {
|
||||||
|
Ok(read_json::<RecentSearches>(&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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -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 |
|
| 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/` |
|
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
|
||||||
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/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
|
The library database holds file names and your watch history, in plain text. It holds no
|
||||||
secrets.
|
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
|
## Other people on the same computer
|
||||||
|
|
||||||
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
|
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
|
||||||
|
|||||||
+10
-1
@@ -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.
|
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
|
**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
|
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
|
||||||
has not been read for 6 hours.
|
has not been read for 6 hours.
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ final class AppModel {
|
|||||||
/// A crawl is running, which the banner offers to stop.
|
/// A crawl is running, which the banner offers to stop.
|
||||||
private(set) var crawling = false
|
private(set) var crawling = false
|
||||||
private(set) var query = ""
|
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] = []
|
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
|
/// The grid: `titles` ordered, each franchise folded into one tile when
|
||||||
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
|
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
|
||||||
/// when the grid falls back to `titles` as they are.
|
/// when the grid falls back to `titles` as they are.
|
||||||
@@ -85,6 +92,7 @@ final class AppModel {
|
|||||||
started = true
|
started = true
|
||||||
reload()
|
reload()
|
||||||
enteredForeground()
|
enteredForeground()
|
||||||
|
Task { recentSearches = (try? await run { try $0.recentSearches() }) ?? [] }
|
||||||
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
||||||
DownloadCoordinator.shared.resumeQueued()
|
DownloadCoordinator.shared.resumeQueued()
|
||||||
Task {
|
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() {
|
func refresh() {
|
||||||
crawl(nil)
|
crawl(nil)
|
||||||
}
|
}
|
||||||
@@ -840,7 +874,7 @@ final class AppModel {
|
|||||||
_ = try engine.pruneOfflineFiles()
|
_ = try engine.pruneOfflineFiles()
|
||||||
return Reloaded(
|
return Reloaded(
|
||||||
shares: try engine.shares(),
|
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),
|
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
|
||||||
offline: try engine.offlineFiles(),
|
offline: try engine.offlineFiles(),
|
||||||
metadataSettings: try engine.metadataSettings(),
|
metadataSettings: try engine.metadataSettings(),
|
||||||
@@ -866,11 +900,16 @@ final class AppModel {
|
|||||||
let sort = sort
|
let sort = sort
|
||||||
let grouped = grouped
|
let grouped = grouped
|
||||||
do {
|
do {
|
||||||
let (titles, arrangement) = try await run { engine in
|
let (titles, arrangement, extras) = try await run { engine in
|
||||||
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
|
(
|
||||||
|
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.titles = titles
|
||||||
self.arrangement = arrangement
|
self.arrangement = arrangement
|
||||||
|
self.searchExtras = extras
|
||||||
} catch {
|
} catch {
|
||||||
message = errorMessage(error)
|
message = errorMessage(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,14 +232,26 @@ struct RootView: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LibraryView(
|
LibraryView(
|
||||||
onResume: play,
|
// A search that led somewhere is one worth offering again.
|
||||||
onTitle: open,
|
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.
|
// The form as a pasted link opens it, only with nothing in it.
|
||||||
onAddShare: {
|
onAddShare: {
|
||||||
incomingLink = ""
|
incomingLink = ""
|
||||||
destination = .shares
|
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
|
let onTitle: (TitleRecord) -> Void
|
||||||
var onAddShare: () -> Void = {}
|
var onAddShare: () -> Void = {}
|
||||||
var onOpenSettings: () -> 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.
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
@State private var menuFor: TitleRecord?
|
@State private var menuFor: TitleRecord?
|
||||||
@@ -178,8 +180,13 @@ struct LibraryView: View {
|
|||||||
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
|
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
|
||||||
if model.loading {
|
if model.loading {
|
||||||
LibrarySkeleton()
|
LibrarySkeleton()
|
||||||
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
} else if searching && model.query.trimmingCharacters(in: .whitespaces).isEmpty && !model.recentSearches.isEmpty {
|
||||||
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
// 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 {
|
} else if model.titles.isEmpty, let activity = model.activity {
|
||||||
// A share just added: say it is being looked through, rather than
|
// A share just added: say it is being looked through, rather than
|
||||||
// offer to add the one that is already filling the page.
|
// 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)
|
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 {
|
if browsing {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -316,6 +331,71 @@ struct LibraryView: View {
|
|||||||
.scrollDismissesKeyboard(.immediately)
|
.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) {
|
private func longPressed(_ open: () -> Void) {
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
open()
|
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