Web frontend
This commit is contained in:
34
web/src/lib/__tests__/format.test.ts
Normal file
34
web/src/lib/__tests__/format.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { clock, remainingInAlbum } from "../format";
|
||||
|
||||
describe("clock", () => {
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(clock(0)).toBe("0:00");
|
||||
expect(clock(9)).toBe("0:09");
|
||||
expect(clock(200.5)).toBe("3:21");
|
||||
});
|
||||
|
||||
it("grows an hours field for a long audiobook", () => {
|
||||
expect(clock(3661)).toBe("1:01:01");
|
||||
});
|
||||
|
||||
it("never shows negative time", () => {
|
||||
expect(clock(-5)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remainingInAlbum", () => {
|
||||
it("counts the rest of this track plus every track after it", () => {
|
||||
expect(remainingInAlbum([60, 30, 10], 0, 20)).toBe(80);
|
||||
expect(remainingInAlbum([60, 30, 10], 2, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it("copes with a position past the end of the track", () => {
|
||||
expect(remainingInAlbum([60, 30], 0, 999)).toBe(30);
|
||||
});
|
||||
|
||||
it("returns zero for an empty album", () => {
|
||||
expect(remainingInAlbum([], 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
224
web/src/lib/__tests__/keyboard.test.ts
Normal file
224
web/src/lib/__tests__/keyboard.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album } from "../../api/types";
|
||||
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
|
||||
import { normalize, results as computeResults } from "../search";
|
||||
|
||||
function album(id: string, over: Partial<Album> = {}): Album {
|
||||
return {
|
||||
id,
|
||||
section: "Musik",
|
||||
kind: "music",
|
||||
title: `Album ${id}`,
|
||||
artist: "Kinderparty",
|
||||
series: null,
|
||||
figure: null,
|
||||
category: "Kinderparty",
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [
|
||||
{ title: `Lied ${id}`, duration: 60, analysis: null },
|
||||
{ title: "Zweites Lied", duration: 60, analysis: null },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ALBUMS: Album[] = [
|
||||
album("a"),
|
||||
album("b", { title: "Hörbuch Conni", artist: "Conni", kind: "book", series: "Conni", category: "Conni" }),
|
||||
album("c", { artist: "Rolf", category: "Rolf" }),
|
||||
];
|
||||
|
||||
const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) => ({
|
||||
key: k,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const resultsFor = (ui: UiState) =>
|
||||
computeResults({
|
||||
albums: ALBUMS,
|
||||
search: ui.search,
|
||||
mode: ui.mode,
|
||||
filter: ui.filter,
|
||||
category: ui.category,
|
||||
});
|
||||
|
||||
const press = (k: string, ui: UiState = initialUiState, over = {}) =>
|
||||
handleKey(key(k, over), ui, resultsFor(ui));
|
||||
|
||||
describe("normalize", () => {
|
||||
it("strips case, punctuation and diacritics so a child's letters match", () => {
|
||||
expect(normalize("Hörbücher, Folge 2!")).toBe("horbucherfolge2");
|
||||
expect(normalize("Käpt'n Krabbe")).toBe("kaptnkrabbe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("shows categories with no query, albums once there is one", () => {
|
||||
expect(resultsFor(initialUiState).categories).toHaveLength(3);
|
||||
expect(resultsFor(initialUiState).albums).toHaveLength(0);
|
||||
|
||||
const searching = { ...initialUiState, search: "conni" };
|
||||
expect(resultsFor(searching).albums.map((a) => a.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("filters books and music apart", () => {
|
||||
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
|
||||
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches title and artist together", () => {
|
||||
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard", () => {
|
||||
it("types letters into the search", () => {
|
||||
expect(press("k")).toEqual([
|
||||
{ type: "ui", patch: { search: "k", view: "browse", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts umlauts, which the mockup's a-z0-9 test rejected", () => {
|
||||
expect(press("ö")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores keys that mean nothing, so the browser keeps its own", () => {
|
||||
expect(press("F5")).toEqual([]);
|
||||
expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps the ctrl chords to transport and volume", () => {
|
||||
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]);
|
||||
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]);
|
||||
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
|
||||
{ type: "volume", delta: 10 },
|
||||
]);
|
||||
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([
|
||||
{ type: "volume", delta: -10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cycles the filter with TAB", () => {
|
||||
expect(press("Tab")).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
|
||||
});
|
||||
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("gives arrows to navigation while browsing and to transport while playing", () => {
|
||||
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
|
||||
|
||||
const playing: UiState = { ...initialUiState, view: "play" };
|
||||
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
|
||||
expect(press("ArrowUp", playing)).toEqual([{ type: "volume", delta: 10 }]);
|
||||
expect(press("ArrowDown", playing)).toEqual([{ type: "volume", delta: -10 }]);
|
||||
});
|
||||
|
||||
it("seeks with shift+arrows in either view", () => {
|
||||
expect(press("ArrowRight", initialUiState, { shiftKey: true })).toEqual([
|
||||
{ type: "seek", delta: 15 },
|
||||
]);
|
||||
expect(press("ArrowLeft", { ...initialUiState, view: "play" }, { shiftKey: true })).toEqual([
|
||||
{ type: "seek", delta: -15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a whole row at a time in the grid", () => {
|
||||
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
|
||||
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps the selection to what is on screen", () => {
|
||||
const ui = { ...initialUiState, selIndex: 2 };
|
||||
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("peels one layer at a time with ESC", () => {
|
||||
const deep: UiState = {
|
||||
...initialUiState,
|
||||
showHelp: true,
|
||||
openAlbumId: "a",
|
||||
search: "x",
|
||||
mode: "tracks",
|
||||
category: "Conni",
|
||||
};
|
||||
expect(press("Escape", deep)).toEqual([
|
||||
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
|
||||
]);
|
||||
|
||||
const searching = { ...deep, showHelp: false, openAlbumId: null };
|
||||
expect(press("Escape", searching)).toEqual([
|
||||
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
||||
]);
|
||||
|
||||
const inTracks = { ...searching, search: "" };
|
||||
expect(press("Escape", inTracks)).toEqual([
|
||||
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
|
||||
]);
|
||||
|
||||
const inCategory = { ...inTracks, mode: "albums" as const };
|
||||
expect(press("Escape", inCategory)).toEqual([
|
||||
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not swallow backspace when there is nothing to delete", () => {
|
||||
expect(press("Backspace")).toEqual([]);
|
||||
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
|
||||
{ type: "ui", patch: { search: "a", selIndex: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to track search with ?", () => {
|
||||
expect(press("?")).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the open album from the top on ENTER", () => {
|
||||
expect(press("Enter", { ...initialUiState, openAlbumId: "b" })).toEqual([
|
||||
{ type: "play", albumId: "b", trackIndex: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionAt", () => {
|
||||
it("walks songs, then categories, then albums in one flat index space", () => {
|
||||
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
|
||||
const found = resultsFor(ui);
|
||||
expect(found.songs.length).toBeGreaterThan(0);
|
||||
expect(selectionAt(found, 0)).toEqual({
|
||||
type: "play",
|
||||
albumId: found.songs[0]!.album.id,
|
||||
trackIndex: found.songs[0]!.index,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a category rather than playing it", () => {
|
||||
const found = resultsFor(initialUiState);
|
||||
expect(selectionAt(found, 0)).toEqual({
|
||||
type: "ui",
|
||||
patch: { category: found.categories[0]!.key, selIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns nothing when there is nothing to select", () => {
|
||||
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
||||
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
77
web/src/lib/covers.ts
Normal file
77
web/src/lib/covers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** How an album is painted when there is no artwork, and how a book is made to look
|
||||
* like a book.
|
||||
*
|
||||
* The mockup generated everything from a single per-album `hue`. The backend now sends
|
||||
* three colours pulled out of the real cover art, so the generated fallback and the
|
||||
* artwork agree - and so do the LED strips, which run the same primary colour.
|
||||
*/
|
||||
|
||||
import type { Album, AlbumKind } from "../api/types";
|
||||
|
||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
|
||||
/** Shape is how you tell the two apart without reading anything: albums are square,
|
||||
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
|
||||
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
|
||||
export const ALBUM_ASPECT = 1;
|
||||
export const BOOK_ASPECT = 0.82;
|
||||
|
||||
export const aspectOfKind = (kind: AlbumKind): number =>
|
||||
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
|
||||
|
||||
export const aspectOf = (album: Album): number => aspectOfKind(album.kind);
|
||||
|
||||
const colours = (album: Album): [string, string, string] => [
|
||||
album.colors[0] ?? "#4a6fa5",
|
||||
album.colors[1] ?? "#6a8fc5",
|
||||
album.colors[2] ?? "#a5804a",
|
||||
];
|
||||
|
||||
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
|
||||
export function stripes(album: Album, width: number): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return (
|
||||
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
|
||||
`${secondary} ${width}px, ${secondary} ${width * 2}px)`
|
||||
);
|
||||
}
|
||||
|
||||
/** A book spine: page edges on the right, a darker board on the left. */
|
||||
export function spine(album: Album): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return [
|
||||
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
|
||||
" oklch(90% 0.03 88) 97.5% 100%)",
|
||||
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
||||
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
||||
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
||||
].join(",");
|
||||
}
|
||||
|
||||
export function coverBackground(album: Album, stripeWidth: number): string {
|
||||
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||
}
|
||||
|
||||
/** The card's own tint: warm paper for books, cool glass for music. */
|
||||
export const cardBackground = (album: Album): string =>
|
||||
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
|
||||
|
||||
export const cardShadow = (album: Album): string =>
|
||||
isBook(album)
|
||||
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
|
||||
" 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
: "0 6px 18px oklch(15% 0.05 210 / .35)";
|
||||
|
||||
export const unitLabel = (album: Album, count: number): string =>
|
||||
isBook(album)
|
||||
? `${count} ${count === 1 ? "Kapitel" : "Kapitel"}`
|
||||
: `${count} ${count === 1 ? "Song" : "Songs"}`;
|
||||
|
||||
/** "Album · Artist", unless a podcast makes those the same string. */
|
||||
export const albumLine = (album: Album): string => {
|
||||
const prefix = isBook(album) ? "📖 " : "";
|
||||
return album.artist && album.artist !== album.title
|
||||
? `${prefix}${album.title} · ${album.artist}`
|
||||
: `${prefix}${album.title}`;
|
||||
};
|
||||
25
web/src/lib/format.ts
Normal file
25
web/src/lib/format.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Time formatting, ported from the design mockup. */
|
||||
|
||||
/** Seconds as `m:ss`, or `h:mm:ss` once an audiobook runs past the hour. */
|
||||
export function clock(totalSeconds: number): string {
|
||||
const total = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
/** How much of the album is left after the current position. */
|
||||
export function remainingInAlbum(
|
||||
trackDurations: number[],
|
||||
trackIndex: number,
|
||||
position: number,
|
||||
): number {
|
||||
const current = trackDurations[trackIndex] ?? 0;
|
||||
let rest = Math.max(0, current - position);
|
||||
for (let i = trackIndex + 1; i < trackDurations.length; i++) {
|
||||
rest += trackDurations[i] ?? 0;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
187
web/src/lib/keyboard.ts
Normal file
187
web/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/** The keyboard state machine, ported from the design mockup.
|
||||
|
||||
Kept pure - it takes a key and the current view state and returns what should happen -
|
||||
so the whole interaction model can be tested without a DOM, and so the component that
|
||||
mounts it stays a thin adapter.
|
||||
|
||||
The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain
|
||||
arrows were already taken (selection while browsing, transport and volume while
|
||||
playing), and seeking is the one thing a real player can do that the mockup could not.
|
||||
*/
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import type { Filter, Mode, Results } from "./search";
|
||||
|
||||
export interface UiState {
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
filter: "all",
|
||||
category: null,
|
||||
selIndex: 0,
|
||||
view: "browse",
|
||||
openAlbumId: null,
|
||||
showHelp: false,
|
||||
cols: 4,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| { type: "ui"; patch: Partial<UiState> }
|
||||
| { type: "play"; albumId: string; trackIndex: number }
|
||||
| { type: "toggle" }
|
||||
| { type: "next" }
|
||||
| { type: "previous" }
|
||||
| { type: "volume"; delta: number }
|
||||
| { type: "seek"; delta: number }
|
||||
| { type: "pop"; freq: number };
|
||||
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
|
||||
/** What the flat selection index currently points at. */
|
||||
export function selectionAt(results: Results, selIndex: number): Action | null {
|
||||
const { songs, categories, albums } = results;
|
||||
const index = Math.min(selIndex, results.total - 1);
|
||||
if (index < 0) return null;
|
||||
|
||||
if (index < songs.length) {
|
||||
const hit = songs[index];
|
||||
return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null;
|
||||
}
|
||||
const afterSongs = index - songs.length;
|
||||
if (afterSongs < categories.length) {
|
||||
const category = categories[afterSongs];
|
||||
return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null;
|
||||
}
|
||||
const album: Album | undefined = albums[afterSongs - categories.length];
|
||||
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
|
||||
}
|
||||
|
||||
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
||||
if (!results.total) return [];
|
||||
const cols = Math.max(1, state.cols);
|
||||
let index = Math.min(state.selIndex, results.total - 1);
|
||||
if (dx) index += dx;
|
||||
if (dy) {
|
||||
// Song hits are a single-column list; everything below them is a grid.
|
||||
index += index < results.songs.length ? dy : dy * cols;
|
||||
}
|
||||
index = Math.max(0, Math.min(index, results.total - 1));
|
||||
return [{ type: "ui", patch: { selIndex: index } }];
|
||||
}
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one keypress. Returns the actions to run, or nothing when the key means
|
||||
* nothing here - the caller only calls `preventDefault()` when something came back.
|
||||
*/
|
||||
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
|
||||
const { key } = event;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (key.toLowerCase()) {
|
||||
case "l":
|
||||
return [{ type: "next" }];
|
||||
case "h":
|
||||
return [{ type: "previous" }];
|
||||
case "k":
|
||||
return [{ type: "volume", delta: VOLUME_STEP }];
|
||||
case "j":
|
||||
return [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||
|
||||
switch (key) {
|
||||
case "Tab": {
|
||||
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
|
||||
return [
|
||||
{ type: "pop", freq: 380 },
|
||||
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
|
||||
];
|
||||
}
|
||||
case "/":
|
||||
return [{ type: "ui", patch: { view: "browse" } }];
|
||||
case " ":
|
||||
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||
case "ArrowLeft":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||
case "ArrowDown":
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, 1)
|
||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
case "ArrowUp":
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, -1)
|
||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||
|
||||
case "?":
|
||||
return [
|
||||
{ type: "pop", freq: 460 },
|
||||
{
|
||||
type: "ui",
|
||||
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
|
||||
},
|
||||
];
|
||||
case "F1":
|
||||
return [{ type: "ui", patch: { showHelp: !state.showHelp } }];
|
||||
case "Escape":
|
||||
return escape(state);
|
||||
case "Backspace":
|
||||
return state.search
|
||||
? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }]
|
||||
: [];
|
||||
case "Enter": {
|
||||
if (state.openAlbumId !== null) {
|
||||
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
|
||||
}
|
||||
const chosen = selectionAt(results, state.selIndex);
|
||||
return chosen ? [chosen] : [];
|
||||
}
|
||||
default:
|
||||
if (SEARCHABLE.test(key)) {
|
||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
22
web/src/lib/pop.ts
Normal file
22
web/src/lib/pop.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** The mockup's click feedback: a short rising blip per interaction. */
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
|
||||
export function playPop(frequency: number): void {
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
const now = context.currentTime;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(frequency, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.8, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.15, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(now + 0.16);
|
||||
} catch {
|
||||
// No audio context before the first user gesture, and on some browsers never.
|
||||
}
|
||||
}
|
||||
114
web/src/lib/search.ts
Normal file
114
web/src/lib/search.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/** Browsing and searching the library, entirely in the browser.
|
||||
|
||||
The whole index arrives in one response, so type-to-search has no round trip and feels
|
||||
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
||||
*/
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
export type Filter = "all" | "music" | "book";
|
||||
export type Mode = "albums" | "tracks";
|
||||
|
||||
export interface SongHit {
|
||||
album: Album;
|
||||
index: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
key: string;
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
/** Diacritics and punctuation are noise when a child is hunting for letters. */
|
||||
export function normalize(value: string): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.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;
|
||||
}
|
||||
|
||||
export function pool(albums: Album[], filter: Filter): Album[] {
|
||||
return albums.filter((album) => inFilter(album, filter));
|
||||
}
|
||||
|
||||
export interface BrowseQuery {
|
||||
albums: Album[];
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/** Which of the three lists the browse view is showing. */
|
||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||
if (query.mode === "tracks") return "tracks";
|
||||
if (query.search || query.category) return "albums";
|
||||
return "categories";
|
||||
}
|
||||
|
||||
export function categoryMatches(query: BrowseQuery): Category[] {
|
||||
if (listMode(query) !== "categories") return [];
|
||||
const map = new Map<string, Category>();
|
||||
for (const album of pool(query.albums, query.filter)) {
|
||||
const key = album.category;
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, albums: [] };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.albums.push(album);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
|
||||
}
|
||||
|
||||
export function albumMatches(query: BrowseQuery): Album[] {
|
||||
if (query.mode === "tracks") return [];
|
||||
const needle = normalize(query.search);
|
||||
let candidates = pool(query.albums, query.filter);
|
||||
if (!needle && !query.category) return [];
|
||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
||||
if (!needle) return candidates;
|
||||
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
|
||||
}
|
||||
|
||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||
export const MAX_SONG_HITS = 40;
|
||||
|
||||
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)) {
|
||||
album.tracks.forEach((track, index) => {
|
||||
if (!needle || normalize(track.title).includes(needle)) {
|
||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||
}
|
||||
});
|
||||
if (hits.length >= MAX_SONG_HITS) break;
|
||||
}
|
||||
return hits.slice(0, MAX_SONG_HITS);
|
||||
}
|
||||
|
||||
export interface Results {
|
||||
songs: SongHit[];
|
||||
categories: Category[];
|
||||
albums: Album[];
|
||||
/** Selection indices run flat across songs, then categories, then albums. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function results(query: BrowseQuery): Results {
|
||||
const songs = songMatches(query);
|
||||
const categories = categoryMatches(query);
|
||||
const albums = albumMatches(query);
|
||||
return { songs, categories, albums, total: songs.length + categories.length + albums.length };
|
||||
}
|
||||
Reference in New Issue
Block a user