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

@@ -21,6 +21,8 @@ import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
import { HelpOverlay } from "./components/HelpOverlay";
import { MobileBrowse } from "./components/mobile/MobileBrowse";
import { MobileDock } from "./components/mobile/MobileDock";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView";
@@ -28,6 +30,7 @@ import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { TabRail } from "./components/TabRail";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
import { usePhoneLayout } from "./hooks/usePhoneLayout";
import { usePlayerState } from "./hooks/usePlayerState";
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
@@ -35,7 +38,7 @@ import type { Action, UiState } from "./lib/keyboard";
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search";
import type { Group, Mode, Results, SongHit } 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";
@@ -64,6 +67,9 @@ export function App() {
const library = useLibrary();
const connection = usePlayerState(library.reload);
const [gridRef, columns] = useGridColumns();
// A phone gets its own touch layout (components/mobile/); everything else - the
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
const phone = usePhoneLayout();
const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1",
);
@@ -413,6 +419,7 @@ export function App() {
};
const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
const onMode = useEvent((mode: Mode) => setUi((previous) => ({ ...previous, mode, selIndex: 0 })));
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
@@ -420,7 +427,9 @@ export function App() {
// search, categories, play view - so it always means "one step out" no matter what
// is on screen. Nothing to back out of on the room page (it has no sub-levels), and
// the toggle button is what moves between the two pages.
const canGoBack = ui.page === "music" && (ui.view === "play" || !isRootShelf(ui));
// The phone layout has its own back button in the browse screen's nav bar.
const canGoBack =
ui.page === "music" && (ui.view === "play" || (!phone && !isRootShelf(ui)));
const onBack = () => {
if (ui.view === "play") {
setUi((previous) => ({ ...previous, view: "browse" }));
@@ -430,6 +439,9 @@ export function App() {
};
const onSelectPage = (page: UiState["page"]) =>
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
// Tabs, unlike the rail's toggles: tapping the open one again leaves it open.
const onSelectPhonePage = (page: UiState["page"]) =>
setUi((previous) => ({ ...previous, page, search: "", searchOpen: false }));
return (
<div
@@ -447,7 +459,28 @@ export function App() {
/>
)}
{ui.page === "music" && ui.view === "browse" && state && (
{phone && ui.page === "music" && ui.view === "browse" && state && (
<div className="m-page">
<MobileBrowse
index={index}
group={ui.group}
category={ui.category}
searching={ui.searchOpen}
search={deferredSearch}
typed={ui.search}
mode={ui.mode}
currentAlbumId={state.album_id}
statusLabel={connectionLabel(connection.online, state)}
offline={!connection.online}
onEnterGroup={onEnterGroup}
onMode={onMode}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
/>
</div>
)}
{!phone && ui.page === "music" && ui.view === "browse" && state && (
<div
style={{
position: "relative",
@@ -531,7 +564,7 @@ export function App() {
)}
<Suspense fallback={null}>
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
{ui.page === "room" && haConfig && <RoomView config={haConfig} phone={phone} />}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
@@ -546,6 +579,7 @@ export function App() {
selectedIndex={ui.modalTrackIndex}
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
phone={phone}
/>
)}
@@ -563,13 +597,33 @@ export function App() {
</CornerButton>
)}
<TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />
{!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />}
{phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && (
<MobileDock
page={ui.page}
onSelectPage={onSelectPhonePage}
showRoom={haConfig != null}
searching={ui.page === "music" && ui.searchOpen}
typed={ui.search}
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
onCloseSearch={() =>
setUi((previous) => ({ ...previous, search: "", searchOpen: false, mode: "albums" }))
}
onSearchChange={(text) => setUi((previous) => ({ ...previous, search: text, selIndex: 0 }))}
state={state}
album={currentAlbum}
onToggle={toggle}
onNext={onNext}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/>
)}
{ui.showHelp && (
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
)}
{ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
{!phone && ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
<PlayerBar
state={state}
album={currentAlbum}
@@ -669,8 +723,12 @@ function CornerButton({
);
}
function connectionLabel(online: boolean, state: { active_figure: string | null }): string | null {
return !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
}
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
const label = connectionLabel(online, state);
if (!label) return null;
return (
<div