Web frontend: touch layout for phones

Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off
by the tab rail and a header taking a fifth of the screen. They now get
their own layout, chosen by a media query (narrow, or short landscape on a
touch screen); tablets and desktop are unchanged.

- Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll
  through a grid of smaller albums.
- A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a
  phone has no keyboard for it) and a search button that turns into a real
  search field, raised above the on-screen keyboard.
- Album sheet and room page get compact phone variants.

Browse state stays in the shared UiState, so resizing or rotating keeps
your place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 21:33:26 +02:00
parent 61104d1e25
commit 0f08e1d16c
11 changed files with 1232 additions and 28 deletions

View File

@@ -0,0 +1,278 @@
/** The music page on a phone. Where the kiosk lays out three shelves side by side and
* walks them with the keyboard, this is the usual touch app shape: pick a group from
* the segmented control, then a category, then scroll down through its albums. Search
* results replace all of it while the search bar in `MobileDock` is open.
*
* Browse state is the same `UiState` the kiosk layout reads (`group`, `category`,
* `search`, `mode`), so rotating a tablet or resizing a window keeps your place. */
import { memo, useMemo } from "react";
import type { ReactNode } from "react";
import type { Album } from "../../api/types";
import { albumLine, isBook, unitLabel } from "../../lib/covers";
import { clock } from "../../lib/format";
import type { Group, Mode, SearchIndex, SongHit } from "../../lib/search";
import { effectiveSearch, groupOf, results as computeResults } from "../../lib/search";
import { GROUP_ICON, GROUP_LABEL } from "../../lib/theme";
import { CategoryTile } from "../BrowseView";
import { Cover } from "../Cover";
interface Props {
index: SearchIndex;
/** `null` is the kiosk's three-shelf root, which a phone shows as the music group. */
group: Group | null;
category: string | null;
/** The search bar is open: results replace the browse screen. */
searching: boolean;
/** The query the results are computed for - may trail `typed` by a render. */
search: string;
typed: string;
mode: Mode;
currentAlbumId: string | null;
/** Pill next to the title: connection lost, or the figure on the reader. Primitives
* rather than an element, so `memo` still holds across position frames. */
statusLabel: string | null;
offline: boolean;
onEnterGroup: (group: Group, category: string | null) => void;
onMode: (mode: Mode) => void;
onOpenAlbum: (album: Album, navIndex: number) => void;
onPlaySong: (hit: SongHit) => void;
}
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
function MobileBrowseImpl({
index,
group: rawGroup,
category,
searching,
search,
typed,
mode,
currentAlbumId,
statusLabel,
offline,
onEnterGroup,
onMode,
onOpenAlbum,
onPlaySong,
}: Props) {
const group = rawGroup ?? "music";
const query = effectiveSearch(search);
const results = useMemo(
() =>
searching
? computeResults({ index, search: query, mode, group: null, category: null })
: computeResults({ index, search: "", mode: "albums", group, category }),
[index, searching, query, mode, group, category],
);
if (searching) {
return (
<div className="m-scroll" key="search">
<div className="m-sticky">
<div className="m-segmented">
<Segment active={mode === "albums"} onClick={() => onMode("albums")}>
Alben
</Segment>
<Segment active={mode === "tracks"} onClick={() => onMode("tracks")}>
Titel
</Segment>
</div>
</div>
{!effectiveSearch(typed) ? (
<Hint>Was möchtest du hören?</Hint>
) : mode === "tracks" ? (
results.songs.length ? (
<div className="m-list">
{results.songs.map((hit) => (
<SongRow
key={`${hit.album.id}-${hit.index}`}
hit={hit}
current={hit.album.id === currentAlbumId}
onClick={() => onPlaySong(hit)}
/>
))}
</div>
) : (
<Hint>Nichts gefunden — probier andere Buchstaben!</Hint>
)
) : results.albums.length ? (
<AlbumGrid albums={results.albums} currentAlbumId={currentAlbumId} onOpen={onOpenAlbum} />
) : (
<Hint>Nichts gefunden — probier andere Buchstaben!</Hint>
)}
</div>
);
}
if (category !== null) {
return (
<div className="m-scroll" key={`${group}:${category}`}>
<div className="m-sticky m-navbar">
<button
className="m-glass m-circle m-circle-small"
onClick={() => onEnterGroup(group, null)}
aria-label="Zurück"
>
<ChevronLeft />
</button>
<div className="m-navbar-title">{category}</div>
<div className="m-circle-small" aria-hidden />
</div>
<div className="m-subtitle">
{GROUP_ICON[group]} {GROUP_LABEL[group]} · {results.albums.length}{" "}
{results.albums.length === 1 ? "Eintrag" : "Einträge"}
</div>
<AlbumGrid albums={results.albums} currentAlbumId={currentAlbumId} onOpen={onOpenAlbum} />
</div>
);
}
return (
<div className="m-scroll" key={group}>
<div className="m-title-row">
<img src="/dolphin-mascot.png" alt="" className="m-title-mascot" />
<div className="m-title">Musik Delfin</div>
{statusLabel && (
<div className="m-status" data-offline={offline}>
{statusLabel}
</div>
)}
</div>
<div className="m-sticky">
<div className="m-segmented">
{GROUPS.map((candidate) => (
<Segment
key={candidate}
active={candidate === group}
onClick={() => onEnterGroup(candidate, null)}
>
<span aria-hidden>{GROUP_ICON[candidate]}</span> {GROUP_LABEL[candidate]}
</Segment>
))}
</div>
</div>
{results.categories.length ? (
<div className="m-category-grid">
{results.categories.map((entry) => (
<CategoryTile key={entry.key} entry={entry} onClick={() => onEnterGroup(group, entry.key)} />
))}
</div>
) : (
<Hint>Noch nichts hier</Hint>
)}
</div>
);
}
function AlbumGrid({
albums,
currentAlbumId,
onOpen,
}: {
albums: Album[];
currentAlbumId: string | null;
onOpen: (album: Album, navIndex: number) => void;
}) {
return (
<div className="m-album-grid">
{albums.map((album, index) => {
const podcast = groupOf(album) === "podcasts";
return (
<button
key={album.id}
className="m-album"
data-current={album.id === currentAlbumId}
disabled={album.locked}
onClick={() => onOpen(album, index)}
aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: podcast
? `${album.title} abspielen`
: `${album.title}: Titel wählen`
}
>
<Cover
album={album}
size={120}
radius={isBook(album) ? "4px 12px 12px 4px" : "12px"}
className="m-album-cover"
label
locked={album.locked}
/>
<div className="m-album-title">{album.locked ? "❓ Geheimnis" : album.title}</div>
{!album.locked && (
<div className="m-album-sub">
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
)}
</button>
);
})}
</div>
);
}
function SongRow({ hit, current, onClick }: { hit: SongHit; current: boolean; onClick: () => void }) {
return (
<button className="m-song" data-current={current} onClick={onClick}>
<div className="m-song-cover">
<Cover album={hit.album} size={44} fit="height" radius={isBook(hit.album) ? "3px 8px 8px 3px" : "8px"} />
</div>
<div className="m-song-text">
<div className="m-song-title">{hit.title}</div>
<div className="m-song-sub">{albumLine(hit.album)}</div>
</div>
<div className="m-song-time">{clock(hit.duration)}</div>
</button>
);
}
function Segment({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button className="m-segment" data-active={active} aria-pressed={active} onClick={onClick}>
{children}
</button>
);
}
function Hint({ children }: { children: ReactNode }) {
return (
<div className="m-hint">
<img src="/dolphin-mascot.png" alt="" />
<div>{children}</div>
</div>
);
}
function ChevronLeft() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<path
d="M12.5 4.5 6.5 10l6 5.5"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** Memoised for the same reason as `BrowseView`: `App` re-renders on every position
* frame during playback, and nothing here changes with the position. */
export const MobileBrowse = memo(MobileBrowseImpl);

