UI cleanup, visual and keyboard navigation

This commit is contained in:
2026-09-11 13:32:07 +02:00
parent df89acd9a8
commit fbf03a9847
13 changed files with 445 additions and 105 deletions

View File

@@ -7,7 +7,7 @@ interface Props {
status?: ReactNode;
}
export function AppHeader({ title = "Musik Delphin", mascot = "/dolphin-mascot.png", status }: Props) {
export function AppHeader({ title = "Musik Delfin", mascot = "/dolphin-mascot.png", status }: Props) {
return (
<div
style={{

View File

@@ -19,6 +19,16 @@ import {
import { clock } from "../lib/format";
import type { Category, Group, Results, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search";
import {
glassTint,
GROUP_HUE,
GROUP_ICON,
GROUP_LABEL,
ROW_SPACING,
rowTint,
SHOW_ROW_ICONS,
SHOW_ROW_TITLES,
} from "../lib/theme";
import { Cover } from "./Cover";
interface Props {
@@ -29,6 +39,9 @@ interface Props {
search: string;
category: string | null;
selIndex: number;
/** Which of the three root shelves is focused - only meaningful at the bare root,
* where `selIndex` means "which tile in that row" instead of a flat position. */
shelfRow: number;
currentAlbumId: string | null;
gridRef: (element: HTMLElement | null) => void;
onEnterGroup: (group: Group, category: string | null) => void;
@@ -43,12 +56,6 @@ interface Props {
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
const GROUP_TITLE: Record<Group, string> = {
music: "🎵 Musik",
audiobooks: "📖 Hörbücher",
podcasts: "🎙️ Podcasts",
};
/** Heading over the category tiles inside a group. */
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
music: "Künstler",
@@ -71,6 +78,7 @@ export function BrowseView({
search,
category,
selIndex,
shelfRow,
currentAlbumId,
gridRef,
onEnterGroup,
@@ -81,10 +89,23 @@ export function BrowseView({
}: Props) {
const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums: albumResults, total } = results;
const selected = total ? Math.min(selIndex, total - 1) : -1;
const isRootShelf = group === null && mode !== "tracks" && !search;
// 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`
// is which of the three rows is focused, `selIndex` which tile within that row,
// mirroring keyboard.ts's `moveRootShelf`. `selected` here only drives the outer
// scroller's keep-the-focused-row-on-screen effect below, so at the root it only
// needs the row half of that.
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
const focusedCol = Math.max(0, selIndex);
const selected = isRootShelf
? focusedRow
: total
? Math.min(selIndex, total - 1)
: -1;
const shelves = useMemo(
() =>
GROUPS.map((shelfGroup) => ({
@@ -117,28 +138,52 @@ export function BrowseView({
if (isRootShelf) {
return (
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
<div key={shelfGroup} style={{ marginBottom: 34 }}>
<button
onClick={() => onEnterGroup(shelfGroup, null)}
aria-label={`Alle ${GROUP_TITLE[shelfGroup]} durchsuchen`}
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: 12,
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
fontSize: 20,
fontWeight: 900,
color: "var(--paper)",
}}
>
{GROUP_TITLE[shelfGroup]}
<span style={{ opacity: 0.6, fontSize: 17 }}></span>
</button>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
<div
key={shelfGroup}
className="glass-panel"
role="button"
tabIndex={0}
data-nav-index={index}
onClick={() => onEnterGroup(shelfGroup, null)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onEnterGroup(shelfGroup, null);
}}
aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
style={{
marginBottom: ROW_SPACING,
padding: "12px 14px",
cursor: "pointer",
// Once a row has tiles, the focus ring belongs on the tile the keyboard
// is actually on (below) rather than the row around it - this only
// stands in for that when there's nothing to focus.
outline:
focusedRow === index && shelfCategories.length === 0
? "4px solid var(--paper)"
: undefined,
outlineOffset: 2,
...glassTint(GROUP_HUE[shelfGroup]),
}}
>
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
marginBottom: 12,
fontSize: 20,
fontWeight: 900,
color: "var(--paper)",
}}
>
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}></span>}
</div>
)}
{shelfCategories.length === 0 ? (
<div
style={{
@@ -151,12 +196,19 @@ export function BrowseView({
Noch nichts hier
</div>
) : (
<ShelfRow>
{shelfCategories.map((entry) => (
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
{shelfCategories.map((entry, tileIndex) => (
<CategoryTile
key={entry.key}
entry={entry}
onClick={() => onEnterGroup(shelfGroup, entry.key)}
navIndex={tileIndex}
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>
@@ -183,12 +235,12 @@ export function BrowseView({
textAlign: "center",
padding: "4px 32px 6px",
flex: "none",
fontSize: 18,
fontWeight: 900,
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
}}
>
{GROUP_TITLE[group]}
{GROUP_LABEL[group]}
</div>
)}
@@ -250,7 +302,7 @@ export function BrowseView({
style={{
display: "flex",
flexDirection: "column",
gap: 8,
gap: 6,
maxWidth: 760,
margin: "0 auto",
}}
@@ -260,19 +312,17 @@ export function BrowseView({
key={`${hit.album.id}-${hit.index}`}
data-nav-index={index}
onClick={() => onPlaySong(hit)}
className="list-row"
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "10px 16px",
borderRadius: 14,
padding: "8px 16px",
borderRadius: 12,
cursor: "pointer",
border: "none",
textAlign: "left",
font: "inherit",
background: `oklch(97% 0.01 210 / ${
currentAlbumId === hit.album.id ? ".22" : ".10"
})`,
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
outline:
selected === index ? "4px solid var(--paper)" : undefined,
outlineOffset: 2,
@@ -485,9 +535,32 @@ export function BrowseView({
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
* pointer capture, so a plain click still reaches the tile underneath - capturing the
* pointer on the row would retarget even a non-dragging click's events to the row. */
function ShelfRow({ children }: { children: React.ReactNode }) {
function ShelfRow({
children,
focusedIndex,
}: {
children: React.ReactNode;
/** Which tile in this row the keyboard is on, or `null` while some other row has
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
focusedIndex: number | null;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || focusedIndex === null) return;
const tile = element.querySelector<HTMLElement>(`[data-nav-index="${focusedIndex}"]`);
if (!tile) return;
const left = tile.offsetLeft;
const right = left + tile.offsetWidth;
const pad = 24;
if (left - pad < element.scrollLeft) element.scrollLeft = Math.max(0, left - pad);
else if (right + pad > element.scrollLeft + element.clientWidth) {
element.scrollLeft = right + pad - element.clientWidth;
}
}, [focusedIndex]);
useEffect(() => {
const element = ref.current;
if (!element) return;
@@ -552,7 +625,7 @@ function CategoryTile({
entry: Category;
navIndex?: number;
selected?: boolean;
onClick: () => void;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
@@ -565,7 +638,9 @@ function CategoryTile({
data-selected={selected}
onClick={onClick}
style={{
background: allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)",
background: allBooks
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
boxShadow: "0 6px 18px var(--shadow)",
}}

View File

@@ -7,6 +7,7 @@ import { remoteSlotView } from "../lib/remote";
import { Cover } from "./Cover";
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
const ZERO_COLUMN = 2;
interface Props {
album: Album;
@@ -44,14 +45,20 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{DIGITS.map((digit) => {
const view = remoteSlotView(byDigit.get(digit), albums);
return (
<div key={digit} style={{ position: "relative" }}>
<div
key={digit}
style={{
position: "relative",
...(digit === "0" ? { gridColumn: ZERO_COLUMN } : {}),
}}
>
<button
onClick={() => onAssign(digit)}
title={