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:
278
web/src/components/mobile/MobileBrowse.tsx
Normal file
278
web/src/components/mobile/MobileBrowse.tsx
Normal 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);
|
||||
230
web/src/components/mobile/MobileDock.tsx
Normal file
230
web/src/components/mobile/MobileDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user