From eb67df0aa0ac0a9a24bfde618399bd7fe7608b8c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 13 Oct 2026 15:34:59 +0200 Subject: [PATCH] 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. --- .../protonstream/settings/SettingsStore.kt | 6 ++ .../io/narl/protonstream/ui/AppViewModel.kt | 26 +++-- .../io/narl/protonstream/ui/LibraryScreen.kt | 46 ++++++++- .../narl/protonstream/ui/ProtonStreamApp.kt | 1 + crates/pstr-android/src/lib.rs | 62 ++++++++++-- crates/pstr-app/src/app.rs | 71 ++++---------- crates/pstr-app/src/ui/library.rs | 13 +-- crates/pstr-core/src/browse.rs | 97 ++++++++++++++++++- ios/ProtonStream/App/AppModel.swift | 14 ++- ios/ProtonStream/App/SettingsStore.swift | 11 +++ ios/ProtonStream/UI/LibraryView.swift | 33 ++++++- 11 files changed, 292 insertions(+), 88 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt index f951b41..d405eec 100644 --- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt @@ -46,6 +46,11 @@ class SettingsStore(context: Context) { get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false) 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. */ var libraryGrouped: Boolean 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_LIBRARY_SORT = "library_sort" 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" } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index f62ed5f..1e4c409 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -28,6 +28,7 @@ import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.theme.AppearanceState import uniffi.pstr_android.ArrangementRecord +import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.TitleRecord @@ -57,6 +58,7 @@ data class AppUiState( * when the grid falls back to [titles] as they are. */ val arrangement: ArrangementRecord? = null, + val filter: LibraryFilter = LibraryFilter.ALL, val sort: LibrarySort = LibrarySort.NAME, val grouped: Boolean = true, val shares: List = 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 // left it rather than A–Z and then re-sorted under them. viewModelScope.launch { - val (sort, grouped) = withContext(Dispatchers.IO) { + val (filter, sort, grouped) = withContext(Dispatchers.IO) { 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() } 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. */ fun arrange(sort: LibrarySort, grouped: Boolean) { 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) { mutableState.update { it.copy(query = query) } searchQuery.value = query @@ -858,7 +872,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie Reloaded( engine.shares(), engine.library(query), - engine.arrangement(query, current.sort, current.grouped), + engine.arrangement(query, current.filter, current.sort, current.grouped), engine.offlineFiles(), engine.metadataSettings(), engine.storageUsage(), @@ -886,7 +900,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie withContext(Dispatchers.IO) { val engine = NativeRuntime.engine() val search = query.takeIf(String::isNotBlank) - engine.library(search) to engine.arrangement(search, current.sort, current.grouped) + engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped) } }.onSuccess { (titles, arrangement) -> mutableState.update { it.copy(titles = titles, arrangement = arrangement) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index c005440..832dd72 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -84,6 +85,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.LibraryFilter import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.SeasonRecord 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. */ 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. */ internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit) @@ -151,6 +163,7 @@ internal fun LibraryScreen( onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onAddShare: () -> Unit = {}, onOpenSettings: () -> Unit = {}, + onFilter: (LibraryFilter) -> Unit = {}, ) { val download = rememberDownload() val context = LocalContext.current @@ -236,6 +249,7 @@ internal fun LibraryScreen( onTitle, onTitleMenu = { menuFor = it }, onResumeMenu = { menuResume = it }, + onFilter = onFilter, metadataOffer = if (state.metadataSettings.enabled || offerDismissed) { null } else { @@ -438,6 +452,7 @@ private fun LibraryGrid( onTitle: (TitleRecord) -> Unit, onTitleMenu: (TitleRecord) -> Unit, onResumeMenu: (Resumable) -> Unit, + onFilter: (LibraryFilter) -> Unit = {}, metadataOffer: MetadataOffer? = null, ) { // The arrangement names titles by key; before it has arrived, the titles @@ -499,7 +514,10 @@ private fun LibraryGrid( } } 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) }) { Column { 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) }) { - Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { + Column { + 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, + ) } } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index c4278d9..e8b8211 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -233,6 +233,7 @@ fun ProtonStreamApp( addingShare = true }, onOpenSettings = { destination = Destination.Settings }, + onFilter = model::filter, ) } val titlePane = @Composable { selected: TitleRecord -> diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 699935e..0d1c457 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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, + filter: LibraryFilter, sort: LibrarySort, grouped: bool, ) -> Result { 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, titles: Vec, + /// 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"); diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 9a63aff..353d738 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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, + /// The files kept on this computer, for the Downloaded filter. + pub offline: &'a std::collections::HashSet, } /// 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!( diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 7501dd4..55f43a0 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -327,17 +327,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec &'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)); + } } diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 3838379..2182cd5 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -47,6 +47,7 @@ final class AppModel { /// when the grid falls back to `titles` as they are. var arrangement: ArrangementRecord? /// As the viewer last left them. + var filter: LibraryFilter = SettingsStore().libraryFilter var sort: LibrarySort = SettingsStore().librarySort var grouped = SettingsStore().libraryGrouped var shares: [ShareRecord] = [] @@ -118,6 +119,13 @@ final class AppModel { 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) { self.query = query searchTask?.cancel() @@ -787,6 +795,7 @@ final class AppModel { /// `reload`, for a caller that goes on to read what it loaded. func reloadNow() async { let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query + let filter = filter let sort = sort let grouped = grouped do { @@ -798,7 +807,7 @@ final class AppModel { return Reloaded( shares: try engine.shares(), 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(), metadataSettings: try engine.metadataSettings(), storage: try engine.storageUsage() @@ -819,11 +828,12 @@ final class AppModel { private func reloadLibrary(_ query: String) async { let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query + let filter = filter let sort = sort let grouped = grouped do { let (titles, arrangement) = try await run { engine in - (try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped)) + (try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped)) } self.titles = titles self.arrangement = arrangement diff --git a/ios/ProtonStream/App/SettingsStore.swift b/ios/ProtonStream/App/SettingsStore.swift index 5e49a53..296aa33 100644 --- a/ios/ProtonStream/App/SettingsStore.swift +++ b/ios/ProtonStream/App/SettingsStore.swift @@ -51,6 +51,15 @@ struct SettingsStore { 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. var metadataOfferDismissed: Bool { get { bool(Key.metadataOfferDismissed, default: false) } @@ -63,6 +72,7 @@ struct SettingsStore { 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 func bool(_ key: String, default value: Bool) -> Bool { @@ -75,6 +85,7 @@ struct SettingsStore { static let hardwareDecoding = "hardware_decoding" static let cacheBudgetGib = "cache_budget_gib" static let librarySort = "library_sort" + static let libraryFilter = "library_filter" static let libraryGrouped = "library_grouped" static let metadataOfferDismissed = "metadata_offer_dismissed" } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 943ca57..4e7d7dd 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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 VStack(alignment: .leading, spacing: 10) { 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) } } + 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. LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) { 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. private struct MetadataOfferCard: View { let onOpenSettings: () -> Void