feat(core,app,android,ios): filter the library on every client, including by download

The library filter moves from the desktop into pstr_core::browse and
gains a Downloaded filter: titles with at least one episode kept on the
device. The bridge's arrangement() takes the filter. Android and iOS get
a row of filter chips above the grid. The choice is remembered per
device, and shelves stay hidden under a filter, as on the desktop.
This commit is contained in:
nvrl committed 2026-10-13 15:36:29 +02:00
1 parent fdf6b0359b
commit eb67df0aa0
11 files changed
+292 -88

No files matched your search

@@ -46,6 +46,11 @@ class SettingsStore(context: Context) {
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false) get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } } set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
var libraryFilter: String?
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
/** Whether the library folds each franchise into one tile. */ /** Whether the library folds each franchise into one tile. */
var libraryGrouped: Boolean var libraryGrouped: Boolean
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true) get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
@@ -67,6 +72,7 @@ class SettingsStore(context: Context) {
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib" private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
private const val KEY_LIBRARY_SORT = "library_sort" private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped" private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_LIBRARY_FILTER = "library_filter"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed" private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
} }
} }
@@ -28,6 +28,7 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore 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.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
@@ -57,6 +58,7 @@ data class AppUiState(
* when the grid falls back to [titles] as they are. * when the grid falls back to [titles] as they are.
*/ */
val arrangement: ArrangementRecord? = null, val arrangement: ArrangementRecord? = null,
val filter: LibraryFilter = LibraryFilter.ALL,
val sort: LibrarySort = LibrarySort.NAME, val sort: LibrarySort = LibrarySort.NAME,
val grouped: Boolean = true, val grouped: Boolean = true,
val shares: List<ShareRecord> = emptyList(), val shares: List<ShareRecord> = emptyList(),
@@ -135,12 +137,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// The arrangement first, so the first grid is drawn the way the viewer // The arrangement first, so the first grid is drawn the way the viewer
// left it rather than A–Z and then re-sorted under them. // left it rather than A–Z and then re-sorted under them.
viewModelScope.launch { viewModelScope.launch {
val (sort, grouped) = withContext(Dispatchers.IO) { val (filter, sort, grouped) = withContext(Dispatchers.IO) {
SettingsStore(appContext).run { SettingsStore(appContext).run {
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } } to libraryGrouped Triple(
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
libraryGrouped,
)
} }
} }
mutableState.update { it.copy(sort = sort ?: it.sort, grouped = grouped) } mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
reload() reload()
} }
catchUp() catchUp()
@@ -171,7 +177,6 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Reorder the grid, or fold or unfold its franchises. */
/** Sort and fold the library, and keep both for the next launch. */ /** Sort and fold the library, and keep both for the next launch. */
fun arrange(sort: LibrarySort, grouped: Boolean) { fun arrange(sort: LibrarySort, grouped: Boolean) {
mutableState.update { it.copy(sort = sort, grouped = grouped) } mutableState.update { it.copy(sort = sort, grouped = grouped) }
@@ -186,6 +191,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Narrow the grid to some titles, and keep the choice for the next launch. */
fun filter(filter: LibraryFilter) {
mutableState.update { it.copy(filter = filter) }
viewModelScope.launch {
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
reloadLibrary(mutableState.value.query)
}
}
fun search(query: String) { fun search(query: String) {
mutableState.update { it.copy(query = query) } mutableState.update { it.copy(query = query) }
searchQuery.value = query searchQuery.value = query
@@ -858,7 +872,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(query), engine.library(query),
engine.arrangement(query, current.sort, current.grouped), engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
engine.storageUsage(), engine.storageUsage(),
@@ -886,7 +900,7 @@ 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.sort, current.grouped) engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
} }
}.onSuccess { (titles, arrangement) -> }.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) } mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
@@ -63,6 +63,7 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
@@ -84,6 +85,7 @@ 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.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
@@ -109,6 +111,16 @@ private data class Resumable(
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */ /** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
private data class Tile(val title: TitleRecord, val folded: Int) private data class Tile(val title: TitleRecord, val folded: Int)
/** The desktop's names for the filters, from `browse::Filter::label`. */
internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.ALL -> "All"
LibraryFilter.SERIES -> "Series"
LibraryFilter.FILMS -> "Films"
LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded"
}
/** The offer of posters and synopses, and its two answers. */ /** The offer of posters and synopses, and its two answers. */
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit) internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
@@ -151,6 +163,7 @@ internal fun LibraryScreen(
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> Unit = {}, onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
) { ) {
val download = rememberDownload() val download = rememberDownload()
val context = LocalContext.current val context = LocalContext.current
@@ -236,6 +249,7 @@ internal fun LibraryScreen(
onTitle, onTitle,
onTitleMenu = { menuFor = it }, onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it }, onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) { metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null null
} else { } else {
@@ -438,6 +452,7 @@ private fun LibraryGrid(
onTitle: (TitleRecord) -> Unit, onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit, onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit, onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null, metadataOffer: MetadataOffer? = null,
) { ) {
// The arrangement names titles by key; before it has arrived, the titles // The arrangement names titles by key; before it has arrived, the titles
@@ -499,7 +514,10 @@ private fun LibraryGrid(
} }
} }
if (state.query.isBlank()) { if (state.query.isBlank()) {
shelves.forEach { shelf -> // Only over the whole library: under a filter, a shelf of titles
// the grid below is hiding reads as the filter not working. The
// bridge leaves them out too; this covers the frame before it.
shelves.takeIf { state.filter == LibraryFilter.ALL }.orEmpty().forEach { shelf ->
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) { item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
Column { Column {
Text(shelf.label, style = MaterialTheme.typography.titleMedium) Text(shelf.label, style = MaterialTheme.typography.titleMedium)
@@ -520,9 +538,29 @@ private fun LibraryGrid(
} }
} }
} }
if (resumable.isNotEmpty() || shelves.isNotEmpty()) { item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { Column {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(LibraryFilter.entries, key = { it.name }) { filter ->
FilterChip(
selected = filter == state.filter,
onClick = { onFilter(filter) },
label = { Text(filter.label()) },
)
}
}
}
}
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
Text(
"Nothing in the library fits that filter.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -233,6 +233,7 @@ fun ProtonStreamApp(
addingShare = true addingShare = true
}, },
onOpenSettings = { destination = Destination.Settings }, onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
+53 -9
View File
@@ -4,7 +4,7 @@
//! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing; //! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing;
//! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes. //! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes.
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
@@ -12,7 +12,7 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn}; use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort; use pstr_core::browse::{Filter, Sort};
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
@@ -317,6 +317,17 @@ pub enum TitleNamesChoice {
Romaji, Romaji,
} }
/// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter {
All,
Series,
Films,
Watching,
Unwatched,
Downloaded,
}
/// The shared `pstr_core::browse::Sort`. /// The shared `pstr_core::browse::Sort`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibrarySort { pub enum LibrarySort {
@@ -1460,19 +1471,31 @@ impl AndroidEngine {
}) })
} }
/// The library grid for `search`, in `sort` order, each franchise folded /// The library grid for `search`, narrowed by `filter`, in `sort` order,
/// into one tile when `grouped` — and the shelves above it. Keys into /// each franchise folded into one tile when `grouped` — and the shelves
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge /// above it. Keys into [`AndroidEngine::library`]'s titles, so the records
/// once. /// cross the bridge once.
pub fn arrangement( pub fn arrangement(
&self, &self,
search: Option<String>, search: Option<String>,
filter: LibraryFilter,
sort: LibrarySort, sort: LibrarySort,
grouped: bool, grouped: bool,
) -> Result<ArrangementRecord, BridgeError> { ) -> Result<ArrangementRecord, BridgeError> {
let query = search.unwrap_or_default(); let query = search.unwrap_or_default();
let filter = filter_choice(filter);
self.with_library(|cached| { self.with_library(|cached| {
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query); let titles: Vec<&Title> =
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter()
.filter(|title| {
filter.admits(title, |node| {
cached
.offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
})
})
.collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
&titles, &titles,
pstr_core::browse::Order { pstr_core::browse::Order {
@@ -1483,7 +1506,9 @@ impl AndroidEngine {
added: &cached.added, added: &cached.added,
}, },
); );
let shelves = if query.trim().is_empty() { // Shelves are the whole library's, and only when it is shown
// whole.
let shelves = if query.trim().is_empty() && filter == Filter::All {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES) pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
.into_iter() .into_iter()
.map(|shelf| ShelfRecord { .map(|shelf| ShelfRecord {
@@ -2246,6 +2271,7 @@ impl AndroidEngine {
metadata, metadata,
added, added,
titles, titles,
offline: offline.into_keys().collect(),
}; };
let answer = read(&cached); let answer = read(&cached);
*self.library_cache.lock() = Some(cached); *self.library_cache.lock() = Some(cached);
@@ -3092,6 +3118,8 @@ struct CachedLibrary {
/// When each title last gained a file. /// When each title last gained a file.
added: HashMap<String, i64>, added: HashMap<String, i64>,
titles: Vec<TitleRecord>, titles: Vec<TitleRecord>,
/// The files kept on this device, by share and link id.
offline: HashSet<(String, String)>,
} }
/// How many provider lookups may be in flight at once. /// How many provider lookups may be in flight at once.
@@ -3153,6 +3181,17 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
} }
} }
fn filter_choice(filter: LibraryFilter) -> Filter {
match filter {
LibraryFilter::All => Filter::All,
LibraryFilter::Series => Filter::Series,
LibraryFilter::Films => Filter::Films,
LibraryFilter::Watching => Filter::Watching,
LibraryFilter::Unwatched => Filter::Unwatched,
LibraryFilter::Downloaded => Filter::Downloaded,
}
}
fn sort_choice(sort: LibrarySort) -> Sort { fn sort_choice(sort: LibrarySort) -> Sort {
match sort { match sort {
LibrarySort::Name => Sort::Name, LibrarySort::Name => Sort::Name,
@@ -3904,7 +3943,12 @@ mod tests {
} }
let arranged = engine let arranged = engine
.arrangement(None, super::LibrarySort::Name, true) .arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange"); .expect("arrange");
assert_eq!(arranged.tiles.len(), 1); assert_eq!(arranged.tiles.len(), 1);
assert_eq!(arranged.tiles[0].key, "sousou no frieren"); assert_eq!(arranged.tiles[0].key, "sousou no frieren");
+17 -54
View File
@@ -427,37 +427,7 @@ impl Default for LibraryView {
} }
} }
/// Which titles the grid shows. pub use pstr_core::browse::{Filter, Sort};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Filter {
#[default]
All,
Series,
Films,
/// Started and not finished.
Watching,
/// Not started.
Unwatched,
}
impl Filter {
fn admits(self, title: &pstr_core::library::Title) -> bool {
use pstr_core::library::TitleKind;
let watched = title.watched_count();
match self {
Self::All => true,
Self::Series => title.kind == TitleKind::Series,
Self::Films => title.kind == TitleKind::Film,
Self::Watching => {
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
}
}
}
pub use pstr_core::browse::Sort;
/// What the library view is built from, besides the library itself. /// What the library view is built from, besides the library itself.
pub struct ViewInputs<'a> { pub struct ViewInputs<'a> {
@@ -466,6 +436,8 @@ pub struct ViewInputs<'a> {
pub names: TitleNames, pub names: TitleNames,
/// When each title last gained a file, by key. /// When each title last gained a file, by key.
pub added: &'a HashMap<String, i64>, pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>,
} }
/// How many shelves the library shows above the grid. /// How many shelves the library shows above the grid.
@@ -515,7 +487,14 @@ impl LibraryView {
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query) let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter() .into_iter()
.filter(|title| self.filter.admits(title)) .filter(|title| {
self.filter.admits(title, |node| {
inputs.offline.contains(&DownloadKey {
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
})
})
})
.collect(); .collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
&titles, &titles,
@@ -1114,7 +1093,11 @@ impl App {
*row = *item; *row = *item;
} }
} }
Event::OfflineFiles(files) => self.offline_files = files, Event::OfflineFiles(files) => {
// The Downloaded filter is drawn from these.
self.view.stale |= self.view.filter == Filter::Downloaded;
self.offline_files = files;
}
Event::Account(status) => { Event::Account(status) => {
if let AccountStatus::SignedIn(username) = &status if let AccountStatus::SignedIn(username) = &status
&& self.account.busy && self.account.busy
@@ -2284,6 +2267,7 @@ impl eframe::App for App {
metadata: &self.metadata, metadata: &self.metadata,
names: self.engine.appearance().names, names: self.engine.appearance().names,
added: &self.added, added: &self.added,
offline: &self.offline_files,
}, },
); );
@@ -2979,27 +2963,6 @@ mod tests {
) )
} }
#[test]
fn the_filters_split_the_library_by_kind() {
use pstr_core::library::{Title, TitleKind};
let title = |kind| Title {
key: "k".into(),
name: "n".into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
};
let film = title(TitleKind::Film);
let series = title(TitleKind::Series);
assert!(Filter::All.admits(&film) && Filter::All.admits(&series));
assert!(Filter::Films.admits(&film) && !Filter::Films.admits(&series));
assert!(Filter::Series.admits(&series) && !Filter::Series.admits(&film));
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film));
assert!(!Filter::Watching.admits(&film));
}
#[test] #[test]
fn a_screenshot_name_says_what_and_when_without_a_slash() { fn a_screenshot_name_says_what_and_when_without_a_slash() {
assert_eq!( assert_eq!(
+2 -11
View File
@@ -327,17 +327,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
actions.push(Action::SetGrouped(grouped)); actions.push(Action::SetGrouped(grouped));
} }
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented( let filters = Filter::ALL.map(|filter| (filter, filter.label()));
ui, if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
view.filter,
&[
(Filter::All, "All"),
(Filter::Series, "Series"),
(Filter::Films, "Films"),
(Filter::Watching, "Watching"),
(Filter::Unwatched, "Not started"),
],
) {
actions.push(Action::SetShelf(filter, view.sort)); actions.push(Action::SetShelf(filter, view.sort));
} }
} }
+96 -1
View File
@@ -7,7 +7,8 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::library::{Library, Title, sort_name}; use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames}; use crate::metadata::{MetadataRecord, TitleNames};
/// What order a grid is in. /// What order a grid is in.
@@ -51,6 +52,60 @@ impl Sort {
} }
} }
/// Which titles a grid shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Filter {
#[default]
All,
Series,
Films,
/// Started and not finished.
Watching,
/// Not started.
Unwatched,
/// With at least one episode kept on this device.
Downloaded,
}
impl Filter {
pub const ALL: [Self; 6] = [
Self::All,
Self::Series,
Self::Films,
Self::Watching,
Self::Unwatched,
Self::Downloaded,
];
pub fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Series => "Series",
Self::Films => "Films",
Self::Watching => "Watching",
Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded",
}
}
/// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
let watched = title.watched_count();
match self {
Self::All => true,
Self::Series => title.kind == TitleKind::Series,
Self::Films => title.kind == TitleKind::Film,
Self::Watching => {
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
}
}
}
/// The name to show for `title`: the provider's, in the form the viewer /// The name to show for `title`: the provider's, in the form the viewer
/// picked, else the share's. /// picked, else the share's.
pub fn display_name<'a>( pub fn display_name<'a>(
@@ -393,4 +448,44 @@ mod tests {
assert_eq!(found[0].key, "sousou no frieren"); assert_eq!(found[0].key, "sousou no frieren");
assert_eq!(search(&library, &metadata, "").len(), 3); assert_eq!(search(&library, &metadata, "").len(), 3);
} }
#[test]
fn the_filters_split_the_library_by_kind_and_progress() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none));
assert!(!Filter::Watching.admits(&film, none));
}
#[test]
fn the_downloaded_filter_admits_a_title_with_any_episode_kept_offline() {
let node = |link: &str, episode| CatalogNode {
share_id: "s".into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("Show - S01E0{episode}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::ParsedName {
title: "Show".into(),
season: Some(1),
episode: Some(episode),
year: None,
episode_title: None,
},
};
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
assert!(!Filter::Downloaded.admits(show, |_| false));
}
} }
+12 -2
View File
@@ -47,6 +47,7 @@ final class AppModel {
/// when the grid falls back to `titles` as they are. /// when the grid falls back to `titles` as they are.
var arrangement: ArrangementRecord? var arrangement: ArrangementRecord?
/// As the viewer last left them. /// As the viewer last left them.
var filter: LibraryFilter = SettingsStore().libraryFilter
var sort: LibrarySort = SettingsStore().librarySort var sort: LibrarySort = SettingsStore().librarySort
var grouped = SettingsStore().libraryGrouped var grouped = SettingsStore().libraryGrouped
var shares: [ShareRecord] = [] var shares: [ShareRecord] = []
@@ -118,6 +119,13 @@ final class AppModel {
Task { await reloadLibrary(query) } Task { await reloadLibrary(query) }
} }
/// Narrow the grid to some titles, and keep the choice for the next launch.
func setFilter(_ filter: LibraryFilter) {
self.filter = filter
SettingsStore().libraryFilter = filter
Task { await reloadLibrary(query) }
}
func search(_ query: String) { func search(_ query: String) {
self.query = query self.query = query
searchTask?.cancel() searchTask?.cancel()
@@ -787,6 +795,7 @@ final class AppModel {
/// `reload`, for a caller that goes on to read what it loaded. /// `reload`, for a caller that goes on to read what it loaded.
func reloadNow() async { func reloadNow() async {
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let filter = filter
let sort = sort let sort = sort
let grouped = grouped let grouped = grouped
do { do {
@@ -798,7 +807,7 @@ final class AppModel {
return Reloaded( return Reloaded(
shares: try engine.shares(), shares: try engine.shares(),
titles: try engine.library(search: query), titles: try engine.library(search: query),
arrangement: try engine.arrangement(search: query, 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(),
storage: try engine.storageUsage() storage: try engine.storageUsage()
@@ -819,11 +828,12 @@ final class AppModel {
private func reloadLibrary(_ query: String) async { private func reloadLibrary(_ query: String) async {
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
let filter = filter
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) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped)) (try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
} }
self.titles = titles self.titles = titles
self.arrangement = arrangement self.arrangement = arrangement
+11
View File
@@ -51,6 +51,15 @@ struct SettingsStore {
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) } nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) }
} }
/// Which titles the library last showed.
var libraryFilter: LibraryFilter {
get {
let stored = defaults.string(forKey: Key.libraryFilter)
return Self.filters.first { String(describing: $0) == stored } ?? .all
}
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.libraryFilter) }
}
/// The library's offer of posters and synopses was turned down. /// The library's offer of posters and synopses was turned down.
var metadataOfferDismissed: Bool { var metadataOfferDismissed: Bool {
get { bool(Key.metadataOfferDismissed, default: false) } get { bool(Key.metadataOfferDismissed, default: false) }
@@ -63,6 +72,7 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) } nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
} }
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity] private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
private func bool(_ key: String, default value: Bool) -> Bool { private func bool(_ key: String, default value: Bool) -> Bool {
@@ -75,6 +85,7 @@ struct SettingsStore {
static let hardwareDecoding = "hardware_decoding" static let hardwareDecoding = "hardware_decoding"
static let cacheBudgetGib = "cache_budget_gib" static let cacheBudgetGib = "cache_budget_gib"
static let librarySort = "library_sort" static let librarySort = "library_sort"
static let libraryFilter = "library_filter"
static let libraryGrouped = "library_grouped" static let libraryGrouped = "library_grouped"
static let metadataOfferDismissed = "metadata_offer_dismissed" static let metadataOfferDismissed = "metadata_offer_dismissed"
} }
+32 -1
View File
@@ -229,7 +229,10 @@ struct LibraryView: View {
} }
} }
} }
if browsing { // Only over the whole library: under a filter, a shelf of
// titles the grid below is hiding reads as the filter not
// working.
if browsing && model.filter == .all {
ForEach(shelves) { shelf in ForEach(shelves) { shelf in
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(shelf.label).textStyle(.titleMedium).foregroundStyle(scheme.onSurface) Text(shelf.label).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
@@ -248,6 +251,20 @@ 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)
} }
} }
if browsing {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SettingsStore.filters, id: \.self) { filter in
FilterChip(label: filter.label, selected: filter == model.filter) { model.setFilter(filter) }
}
}
}
if tiles.isEmpty && model.filter != .all {
Text("Nothing in the library fits that filter.")
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurfaceVariant)
}
}
// Posters, three across on a phone. // Posters, three across on a phone.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(tiles) { tile in ForEach(tiles) { tile in
@@ -421,6 +438,20 @@ private struct LibrarySkeleton: View {
} }
} }
extension LibraryFilter {
/// The desktop's names for the filters, from `browse::Filter::label`.
var label: String {
switch self {
case .all: "All"
case .series: "Series"
case .films: "Films"
case .watching: "Watching"
case .unwatched: "Not started"
case .downloaded: "Downloaded"
}
}
}
/// The offer of posters and synopses, and its two answers. /// The offer of posters and synopses, and its two answers.
private struct MetadataOfferCard: View { private struct MetadataOfferCard: View {
let onOpenSettings: () -> Void let onOpenSettings: () -> Void