View File

@@ -0,0 +1,230 @@
/** Everything that floats over the bottom of the phone layout, in glass: the mini
* player, and below it the tab bar with a round search button beside it. Tapping
* search turns that row into the search field - the tabs shrink to one round button
* that closes it again - the way iOS's own apps put search at the bottom, in reach of
* a thumb.
*
* Lifted by `useKeyboardInset` while the keyboard is up, so the field stays above it. */
import { useRef } from "react";
import type { Album, PlayerState } from "../../api/types";
import { useKeyboardInset } from "../../hooks/useKeyboardInset";
import { usePlaybackClock } from "../../hooks/usePlaybackClock";
import type { UiState } from "../../lib/keyboard";
import { nowPlayingText } from "../../lib/covers";
import { PAGE_ICON } from "../../lib/theme";
import { Cover } from "../Cover";
type Page = UiState["page"];
interface Props {
page: Page;
onSelectPage: (page: Page) => void;
showRoom: boolean;
searching: boolean;
typed: string;
onOpenSearch: () => void;
onCloseSearch: () => void;
onSearchChange: (text: string) => void;
state: PlayerState | null;
album: Album | null;
onToggle: () => void;
onNext: () => void;
onOpenPlayView: () => void;
}
/** The phone has no keyboard to type on, so no typing tab. */
const TABS: { page: Page; label: string }[] = [
{ page: "music", label: "Musik" },
{ page: "room", label: "Zimmer" },
];
export function MobileDock({
page,
onSelectPage,
showRoom,
searching,
typed,
onOpenSearch,
onCloseSearch,
onSearchChange,
state,
album,
onToggle,
onNext,
onOpenPlayView,
}: Props) {
const keyboardInset = useKeyboardInset();
const input = useRef<HTMLInputElement | null>(null);
const tabs = TABS.filter((tab) => tab.page !== "room" || showRoom);
const canSearch = page === "music";
return (
<div className="m-dock" style={{ bottom: keyboardInset }} data-keyboard={keyboardInset > 0}>
{state && album && !searching && (
<MiniPlayer state={state} album={album} onToggle={onToggle} onNext={onNext} onOpen={onOpenPlayView} />
)}
<div className="m-dock-row">
{searching ? (
<>
<button className="m-glass m-circle" onClick={onCloseSearch} aria-label="Suche schließen">
<CloseIcon />
</button>
<form
className="m-glass m-search"
role="search"
onSubmit={(event) => {
// Enter only puts the keyboard away - results are already live.
event.preventDefault();
input.current?.blur();
}}
>
<SearchIcon />
<input
ref={input}
autoFocus
type="search"
inputMode="search"
enterKeyHint="search"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
placeholder="Alben, Hörbücher, Titel …"
value={typed}
onChange={(event) => onSearchChange(event.target.value)}
/>
{typed && (
<button
type="button"
className="m-search-clear"
aria-label="Suche leeren"
onClick={() => {
onSearchChange("");
input.current?.focus();
}}
>
<CloseIcon size={12} />
</button>
)}
</form>
</>
) : (
<>
{tabs.length > 1 && (
<nav className="m-glass m-tabs">
{tabs.map((tab) => (
<button
key={tab.page}
className="m-tab"
data-active={tab.page === page}
aria-current={tab.page === page}
onClick={() => onSelectPage(tab.page)}
>
<span className="m-tab-icon" aria-hidden>
{PAGE_ICON[tab.page]}
</span>
<span className="m-tab-label">{tab.label}</span>
</button>
))}
</nav>
)}
{canSearch &&
(tabs.length > 1 ? (
<button className="m-glass m-circle" onClick={onOpenSearch} aria-label="Suchen">
<SearchIcon />
</button>
) : (
<button className="m-glass m-search m-search-closed" onClick={onOpenSearch}>
<SearchIcon />
<span>Suchen</span>
</button>
))}
</>
)}
</div>
</div>
);
}
function MiniPlayer({
state,
album,
onToggle,
onNext,
onOpen,
}: {
state: PlayerState;
album: Album;
onToggle: () => void;
onNext: () => void;
onOpen: () => void;
}) {
const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
const progress = state.duration ? Math.min(1, position / state.duration) : 0;
return (
<div className="m-glass m-mini">
<button className="m-mini-main" onClick={onOpen} aria-label="Große Ansicht">
<div className="m-mini-cover">
<Cover album={album} size={40} fit="height" radius="8px" />
</div>
<div className="m-mini-text">
<div className="m-mini-title">{now.title}</div>
<div className="m-mini-sub">{now.subtitle}</div>
</div>
</button>
<button className="m-mini-button" onClick={onToggle} aria-label={state.playing ? "Pause" : "Abspielen"}>
{state.playing ? <PauseIcon /> : <PlayIcon />}
</button>
<button className="m-mini-button" onClick={onNext} aria-label="Nächster Titel">
<NextIcon />
</button>
<div className="m-mini-progress" style={{ transform: `scaleX(${progress})` }} />
</div>
);
}
function SearchIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<circle cx="8.5" cy="8.5" r="5.75" stroke="currentColor" strokeWidth="2.2" />
<path d="m13 13 4.5 4.5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="m3 3 10 10M13 3 3 13" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
);
}
function PlayIcon() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" aria-hidden>
<path d="M6 3.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L7.5 2.64A1 1 0 0 0 6 3.5Z" fill="currentColor" />
</svg>
);
}
function PauseIcon() {
return (
<svg width="22" height="22" viewBox="0 0 22 22" aria-hidden>
<rect x="4.5" y="3" width="4.5" height="16" rx="1.5" fill="currentColor" />
<rect x="13" y="3" width="4.5" height="16" rx="1.5" fill="currentColor" />
</svg>
);
}
function NextIcon() {
return (
<svg width="24" height="22" viewBox="0 0 24 22" aria-hidden>
<path d="M2 4.2v13.6a.8.8 0 0 0 1.2.7L12 13v4.8a.8.8 0 0 0 1.2.7l9.6-6.8a.8.8 0 0 0 0-1.4L13.2 3.5a.8.8 0 0 0-1.2.7V9L3.2 3.5a.8.8 0 0 0-1.2.7Z" fill="currentColor" />
</svg>
);
}