Rework navigation for a consistent music/room toggle and back button
- Replace the "Mein Zimmer"/"Musik" text pills and the help button with
two fixed circular corner buttons: a music/room toggle (top-right)
and a single "back one level" button (top-left) used everywhere -
search, categories, and the play view.
- Split `view` (browse/play) from a new `page` (music/room) in UiState
so toggling to the room and back leaves the music side - search,
selection, even the full-screen play view - exactly as it was.
- Back peels one step at a time (search, then track-search mode, then
group+category together, since a root shelf tile sets both at once
and undoing that jump should be one step too).
- Album cards: click the cover to open the track list, click the title
to start playing immediately (matching what Enter already did from
the keyboard). Audiobook cards drop the artist line and clamp the
title to a fixed height so covers stay aligned across a row.
- Root shelf group headings ("Musik", "Hörbücher", "Podcasts") are now
the link into that group's full category grid, replacing the
separate search-icon button.
- "Taste zuweisen" is now an icon-only round button, moved to the
bottom-right so it doesn't sit under the new toggle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
147
web/src/App.tsx
147
web/src/App.tsx
@@ -6,6 +6,7 @@
|
|||||||
* the mouse, a figure on the reader, Home Assistant - and this UI has to follow them.
|
* the mouse, a figure on the reader, Home Assistant - and this UI has to follow them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
@@ -27,7 +28,7 @@ import { usePlayerState } from "./hooks/usePlayerState";
|
|||||||
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
||||||
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||||
import type { Action, UiState } from "./lib/keyboard";
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
import { handleKey, initialUiState } from "./lib/keyboard";
|
import { browseBackActions, handleKey, initialUiState, isRootShelf } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
import { playPop } from "./lib/pop";
|
||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
import type { Group, Results, SongHit } from "./lib/search";
|
||||||
@@ -285,11 +286,6 @@ export function App() {
|
|||||||
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
|
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackToRoot = () => {
|
|
||||||
playPop(380);
|
|
||||||
setUi((previous) => ({ ...previous, group: null, category: null, selIndex: 0 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCategory = (key: string | null) => {
|
const onCategory = (key: string | null) => {
|
||||||
if (key) playPop(440);
|
if (key) playPop(440);
|
||||||
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
|
||||||
@@ -307,6 +303,13 @@ export function App() {
|
|||||||
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
|
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Clicking the title text (as opposed to the cover) starts the album right away -
|
||||||
|
* the same thing ENTER does from the keyboard - instead of opening the track list. */
|
||||||
|
const onPlayAlbum = (album: Album, navIndex: number) => {
|
||||||
|
setUi((previous) => ({ ...previous, selIndex: navIndex }));
|
||||||
|
play(album.id, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const onOpenCurrentAlbum = () => {
|
const onOpenCurrentAlbum = () => {
|
||||||
if (!currentAlbum) return;
|
if (!currentAlbum) return;
|
||||||
playPop(420);
|
playPop(420);
|
||||||
@@ -320,6 +323,21 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||||
|
|
||||||
|
// The single top-left "back" button, shared by every level of the music page -
|
||||||
|
// 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));
|
||||||
|
const onBack = () => {
|
||||||
|
if (ui.view === "play") {
|
||||||
|
setUi((previous) => ({ ...previous, view: "browse" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run(browseBackActions(ui));
|
||||||
|
};
|
||||||
|
const onToggleRoom = () =>
|
||||||
|
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage">
|
<div className="stage">
|
||||||
{state && (
|
{state && (
|
||||||
@@ -332,7 +350,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.view === "browse" && state && (
|
{ui.page === "music" && ui.view === "browse" && state && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -342,17 +360,7 @@ export function App() {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppHeader
|
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
|
||||||
status={<ConnectionDot online={connection.online} state={state} />}
|
|
||||||
link={
|
|
||||||
haConfig
|
|
||||||
? {
|
|
||||||
label: "💡 Mein Zimmer",
|
|
||||||
onClick: () => setUi((previous) => ({ ...previous, view: "room" })),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BrowseView
|
<BrowseView
|
||||||
results={results}
|
results={results}
|
||||||
group={ui.group}
|
group={ui.group}
|
||||||
@@ -364,15 +372,15 @@ export function App() {
|
|||||||
currentAlbumId={state.album_id}
|
currentAlbumId={state.album_id}
|
||||||
gridRef={gridRef}
|
gridRef={gridRef}
|
||||||
onEnterGroup={onEnterGroup}
|
onEnterGroup={onEnterGroup}
|
||||||
onBackToRoot={onBackToRoot}
|
|
||||||
onCategory={onCategory}
|
onCategory={onCategory}
|
||||||
onOpenAlbum={onOpenAlbum}
|
onOpenAlbum={onOpenAlbum}
|
||||||
|
onPlayAlbum={onPlayAlbum}
|
||||||
onPlaySong={onPlaySong}
|
onPlaySong={onPlaySong}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.view === "play" && state && (
|
{ui.page === "music" && ui.view === "play" && state && (
|
||||||
<PlayView
|
<PlayView
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
@@ -382,7 +390,6 @@ export function App() {
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onVolume={setVolume}
|
onVolume={setVolume}
|
||||||
onMute={onMute}
|
onMute={onMute}
|
||||||
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
|
||||||
onOpenAlbum={onOpenCurrentAlbum}
|
onOpenAlbum={onOpenCurrentAlbum}
|
||||||
onOpenAssign={() => setAssignPopupOpen(true)}
|
onOpenAssign={() => setAssignPopupOpen(true)}
|
||||||
assignPending={ui.assignPending}
|
assignPending={ui.assignPending}
|
||||||
@@ -421,12 +428,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.view === "room" && haConfig && (
|
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
||||||
<RoomView
|
|
||||||
config={haConfig}
|
|
||||||
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{openAlbum && state && (
|
{openAlbum && state && (
|
||||||
<AlbumModal
|
<AlbumModal
|
||||||
@@ -438,34 +440,35 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{canGoBack && (
|
||||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }))}
|
<CornerButton side="left" onClick={onBack} label="Zurück">
|
||||||
title="Zaubertasten (F1)"
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
style={{
|
<path
|
||||||
position: "absolute",
|
d="M12.5 4.5 6.5 10l6 5.5"
|
||||||
top: 18,
|
stroke="currentColor"
|
||||||
right: 24,
|
strokeWidth="2.4"
|
||||||
zIndex: 3,
|
strokeLinecap="round"
|
||||||
width: 44,
|
strokeLinejoin="round"
|
||||||
height: 44,
|
/>
|
||||||
background: "oklch(97% 0.01 210 / .95)",
|
</svg>
|
||||||
border: "none",
|
</CornerButton>
|
||||||
borderRadius: 999,
|
)}
|
||||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
|
||||||
fontSize: 20,
|
{haConfig && (
|
||||||
fontWeight: 900,
|
<CornerButton
|
||||||
color: "var(--ink)",
|
side="right"
|
||||||
cursor: "pointer",
|
onClick={onToggleRoom}
|
||||||
}}
|
label={ui.page === "room" ? "Musik" : "Mein Zimmer"}
|
||||||
>
|
>
|
||||||
?
|
{ui.page === "room" ? "🎵" : "💡"}
|
||||||
</button>
|
</CornerButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{ui.showHelp && (
|
{ui.showHelp && (
|
||||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.view === "browse" && state && (
|
{ui.page === "music" && ui.view === "browse" && state && (
|
||||||
<PlayerBar
|
<PlayerBar
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
@@ -497,6 +500,50 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one fixed round button in each top corner: back (left) and the music/room
|
||||||
|
* toggle (right). Same position and shape everywhere they appear, so "top left" and
|
||||||
|
* "top right" always mean the same thing. */
|
||||||
|
function CornerButton({
|
||||||
|
side,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
side: "left" | "right";
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 18,
|
||||||
|
[side]: 24,
|
||||||
|
zIndex: 3,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "oklch(97% 0.01 210 / .95)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--ink)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | 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 = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
|
|||||||
@@ -5,17 +5,9 @@ interface Props {
|
|||||||
mascot?: string;
|
mascot?: string;
|
||||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
/** A pill linking to the other page - there's no router, so it's a click handler
|
|
||||||
* rather than an `href`. */
|
|
||||||
link?: { label: string; onClick: () => void };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({ title = "Musik Delphin", mascot = "/dolphin-mascot.png", status }: Props) {
|
||||||
title = "Musik Delphin",
|
|
||||||
mascot = "/dolphin-mascot.png",
|
|
||||||
status,
|
|
||||||
link,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -29,26 +21,6 @@ export function AppHeader({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{link && (
|
|
||||||
<button
|
|
||||||
onClick={link.onClick}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 24,
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "oklch(97% 0.01 230 / .95)",
|
|
||||||
color: "oklch(30% 0.03 230)",
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 14,
|
|
||||||
padding: "9px 16px",
|
|
||||||
borderRadius: 999,
|
|
||||||
boxShadow: "0 6px 18px oklch(15% 0.04 230 / .4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ interface Props {
|
|||||||
currentAlbumId: string | null;
|
currentAlbumId: string | null;
|
||||||
gridRef: (element: HTMLElement | null) => void;
|
gridRef: (element: HTMLElement | null) => void;
|
||||||
onEnterGroup: (group: Group, category: string | null) => void;
|
onEnterGroup: (group: Group, category: string | null) => void;
|
||||||
onBackToRoot: () => void;
|
|
||||||
onCategory: (key: string | null) => void;
|
onCategory: (key: string | null) => void;
|
||||||
|
/** Cover click: open the track list (or, for a podcast episode, play it - there's
|
||||||
|
* nothing to pick between). */
|
||||||
onOpenAlbum: (album: Album, navIndex: number) => void;
|
onOpenAlbum: (album: Album, navIndex: number) => void;
|
||||||
|
/** Title click: start the album from the top right away. */
|
||||||
|
onPlayAlbum: (album: Album, navIndex: number) => void;
|
||||||
onPlaySong: (hit: SongHit) => void;
|
onPlaySong: (hit: SongHit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +74,9 @@ export function BrowseView({
|
|||||||
currentAlbumId,
|
currentAlbumId,
|
||||||
gridRef,
|
gridRef,
|
||||||
onEnterGroup,
|
onEnterGroup,
|
||||||
onBackToRoot,
|
|
||||||
onCategory,
|
onCategory,
|
||||||
onOpenAlbum,
|
onOpenAlbum,
|
||||||
|
onPlayAlbum,
|
||||||
onPlaySong,
|
onPlaySong,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const scroller = useRef<HTMLDivElement | null>(null);
|
const scroller = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -116,34 +119,26 @@ export function BrowseView({
|
|||||||
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
|
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
|
||||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
|
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
|
||||||
<div key={shelfGroup} style={{ marginBottom: 34 }}>
|
<div key={shelfGroup} style={{ marginBottom: 34 }}>
|
||||||
<div
|
<button
|
||||||
|
onClick={() => onEnterGroup(shelfGroup, null)}
|
||||||
|
aria-label={`Alle ${GROUP_TITLE[shelfGroup]} durchsuchen`}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-start",
|
gap: 6,
|
||||||
gap: 12,
|
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--paper)" }}>
|
{GROUP_TITLE[shelfGroup]}
|
||||||
{GROUP_TITLE[shelfGroup]}
|
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
|
||||||
</div>
|
</button>
|
||||||
<button
|
|
||||||
className="round"
|
|
||||||
onClick={() => onEnterGroup(shelfGroup, null)}
|
|
||||||
aria-label={`${GROUP_TITLE[shelfGroup]} durchsuchen`}
|
|
||||||
title="Nur hier suchen"
|
|
||||||
style={{
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
fontSize: 15,
|
|
||||||
background: "oklch(97% 0.01 210 / .18)",
|
|
||||||
color: "var(--paper)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔎
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{shelfCategories.length === 0 ? (
|
{shelfCategories.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -185,20 +180,15 @@ export function BrowseView({
|
|||||||
{group !== null && (
|
{group !== null && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 10,
|
|
||||||
padding: "4px 32px 6px",
|
padding: "4px 32px 6px",
|
||||||
flex: "none",
|
flex: "none",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="pill" onClick={onBackToRoot}>
|
{GROUP_TITLE[group]}
|
||||||
← Start
|
|
||||||
</button>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 900, color: "var(--paper)" }}>
|
|
||||||
{GROUP_TITLE[group]}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -352,23 +342,14 @@ export function BrowseView({
|
|||||||
{category !== null && !search && (
|
{category !== null && !search && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
textAlign: "center",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{category}
|
||||||
className="pill"
|
|
||||||
data-active="true"
|
|
||||||
onClick={() => onCategory(null)}
|
|
||||||
>
|
|
||||||
← Alle
|
|
||||||
</button>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 900, color: "var(--paper)" }}>
|
|
||||||
{category}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{search.length > 0 && group !== null && (
|
{search.length > 0 && group !== null && (
|
||||||
@@ -378,23 +359,51 @@ export function BrowseView({
|
|||||||
{albumResults.map((album, index) => {
|
{albumResults.map((album, index) => {
|
||||||
const navIndex = songs.length + categories.length + index;
|
const navIndex = songs.length + categories.length + index;
|
||||||
const podcast = groupOf(album) === "podcasts";
|
const podcast = groupOf(album) === "podcasts";
|
||||||
|
// An audiobook's artist is almost always the same as the character/
|
||||||
|
// series it's already grouped under, so dropping it gives the title
|
||||||
|
// more room. A podcast's artist is the show name, which does carry
|
||||||
|
// information, so it stays.
|
||||||
|
const showArtist = groupOf(album) !== "audiobooks";
|
||||||
|
// Fixed regardless of how many lines the title actually needs, so
|
||||||
|
// every card - and thus every row of covers - is the same height.
|
||||||
|
const titleLines = showArtist ? 2 : 3;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={album.id}
|
key={album.id}
|
||||||
className="card"
|
className="card"
|
||||||
data-nav-index={navIndex}
|
data-nav-index={navIndex}
|
||||||
data-selected={selected === navIndex}
|
data-selected={selected === navIndex}
|
||||||
data-current={album.id === currentAlbumId}
|
data-current={album.id === currentAlbumId}
|
||||||
onClick={() => onOpenAlbum(album, navIndex)}
|
|
||||||
style={{
|
style={{
|
||||||
background: cardBackground(album),
|
background: cardBackground(album),
|
||||||
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||||
boxShadow: cardShadow(album),
|
boxShadow: cardShadow(album),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Cover album={album} size={180} radius="0" label />
|
<button
|
||||||
<div
|
onClick={() => onOpenAlbum(album, navIndex)}
|
||||||
|
aria-label={podcast ? `${album.title} abspielen` : `${album.title}: Titel wählen`}
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cover album={album} size={180} radius="0" label />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPlayAlbum(album, navIndex)}
|
||||||
|
aria-label={`${album.title} abspielen`}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
font: "inherit",
|
||||||
|
background: "none",
|
||||||
padding: "10px 12px 14px",
|
padding: "10px 12px 14px",
|
||||||
paddingRight: isBook(album) ? 22 : 12,
|
paddingRight: isBook(album) ? 22 : 12,
|
||||||
}}
|
}}
|
||||||
@@ -405,20 +414,30 @@ export function BrowseView({
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 210)",
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
height: `${titleLines * 1.2}em`,
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: titleLines,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{album.title}
|
{album.title}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{showArtist && (
|
||||||
style={{
|
<div
|
||||||
fontSize: 13,
|
style={{
|
||||||
fontWeight: 700,
|
fontSize: 13,
|
||||||
color: "oklch(30% 0.03 210)",
|
fontWeight: 700,
|
||||||
marginTop: 2,
|
color: "oklch(30% 0.03 210)",
|
||||||
}}
|
marginTop: 2,
|
||||||
>
|
whiteSpace: "nowrap",
|
||||||
{album.artist}
|
overflow: "hidden",
|
||||||
</div>
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.artist}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -430,8 +449,8 @@ export function BrowseView({
|
|||||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||||
{album.figure && " · 🧸"}
|
{album.figure && " · 🧸"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface Props {
|
|||||||
onSeek: (position: number) => void;
|
onSeek: (position: number) => void;
|
||||||
onVolume: (percent: number) => void;
|
onVolume: (percent: number) => void;
|
||||||
onMute: () => void;
|
onMute: () => void;
|
||||||
onBrowse: () => void;
|
|
||||||
onOpenAlbum: () => void;
|
onOpenAlbum: () => void;
|
||||||
/** Assigning the current album/show to a remote key. `assignPending` is true right
|
/** Assigning the current album/show to a remote key. `assignPending` is true right
|
||||||
* after "A" is pressed, waiting for the digit that completes the shortcut. */
|
* after "A" is pressed, waiting for the digit that completes the shortcut. */
|
||||||
@@ -36,7 +35,6 @@ export function PlayView({
|
|||||||
onSeek,
|
onSeek,
|
||||||
onVolume,
|
onVolume,
|
||||||
onMute,
|
onMute,
|
||||||
onBrowse,
|
|
||||||
onOpenAlbum,
|
onOpenAlbum,
|
||||||
onOpenAssign,
|
onOpenAssign,
|
||||||
assignPending,
|
assignPending,
|
||||||
@@ -218,63 +216,30 @@ export function PlayView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onBrowse}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 28,
|
|
||||||
left: 32,
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "oklch(97% 0.01 210 / .95)",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "11px 20px",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "var(--ink)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
font: "800 13px ui-monospace, monospace",
|
|
||||||
background: "var(--ink)",
|
|
||||||
color: "#fff",
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
Zurück zur Suche
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{album && (
|
{album && (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAssign}
|
onClick={onOpenAssign}
|
||||||
|
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
|
||||||
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
|
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 28,
|
bottom: 28,
|
||||||
right: 84,
|
right: 32,
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
width: 52,
|
||||||
|
height: 52,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
padding: "11px 20px",
|
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: assignPending ? "#fff" : "var(--ink)",
|
color: assignPending ? "#fff" : "var(--ink)",
|
||||||
|
fontSize: 22,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
justifyContent: "center",
|
||||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🎛️ {assignPending ? "Zahl drücken …" : "Taste zuweisen"}
|
🎛️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { LightCard } from "./LightCard";
|
|||||||
import { SceneRow } from "./SceneRow";
|
import { SceneRow } from "./SceneRow";
|
||||||
import { ShutterCard } from "./ShutterCard";
|
import { ShutterCard } from "./ShutterCard";
|
||||||
|
|
||||||
export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () => void }) {
|
export function RoomView({ config }: { config: HaConfig }) {
|
||||||
const ha = useHomeAssistant(config);
|
const ha = useHomeAssistant(config);
|
||||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||||
// Cleared by any manual device change, set by activating a scene.
|
// Cleared by any manual device change, set by activating a scene.
|
||||||
@@ -38,11 +38,7 @@ export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: ()
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppHeader
|
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" />
|
||||||
title="Mein Zimmer"
|
|
||||||
mascot="/dolphin-remote.png"
|
|
||||||
link={{ label: "♪ Musik", onClick: onBrowse }}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 64px" }}>
|
<div style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 64px" }}>
|
||||||
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
||||||
{config.scenes.length > 0 && (
|
{config.scenes.length > 0 && (
|
||||||
|
|||||||
@@ -194,20 +194,30 @@ describe("keyboard", () => {
|
|||||||
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
|
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// group and category clear together: a root shelf tile sets both in one click
|
||||||
|
// (jumping straight to one category's albums), so undoing that is one step too,
|
||||||
|
// whether or not a category was chosen.
|
||||||
const inCategory = { ...inTracks, mode: "albums" as const };
|
const inCategory = { ...inTracks, mode: "albums" as const };
|
||||||
expect(press("Escape", inCategory)).toEqual([
|
expect(press("Escape", inCategory)).toEqual([
|
||||||
{ type: "ui", patch: { category: null, selIndex: 0 } },
|
{ type: "ui", patch: { group: null, category: null, selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const inGroup = { ...inCategory, category: null };
|
const inGroup = { ...inCategory, category: null };
|
||||||
expect(press("Escape", inGroup)).toEqual([
|
expect(press("Escape", inGroup)).toEqual([
|
||||||
{ type: "ui", patch: { group: null, selIndex: 0 } },
|
{ type: "ui", patch: { group: null, category: null, selIndex: 0 } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("escapes the room view back to browse, like the play view", () => {
|
it("escapes the room page back to music without touching what was on screen there", () => {
|
||||||
const inRoom: UiState = { ...initialUiState, view: "room" };
|
const inRoom: UiState = { ...initialUiState, page: "room", view: "play", search: "x" };
|
||||||
expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { view: "browse" } }]);
|
expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { page: "music" } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores browse keys while on the room page, but space still toggles playback", () => {
|
||||||
|
const inRoom: UiState = { ...initialUiState, page: "room" };
|
||||||
|
expect(press("k", inRoom)).toEqual([]);
|
||||||
|
expect(press("ArrowRight", inRoom)).toEqual([]);
|
||||||
|
expect(press(" ", inRoom)).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not swallow backspace when there is nothing to delete", () => {
|
it("does not swallow backspace when there is nothing to delete", () => {
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export interface UiState {
|
|||||||
group: Group | null;
|
group: Group | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
selIndex: number;
|
selIndex: number;
|
||||||
view: "browse" | "play" | "room";
|
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
||||||
|
* so toggling to the room and back leaves the music side exactly as it was. */
|
||||||
|
page: "music" | "room";
|
||||||
|
view: "browse" | "play";
|
||||||
openAlbumId: string | null;
|
openAlbumId: string | null;
|
||||||
showHelp: boolean;
|
showHelp: boolean;
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -34,6 +37,7 @@ export const initialUiState: UiState = {
|
|||||||
group: null,
|
group: null,
|
||||||
category: null,
|
category: null,
|
||||||
selIndex: 0,
|
selIndex: 0,
|
||||||
|
page: "music",
|
||||||
view: "browse",
|
view: "browse",
|
||||||
openAlbumId: null,
|
openAlbumId: null,
|
||||||
showHelp: false,
|
showHelp: false,
|
||||||
@@ -100,6 +104,18 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
|
|||||||
return [{ type: "ui", patch: { selIndex: index } }];
|
return [{ type: "ui", patch: { selIndex: index } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One step of "back" within the browse hierarchy - search, then track-search mode,
|
||||||
|
* then group/category together - the same peeling order ESC and the top-left back
|
||||||
|
* button both use. `group` and `category` clear as one step because a root shelf tile
|
||||||
|
* sets both at once (jumping straight to one category's albums); undoing that jump
|
||||||
|
* should be one step too, not two, regardless of whether a category was reached that
|
||||||
|
* way or by opening the group first. Always safe to call. */
|
||||||
|
export function browseBackActions(state: UiState): Action[] {
|
||||||
|
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
||||||
|
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
||||||
|
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
|
||||||
|
}
|
||||||
|
|
||||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||||
function escape(state: UiState): Action[] {
|
function escape(state: UiState): Action[] {
|
||||||
if (state.assignPending) {
|
if (state.assignPending) {
|
||||||
@@ -108,17 +124,17 @@ function escape(state: UiState): Action[] {
|
|||||||
if (state.showHelp || state.openAlbumId !== null) {
|
if (state.showHelp || state.openAlbumId !== null) {
|
||||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||||
}
|
}
|
||||||
if (state.view === "play" || state.view === "room") {
|
if (state.page === "room") {
|
||||||
|
return [{ type: "ui", patch: { page: "music" } }];
|
||||||
|
}
|
||||||
|
if (state.view === "play") {
|
||||||
return [{ type: "ui", patch: { view: "browse" } }];
|
return [{ type: "ui", patch: { view: "browse" } }];
|
||||||
}
|
}
|
||||||
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
|
return browseBackActions(state);
|
||||||
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
|
|
||||||
if (state.category !== null) return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
|
|
||||||
return [{ type: "ui", patch: { group: null, selIndex: 0 } }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
||||||
function isRootShelf(state: UiState): boolean {
|
export function isRootShelf(state: UiState): boolean {
|
||||||
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +173,13 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac
|
|||||||
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }];
|
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The room page has no browse hierarchy of its own - only ESC (handled below) and
|
||||||
|
// transport controls make sense there. Everything else would otherwise mutate the
|
||||||
|
// hidden music state without anything on screen to show for it.
|
||||||
|
if (state.page === "room" && key !== "Escape") {
|
||||||
|
return key === " " ? [{ type: "pop", freq: 340 }, { type: "toggle" }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
|||||||
Reference in New Issue
Block a user