Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
|
import { PERF_ENABLED, PERF } from "./lib/perfSettings";
|
||||||
|
import { PerfPanel } from "./components/PerfPanel";
|
||||||
|
import { useCoverWarmup } from "./hooks/useCoverWarmup";
|
||||||
|
import { useEvent } from "./hooks/useEvent";
|
||||||
import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
|
import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
|
||||||
import { AlbumModal } from "./components/AlbumModal";
|
import { AlbumModal } from "./components/AlbumModal";
|
||||||
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
|
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
|
||||||
@@ -21,22 +25,27 @@ import { ParentPanel } from "./components/ParentPanel";
|
|||||||
import { PlayerBar } from "./components/PlayerBar";
|
import { PlayerBar } from "./components/PlayerBar";
|
||||||
import { PlayView } from "./components/PlayView";
|
import { PlayView } from "./components/PlayView";
|
||||||
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||||
import { RoomView } from "./components/RoomView";
|
|
||||||
import { TabRail } from "./components/TabRail";
|
import { TabRail } from "./components/TabRail";
|
||||||
import { TippenApp } from "./components/TippenApp";
|
|
||||||
import { useGridColumns } from "./hooks/useGridColumns";
|
import { useGridColumns } from "./hooks/useGridColumns";
|
||||||
import { useLibrary } from "./hooks/useLibrary";
|
import { useLibrary } from "./hooks/useLibrary";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
||||||
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||||
import type { Action, UiState } from "./lib/keyboard";
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf } from "./lib/keyboard";
|
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
import { playPop } from "./lib/pop";
|
||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
import type { Group, Results, SongHit } from "./lib/search";
|
||||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||||
|
|
||||||
|
// The room page and the typing game are each a tab away from the player and together a
|
||||||
|
// large share of the bundle; the kiosk parses and compiles only what it opens.
|
||||||
|
const RoomView = lazy(() => import("./components/RoomView").then((m) => ({ default: m.RoomView })));
|
||||||
|
const TippenApp = lazy(() =>
|
||||||
|
import("./components/TippenApp").then((m) => ({ default: m.TippenApp })),
|
||||||
|
);
|
||||||
|
|
||||||
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
||||||
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
@@ -58,6 +67,9 @@ export function App() {
|
|||||||
const [parentMode, setParentMode] = useState(
|
const [parentMode, setParentMode] = useState(
|
||||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
||||||
);
|
);
|
||||||
|
const [perfOpen, setPerfOpen] = useState(
|
||||||
|
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
|
||||||
|
);
|
||||||
const [debugDynamicUI] = useState(
|
const [debugDynamicUI] = useState(
|
||||||
() => new URLSearchParams(location.search).get("debugDynamicUI") === "1",
|
() => new URLSearchParams(location.search).get("debugDynamicUI") === "1",
|
||||||
);
|
);
|
||||||
@@ -97,16 +109,24 @@ export function App() {
|
|||||||
// only reloads when the backend says a rescan finished, so this is genuinely rare.
|
// only reloads when the backend says a rescan finished, so this is genuinely rare.
|
||||||
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
|
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
|
||||||
|
|
||||||
|
// Kiosk only: fetch and decode every card thumbnail up front, in idle time.
|
||||||
|
useCoverWarmup(library.albums, PERF.coverWarmup);
|
||||||
|
|
||||||
|
// Typing updates `ui.search` at once and the search box with it; the results follow a
|
||||||
|
// deferred copy, so a render for an older query is dropped when the next letter lands
|
||||||
|
// instead of being finished first.
|
||||||
|
const deferredSearch = useDeferredValue(ui.search);
|
||||||
|
|
||||||
const results: Results = useMemo(
|
const results: Results = useMemo(
|
||||||
() =>
|
() =>
|
||||||
computeResults({
|
computeResults({
|
||||||
index,
|
index,
|
||||||
search: ui.search,
|
search: deferredSearch,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
category: ui.category,
|
category: ui.category,
|
||||||
}),
|
}),
|
||||||
[index, ui.search, ui.mode, ui.group, ui.category],
|
[index, deferredSearch, ui.mode, ui.group, ui.category],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The bare root's three shelves, by category key only: the keyboard handler needs
|
// The bare root's three shelves, by category key only: the keyboard handler needs
|
||||||
@@ -351,17 +371,17 @@ export function App() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [assignStatus]);
|
}, [assignStatus]);
|
||||||
|
|
||||||
const onEnterGroup = (group: Group, category: string | null) => {
|
const onEnterGroup = useEvent((group: Group, category: string | null) => {
|
||||||
playPop(category ? 440 : 380);
|
playPop(category ? 440 : 380);
|
||||||
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
|
setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 }));
|
||||||
};
|
});
|
||||||
|
|
||||||
const onCategory = (key: string | null) => {
|
const onCategory = useEvent((key: string | null) => {
|
||||||
if (key) playPop(440);
|
if (key) playPop(440);
|
||||||
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
||||||
};
|
});
|
||||||
|
|
||||||
const onOpenAlbum = (album: Album, navIndex: number) => {
|
const onOpenAlbum = useEvent((album: Album, navIndex: number) => {
|
||||||
// A podcast episode is a single track behaving like an audiobook of one chapter -
|
// A podcast episode is a single track behaving like an audiobook of one chapter -
|
||||||
// there's nothing a metadata popup would add, so it just starts playing.
|
// there's nothing a metadata popup would add, so it just starts playing.
|
||||||
if (groupOf(album) === "podcasts") {
|
if (groupOf(album) === "podcasts") {
|
||||||
@@ -371,14 +391,14 @@ export function App() {
|
|||||||
}
|
}
|
||||||
playPop(420);
|
playPop(420);
|
||||||
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 }));
|
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 }));
|
||||||
};
|
});
|
||||||
|
|
||||||
/** Clicking the title text (as opposed to the cover) starts the album right away -
|
/** Clicking the title text (as opposed to the cover) starts the album right away -
|
||||||
* the same thing ENTER does from the keyboard - instead of opening the track list. */
|
* the same thing ENTER does from the keyboard - instead of opening the track list. */
|
||||||
const onPlayAlbum = (album: Album, navIndex: number) => {
|
const onPlayAlbum = useEvent((album: Album, navIndex: number) => {
|
||||||
setUi((previous) => ({ ...previous, selIndex: navIndex }));
|
setUi((previous) => ({ ...previous, selIndex: navIndex }));
|
||||||
play(album.id, 0);
|
play(album.id, 0);
|
||||||
};
|
});
|
||||||
|
|
||||||
const onOpenCurrentAlbum = () => {
|
const onOpenCurrentAlbum = () => {
|
||||||
if (!currentAlbum) return;
|
if (!currentAlbum) return;
|
||||||
@@ -392,7 +412,7 @@ export function App() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
|
||||||
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
||||||
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
|
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
|
||||||
|
|
||||||
@@ -443,7 +463,11 @@ export function App() {
|
|||||||
group={ui.group}
|
group={ui.group}
|
||||||
index={index}
|
index={index}
|
||||||
mode={ui.mode}
|
mode={ui.mode}
|
||||||
search={ui.search}
|
search={deferredSearch}
|
||||||
|
typed={ui.search}
|
||||||
|
searchOpen={ui.searchOpen}
|
||||||
|
onToggleMode={() => run(toggleModeActions(ui))}
|
||||||
|
columns={columns}
|
||||||
category={ui.category}
|
category={ui.category}
|
||||||
selIndex={ui.selIndex}
|
selIndex={ui.selIndex}
|
||||||
shelfRow={ui.shelfRow}
|
shelfRow={ui.shelfRow}
|
||||||
@@ -506,11 +530,13 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
<Suspense fallback={null}>
|
||||||
|
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
||||||
|
|
||||||
{ui.page === "typing" && (
|
{ui.page === "typing" && (
|
||||||
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
||||||
)}
|
)}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{openAlbum && state && (
|
{openAlbum && state && (
|
||||||
<AlbumModal
|
<AlbumModal
|
||||||
@@ -559,6 +585,30 @@ export function App() {
|
|||||||
|
|
||||||
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
|
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
|
||||||
|
|
||||||
|
{PERF_ENABLED && !perfOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPerfOpen(true)}
|
||||||
|
aria-label="Leistungseinstellungen"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 8,
|
||||||
|
bottom: 8,
|
||||||
|
zIndex: 5,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "oklch(15% 0.03 210 / .35)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
|
||||||
|
|
||||||
{(!state || library.loading) && <Splash error={library.error} />}
|
{(!state || library.loading) && <Splash error={library.error} />}
|
||||||
|
|
||||||
{debugDynamicUI && debugSnapshot && (
|
{debugDynamicUI && debugSnapshot && (
|
||||||
|
|||||||
20
web/src/api/__tests__/client.test.ts
Normal file
20
web/src/api/__tests__/client.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { coverUrl } from "../client";
|
||||||
|
|
||||||
|
describe("coverUrl", () => {
|
||||||
|
it("is the plain full-size URL with no options", () => {
|
||||||
|
expect(coverUrl("abc")).toBe("/api/albums/abc/cover");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the thumbnail and carries the cache-busting version", () => {
|
||||||
|
expect(coverUrl("abc", { size: "thumb", version: 42 })).toBe(
|
||||||
|
"/api/albums/abc/cover?size=thumb&v=42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the version off when there is none, so the server revalidates", () => {
|
||||||
|
expect(coverUrl("abc", { size: "thumb" })).toBe("/api/albums/abc/cover?size=thumb");
|
||||||
|
expect(coverUrl("abc", { version: 0 })).toBe("/api/albums/abc/cover");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,4 +122,20 @@ export const api = {
|
|||||||
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
|
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
/** Cover URL. `thumb` is the small file for cards and rows; only the play view needs
|
||||||
|
* `full`. With a `version` (`Album.cover_v`) the URL names one exact set of bytes and the
|
||||||
|
* server marks it immutable, so the browser never asks for it twice; without one it
|
||||||
|
* revalidates on every use. */
|
||||||
|
export function coverUrl(
|
||||||
|
albumId: string,
|
||||||
|
{ size = "full", version }: { size?: "thumb" | "full"; version?: number } = {},
|
||||||
|
): string {
|
||||||
|
const params: string[] = [];
|
||||||
|
if (size === "thumb") params.push("size=thumb");
|
||||||
|
if (version) params.push(`v=${version}`);
|
||||||
|
return `/api/albums/${albumId}/cover${params.length ? `?${params.join("&")}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest `Cover` `size` (CSS px) that is served the thumbnail; the play view's 340 gets
|
||||||
|
* the full file. Keep in step with `THUMB_COVER_PX` in the backend's `library/cache.py`. */
|
||||||
|
export const THUMB_MAX_SIZE = 220;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface Album {
|
|||||||
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
|
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
|
||||||
colors: string[];
|
colors: string[];
|
||||||
has_cover: boolean;
|
has_cover: boolean;
|
||||||
|
/** Cache-busting version of the cover file; put it in the cover URL's `v`. */
|
||||||
|
cover_v?: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
/** Every track is still locked - show a question mark instead of cover art. */
|
/** Every track is still locked - show a question mark instead of cover art. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* flat across songs, then categories, then albums so one pair of arrow keys walks
|
* flat across songs, then categories, then albums so one pair of arrow keys walks
|
||||||
* the whole page. */
|
* the whole page. */
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { memo, useEffect, useMemo, useRef } from "react";
|
||||||
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||||
|
|
||||||
import type { Album } from "../api/types";
|
import type { Album } from "../api/types";
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "../lib/covers";
|
} from "../lib/covers";
|
||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||||
import { categoryMatches, groupOf } from "../lib/search";
|
import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
|
||||||
import {
|
import {
|
||||||
ANIMATE_VIEW_TRANSITIONS,
|
ANIMATE_VIEW_TRANSITIONS,
|
||||||
glassTint,
|
glassTint,
|
||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
SHOW_ROW_TITLES,
|
SHOW_ROW_TITLES,
|
||||||
TAB_RAIL_CLEARANCE,
|
TAB_RAIL_CLEARANCE,
|
||||||
} from "../lib/theme";
|
} from "../lib/theme";
|
||||||
|
import { useIncrementalCount } from "../hooks/useIncrementalCount";
|
||||||
|
import { PERF } from "../lib/perfSettings";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,7 +41,17 @@ interface Props {
|
|||||||
group: Group | null;
|
group: Group | null;
|
||||||
index: SearchIndex;
|
index: SearchIndex;
|
||||||
mode: "albums" | "tracks";
|
mode: "albums" | "tracks";
|
||||||
|
/** The query the results below were computed for. Trails `typed` while a render for
|
||||||
|
* a newer one is still in flight. */
|
||||||
search: string;
|
search: string;
|
||||||
|
/** What is in the search box right now - never behind. */
|
||||||
|
typed: string;
|
||||||
|
/** `/` was pressed, so the box shows while still empty. */
|
||||||
|
searchOpen: boolean;
|
||||||
|
/** The pill in the search box: album search <-> title search. */
|
||||||
|
onToggleMode: () => void;
|
||||||
|
/** Columns in the album grid, so the first paint can be exactly three rows. */
|
||||||
|
columns: number;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
selIndex: number;
|
selIndex: number;
|
||||||
/** Which of the three root shelves is focused - only meaningful at the bare root,
|
/** Which of the three root shelves is focused - only meaningful at the bare root,
|
||||||
@@ -73,12 +85,16 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
|
|||||||
podcasts: "Episoden",
|
podcasts: "Episoden",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BrowseView({
|
function BrowseViewImpl({
|
||||||
results,
|
results,
|
||||||
group,
|
group,
|
||||||
index,
|
index,
|
||||||
mode,
|
mode,
|
||||||
search,
|
search: rawSearch,
|
||||||
|
typed,
|
||||||
|
searchOpen,
|
||||||
|
onToggleMode,
|
||||||
|
columns,
|
||||||
category,
|
category,
|
||||||
selIndex,
|
selIndex,
|
||||||
shelfRow,
|
shelfRow,
|
||||||
@@ -93,7 +109,12 @@ export function BrowseView({
|
|||||||
const scroller = useRef<HTMLDivElement | null>(null);
|
const scroller = useRef<HTMLDivElement | null>(null);
|
||||||
const { songs, categories, albums: albumResults, total } = results;
|
const { songs, categories, albums: albumResults, total } = results;
|
||||||
|
|
||||||
const isRootShelf = group === null && mode !== "tracks" && !search;
|
// One letter is not a query yet - see `effectiveSearch`.
|
||||||
|
const search = effectiveSearch(rawSearch);
|
||||||
|
|
||||||
|
// Gone as soon as the box appears - `typed` and `searchOpen` are never behind, so the
|
||||||
|
// shelves do not linger for the one letter that is not a query yet.
|
||||||
|
const isRootShelf = group === null && mode !== "tracks" && !typed && !searchOpen;
|
||||||
|
|
||||||
// The root shelf has no flat `results` of its own (each row computes its own
|
// The root shelf has no flat `results` of its own (each row computes its own
|
||||||
// categories independently) - it's a genuine two-axis layout instead: `shelfRow`
|
// categories independently) - it's a genuine two-axis layout instead: `shelfRow`
|
||||||
@@ -103,11 +124,7 @@ export function BrowseView({
|
|||||||
// needs the row half of that.
|
// needs the row half of that.
|
||||||
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
|
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
|
||||||
const focusedCol = Math.max(0, selIndex);
|
const focusedCol = Math.max(0, selIndex);
|
||||||
const selected = isRootShelf
|
const selected = isRootShelf ? focusedRow : total ? Math.min(selIndex, total - 1) : -1;
|
||||||
? focusedRow
|
|
||||||
: total
|
|
||||||
? Math.min(selIndex, total - 1)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
const shelves = useMemo(
|
const shelves = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -138,103 +155,146 @@ export function BrowseView({
|
|||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
|
// Long lists render a screenful and grow as the user nears the end. The three lists
|
||||||
|
// share one selection index (songs, then categories, then albums), hence the offsets.
|
||||||
|
const rowKey = `${group}:${category}:${mode}:${search}`;
|
||||||
|
const gridInitial = Math.max(1, columns) * 3;
|
||||||
|
const songWindow = useIncrementalCount(songs.length, 20, 20, rowKey, scroller, selected);
|
||||||
|
const categoryWindow = useIncrementalCount(
|
||||||
|
categories.length,
|
||||||
|
gridInitial,
|
||||||
|
gridInitial,
|
||||||
|
rowKey,
|
||||||
|
scroller,
|
||||||
|
selected - songs.length,
|
||||||
|
);
|
||||||
|
const albumWindow = useIncrementalCount(
|
||||||
|
albumResults.length,
|
||||||
|
gridInitial,
|
||||||
|
gridInitial,
|
||||||
|
rowKey,
|
||||||
|
scroller,
|
||||||
|
selected - songs.length - categories.length,
|
||||||
|
);
|
||||||
|
|
||||||
if (isRootShelf) {
|
if (isRootShelf) {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={scroller}
|
<SearchBox
|
||||||
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
typed={typed}
|
||||||
// Right padding reserves room for the tab rail's own glass bar (see
|
mode={mode}
|
||||||
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it.
|
open={searchOpen}
|
||||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }}
|
onToggleMode={onToggleMode}
|
||||||
>
|
note=""
|
||||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
/>
|
||||||
<div
|
<div
|
||||||
key={shelfGroup}
|
ref={scroller}
|
||||||
className="glass-panel"
|
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||||
role="button"
|
// Right padding reserves room for the tab rail's own glass bar (see
|
||||||
tabIndex={0}
|
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it.
|
||||||
data-nav-index={index}
|
style={{
|
||||||
onClick={() => onEnterGroup(shelfGroup, null)}
|
flex: 1,
|
||||||
onKeyDown={(event) => {
|
overflow: "auto",
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
minHeight: 0,
|
||||||
event.preventDefault();
|
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
|
||||||
onEnterGroup(shelfGroup, null);
|
}}
|
||||||
}}
|
>
|
||||||
aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
|
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
||||||
style={{
|
<div
|
||||||
marginBottom: ROW_SPACING,
|
key={shelfGroup}
|
||||||
padding: "12px 14px",
|
className="glass-panel"
|
||||||
cursor: "pointer",
|
role="button"
|
||||||
// Once a row has tiles, the focus ring belongs on the tile the keyboard
|
tabIndex={0}
|
||||||
// is actually on (below) rather than the row around it - this only
|
data-nav-index={index}
|
||||||
// stands in for that when there's nothing to focus.
|
onClick={() => onEnterGroup(shelfGroup, null)}
|
||||||
outline:
|
onKeyDown={(event) => {
|
||||||
focusedRow === index && shelfCategories.length === 0
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
? "4px solid var(--paper)"
|
event.preventDefault();
|
||||||
: undefined,
|
onEnterGroup(shelfGroup, null);
|
||||||
outlineOffset: 2,
|
}}
|
||||||
...glassTint(GROUP_HUE[shelfGroup]),
|
aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
|
||||||
}}
|
style={{
|
||||||
>
|
marginBottom: ROW_SPACING,
|
||||||
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
padding: "12px 14px",
|
||||||
<div
|
cursor: "pointer",
|
||||||
style={{
|
// Once a row has tiles, the focus ring belongs on the tile the keyboard
|
||||||
display: "flex",
|
// is actually on (below) rather than the row around it - this only
|
||||||
alignItems: "center",
|
// stands in for that when there's nothing to focus.
|
||||||
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
outline:
|
||||||
marginBottom: 12,
|
focusedRow === index && shelfCategories.length === 0
|
||||||
fontSize: 20,
|
? "4px solid var(--paper)"
|
||||||
fontWeight: 900,
|
: undefined,
|
||||||
color: "var(--paper)",
|
outlineOffset: 2,
|
||||||
}}
|
...glassTint(GROUP_HUE[shelfGroup]),
|
||||||
>
|
}}
|
||||||
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
>
|
||||||
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
||||||
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
<div
|
||||||
</div>
|
style={{
|
||||||
)}
|
display: "flex",
|
||||||
{shelfCategories.length === 0 ? (
|
alignItems: "center",
|
||||||
<div
|
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
||||||
style={{
|
marginBottom: 12,
|
||||||
textAlign: "center",
|
fontSize: 20,
|
||||||
fontSize: 14,
|
fontWeight: 900,
|
||||||
fontWeight: 700,
|
color: "var(--paper)",
|
||||||
color: "oklch(88% 0.02 210 / .6)",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
||||||
Noch nichts hier
|
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
||||||
</div>
|
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
||||||
) : (
|
</div>
|
||||||
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
)}
|
||||||
{shelfCategories.map((entry, tileIndex) => (
|
{shelfCategories.length === 0 ? (
|
||||||
<CategoryTile
|
<div
|
||||||
key={entry.key}
|
style={{
|
||||||
entry={entry}
|
textAlign: "center",
|
||||||
navIndex={tileIndex}
|
fontSize: 14,
|
||||||
selected={focusedRow === index && focusedCol === tileIndex}
|
fontWeight: 700,
|
||||||
onClick={(event) => {
|
color: "oklch(88% 0.02 210 / .6)",
|
||||||
// Otherwise this bubbles to the panel's own onClick, which would
|
}}
|
||||||
// open the group root instead of the category just clicked.
|
>
|
||||||
event.stopPropagation();
|
Noch nichts hier
|
||||||
onEnterGroup(shelfGroup, entry.key);
|
</div>
|
||||||
}}
|
) : (
|
||||||
/>
|
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||||
))}
|
{shelfCategories.map((entry, tileIndex) => (
|
||||||
</ShelfRow>
|
<CategoryTile
|
||||||
)}
|
key={entry.key}
|
||||||
</div>
|
entry={entry}
|
||||||
))}
|
navIndex={tileIndex}
|
||||||
</div>
|
selected={focusedRow === index && focusedCol === tileIndex}
|
||||||
|
onClick={(event) => {
|
||||||
|
// Otherwise this bubbles to the panel's own onClick, which would
|
||||||
|
// open the group root instead of the category just clicked.
|
||||||
|
event.stopPropagation();
|
||||||
|
onEnterGroup(shelfGroup, entry.key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ShelfRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSearchBar = search.length > 0 || mode === "tracks";
|
|
||||||
const countLabel =
|
const countLabel =
|
||||||
mode === "tracks"
|
typed.trim() && !search
|
||||||
? `${songs.length} Titel gefunden`
|
? "noch ein Buchstabe …"
|
||||||
: albumResults.length
|
: mode === "tracks"
|
||||||
? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden`
|
? songs.length
|
||||||
: "nichts gefunden";
|
? `${songs.length} Titel gefunden`
|
||||||
|
: search
|
||||||
|
? "nichts gefunden"
|
||||||
|
: ""
|
||||||
|
: albumResults.length
|
||||||
|
? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden`
|
||||||
|
: search
|
||||||
|
? "nichts gefunden"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -253,50 +313,13 @@ export function BrowseView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSearchBar && (
|
<SearchBox
|
||||||
<div
|
typed={typed}
|
||||||
style={{
|
mode={mode}
|
||||||
margin: "2px 32px 6px",
|
open={searchOpen}
|
||||||
display: "flex",
|
onToggleMode={onToggleMode}
|
||||||
alignItems: "center",
|
note={countLabel}
|
||||||
justifyContent: "center",
|
/>
|
||||||
gap: 12,
|
|
||||||
flex: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--paper)",
|
|
||||||
color: "var(--ink)",
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 20,
|
|
||||||
padding: "10px 20px",
|
|
||||||
borderRadius: 999,
|
|
||||||
boxShadow: "0 4px 14px var(--shadow)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 800,
|
|
||||||
background: "var(--ink)",
|
|
||||||
color: "#fff",
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: 999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
|
|
||||||
</span>
|
|
||||||
<span>{search ? `"${search}"` : "tippe …"}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
|
|
||||||
{countLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scroller}
|
ref={scroller}
|
||||||
@@ -304,7 +327,12 @@ export function BrowseView({
|
|||||||
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||||
// Right padding reserves room for the tab rail's own glass bar (see
|
// Right padding reserves room for the tab rail's own glass bar (see
|
||||||
// TAB_RAIL_CLEARANCE) so a full-width row/grid never renders under it.
|
// TAB_RAIL_CLEARANCE) so a full-width row/grid never renders under it.
|
||||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }}
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
minHeight: 0,
|
||||||
|
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{songs.length > 0 && (
|
{songs.length > 0 && (
|
||||||
<div style={{ marginBottom: 30 }}>
|
<div style={{ marginBottom: 30 }}>
|
||||||
@@ -320,7 +348,7 @@ export function BrowseView({
|
|||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{songs.map((hit, index) => (
|
{songs.slice(0, songWindow.visible).map((hit, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${hit.album.id}-${hit.index}`}
|
key={`${hit.album.id}-${hit.index}`}
|
||||||
data-nav-index={index}
|
data-nav-index={index}
|
||||||
@@ -336,8 +364,7 @@ export function BrowseView({
|
|||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
font: "inherit",
|
font: "inherit",
|
||||||
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
|
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
|
||||||
outline:
|
outline: selected === index ? "4px solid var(--paper)" : undefined,
|
||||||
selected === index ? "4px solid var(--paper)" : undefined,
|
|
||||||
outlineOffset: 2,
|
outlineOffset: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -350,7 +377,13 @@ export function BrowseView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{hit.title}
|
{hit.title}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -377,6 +410,9 @@ export function BrowseView({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{songWindow.visible < songs.length && (
|
||||||
|
<div ref={songWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -384,7 +420,7 @@ export function BrowseView({
|
|||||||
<div>
|
<div>
|
||||||
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
|
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
|
||||||
<div className="grid" ref={gridRef}>
|
<div className="grid" ref={gridRef}>
|
||||||
{categories.map((entry, index) => {
|
{categories.slice(0, categoryWindow.visible).map((entry, index) => {
|
||||||
const navIndex = songs.length + index;
|
const navIndex = songs.length + index;
|
||||||
return (
|
return (
|
||||||
<CategoryTile
|
<CategoryTile
|
||||||
@@ -397,6 +433,9 @@ export function BrowseView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{categoryWindow.visible < categories.length && (
|
||||||
|
<div ref={categoryWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -419,7 +458,7 @@ export function BrowseView({
|
|||||||
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
|
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
|
||||||
)}
|
)}
|
||||||
<div className="grid" ref={gridRef}>
|
<div className="grid" ref={gridRef}>
|
||||||
{albumResults.map((album, index) => {
|
{albumResults.slice(0, albumWindow.visible).map((album, index) => {
|
||||||
const navIndex = songs.length + categories.length + index;
|
const navIndex = songs.length + categories.length + index;
|
||||||
const podcast = groupOf(album) === "podcasts";
|
const podcast = groupOf(album) === "podcasts";
|
||||||
// An audiobook's artist is almost always the same as the character/
|
// An audiobook's artist is almost always the same as the character/
|
||||||
@@ -468,7 +507,11 @@ export function BrowseView({
|
|||||||
<button
|
<button
|
||||||
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
|
onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
|
||||||
disabled={album.locked}
|
disabled={album.locked}
|
||||||
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
|
aria-label={
|
||||||
|
album.locked
|
||||||
|
? `${album.title}: noch nicht freigeschaltet`
|
||||||
|
: `${album.title} abspielen`
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -547,7 +590,9 @@ export function BrowseView({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
{podcast
|
||||||
|
? clock(album.duration)
|
||||||
|
: unitLabel(album, album.tracks.length)}
|
||||||
{album.figure && " · 🧸"}
|
{album.figure && " · 🧸"}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -557,20 +602,34 @@ export function BrowseView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{albumWindow.visible < albumResults.length && (
|
||||||
|
<div ref={albumWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{total === 0 && (
|
{total === 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }}
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 60,
|
||||||
|
color: "oklch(92% 0.02 210 / .8)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/dolphin-mascot.png"
|
src="/dolphin-mascot.png"
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }}
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
objectFit: "contain",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
|
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
|
||||||
Nichts gefunden — probier andere Buchstaben!
|
{search
|
||||||
|
? "Nichts gefunden — probier andere Buchstaben!"
|
||||||
|
: "Tippe mindestens zwei Buchstaben …"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -648,11 +707,10 @@ function ShelfRow({
|
|||||||
element.classList.remove("dragging");
|
element.classList.remove("dragging");
|
||||||
if (dragged) {
|
if (dragged) {
|
||||||
// This was a pan, not a click - swallow the click a tile would otherwise get.
|
// This was a pan, not a click - swallow the click a tile would otherwise get.
|
||||||
window.addEventListener(
|
window.addEventListener("click", (clickEvent) => clickEvent.stopPropagation(), {
|
||||||
"click",
|
capture: true,
|
||||||
(clickEvent) => clickEvent.stopPropagation(),
|
once: true,
|
||||||
{ capture: true, once: true },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", onMove);
|
window.addEventListener("mousemove", onMove);
|
||||||
@@ -682,6 +740,7 @@ function CategoryTile({
|
|||||||
}) {
|
}) {
|
||||||
const books = entry.albums.filter(isBook).length;
|
const books = entry.albums.filter(isBook).length;
|
||||||
const allBooks = books === entry.albums.length;
|
const allBooks = books === entry.albums.length;
|
||||||
|
const allPodcasts = entry.albums.every((a) => groupOf(a) === "podcasts");
|
||||||
const mostlyBooks = books * 2 > entry.albums.length;
|
const mostlyBooks = books * 2 > entry.albums.length;
|
||||||
const shown = entry.albums.slice(0, 4);
|
const shown = entry.albums.slice(0, 4);
|
||||||
return (
|
return (
|
||||||
@@ -695,7 +754,8 @@ function CategoryTile({
|
|||||||
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||||
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
|
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
|
||||||
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
||||||
boxShadow: "0 6px 18px var(--shadow)",
|
// One blurred shadow per tile: dropped under `?pi=1`, like `cardShadow`.
|
||||||
|
boxShadow: PERF.tileShadows ? "0 6px 18px var(--shadow)" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -758,26 +818,24 @@ function CategoryTile({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entry.albums.length}{" "}
|
{entry.albums.length}{" "}
|
||||||
{allBooks
|
{allPodcasts
|
||||||
? entry.albums.length === 1
|
? entry.albums.length === 1
|
||||||
? "Hörbuch"
|
? "Episode"
|
||||||
: "Hörbücher"
|
: "Episoden"
|
||||||
: entry.albums.length === 1
|
: allBooks
|
||||||
? "Album"
|
? entry.albums.length === 1
|
||||||
: "Alben"}
|
? "Hörbuch"
|
||||||
|
: "Hörbücher"
|
||||||
|
: entry.albums.length === 1
|
||||||
|
? "Album"
|
||||||
|
: "Alben"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({
|
function SectionTitle({ children, centered }: { children: ReactNode; centered?: boolean }) {
|
||||||
children,
|
|
||||||
centered,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
centered?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -799,8 +857,63 @@ function SectionTitle({
|
|||||||
|
|
||||||
function Muted({ children }: { children: ReactNode }) {
|
function Muted({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(88% 0.02 210 / .7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The search field, shown once there is something in it so it is obvious where typing goes. It only
|
||||||
|
* displays: keys are caught by the window handler in App, which is what lets the whole
|
||||||
|
* UI be driven from the keyboard without focusing anything first. */
|
||||||
|
function SearchBox({
|
||||||
|
typed,
|
||||||
|
mode,
|
||||||
|
open,
|
||||||
|
onToggleMode,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
typed: string;
|
||||||
|
mode: "albums" | "tracks";
|
||||||
|
open: boolean;
|
||||||
|
onToggleMode: () => void;
|
||||||
|
note: string;
|
||||||
|
}) {
|
||||||
|
// Nothing to show until a letter is typed or `/` opens it - and in tracks mode the
|
||||||
|
// box is also how the screen says which mode it is in.
|
||||||
|
if (!typed && !open && mode !== "tracks") return null;
|
||||||
|
return (
|
||||||
|
<div className="searchbox-row">
|
||||||
|
<div className="searchbox" data-empty={typed ? undefined : true}>
|
||||||
|
{/* mousedown is swallowed so the button never takes focus: a focused button
|
||||||
|
would also "click" on the Space or Enter the window handler already used. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="searchbox-mode"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={onToggleMode}
|
||||||
|
title="Suchmodus wechseln ( / )"
|
||||||
|
>
|
||||||
|
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"} ⇄
|
||||||
|
</button>
|
||||||
|
<span className="searchbox-text">
|
||||||
|
{!typed && <span className="searchbox-caret" aria-hidden />}
|
||||||
|
{typed || <span className="searchbox-placeholder">Tippe zum Suchen …</span>}
|
||||||
|
{typed && <span className="searchbox-caret" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{note && <div className="searchbox-note">{note}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Memoised: `App` re-renders twice a second during playback (position frames), and
|
||||||
|
* every prop here is either a primitive, a memoised value or a stable handler, so the
|
||||||
|
* card grid - up to ~340 cards - stays put unless something it shows has changed. */
|
||||||
|
export const BrowseView = memo(BrowseViewImpl);
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { coverUrl } from "../api/client";
|
import { THUMB_MAX_SIZE, coverUrl } from "../api/client";
|
||||||
import type { Album } from "../api/types";
|
import type { Album } from "../api/types";
|
||||||
import { aspectOf, coverBackground, isBook } from "../lib/covers";
|
import { aspectOf, coverBackground, coverUnderlay, isBook } from "../lib/covers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album;
|
album: Album;
|
||||||
@@ -47,7 +47,11 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
|||||||
flex: "none",
|
flex: "none",
|
||||||
borderRadius: radius ?? defaultRadius,
|
borderRadius: radius ?? defaultRadius,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: coverBackground(album, Math.max(6, Math.round(size / 13))),
|
// Real art is opaque: paint the cheap flat underlay, not the stripes/spine.
|
||||||
|
background:
|
||||||
|
album.has_cover && !locked
|
||||||
|
? coverUnderlay(album)
|
||||||
|
: coverBackground(album, Math.max(6, Math.round(size / 13))),
|
||||||
...box,
|
...box,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
@@ -90,9 +94,13 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
|||||||
)}
|
)}
|
||||||
{album.has_cover && (
|
{album.has_cover && (
|
||||||
<img
|
<img
|
||||||
src={coverUrl(album.id)}
|
src={coverUrl(album.id, {
|
||||||
|
size: size <= THUMB_MAX_SIZE ? "thumb" : "full",
|
||||||
|
version: album.cover_v,
|
||||||
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|||||||
158
web/src/components/PerfPanel.tsx
Normal file
158
web/src/components/PerfPanel.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/** Per-knob performance settings, shown under `?pi=1`. Stored in this browser's
|
||||||
|
* localStorage (see lib/perfSettings.ts) and applied by a reload, because the knobs are
|
||||||
|
* read once at module load. Nothing changes until "Speichern". */
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FULL,
|
||||||
|
PERF,
|
||||||
|
PI,
|
||||||
|
resetPerfSettings,
|
||||||
|
savePerfSettings,
|
||||||
|
type PerfSettings,
|
||||||
|
} from "../lib/perfSettings";
|
||||||
|
|
||||||
|
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
|
||||||
|
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
|
||||||
|
|
||||||
|
const TOGGLES: [BoolKey, string, string][] = [
|
||||||
|
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
|
||||||
|
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
|
||||||
|
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
|
||||||
|
["decorativeAnimations", "Deko-Animationen", "Blasen im Zimmer, schwimmender Delfin"],
|
||||||
|
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
|
||||||
|
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
|
||||||
|
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
|
||||||
|
["tippenBubbles", "Tippen: Blasen", "Aufsteigende Blasen im Hintergrund"],
|
||||||
|
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
|
||||||
|
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
|
||||||
|
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUMBERS: [NumKey, string, string, number, number, number][] = [
|
||||||
|
["playbackClockFps", "Fortschrittsbalken: Updates/s", "0 = jeder Frame", 0, 60, 1],
|
||||||
|
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
|
||||||
|
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
|
||||||
|
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
|
||||||
|
["ambienceMaxBubbles", "Hintergrund: max. Blasen", "0 = unbegrenzt", 0, 500, 5],
|
||||||
|
];
|
||||||
|
|
||||||
|
const pill = (background: string, color: string) =>
|
||||||
|
({
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background,
|
||||||
|
color,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "11px 20px",
|
||||||
|
borderRadius: 999,
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const [draft, setDraft] = useState<PerfSettings>({ ...PERF });
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
savePerfSettings(draft);
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
resetPerfSettings();
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||||
|
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||||
|
⚡ Leistung
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
style={{
|
||||||
|
...pill("oklch(88% 0.02 210)", "var(--ink)"),
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
||||||
|
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||||
|
Pi-Vorgabe
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||||
|
Alles an
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 18 }}>
|
||||||
|
{TOGGLES.map(([key, label, hint]) => (
|
||||||
|
<label key={key} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft[key]}
|
||||||
|
onChange={(event) => setDraft({ ...draft, [key]: event.target.checked })}
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
/>
|
||||||
|
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{NUMBERS.map(([key, label, hint, min, max, step]) => (
|
||||||
|
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={draft[key]}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.valueAsNumber;
|
||||||
|
if (Number.isFinite(value)) setDraft({ ...draft, [key]: value });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "2px solid oklch(85% 0.02 210)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||||
|
<button onClick={save} style={pill("var(--accent)", "#fff")}>
|
||||||
|
Speichern & neu laden
|
||||||
|
</button>
|
||||||
|
<button onClick={reset} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)", lineHeight: 1.5 }}>
|
||||||
|
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { LOW_POWER } from "../../lib/lowPower";
|
import { PERF } from "../../lib/perfSettings";
|
||||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
|||||||
// takes the same door: on that device the point is that nothing runs a frame loop,
|
// takes the same door: on that device the point is that nothing runs a frame loop,
|
||||||
// and the pets are the reward - they should be *there*, they just need not swim.
|
// and the pets are the reward - they should be *there*, they just need not swim.
|
||||||
const reducedMotion =
|
const reducedMotion =
|
||||||
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
!PERF.petAnimations || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let lastTime = performance.now();
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||||
import { LOW_POWER } from "../../lib/lowPower";
|
import { PERF } from "../../lib/perfSettings";
|
||||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||||
import { AquariumCreatures } from "./AquariumCreatures";
|
import { AquariumCreatures } from "./AquariumCreatures";
|
||||||
import { Bubbles } from "./Bubbles";
|
import { Bubbles } from "./Bubbles";
|
||||||
@@ -27,7 +27,7 @@ export function Stage({ children, creatures, dimmed }: Props) {
|
|||||||
<div
|
<div
|
||||||
className="tp-stage"
|
className="tp-stage"
|
||||||
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||||
data-anim={LOW_POWER ? "off" : "on"}
|
data-anim={PERF.petAnimations ? "on" : "off"}
|
||||||
>
|
>
|
||||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||||
{SHOW_BUBBLES && <Bubbles />}
|
{SHOW_BUBBLES && <Bubbles />}
|
||||||
|
|||||||
55
web/src/hooks/useCoverWarmup.ts
Normal file
55
web/src/hooks/useCoverWarmup.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/** Pull every card thumbnail into the browser's caches once, while the kiosk is idle.
|
||||||
|
*
|
||||||
|
* Cover URLs carry a version and are served `immutable`, so once a thumbnail has been
|
||||||
|
* fetched the browser never asks for it again - but the *first* search over the whole
|
||||||
|
* library would otherwise fetch, and decode, hundreds of them while the user waits. This
|
||||||
|
* does that work up front, a few at a time in idle periods. The `Image`s are held so
|
||||||
|
* their decoded bitmaps stay resident: RAM is not the constraint on the machine this is
|
||||||
|
* for (`?pi=1`, see lib/lowPower.ts), the main thread during a keystroke is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../api/client";
|
||||||
|
import type { Album } from "../api/types";
|
||||||
|
|
||||||
|
const BATCH = 6;
|
||||||
|
|
||||||
|
const idle = (run: () => void): void => {
|
||||||
|
if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 2000 });
|
||||||
|
else setTimeout(run, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Module-level on purpose: dropping these would let the browser discard what they hold. */
|
||||||
|
const held = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
export function useCoverWarmup(albums: Album[], enabled: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const pending = albums.filter((a) => a.has_cover && !a.locked);
|
||||||
|
let next = 0;
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
if (cancelled || next >= pending.length) return;
|
||||||
|
const batch = pending.slice(next, next + BATCH);
|
||||||
|
next += BATCH;
|
||||||
|
void Promise.all(
|
||||||
|
batch.map((album) => {
|
||||||
|
const url = coverUrl(album.id, { size: "thumb", version: album.cover_v });
|
||||||
|
if (held.has(url)) return undefined;
|
||||||
|
const img = new Image();
|
||||||
|
img.decoding = "async";
|
||||||
|
img.src = url;
|
||||||
|
held.set(url, img);
|
||||||
|
return img.decode().catch(() => undefined);
|
||||||
|
}),
|
||||||
|
).then(() => idle(step));
|
||||||
|
};
|
||||||
|
idle(step);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [albums, enabled]);
|
||||||
|
}
|
||||||
17
web/src/hooks/useEvent.ts
Normal file
17
web/src/hooks/useEvent.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** A callback with a stable identity that always calls the latest closure.
|
||||||
|
*
|
||||||
|
* For handlers passed to a `memo`ised child: `App` re-renders twice a second while a
|
||||||
|
* track plays (websocket position frames), and a fresh inline handler each time would
|
||||||
|
* defeat `memo` and re-render the whole card grid with it. */
|
||||||
|
|
||||||
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useEvent<Args extends unknown[], Result>(
|
||||||
|
handler: (...args: Args) => Result,
|
||||||
|
): (...args: Args) => Result {
|
||||||
|
const latest = useRef(handler);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latest.current = handler;
|
||||||
|
});
|
||||||
|
return useCallback((...args: Args) => latest.current(...args), []);
|
||||||
|
}
|
||||||
68
web/src/hooks/useIncrementalCount.ts
Normal file
68
web/src/hooks/useIncrementalCount.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/** How many items of a long list to render, growing as the user gets near the end.
|
||||||
|
|
||||||
|
Committing every card of a 340-album search result inside one keystroke is what made typing
|
||||||
|
feel stuck, and it is work for cards nobody has scrolled to. So a list renders its first
|
||||||
|
`initial` items and asks for `step` more whenever a sentinel placed after the list comes
|
||||||
|
within a screen of the scroll box's edge, or the keyboard selection reaches the last one
|
||||||
|
rendered. A new `resetKey` (another query, group or category) starts over at `initial`, so
|
||||||
|
a short search never inherits the length of the previous long one.
|
||||||
|
|
||||||
|
Growth runs in a transition: the next keystroke interrupts it rather than waiting for it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
|
export function nextCount(count: number, step: number, total: number): number {
|
||||||
|
return Math.min(total, count + step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIncrementalCount(
|
||||||
|
total: number,
|
||||||
|
initial: number,
|
||||||
|
step: number,
|
||||||
|
resetKey: string,
|
||||||
|
root: RefObject<HTMLElement | null>,
|
||||||
|
selected: number,
|
||||||
|
): { visible: number; sentinelRef: (node: HTMLElement | null) => void } {
|
||||||
|
const [state, setState] = useState({ key: resetKey, count: initial });
|
||||||
|
const [sentinel, setSentinel] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Adjusting state during render is React's documented way to reset on a changed input;
|
||||||
|
// an effect would paint one frame of the old, long list first.
|
||||||
|
let count = state.count;
|
||||||
|
if (state.key !== resetKey) {
|
||||||
|
count = initial;
|
||||||
|
setState({ key: resetKey, count });
|
||||||
|
} else if (count < initial) {
|
||||||
|
// The grid measured its real column count after first paint.
|
||||||
|
count = initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grow = useCallback(() => {
|
||||||
|
startTransition(() =>
|
||||||
|
setState((previous) => ({ ...previous, count: nextCount(Math.max(previous.count, initial), step, total) })),
|
||||||
|
);
|
||||||
|
}, [initial, step, total]);
|
||||||
|
|
||||||
|
// Recreated on every growth: an observer only reports *changes*, so one that stays
|
||||||
|
// attached to a sentinel that is still on screen would never ask a second time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sentinel || count >= total) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) grow();
|
||||||
|
},
|
||||||
|
{ root: root.current, rootMargin: "100% 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [sentinel, count, total, grow, root]);
|
||||||
|
|
||||||
|
// Arrowing down past the fold: the selected item must exist to be scrolled to.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected >= count - 1 && count < total) grow();
|
||||||
|
}, [selected, count, total, grow]);
|
||||||
|
|
||||||
|
return { visible: Math.min(count, total), sentinelRef: setSentinel };
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { Album } from "../../api/types";
|
import type { Album } from "../../api/types";
|
||||||
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
import { handleKey, initialUiState, isRootShelf, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
|
||||||
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
|
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
|
||||||
|
|
||||||
function album(id: string, over: Partial<Album> = {}): Album {
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
@@ -291,7 +291,7 @@ describe("keyboard", () => {
|
|||||||
|
|
||||||
const searching = { ...inPlay, view: "browse" as const };
|
const searching = { ...inPlay, view: "browse" as const };
|
||||||
expect(press("Escape", searching)).toEqual([
|
expect(press("Escape", searching)).toEqual([
|
||||||
{ type: "ui", patch: { search: "", selIndex: 0 } },
|
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const inTracks = { ...searching, search: "" };
|
const inTracks = { ...searching, search: "" };
|
||||||
@@ -471,4 +471,33 @@ describe("selectionAt", () => {
|
|||||||
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
|
||||||
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the search box on / and closes it with ESC before anything else", () => {
|
||||||
|
expect(press("/")).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { view: "browse", searchOpen: true },
|
||||||
|
});
|
||||||
|
const open = { ...initialUiState, searchOpen: true };
|
||||||
|
expect(press("Escape", open)).toEqual([
|
||||||
|
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the root shelves as soon as the search box is up", () => {
|
||||||
|
expect(isRootShelf(initialUiState)).toBe(true);
|
||||||
|
expect(isRootShelf({ ...initialUiState, search: "a" })).toBe(false);
|
||||||
|
expect(isRootShelf({ ...initialUiState, searchOpen: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips between album and title search with / while the box is up", () => {
|
||||||
|
const open = { ...initialUiState, searchOpen: true, search: "ab" };
|
||||||
|
expect(press("/", open)).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { mode: "tracks", searchOpen: true, selIndex: 0 },
|
||||||
|
});
|
||||||
|
expect(press("/", { ...open, mode: "tracks" })).toContainEqual({
|
||||||
|
type: "ui",
|
||||||
|
patch: { mode: "albums", searchOpen: true, selIndex: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
55
web/src/lib/__tests__/perfSettings.test.ts
Normal file
55
web/src/lib/__tests__/perfSettings.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
async function load(search: string) {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal("location", { search });
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
});
|
||||||
|
return import("../perfSettings");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("perfSettings", () => {
|
||||||
|
beforeEach(() => store.clear());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("is full-fat without pi, ignoring stored values", async () => {
|
||||||
|
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: false }));
|
||||||
|
const m = await load("");
|
||||||
|
expect(m.PERF_ENABLED).toBe(false);
|
||||||
|
expect(m.PERF).toEqual(m.FULL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the pi preset under ?pi=1", async () => {
|
||||||
|
const m = await load("?pi=1");
|
||||||
|
expect(m.PERF).toEqual(m.PI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges stored overrides over the preset", async () => {
|
||||||
|
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: true, playbackClockFps: 30 }));
|
||||||
|
const m = await load("?pi=1");
|
||||||
|
expect(m.PERF.ambience).toBe(true);
|
||||||
|
expect(m.PERF.playbackClockFps).toBe(30);
|
||||||
|
expect(m.PERF.cardBlur).toBe(m.PI.cardBlur);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops wrong-typed fields and survives corrupt JSON", async () => {
|
||||||
|
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: "yes", ambienceScale: null }));
|
||||||
|
expect((await load("?pi=1")).PERF).toEqual((await load("?pi=1")).PI);
|
||||||
|
store.set("musicmouse.perf.v1", "{nope");
|
||||||
|
const m = await load("?pi=1");
|
||||||
|
expect(m.PERF).toEqual(m.PI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through save and reset", async () => {
|
||||||
|
const m = await load("?pi=1");
|
||||||
|
m.savePerfSettings({ ...m.PI, cardBlur: true });
|
||||||
|
expect((await load("?pi=1")).PERF.cardBlur).toBe(true);
|
||||||
|
m.resetPerfSettings();
|
||||||
|
expect((await load("?pi=1")).PERF.cardBlur).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { Album, Track } from "../../api/types";
|
import type { Album, Track } from "../../api/types";
|
||||||
import {
|
import {
|
||||||
buildSearchIndex,
|
buildSearchIndex,
|
||||||
|
effectiveSearch,
|
||||||
|
listMode,
|
||||||
MAX_SONG_HITS,
|
MAX_SONG_HITS,
|
||||||
normalize,
|
normalize,
|
||||||
results as computeResults,
|
results as computeResults,
|
||||||
@@ -111,14 +113,14 @@ describe("songMatches", () => {
|
|||||||
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
|
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps an empty query, which would otherwise be the whole library", () => {
|
it("caps a query that matches most of the library", () => {
|
||||||
const many = Array.from({ length: 60 }, (_, i) =>
|
const many = Array.from({ length: 60 }, (_, i) =>
|
||||||
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
|
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
|
||||||
);
|
);
|
||||||
const index = buildSearchIndex(many);
|
const index = buildSearchIndex(many);
|
||||||
const hits = computeResults({
|
const hits = computeResults({
|
||||||
index,
|
index,
|
||||||
search: "",
|
search: "lied",
|
||||||
mode: "tracks",
|
mode: "tracks",
|
||||||
group: null,
|
group: null,
|
||||||
category: null,
|
category: null,
|
||||||
@@ -129,3 +131,28 @@ describe("songMatches", () => {
|
|||||||
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
|
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("minimum query length", () => {
|
||||||
|
it("ignores a single letter and anything that normalizes to one", () => {
|
||||||
|
expect(effectiveSearch("e")).toBe("");
|
||||||
|
expect(effectiveSearch(" é ")).toBe("");
|
||||||
|
expect(effectiveSearch("ab")).toBe("ab");
|
||||||
|
expect(effectiveSearch("a b")).toBe("a b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the screen alone for one letter and filters from two", () => {
|
||||||
|
const index = buildSearchIndex([album("1"), album("2")]);
|
||||||
|
const base = { index, mode: "albums" as const, group: "music" as Group, category: null };
|
||||||
|
expect(listMode({ ...base, search: "a" })).toBe("categories");
|
||||||
|
expect(computeResults({ ...base, search: "a" }).albums).toHaveLength(0);
|
||||||
|
expect(listMode({ ...base, search: "al" })).toBe("albums");
|
||||||
|
expect(computeResults({ ...base, search: "al" }).albums).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no titles until two letters are typed", () => {
|
||||||
|
const base = { index: INDEX, mode: "tracks" as const, group: null, category: null };
|
||||||
|
expect(computeResults({ ...base, search: "" }).songs).toEqual([]);
|
||||||
|
expect(computeResults({ ...base, search: "v" }).songs).toEqual([]);
|
||||||
|
expect(computeResults({ ...base, search: "va" }).songs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Album, AlbumKind, PlayerState } from "../api/types";
|
import type { Album, AlbumKind, PlayerState } from "../api/types";
|
||||||
|
import { PERF } from "./perfSettings";
|
||||||
import { GROUP_HUE } from "./theme";
|
import { GROUP_HUE } from "./theme";
|
||||||
|
|
||||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||||
@@ -37,15 +38,18 @@ function stripes(album: Album, width: number): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A book spine: page edges on the right, a darker board on the left. */
|
/** A book spine: page edges on the right, a darker board on the left.
|
||||||
|
*
|
||||||
|
* Stops are sRGB hex on purpose - see the note on `.stage` in app.css: an oklch stop
|
||||||
|
* makes Gecko interpolate in Oklab on the CPU at every display-list build, and these
|
||||||
|
* four gradients sit under every book card. The page edges are oklch(97% 0.02 55) and
|
||||||
|
* oklch(90% 0.03 55) (`GROUP_HUE.audiobooks`), the ridges oklch(98% 0 0 / .35). */
|
||||||
export function spine(album: Album): string {
|
export function spine(album: Album): string {
|
||||||
const [primary, secondary] = colours(album);
|
const [primary, secondary] = colours(album);
|
||||||
const hue = GROUP_HUE.audiobooks;
|
|
||||||
return [
|
return [
|
||||||
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` +
|
"linear-gradient(90deg, transparent 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)",
|
||||||
` oklch(90% 0.03 ${hue}) 97.5% 100%)`,
|
"linear-gradient(90deg, transparent 0 3.5%, #f8f8f859 3.5% 4.3%," +
|
||||||
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
" transparent 4.3% 7%, #f8f8f859 7% 7.8%, transparent 7.8%)",
|
||||||
" 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(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||||
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
|
||||||
].join(",");
|
].join(",");
|
||||||
@@ -55,6 +59,17 @@ export function coverBackground(album: Album, stripeWidth: number): string {
|
|||||||
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What sits under a cover that has real art. The art is opaque, so the stripes or the
|
||||||
|
* four-gradient spine would be painted for nothing - on a 340-card grid that is real
|
||||||
|
* display-list work. A flat primary colour is all a still-loading image needs; a book
|
||||||
|
* keeps one cheap gradient for the page edges its clip leaves showing on the right. */
|
||||||
|
export function coverUnderlay(album: Album): string {
|
||||||
|
const [primary] = colours(album);
|
||||||
|
return isBook(album)
|
||||||
|
? `linear-gradient(90deg, ${primary} 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)`
|
||||||
|
: primary;
|
||||||
|
}
|
||||||
|
|
||||||
/** The card's own tint: warm paper for books, cool glass for music. Amber
|
/** The card's own tint: warm paper for books, cool glass for music. Amber
|
||||||
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
|
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
|
||||||
* rather than washed-out. */
|
* rather than washed-out. */
|
||||||
@@ -63,8 +78,16 @@ export const cardBackground = (album: Album): string =>
|
|||||||
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||||
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
|
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
|
||||||
|
|
||||||
|
/** One blurred shadow per card, up to ~340 of them: dropped under `?pi=1`, where a plain
|
||||||
|
* translucent card reads fine and the shadow is a per-card blur render task. Books keep
|
||||||
|
* their two inset "board" bands, which are cheap and are what make them look like books. */
|
||||||
export const cardShadow = (album: Album): string =>
|
export const cardShadow = (album: Album): string =>
|
||||||
isBook(album)
|
!PERF.cardShadows
|
||||||
|
? isBook(album)
|
||||||
|
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||||
|
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||||
|
: "none"
|
||||||
|
: isBook(album)
|
||||||
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||||
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||||
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`
|
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type { Group, Mode, Results } from "./search";
|
|||||||
|
|
||||||
export interface UiState {
|
export interface UiState {
|
||||||
search: string;
|
search: string;
|
||||||
|
/** `/` was pressed: show the search box before the first letter arrives. */
|
||||||
|
searchOpen: boolean;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
@@ -41,6 +43,7 @@ export interface UiState {
|
|||||||
|
|
||||||
export const initialUiState: UiState = {
|
export const initialUiState: UiState = {
|
||||||
search: "",
|
search: "",
|
||||||
|
searchOpen: false,
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
group: null,
|
group: null,
|
||||||
category: null,
|
category: null,
|
||||||
@@ -179,7 +182,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
|
|||||||
* should be one step too, not two, regardless of whether a category was reached that
|
* should be one step too, not two, regardless of whether a category was reached that
|
||||||
* way or by opening the group first. Always safe to call. */
|
* way or by opening the group first. Always safe to call. */
|
||||||
export function browseBackActions(state: UiState): Action[] {
|
export function browseBackActions(state: UiState): Action[] {
|
||||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
if (state.search || state.searchOpen) {
|
||||||
|
return [{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } }];
|
||||||
|
}
|
||||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||||
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
|
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
|
||||||
}
|
}
|
||||||
@@ -201,9 +206,28 @@ function escape(state: UiState): Action[] {
|
|||||||
return browseBackActions(state);
|
return browseBackActions(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
/** Whether the search box is on screen: something typed, `/` pressed, or track search. */
|
||||||
|
export function searchBoxShown(state: UiState): boolean {
|
||||||
|
return state.search.length > 0 || state.searchOpen || state.mode === "tracks";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. The
|
||||||
|
* shelves go away the moment the search box appears, before there is a query to filter by. */
|
||||||
export function isRootShelf(state: UiState): boolean {
|
export function isRootShelf(state: UiState): boolean {
|
||||||
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
return state.group === null && !searchBoxShown(state) && state.category === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Album search <-> title search, keeping whatever has been typed. `searchOpen` keeps the
|
||||||
|
* box up when the switch lands on albums with nothing typed, where it would otherwise
|
||||||
|
* vanish and bring the shelves back. */
|
||||||
|
export function toggleModeActions(state: UiState): Action[] {
|
||||||
|
return [
|
||||||
|
{ type: "pop", freq: 460 },
|
||||||
|
{
|
||||||
|
type: "ui",
|
||||||
|
patch: { mode: state.mode === "tracks" ? "albums" : "tracks", searchOpen: true, selIndex: 0 },
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyEvent {
|
export interface KeyEvent {
|
||||||
@@ -340,7 +364,9 @@ export function handleKey(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
case "/":
|
case "/":
|
||||||
return [{ type: "ui", patch: { view: "browse" } }];
|
// With the box already up, `/` flips what it searches; otherwise it opens it.
|
||||||
|
if (browsing && searchBoxShown(state)) return toggleModeActions(state);
|
||||||
|
return [{ type: "ui", patch: { view: "browse", searchOpen: true } }];
|
||||||
|
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||||
|
|||||||
@@ -44,15 +44,11 @@
|
|||||||
* answerable by editing the address bar.
|
* answerable by editing the address bar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** True when the page was loaded with `?pi=1`. */
|
import { PERF } from "./perfSettings";
|
||||||
export const LOW_POWER = readLowPowerFlag();
|
|
||||||
|
|
||||||
function readLowPowerFlag(): boolean {
|
// Superseded as a single switch: every knob below and in theme.ts now comes from
|
||||||
// `location` is absent under vitest's default node environment.
|
// `PERF` (lib/perfSettings.ts), which `?pi=1` seeds and the settings dialog overrides.
|
||||||
if (typeof location === "undefined") return false;
|
// This header is kept for the measurements it records.
|
||||||
const value = new URLSearchParams(location.search).get("pi");
|
|
||||||
return value === "1" || value === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AmbienceQuality {
|
export interface AmbienceQuality {
|
||||||
/** Multiplies the canvas backing store relative to its on-screen size, before
|
/** Multiplies the canvas backing store relative to its on-screen size, before
|
||||||
@@ -80,9 +76,12 @@ export interface AmbienceQuality {
|
|||||||
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
|
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
|
||||||
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
|
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
|
||||||
* backing store is what took it from 53.5% to 25%. */
|
* backing store is what took it from 53.5% to 25%. */
|
||||||
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
|
export const AMBIENCE_QUALITY: AmbienceQuality = {
|
||||||
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
|
resolutionScale: PERF.ambienceScale,
|
||||||
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
|
maxBackingStorePx: PERF.ambienceMaxPx,
|
||||||
|
maxFps: PERF.ambienceMaxFps,
|
||||||
|
maxBubbles: PERF.ambienceMaxBubbles,
|
||||||
|
};
|
||||||
|
|
||||||
/** How many times a second the interpolated playback position may push a new React
|
/** How many times a second the interpolated playback position may push a new React
|
||||||
* render. 0 means "every animation frame", which is what it has always done.
|
* render. 0 means "every animation frame", which is what it has always done.
|
||||||
@@ -96,4 +95,4 @@ export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
|
|||||||
*
|
*
|
||||||
* Only bites while something is playing, so it is not in the idle table above.
|
* Only bites while something is playing, so it is not in the idle table above.
|
||||||
*/
|
*/
|
||||||
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;
|
export const PLAYBACK_CLOCK_FPS = PERF.playbackClockFps;
|
||||||
|
|||||||
147
web/src/lib/perfSettings.ts
Normal file
147
web/src/lib/perfSettings.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/** Per-knob performance settings, the granular successor to the old all-or-nothing `?pi=1`.
|
||||||
|
*
|
||||||
|
* `?pi=1` still switches the panel on, but no longer flips everything at once: it selects
|
||||||
|
* the `PI` preset as the starting point, and each knob can then be overridden from the
|
||||||
|
* settings dialog (`PerfPanel`) and is stored in this browser's localStorage. Without
|
||||||
|
* `?pi=1` the stored values are ignored and the full-fat `FULL` preset applies, so a
|
||||||
|
* laptop opening the same page always gets the full version - which is what makes "is this
|
||||||
|
* the profile or the hardware?" answerable by editing the address bar.
|
||||||
|
*
|
||||||
|
* Read once at module load. The knobs feed module-level constants (`SHOW_AMBIENCE`, ...)
|
||||||
|
* and the service worker registration, so applying a change means a page reload.
|
||||||
|
*
|
||||||
|
* `SHOW_GLASS_BLUR` is deliberately not a knob: on the Pi it measured five times *worse*
|
||||||
|
* with the blur off. See the table in lib/lowPower.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PerfSettings {
|
||||||
|
/** Fade/slide animation when entering a group/category or opening the album modal. */
|
||||||
|
viewTransitions: boolean;
|
||||||
|
/** The play view's animated canvas background (gradient + bubbles). */
|
||||||
|
ambience: boolean;
|
||||||
|
/** Real backdrop blur on each repeated glass card/row. */
|
||||||
|
cardBlur: boolean;
|
||||||
|
/** Decorative CSS loops: the room page's bubbles and the dolphin mascot. */
|
||||||
|
decorativeAnimations: boolean;
|
||||||
|
/** Drop shadows under covers in the album cards. */
|
||||||
|
cardShadows: boolean;
|
||||||
|
/** Drop shadows under tiles in the browse grid. */
|
||||||
|
tileShadows: boolean;
|
||||||
|
/** Typing game: pets move (off = placed but held still). */
|
||||||
|
petAnimations: boolean;
|
||||||
|
/** Typing game: rising bubbles. */
|
||||||
|
tippenBubbles: boolean;
|
||||||
|
/** Typing game: fade/slide view transitions. */
|
||||||
|
tippenViewTransitions: boolean;
|
||||||
|
/** Register the service worker (off also unregisters an existing one). */
|
||||||
|
serviceWorker: boolean;
|
||||||
|
/** Pre-decode covers in the background. */
|
||||||
|
coverWarmup: boolean;
|
||||||
|
/** Max playback-position renders per second. 0 = every animation frame. */
|
||||||
|
playbackClockFps: number;
|
||||||
|
/** Ambient canvas backing-store scale relative to its on-screen size. */
|
||||||
|
ambienceScale: number;
|
||||||
|
/** Ceiling on either ambient canvas backing-store axis, in px. */
|
||||||
|
ambienceMaxPx: number;
|
||||||
|
/** Ambient canvas frame-rate cap. 0 = whatever the display offers. */
|
||||||
|
ambienceMaxFps: number;
|
||||||
|
/** Ceiling on ambient bubbles alive at once. 0 = unlimited. */
|
||||||
|
ambienceMaxBubbles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's behaviour without `?pi=1`. */
|
||||||
|
export const FULL: PerfSettings = {
|
||||||
|
viewTransitions: true,
|
||||||
|
ambience: true,
|
||||||
|
cardBlur: true,
|
||||||
|
decorativeAnimations: true,
|
||||||
|
cardShadows: true,
|
||||||
|
tileShadows: true,
|
||||||
|
petAnimations: true,
|
||||||
|
tippenBubbles: true,
|
||||||
|
tippenViewTransitions: true,
|
||||||
|
serviceWorker: true,
|
||||||
|
coverWarmup: false,
|
||||||
|
playbackClockFps: 0,
|
||||||
|
ambienceScale: 1,
|
||||||
|
ambienceMaxPx: 4096,
|
||||||
|
ambienceMaxFps: 0,
|
||||||
|
ambienceMaxBubbles: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Today's behaviour with `?pi=1`. */
|
||||||
|
export const PI: PerfSettings = {
|
||||||
|
viewTransitions: false,
|
||||||
|
ambience: false,
|
||||||
|
cardBlur: false,
|
||||||
|
decorativeAnimations: false,
|
||||||
|
cardShadows: false,
|
||||||
|
tileShadows: false,
|
||||||
|
petAnimations: false,
|
||||||
|
tippenBubbles: false,
|
||||||
|
tippenViewTransitions: false,
|
||||||
|
serviceWorker: false,
|
||||||
|
coverWarmup: true,
|
||||||
|
playbackClockFps: 10,
|
||||||
|
ambienceScale: 0.5,
|
||||||
|
ambienceMaxPx: 1280,
|
||||||
|
ambienceMaxFps: 30,
|
||||||
|
ambienceMaxBubbles: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORAGE_KEY = "musicmouse.perf.v1";
|
||||||
|
|
||||||
|
/** True when the page was loaded with `?pi=1`. */
|
||||||
|
export const PERF_ENABLED = readPiFlag();
|
||||||
|
|
||||||
|
function readPiFlag(): boolean {
|
||||||
|
// `location` is absent under vitest's default node environment.
|
||||||
|
if (typeof location === "undefined") return false;
|
||||||
|
const value = new URLSearchParams(location.search).get("pi");
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only stored fields whose type matches the preset's; anything else is dropped. */
|
||||||
|
function sanitize(raw: unknown): Partial<PerfSettings> {
|
||||||
|
if (typeof raw !== "object" || raw === null) return {};
|
||||||
|
const out: Record<string, boolean | number> = {};
|
||||||
|
for (const [key, def] of Object.entries(PI)) {
|
||||||
|
const value = (raw as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) {
|
||||||
|
out[key] = value as boolean | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out as Partial<PerfSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoredPerf(): Partial<PerfSettings> {
|
||||||
|
try {
|
||||||
|
const text = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return text ? sanitize(JSON.parse(text)) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPerfSettings(): PerfSettings {
|
||||||
|
if (!PERF_ENABLED) return { ...FULL };
|
||||||
|
return { ...PI, ...loadStoredPerf() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePerfSettings(settings: PerfSettings): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: the dialog still reloads, it just comes back with the preset.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPerfSettings(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERF: PerfSettings = loadPerfSettings();
|
||||||
@@ -53,6 +53,16 @@ function searchWords(value: string): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One letter matches most of the library, which is a screen of noise and not a
|
||||||
|
* search - the first character is the user finding their footing, not asking. */
|
||||||
|
export const MIN_SEARCH_CHARS = 2;
|
||||||
|
|
||||||
|
/** The query that actually filters: `""` until there are at least `MIN_SEARCH_CHARS`
|
||||||
|
* letters to go on, so the screen stays where it is while the first one is typed. */
|
||||||
|
export function effectiveSearch(value: string): string {
|
||||||
|
return searchWords(value).join("").length >= MIN_SEARCH_CHARS ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
/** Every word has to show up somewhere in the haystack, in any order. */
|
/** Every word has to show up somewhere in the haystack, in any order. */
|
||||||
function matchesWords(haystack: string, words: string[]): boolean {
|
function matchesWords(haystack: string, words: string[]): boolean {
|
||||||
return words.every((word) => haystack.includes(word));
|
return words.every((word) => haystack.includes(word));
|
||||||
@@ -185,7 +195,7 @@ function trackPool(query: BrowseQuery): TrackEntry[] {
|
|||||||
/** Which of the three lists the browse view is showing. */
|
/** Which of the three lists the browse view is showing. */
|
||||||
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
|
||||||
if (query.mode === "tracks") return "tracks";
|
if (query.mode === "tracks") return "tracks";
|
||||||
if (query.search || query.category) return "albums";
|
if (effectiveSearch(query.search) || query.category) return "albums";
|
||||||
return "categories";
|
return "categories";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +208,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
|||||||
|
|
||||||
export function albumMatches(query: BrowseQuery): Album[] {
|
export function albumMatches(query: BrowseQuery): Album[] {
|
||||||
if (query.mode === "tracks") return [];
|
if (query.mode === "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(effectiveSearch(query.search));
|
||||||
if (!words.length && !query.category) return [];
|
if (!words.length && !query.category) return [];
|
||||||
let candidates = albumPool(query);
|
let candidates = albumPool(query);
|
||||||
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
||||||
@@ -206,15 +216,17 @@ export function albumMatches(query: BrowseQuery): Album[] {
|
|||||||
return candidates.map((e) => e.album);
|
return candidates.map((e) => e.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
/** Capped, because a query like "e" over 900 podcast episodes is not a useful screen. */
|
||||||
export const MAX_SONG_HITS = 40;
|
export const MAX_SONG_HITS = 40;
|
||||||
|
|
||||||
export function songMatches(query: BrowseQuery): SongHit[] {
|
export function songMatches(query: BrowseQuery): SongHit[] {
|
||||||
if (query.mode !== "tracks") return [];
|
if (query.mode !== "tracks") return [];
|
||||||
const words = searchWords(query.search);
|
const words = searchWords(effectiveSearch(query.search));
|
||||||
|
// Titles are five thousand rows: nothing until there is a query, not the first 40.
|
||||||
|
if (!words.length) return [];
|
||||||
const hits: SongHit[] = [];
|
const hits: SongHit[] = [];
|
||||||
for (const track of trackPool(query)) {
|
for (const track of trackPool(query)) {
|
||||||
if (words.length && !matchesWords(track.haystack, words)) continue;
|
if (!matchesWords(track.haystack, words)) continue;
|
||||||
hits.push({
|
hits.push({
|
||||||
album: track.album,
|
album: track.album,
|
||||||
index: track.index,
|
index: track.index,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { LOW_POWER } from "./lowPower";
|
import { PERF } from "./perfSettings";
|
||||||
import type { Group } from "./search";
|
import type { Group } from "./search";
|
||||||
import type { UiState } from "./keyboard";
|
import type { UiState } from "./keyboard";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
|
|||||||
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
||||||
* category is entered, and the album modal in when it opens. One animation per
|
* category is entered, and the album modal in when it opens. One animation per
|
||||||
* container, not per card, so cost stays flat regardless of grid size. */
|
* container, not per card, so cost stays flat regardless of grid size. */
|
||||||
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
|
export const ANIMATE_VIEW_TRANSITIONS = PERF.viewTransitions;
|
||||||
|
|
||||||
/** Frost the glass panels/cards/rows with a real backdrop blur.
|
/** Frost the glass panels/cards/rows with a real backdrop blur.
|
||||||
*
|
*
|
||||||
@@ -54,7 +54,7 @@ export const SHOW_GLASS_BLUR = true;
|
|||||||
* loop that repaints the full viewport is a floor you cannot get under while it runs at
|
* loop that repaints the full viewport is a floor you cannot get under while it runs at
|
||||||
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
|
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
|
||||||
* so with this off the browser has nothing to do between one keypress and the next. */
|
* so with this off the browser has nothing to do between one keypress and the next. */
|
||||||
export const SHOW_AMBIENCE = !LOW_POWER;
|
export const SHOW_AMBIENCE = PERF.ambience;
|
||||||
|
|
||||||
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
|
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
|
||||||
* track list - as opposed to the handful of panels wrapped around them.
|
* track list - as opposed to the handful of panels wrapped around them.
|
||||||
@@ -65,14 +65,14 @@ export const SHOW_AMBIENCE = !LOW_POWER;
|
|||||||
* card is one live backdrop copy out of three hundred sitting directly over the
|
* card is one live backdrop copy out of three hundred sitting directly over the
|
||||||
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
|
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
|
||||||
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
|
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
|
||||||
export const SHOW_CARD_BLUR = !LOW_POWER;
|
export const SHOW_CARD_BLUR = PERF.cardBlur;
|
||||||
|
|
||||||
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
||||||
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
|
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
|
||||||
* noise, but "nothing on this screen moves by itself" is a property worth having
|
* noise, but "nothing on this screen moves by itself" is a property worth having
|
||||||
* outright rather than a sum of small wins - a compositor with no animation to service
|
* outright rather than a sum of small wins - a compositor with no animation to service
|
||||||
* has nothing to wake up for. */
|
* has nothing to wake up for. */
|
||||||
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
|
export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
|
||||||
|
|
||||||
// ------------------------------------------------------------------ colors --
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
||||||
* it is repeated here only for the canvas, which cannot read a custom property. */
|
* it is repeated here only for the canvas, which cannot read a custom property. */
|
||||||
|
|
||||||
import { LOW_POWER } from "../lowPower";
|
import { PERF } from "../perfSettings";
|
||||||
|
|
||||||
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
||||||
export const HUE = 175;
|
export const HUE = 175;
|
||||||
@@ -17,7 +17,7 @@ export const SHOW_GLASS_BLUR = true;
|
|||||||
|
|
||||||
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
||||||
* loop - off under `?pi=1`, like every other loop in the app. */
|
* loop - off under `?pi=1`, like every other loop in the app. */
|
||||||
export const SHOW_BUBBLES = !LOW_POWER;
|
export const SHOW_BUBBLES = PERF.tippenBubbles;
|
||||||
|
|
||||||
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
||||||
* only to rule it out when chasing a performance problem.
|
* only to rule it out when chasing a performance problem.
|
||||||
@@ -29,7 +29,7 @@ export const SHOW_BUBBLES = !LOW_POWER;
|
|||||||
export const SHOW_AQUARIUM_CREATURES = true;
|
export const SHOW_AQUARIUM_CREATURES = true;
|
||||||
|
|
||||||
/** Fade screens in on entry. */
|
/** Fade screens in on entry. */
|
||||||
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
|
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
|
||||||
|
|
||||||
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
|
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
|
||||||
* as each one is mastered - the scaffold that removes itself, which is the whole point
|
* as each one is mastered - the scaffold that removes itself, which is the whole point
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { PERF } from "./lib/perfSettings";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
import "./styles/room.css";
|
import "./styles/room.css";
|
||||||
import "./styles/tippen.css";
|
import "./styles/tippen.css";
|
||||||
@@ -15,8 +16,20 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
// Registered after load so it never competes with the first paint. It is what makes
|
// Registered after load so it never competes with the first paint. It is what makes
|
||||||
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
|
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
|
||||||
// for what it does and deliberately does not cache.
|
// for what it does and deliberately does not cache.
|
||||||
|
//
|
||||||
|
// Not under `?pi=1`: that is the kiosk, which *is* the server. There is nothing to
|
||||||
|
// install, and the worker only adds a fetch handler (and a `cache.put` of every response)
|
||||||
|
// in front of requests that are already on localhost. Any worker an earlier visit left
|
||||||
|
// behind is removed so the kiosk stops paying for it.
|
||||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
|
if (!PERF.serviceWorker) {
|
||||||
|
void navigator.serviceWorker
|
||||||
|
.getRegistrations()
|
||||||
|
.then((registrations) => Promise.all(registrations.map((r) => r.unregister())))
|
||||||
|
.catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void navigator.serviceWorker.register("/sw.js").catch(() => {
|
void navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||||
// A plain http:// origin that is not localhost cannot register one. The app
|
// A plain http:// origin that is not localhost cannot register one. The app
|
||||||
// works exactly the same, it just cannot be installed.
|
// works exactly the same, it just cannot be installed.
|
||||||
|
|||||||
@@ -104,12 +104,11 @@ button {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(
|
/* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop
|
||||||
180deg,
|
interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the
|
||||||
oklch(55% 0.07 210) 0%,
|
display list is rebuilt - about 15% of a Pi-profile's busy time. Values are the
|
||||||
oklch(38% 0.06 210) 45%,
|
oklch(55% 0.07 210) / (38% 0.06 210) / (20% 0.045 210) they replace. */
|
||||||
var(--sea-deep) 100%
|
background: linear-gradient(180deg, #397d88 0%, #0e4b54 45%, #001b21 100%);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
|
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
|
||||||
@@ -270,7 +269,7 @@ button {
|
|||||||
flex: none;
|
flex: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.38), oklch(97% 0.01 210 / 0.12));
|
background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
border: 1px solid oklch(97% 0.01 210 / 0.4);
|
border: 1px solid oklch(97% 0.01 210 / 0.4);
|
||||||
@@ -282,7 +281,7 @@ button {
|
|||||||
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
|
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
|
||||||
surface floating over the stage gradient, rather than flat text on a gradient. */
|
surface floating over the stage gradient, rather than flat text on a gradient. */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.14), oklch(97% 0.01 210 / 0.05));
|
background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
||||||
@@ -344,3 +343,89 @@ input[type="number"],
|
|||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The search field. Big on purpose: typing is how the whole app is driven, so where the
|
||||||
|
letters land has to be unmissable. */
|
||||||
|
.searchbox-row {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 32px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: min(680px, 100%);
|
||||||
|
min-height: 68px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 26px 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 3px solid var(--accent);
|
||||||
|
box-shadow: 0 6px 22px var(--shadow);
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-mode {
|
||||||
|
flex: none;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-mode:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-placeholder {
|
||||||
|
color: oklch(30% 0.04 210 / .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 3px;
|
||||||
|
vertical-align: -0.12em;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: searchbox-blink 1.1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty: the caret sits where the first letter will land, ahead of the placeholder. */
|
||||||
|
.searchbox[data-empty] .searchbox-caret {
|
||||||
|
margin: 0 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbox-note {
|
||||||
|
color: oklch(90% 0.02 210 / .85);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchbox-blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.searchbox-caret { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user