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

@@ -16,6 +16,8 @@ interface Props {
selectedIndex: number;
onClose: () => void;
onPlay: (trackIndex: number) => void;
/** A bottom sheet with a smaller header, and no keyboard highlight to show. */
phone?: boolean;
}
export function AlbumModal({
@@ -25,7 +27,9 @@ export function AlbumModal({
selectedIndex,
onClose,
onPlay,
phone = false,
}: Props) {
const coverSize = phone ? 108 : 150;
const book = isBook(album);
const sheet = useRef<HTMLDivElement | null>(null);
@@ -47,7 +51,11 @@ export function AlbumModal({
return (
<div
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }}
style={{
zIndex: 4,
background: "oklch(15% 0.03 210 / .6)",
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
}}
onClick={onClose}
>
<div
@@ -56,20 +64,20 @@ export function AlbumModal({
onClick={(event) => event.stopPropagation()}
style={{
background: "oklch(96% 0.012 210)",
borderRadius: 26,
padding: 26,
width: 640,
borderRadius: phone ? "26px 26px 0 0" : 26,
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
width: phone ? "100%" : 640,
maxWidth: "100%",
maxHeight: "100%",
overflow: "auto",
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
}}
>
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
<div style={{ width: 150, flex: "none" }}>
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
<div style={{ width: coverSize, flex: "none" }}>
<Cover
album={album}
size={150}
size={coverSize}
radius={book ? "10px 18px 18px 10px" : "16px"}
label
locked={album.locked}
@@ -78,7 +86,7 @@ export function AlbumModal({
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 26,
fontSize: phone ? 20 : 26,
fontWeight: 900,
color: "oklch(22% 0.03 210)",
lineHeight: 1.15,
@@ -117,9 +125,9 @@ export function AlbumModal({
cursor: album.locked ? "default" : "pointer",
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
fontSize: 16,
fontSize: phone ? 15 : 16,
fontWeight: 800,
padding: "12px 22px",
padding: phone ? "10px 18px" : "12px 22px",
borderRadius: 999,
display: "flex",
alignItems: "center",
@@ -139,7 +147,7 @@ export function AlbumModal({
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
}}
/>
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
{phone ? "Abspielen" : book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
</>
)}
</button>
@@ -187,7 +195,7 @@ export function AlbumModal({
background: current
? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)",
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
outlineOffset: 2,
}}
>

View File

@@ -5,26 +5,36 @@ interface Props {
mascot?: string;
/** Shown next to the title when the mouse is reachable but the firmware is not. */
status?: ReactNode;
/** The phone size: a smaller mascot and title. */
compact?: boolean;
}
export function AppHeader({ title = "Musik Delfin", mascot = "/dolphin-mascot.png", status }: Props) {
export function AppHeader({
title = "Musik Delfin",
mascot = "/dolphin-mascot.png",
status,
compact = false,
}: Props) {
const mascotSize = compact ? 44 : 64;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 16,
gap: compact ? 10 : 16,
// The status bar is translucent in standalone mode, so make room for it.
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
padding: compact
? "calc(12px + env(safe-area-inset-top)) 16px 4px"
: "calc(18px + env(safe-area-inset-top)) 32px 6px",
flex: "none",
position: "relative",
}}
>
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
<img src={mascot} alt="" style={{ width: mascotSize, height: mascotSize, objectFit: "contain" }} />
<div
style={{
fontSize: 34,
fontSize: compact ? 28 : 34,
fontWeight: 900,
color: "var(--paper)",
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",

View File

@@ -727,7 +727,7 @@ function ShelfRow({
/** One category's 2x2 cover preview + name + count. Used both by the root shelves
* (pointer-only) and by the in-group category grid (keyboard-navigable, hence the
* optional `navIndex`/`selected`). */
function CategoryTile({
export function CategoryTile({
entry,
navIndex,
selected,

View File

@@ -14,7 +14,9 @@ import { LightCard } from "./LightCard";
import { SceneRow } from "./SceneRow";
import { ShutterCard } from "./ShutterCard";
export function RoomView({ config }: { config: HaConfig }) {
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
* bottom (see `MobileDock`) instead. */
export function RoomView({ config, phone = false }: { config: HaConfig; phone?: boolean }) {
const ha = useHomeAssistant(config);
// Purely local: Home Assistant scenes have no "currently active" state of their own.
// Cleared by any manual device change, set by activating a scene.
@@ -39,11 +41,18 @@ export function RoomView({ config }: { config: HaConfig }) {
flexDirection: "column",
}}
>
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" />
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
<div
// Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 64px 32px` }}
style={{
flex: 1,
overflow: "auto",
minHeight: 0,
padding: phone
? "8px 16px calc(var(--m-dock-clearance) + 24px)"
: `14px ${TAB_RAIL_CLEARANCE}px 64px 32px`,
}}
>
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
{config.scenes.length > 0 && (

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>
);
}