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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user