mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
fdf6b0359b
commit
eb67df0aa0
11 files changed
+292
-88
No files matched your search
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user