mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +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
@@ -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 ->
|
||||||
|
|||||||
@@ -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
@@ -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!(
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user