UI cleanup, visual and keyboard navigation
This commit is contained in:
@@ -20,6 +20,10 @@ export interface UiState {
|
||||
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";
|
||||
@@ -41,6 +45,7 @@ export const initialUiState: UiState = {
|
||||
group: null,
|
||||
category: null,
|
||||
selIndex: 0,
|
||||
shelfRow: 0,
|
||||
page: "music",
|
||||
view: "browse",
|
||||
openAlbumId: null,
|
||||
@@ -73,7 +78,7 @@ function typeIntoSearch(state: UiState, key: string): Action[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
@@ -133,6 +138,27 @@ export function selectionAlbumAt(results: Results, selIndex: number): Album | nu
|
||||
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);
|
||||
@@ -193,13 +219,16 @@ export interface KeyEvent {
|
||||
*
|
||||
* `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.
|
||||
* `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;
|
||||
|
||||
@@ -257,7 +286,25 @@ export function handleKey(
|
||||
}
|
||||
}
|
||||
if (state.view !== "browse" || state.showHelp) return [];
|
||||
switch (key.toLowerCase()) {
|
||||
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":
|
||||
@@ -291,27 +338,19 @@ export function handleKey(
|
||||
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||
if (browsing && isRootShelf(state)) {
|
||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
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 [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
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 [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
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 [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
||||
}
|
||||
if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, -1, rootShelves);
|
||||
return browsing
|
||||
? moveSelection(state, results, 0, -1)
|
||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||
@@ -336,6 +375,15 @@ export function handleKey(
|
||||
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 } }];
|
||||
|
||||
Reference in New Issue
Block a user