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

+53 -9
View File
@@ -4,7 +4,7 @@
//! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing;
//! 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::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
@@ -12,7 +12,7 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
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::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs;
@@ -317,6 +317,17 @@ pub enum TitleNamesChoice {
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`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibrarySort {
@@ -1460,19 +1471,31 @@ impl AndroidEngine {
})
}
/// The library grid for `search`, in `sort` order, each franchise folded
/// into one tile when `grouped` — and the shelves above it. Keys into
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
/// once.
/// The library grid for `search`, narrowed by `filter`, in `sort` order,
/// each franchise folded into one tile when `grouped` — and the shelves
/// above it. Keys into [`AndroidEngine::library`]'s titles, so the records
/// cross the bridge once.
pub fn arrangement(
&self,
search: Option<String>,
filter: LibraryFilter,
sort: LibrarySort,
grouped: bool,
) -> Result<ArrangementRecord, BridgeError> {
let query = search.unwrap_or_default();
let filter = filter_choice(filter);
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(
&titles,
pstr_core::browse::Order {
@@ -1483,7 +1506,9 @@ impl AndroidEngine {
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)
.into_iter()
.map(|shelf| ShelfRecord {
@@ -2246,6 +2271,7 @@ impl AndroidEngine {
metadata,
added,
titles,
offline: offline.into_keys().collect(),
};
let answer = read(&cached);
*self.library_cache.lock() = Some(cached);
@@ -3092,6 +3118,8 @@ struct CachedLibrary {
/// When each title last gained a file.
added: HashMap<String, i64>,
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.
@@ -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 {
match sort {
LibrarySort::Name => Sort::Name,
@@ -3904,7 +3943,12 @@ mod tests {
}
let arranged = engine
.arrangement(None, super::LibrarySort::Name, true)
.arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange");
assert_eq!(arranged.tiles.len(), 1);
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.
#[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;
pub use pstr_core::browse::{Filter, Sort};
/// What the library view is built from, besides the library itself.
pub struct ViewInputs<'a> {
@@ -466,6 +436,8 @@ pub struct ViewInputs<'a> {
pub names: TitleNames,
/// When each title last gained a file, by key.
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.
@@ -515,7 +487,14 @@ impl LibraryView {
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.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();
let tiles = pstr_core::browse::arrange(
&titles,
@@ -1114,7 +1093,11 @@ impl App {
*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) => {
if let AccountStatus::SignedIn(username) = &status
&& self.account.busy
@@ -2284,6 +2267,7 @@ impl eframe::App for App {
metadata: &self.metadata,
names: self.engine.appearance().names,
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]
fn a_screenshot_name_says_what_and_when_without_a_slash() {
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));
}
ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
&[
(Filter::All, "All"),
(Filter::Series, "Series"),
(Filter::Films, "Films"),
(Filter::Watching, "Watching"),
(Filter::Unwatched, "Not started"),
],
) {
let filters = Filter::ALL.map(|filter| (filter, filter.label()));
if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
actions.push(Action::SetShelf(filter, view.sort));
}
}
+96 -1
View File
@@ -7,7 +7,8 @@
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};
/// 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
/// picked, else the share's.
pub fn display_name<'a>(
@@ -393,4 +448,44 @@ mod tests {
assert_eq!(found[0].key, "sousou no frieren");
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));
}
}