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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user