diff --git a/web/src/App.tsx b/web/src/App.tsx index 94904de..2390a0b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ * 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 { api } from "./api/client"; @@ -27,7 +28,7 @@ import { usePlayerState } from "./hooks/usePlayerState"; import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables"; import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables"; 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 { targetForAlbum } from "./lib/remote"; import type { Group, Results, SongHit } from "./lib/search"; @@ -285,11 +286,6 @@ export function App() { 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) => { if (key) playPop(440); setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); @@ -307,6 +303,13 @@ export function App() { 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 = () => { if (!currentAlbum) return; playPop(420); @@ -320,6 +323,21 @@ export function App() { }; 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 (
{state && ( @@ -332,7 +350,7 @@ export function App() { /> )} - {ui.view === "browse" && state && ( + {ui.page === "music" && ui.view === "browse" && state && (
- } - link={ - haConfig - ? { - label: "💡 Mein Zimmer", - onClick: () => setUi((previous) => ({ ...previous, view: "room" })), - } - : undefined - } - /> + } />
)} - {ui.view === "play" && state && ( + {ui.page === "music" && ui.view === "play" && state && ( setUi((previous) => ({ ...previous, view: "browse" }))} onOpenAlbum={onOpenCurrentAlbum} onOpenAssign={() => setAssignPopupOpen(true)} assignPending={ui.assignPending} @@ -421,12 +428,7 @@ export function App() {
)} - {ui.view === "room" && haConfig && ( - setUi((previous) => ({ ...previous, view: "browse" }))} - /> - )} + {ui.page === "room" && haConfig && } {openAlbum && state && ( )} - + {canGoBack && ( + + + + + + )} + + {haConfig && ( + + {ui.page === "room" ? "🎵" : "💡"} + + )} {ui.showHelp && ( setUi((previous) => ({ ...previous, showHelp: false }))} /> )} - {ui.view === "browse" && state && ( + {ui.page === "music" && ui.view === "browse" && state && ( void; + label: string; + children: ReactNode; +}) { + return ( + + ); +} + function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) { const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null; if (!label) return null; diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx index 920aaa6..76e4a93 100644 --- a/web/src/components/AppHeader.tsx +++ b/web/src/components/AppHeader.tsx @@ -5,17 +5,9 @@ interface Props { mascot?: string; /** Shown next to the title when the mouse is reachable but the firmware is not. */ 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({ - title = "Musik Delphin", - mascot = "/dolphin-mascot.png", - status, - link, -}: Props) { +export function AppHeader({ title = "Musik Delphin", mascot = "/dolphin-mascot.png", status }: Props) { return (
- {link && ( - - )}
void; onEnterGroup: (group: Group, category: string | null) => void; - onBackToRoot: () => 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; + /** Title click: start the album from the top right away. */ + onPlayAlbum: (album: Album, navIndex: number) => void; onPlaySong: (hit: SongHit) => void; } @@ -71,9 +74,9 @@ export function BrowseView({ currentAlbumId, gridRef, onEnterGroup, - onBackToRoot, onCategory, onOpenAlbum, + onPlayAlbum, onPlaySong, }: Props) { const scroller = useRef(null); @@ -116,34 +119,26 @@ export function BrowseView({
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
-
onEnterGroup(shelfGroup, null)} + aria-label={`Alle ${GROUP_TITLE[shelfGroup]} durchsuchen`} style={{ display: "flex", alignItems: "center", - justifyContent: "flex-start", - gap: 12, + gap: 6, marginBottom: 12, + border: "none", + background: "none", + padding: 0, + cursor: "pointer", + fontSize: 20, + fontWeight: 900, + color: "var(--paper)", }} > -
- {GROUP_TITLE[shelfGroup]} -
- -
+ {GROUP_TITLE[shelfGroup]} + + {shelfCategories.length === 0 ? (
- -
- {GROUP_TITLE[group]} -
+ {GROUP_TITLE[group]}
)} @@ -352,23 +342,14 @@ export function BrowseView({ {category !== null && !search && (
- -
- {category} -
+ {category}
)} {search.length > 0 && group !== null && ( @@ -378,23 +359,51 @@ export function BrowseView({ {albumResults.map((album, index) => { const navIndex = songs.length + categories.length + index; 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 ( - +
-
- {album.artist} -
+ {showArtist && ( +
+ {album.artist} +
+ )}
-
- + +
); })}
diff --git a/web/src/components/PlayView.tsx b/web/src/components/PlayView.tsx index 2e53e12..8d9ffe9 100644 --- a/web/src/components/PlayView.tsx +++ b/web/src/components/PlayView.tsx @@ -19,7 +19,6 @@ interface Props { onSeek: (position: number) => void; onVolume: (percent: number) => void; onMute: () => void; - onBrowse: () => void; onOpenAlbum: () => void; /** 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. */ @@ -36,7 +35,6 @@ export function PlayView({ onSeek, onVolume, onMute, - onBrowse, onOpenAlbum, onOpenAssign, assignPending, @@ -218,63 +216,30 @@ export function PlayView({ />
- - {album && ( )} diff --git a/web/src/components/RoomView.tsx b/web/src/components/RoomView.tsx index 2872c61..35023f7 100644 --- a/web/src/components/RoomView.tsx +++ b/web/src/components/RoomView.tsx @@ -13,7 +13,7 @@ import { LightCard } from "./LightCard"; import { SceneRow } from "./SceneRow"; import { ShutterCard } from "./ShutterCard"; -export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () => void }) { +export function RoomView({ config }: { config: HaConfig }) { 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. @@ -38,11 +38,7 @@ export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () flexDirection: "column", }} > - +
{config.scenes.length > 0 && ( diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index 33ebdab..ab8e0e7 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -194,20 +194,30 @@ describe("keyboard", () => { { 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 }; 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 }; 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", () => { - const inRoom: UiState = { ...initialUiState, view: "room" }; - expect(press("Escape", inRoom)).toEqual([{ type: "ui", patch: { view: "browse" } }]); + it("escapes the room page back to music without touching what was on screen there", () => { + const inRoom: UiState = { ...initialUiState, page: "room", view: "play", search: "x" }; + 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", () => { diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 4716f49..2859a3d 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -19,7 +19,10 @@ export interface UiState { group: Group | null; category: string | null; 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; showHelp: boolean; cols: number; @@ -34,6 +37,7 @@ export const initialUiState: UiState = { group: null, category: null, selIndex: 0, + page: "music", view: "browse", openAlbumId: null, showHelp: false, @@ -100,6 +104,18 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number) 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. */ function escape(state: UiState): Action[] { if (state.assignPending) { @@ -108,17 +124,17 @@ function escape(state: UiState): Action[] { if (state.showHelp || state.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" } }]; } - if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }]; - 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 } }]; + return browseBackActions(state); } /** 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; } @@ -157,6 +173,13 @@ export function handleKey(event: KeyEvent, state: UiState, results: Results): Ac 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; switch (key) {