406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
/** 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;
|
|
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 toggling to the room and back leaves the music side exactly as it was. */
|
|
page: "music" | "room";
|
|
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: "",
|
|
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<UiState> }
|
|
| { 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<string, Action[]> = {
|
|
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) 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) {
|
|
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);
|
|
}
|
|
|
|
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
|
|
export function isRootShelf(state: UiState): boolean {
|
|
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
|
|
}
|
|
|
|
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;
|
|
|
|
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 "/":
|
|
return [{ type: "ui", patch: { view: "browse" } }];
|
|
|
|
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);
|
|
}
|
|
}
|