This commit is contained in:
2026-08-27 23:46:19 +02:00
parent a8ed350aec
commit 8aed3b022b
16 changed files with 602 additions and 186 deletions

View File

@@ -6,7 +6,7 @@ instant - which is the point of a keyboard-first UI. Ported from the design mock
import type { Album } from "../api/types";
export type Filter = "all" | "music" | "book";
export type Group = "music" | "audiobooks" | "podcasts";
export type Mode = "albums" | "tracks";
export interface SongHit {
@@ -30,21 +30,28 @@ export function normalize(value: string): string {
.replace(/[^a-z0-9]/g, "");
}
export function inFilter(album: Album, filter: Filter): boolean {
if (filter === "book") return album.kind === "book";
if (filter === "music") return album.kind === "music";
return true;
/** Which of the three shelves an album belongs on. A podcast is exactly a
* `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
* album joins whichever of music/audiobooks matches what it actually holds. */
export function groupOf(album: Album): Group {
if (album.section === "Kinderpodcasts") return "podcasts";
return album.kind === "book" ? "audiobooks" : "music";
}
export function pool(albums: Album[], filter: Filter): Album[] {
return albums.filter((album) => inFilter(album, filter));
export function inGroup(album: Album, group: Group): boolean {
return groupOf(album) === group;
}
/** `null` means unrestricted - typing at the root searches every group at once. */
export function pool(albums: Album[], group: Group | null): Album[] {
return group === null ? albums : albums.filter((album) => inGroup(album, group));
}
export interface BrowseQuery {
albums: Album[];
search: string;
mode: Mode;
filter: Filter;
group: Group | null;
category: string | null;
}
@@ -56,9 +63,11 @@ export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories"
}
export function categoryMatches(query: BrowseQuery): Category[] {
if (listMode(query) !== "categories") return [];
// `group === null` is the bare root screen, rendered as three shelves instead of a
// flat category list - each shelf calls this again with its own group filled in.
if (listMode(query) !== "categories" || query.group === null) return [];
const map = new Map<string, Category>();
for (const album of pool(query.albums, query.filter)) {
for (const album of pool(query.albums, query.group)) {
const key = album.category;
let entry = map.get(key);
if (!entry) {
@@ -73,7 +82,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const needle = normalize(query.search);
let candidates = pool(query.albums, query.filter);
let candidates = pool(query.albums, query.group);
if (!needle && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!needle) return candidates;
@@ -87,7 +96,7 @@ export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const needle = normalize(query.search);
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.filter)) {
for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => {
if (!needle || normalize(track.title).includes(needle)) {
hits.push({ album, index, title: track.title, duration: track.duration });