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

@@ -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"
}
}
@@ -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<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
// 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) }
@@ -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,
)
}
}
}
@@ -233,6 +233,7 @@ fun ProtonStreamApp(
addingShare = true
},
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
)
}
val titlePane = @Composable { selected: TitleRecord ->