/** The keyboard state machine, ported from the design mockup. Kept pure - it takes a key and the current view state and returns what should happen - so the whole interaction model can be tested without a DOM, and so the component that mounts it stays a thin adapter. The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain arrows were already taken (selection while browsing, transport and volume while playing), and seeking is the one thing a real player can do that the mockup could not. */ import type { Album } from "../api/types"; import { groupOf } from "./search"; import type { Group, Mode, Results } from "./search"; export interface UiState { search: string; /** `/` was pressed: show the search box before the first letter arrives. */ searchOpen: boolean; mode: Mode; /** `null` is the bare root screen (three shelves); otherwise which one is open. */ group: Group | null; category: string | null; selIndex: number; /** Which of the three root shelves (music/audiobooks/podcasts) is focused. Only * meaningful at the bare root screen - `selIndex` doubles up there as "which tile in * that row", the way it means "which flat position" everywhere else. */ shelfRow: number; /** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below, * so switching pages and back leaves the music side exactly as it was. */ page: "music" | "room" | "typing"; view: "browse" | "play"; openAlbumId: string | null; /** Which track is highlighted in the open album's track list. Only meaningful while * `openAlbumId` is set; reset to a sensible value each time a modal opens. */ modalTrackIndex: number; showHelp: boolean; cols: number; /** Armed by `A` on the play screen: the next digit assigns what's playing to that * remote key instead of doing whatever it would normally do. */ assignPending: boolean; } export const initialUiState: UiState = { search: "", searchOpen: false, mode: "albums", group: null, category: null, selIndex: 0, shelfRow: 0, page: "music", view: "browse", openAlbumId: null, modalTrackIndex: 0, showHelp: false, cols: 4, assignPending: false, }; export type Action = | { type: "ui"; patch: Partial } | { type: "play"; albumId: string; trackIndex: number } | { type: "toggle" } | { type: "next" } | { type: "previous" } | { type: "volume"; delta: number } | { type: "seek"; delta: number } | { type: "pop"; freq: number } | { type: "assign"; digit: string } | { type: "mute" }; /** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */ const SEARCHABLE = /^[\p{L}\p{N}]$/u; /** The default behaviour for a plain character key: type it into search. Shared by * `default` and by `a`/`A`, which only sometimes means something else. */ function typeIntoSearch(state: UiState, key: string): Action[] { return SEARCHABLE.test(key) ? [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }] : []; } export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"]; export const VOLUME_STEP = 10; export const SEEK_STEP = 15; /** How far Ctrl+d/Ctrl+u jump through the grid - vim's own half-page scroll, applied to * rows of cards instead of lines of text. */ export const PAGE_ROWS = 3; /** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of * every other vim-ish media app. Keyed on the exact shifted letter, so this only ever * intercepts those five keys - every other Shift+letter (capitalizing a search) still * reaches `typeIntoSearch` untouched. Search is case-insensitive, so nothing searchable * is lost: there was never a reason to hold Shift while typing a query here. */ const SHIFT_MEDIA: Record = { H: [{ type: "previous" }], L: [{ type: "next" }], K: [{ type: "volume", delta: VOLUME_STEP }], J: [{ type: "volume", delta: -VOLUME_STEP }], M: [{ type: "mute" }], }; /** What the flat selection index currently points at. */ export function selectionAt(results: Results, selIndex: number): Action | null { const { songs, categories, albums } = results; const index = Math.min(selIndex, results.total - 1); if (index < 0) return null; if (index < songs.length) { const hit = songs[index]; return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null; } const afterSongs = index - songs.length; if (afterSongs < categories.length) { const category = categories[afterSongs]; return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null; } const album: Album | undefined = albums[afterSongs - categories.length]; return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null; } /** The album behind the current selection, if Shift+Enter's "show me the tracks" makes * sense for it: an album tile directly, or - so you can jump to the rest of it - the * album behind a song hit. `null` for a category tile, or a podcast episode, which is a * single track with nothing else to show. */ export function selectionAlbumAt(results: Results, selIndex: number): Album | null { const { songs, categories, albums } = results; const index = Math.min(selIndex, results.total - 1); if (index < 0) return null; let album: Album | undefined; if (index < songs.length) { album = songs[index]?.album; } else { const afterSongs = index - songs.length; if (afterSongs < categories.length) return null; album = albums[afterSongs - categories.length]; } return album && groupOf(album) !== "podcasts" ? album : null; } /** The bare root (three shelves) has no flat selection for `moveSelection` to move - * `results` is empty there, since each shelf computes its own categories independently. * It's a genuine two-axis layout instead: three rows (tracked by `shelfRow`), each a * differently-long row of category tiles (tracked by `selIndex`, doubling up as "which * tile in the focused row" the way it means "which flat position" everywhere else). * `rootShelves[row]` is that row's tile keys in display order - the same data * BrowseView renders - so a vertical move can clamp the column to how long the row it * lands on actually is, rather than guessing. */ function moveRootShelf(state: UiState, dx: number, dy: number, rootShelves: string[][]): Action[] { let row = state.shelfRow; let col = state.selIndex; if (dy) { row = Math.max(0, Math.min(GROUP_ORDER.length - 1, row + dy)); col = Math.min(col, Math.max(0, (rootShelves[row]?.length ?? 0) - 1)); } if (dx) { col = Math.max(0, Math.min((rootShelves[row]?.length ?? 0) - 1, col + dx)); } return [{ type: "ui", patch: { shelfRow: row, selIndex: col } }]; } function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] { if (!results.total) return []; const cols = Math.max(1, state.cols); let index = Math.min(state.selIndex, results.total - 1); if (dx) index += dx; if (dy) { // Song hits are a single-column list; everything below them is a grid. index += index < results.songs.length ? dy : dy * cols; } index = Math.max(0, Math.min(index, results.total - 1)); 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 || state.searchOpen) { return [{ type: "ui", patch: { search: "", searchOpen: false, 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) { return [{ type: "ui", patch: { assignPending: false } }]; } if (state.showHelp || state.openAlbumId !== null) { return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; } if (state.page === "room") { return [{ type: "ui", patch: { page: "music" } }]; } if (state.view === "play") { return [{ type: "ui", patch: { view: "browse" } }]; } return browseBackActions(state); } /** Whether the search box is on screen: something typed, `/` pressed, or track search. */ export function searchBoxShown(state: UiState): boolean { return state.search.length > 0 || state.searchOpen || state.mode === "tracks"; } /** The true root: nothing chosen yet, rendered as three shelves rather than a list. The * shelves go away the moment the search box appears, before there is a query to filter by. */ export function isRootShelf(state: UiState): boolean { return state.group === null && !searchBoxShown(state) && state.category === null; } /** Album search <-> title search, keeping whatever has been typed. `searchOpen` keeps the * box up when the switch lands on albums with nothing typed, where it would otherwise * vanish and bring the shelves back. */ export function toggleModeActions(state: UiState): Action[] { return [ { type: "pop", freq: 460 }, { type: "ui", patch: { mode: state.mode === "tracks" ? "albums" : "tracks", searchOpen: true, selIndex: 0 }, }, ]; } export interface KeyEvent { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; } /** * Translate one keypress. Returns the actions to run, or nothing when the key means * nothing here - the caller only calls `preventDefault()` when something came back. * * `openAlbumTrackCount` is the track count of the open album (when `openAlbumId` is * set) - the one piece of data Ctrl+j/k needs to clamp the track-list highlight that * `results` doesn't otherwise carry. `rootShelves` is the equivalent for the bare root * screen: each of the three rows' category keys, in display order, for `moveRootShelf` * to clamp against and ENTER to open. */ export function handleKey( event: KeyEvent, state: UiState, results: Results, openAlbumTrackCount = 0, rootShelves: string[][] = [], ): Action[] { const { key } = event; // The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its // own complete keyboard handling, including its own Escape/F1 bindings. It must own // every key while active - even Shift+media and Space below, which would otherwise // hijack a key a lesson happens to be drilling (see TippenApp.tsx's own doc comment). if (state.page === "typing") return []; if (event.shiftKey && !event.ctrlKey && !event.metaKey) { const media = SHIFT_MEDIA[key.toUpperCase()]; if (media) return media; } // Space is the one other transport key reachable from anywhere, same as Shift+media - // except mid-search, where a space is punctuation the query needs (e.g. "geolino azte"). if (key === " ") { if (state.page === "music" && state.view === "browse" && state.openAlbumId === null && !state.showHelp && state.search) { return [{ type: "ui", patch: { search: state.search + " ", selIndex: 0 } }]; } return [{ type: "pop", freq: 340 }, { type: "toggle" }]; } // Armed by "A" below; the next digit assigns what's playing to that remote key // instead of whatever it would normally do (typing into search, seeking, ...). if (state.assignPending && /^[0-9]$/.test(key)) { 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 // the global transport above (Shift+media, Space) 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 []; } // Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's // track list if one is open, otherwise the browse grid - the same job the arrow keys // already do, just reachable without leaving the home row. Ctrl+d/u are vim's own // half-page jump, applied to rows of cards. if (event.ctrlKey || event.metaKey) { if (state.openAlbumId !== null) { switch (key.toLowerCase()) { case "j": return [ { type: "ui", patch: { modalTrackIndex: Math.max( 0, Math.min(openAlbumTrackCount - 1, state.modalTrackIndex + 1), ), }, }, ]; case "k": return [{ type: "ui", patch: { modalTrackIndex: Math.max(0, state.modalTrackIndex - 1) } }]; default: return []; } } if (state.view !== "browse" || state.showHelp) return []; const lower = key.toLowerCase(); if (!"hjkldu".includes(lower)) return []; if (isRootShelf(state)) { switch (lower) { case "h": return moveRootShelf(state, -1, 0, rootShelves); case "l": return moveRootShelf(state, 1, 0, rootShelves); case "j": case "d": return moveRootShelf(state, 0, 1, rootShelves); case "k": case "u": return moveRootShelf(state, 0, -1, rootShelves); default: return []; } } switch (lower) { case "h": return moveSelection(state, results, -1, 0); case "l": return moveSelection(state, results, 1, 0); case "j": return moveSelection(state, results, 0, 1); case "k": return moveSelection(state, results, 0, -1); case "d": return moveSelection(state, results, 0, PAGE_ROWS); case "u": return moveSelection(state, results, 0, -PAGE_ROWS); default: return []; } } const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp; switch (key) { case "Tab": { const currentIndex = state.group ? GROUP_ORDER.indexOf(state.group) : -1; const next = GROUP_ORDER[(currentIndex + 1) % GROUP_ORDER.length]!; return [ { type: "pop", freq: 380 }, { type: "ui", patch: { group: next, selIndex: 0, view: "browse", category: null } }, ]; } case "/": // With the box already up, `/` flips what it searches; otherwise it opens it. if (browsing && searchBoxShown(state)) return toggleModeActions(state); return [{ type: "ui", patch: { view: "browse", searchOpen: true } }]; case "ArrowRight": if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }]; if (browsing && isRootShelf(state)) return moveRootShelf(state, 1, 0, rootShelves); return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }]; case "ArrowLeft": if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }]; if (browsing && isRootShelf(state)) return moveRootShelf(state, -1, 0, rootShelves); return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }]; case "ArrowDown": if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, 1, rootShelves); return browsing ? moveSelection(state, results, 0, 1) : [{ type: "volume", delta: -VOLUME_STEP }]; case "ArrowUp": if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, -1, rootShelves); return browsing ? moveSelection(state, results, 0, -1) : [{ type: "volume", delta: VOLUME_STEP }]; case "?": return [ { type: "pop", freq: 460 }, { type: "ui", patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false }, }, ]; case "F1": return [{ type: "ui", patch: { showHelp: !state.showHelp } }]; case "Escape": return escape(state); case "Backspace": return state.search ? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }] : []; case "Enter": { if (state.openAlbumId !== null) { return [{ type: "play", albumId: state.openAlbumId, trackIndex: state.modalTrackIndex }]; } if (state.view === "browse" && isRootShelf(state)) { const row = Math.max(0, Math.min(GROUP_ORDER.length - 1, state.shelfRow)); const group = GROUP_ORDER[row]!; const category = rootShelves[row]?.[state.selIndex] ?? null; return [ { type: "pop", freq: category ? 440 : 380 }, { type: "ui", patch: { group, category, selIndex: 0 } }, ]; } if (event.shiftKey) { const album = selectionAlbumAt(results, state.selIndex); if (album) return [{ type: "ui", patch: { openAlbumId: album.id, modalTrackIndex: 0 } }]; } const chosen = selectionAt(results, state.selIndex); return chosen ? [chosen] : []; } case "a": case "A": // Only while something is loaded on the play screen - everywhere else "a" is // just the first letter of a search, like any other key. if (state.view === "play" && !state.showHelp && state.openAlbumId === null) { return [{ type: "ui", patch: { assignPending: true } }]; } return typeIntoSearch(state, key); default: return typeIntoSearch(state, key); } }