Web frontend
This commit is contained in:
187
web/src/lib/keyboard.ts
Normal file
187
web/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/** 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 type { Filter, Mode, Results } from "./search";
|
||||
|
||||
export interface UiState {
|
||||
search: string;
|
||||
mode: Mode;
|
||||
filter: Filter;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
showHelp: boolean;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
filter: "all",
|
||||
category: null,
|
||||
selIndex: 0,
|
||||
view: "browse",
|
||||
openAlbumId: null,
|
||||
showHelp: false,
|
||||
cols: 4,
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
|
||||
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
|
||||
|
||||
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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 } }];
|
||||
}
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
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: { category: null, 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.
|
||||
*/
|
||||
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
|
||||
const { key } = event;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (key.toLowerCase()) {
|
||||
case "l":
|
||||
return [{ type: "next" }];
|
||||
case "h":
|
||||
return [{ type: "previous" }];
|
||||
case "k":
|
||||
return [{ type: "volume", delta: VOLUME_STEP }];
|
||||
case "j":
|
||||
return [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
|
||||
|
||||
switch (key) {
|
||||
case "Tab": {
|
||||
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
|
||||
return [
|
||||
{ type: "pop", freq: 380 },
|
||||
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
|
||||
];
|
||||
}
|
||||
case "/":
|
||||
return [{ type: "ui", patch: { view: "browse" } }];
|
||||
case " ":
|
||||
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||
case "ArrowLeft":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||
case "ArrowDown":
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, 1)
|
||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||
case "ArrowUp":
|
||||
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: 0 }];
|
||||
}
|
||||
const chosen = selectionAt(results, state.selIndex);
|
||||
return chosen ? [chosen] : [];
|
||||
}
|
||||
default:
|
||||
if (SEARCHABLE.test(key)) {
|
||||
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user