feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp chapter steps, B bookmark capture (note box auto-focuses, Enter saves even when blank, Shift+Enter inserts a newline, Esc cancels), Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD, M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the top-most surface (help → capture → note edit → drawers → dropdowns). audio-keys.ts is the ONE declarative table: resolveShortcut() drives the document keydown dispatcher and visibleKeymap() renders the templ dialog (x-for over the exposed keymap — templ cannot import TS). Hardware media keys ride the existing MediaSession handlers; the Media* keydown codes resolve through the same table to the SAME methods, so both layers stay one behavior. Guards mirror the ebook reader: typing in any INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark notes included), a focused button keeps Space/Enter for itself, and the focused scrubber keeps its arrows (its own @change seeks — a native arrow nudge moves 0.08s, not a 10s skip). SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the same one-source pattern as the §5 speed-list consolidation. Unit: audio-keys.test.ts (resolution, Alt disambiguation, media keys, keymap integrity and conflict-freedom, dialog visibility) and cycleSkip tests. Live: 39 Playwright checks on the KJV folder book covering every binding, the guards, dialog and OSD; probe user created and deleted.
This commit is contained in:
1 parent
db96ea91cd
commit
295c4b0664
6 files changed
+569
-22
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
|
||||
|
||||
describe("resolveShortcut", () => {
|
||||
it("resolves every in-page binding", () => {
|
||||
expect(resolveShortcut(" ", false)).toBe("toggle-play");
|
||||
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
|
||||
expect(resolveShortcut("h", false)).toBe("skip-back");
|
||||
expect(resolveShortcut("H", false)).toBe("skip-back");
|
||||
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
|
||||
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
||||
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
|
||||
expect(resolveShortcut("k", false)).toBe("prev-chapter");
|
||||
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
|
||||
expect(resolveShortcut("j", false)).toBe("next-chapter");
|
||||
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
|
||||
expect(resolveShortcut("b", false)).toBe("bookmark");
|
||||
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
|
||||
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
|
||||
expect(resolveShortcut("m", false)).toBe("toggle-mute");
|
||||
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
|
||||
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
|
||||
expect(resolveShortcut("?", false)).toBe("toggle-help");
|
||||
expect(resolveShortcut("Escape", false)).toBe("escape");
|
||||
});
|
||||
|
||||
it("keeps Alt bindings distinct from their plain counterparts", () => {
|
||||
expect(resolveShortcut("l", true)).toBe("speed-up");
|
||||
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
||||
expect(resolveShortcut("h", true)).toBe("speed-down");
|
||||
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
|
||||
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
|
||||
// Alt on an unbound key claims nothing.
|
||||
expect(resolveShortcut("ArrowUp", true)).toBeNull();
|
||||
expect(resolveShortcut("m", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves in-page media key events", () => {
|
||||
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
|
||||
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
|
||||
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
|
||||
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
|
||||
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
|
||||
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
|
||||
});
|
||||
|
||||
it("returns null for unknown keys", () => {
|
||||
expect(resolveShortcut("x", false)).toBeNull();
|
||||
expect(resolveShortcut("F5", false)).toBeNull();
|
||||
expect(resolveShortcut("", false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KEYMAP integrity", () => {
|
||||
it("has exactly one spec per action", () => {
|
||||
const actions = KEYMAP.map((spec) => spec.action);
|
||||
expect(new Set(actions).size).toBe(actions.length);
|
||||
});
|
||||
|
||||
it("has no conflicting (key, alt) combos across actions", () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const spec of KEYMAP) {
|
||||
for (const k of spec.keys) {
|
||||
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
|
||||
const owner = seen.get(combo);
|
||||
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
|
||||
seen.set(combo, spec.action);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every spec at least one key, a label and a description", () => {
|
||||
for (const spec of KEYMAP) {
|
||||
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
|
||||
expect(spec.label.length, spec.action).toBeGreaterThan(0);
|
||||
expect(spec.description.length, spec.action).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides only media-key rows from the dialog", () => {
|
||||
const visible = new Set(visibleKeymap().map((spec) => spec.action));
|
||||
for (const spec of KEYMAP) {
|
||||
if (spec.action.startsWith("media-")) {
|
||||
expect(visible.has(spec.action), spec.action).toBe(false);
|
||||
} else {
|
||||
expect(visible.has(spec.action), spec.action).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Declarative keyboard map for the audio player (player plan §2).
|
||||
// ONE table drives BOTH the document keydown dispatcher (audio.ts
|
||||
// resolves events through resolveShortcut) and the templ-rendered
|
||||
// shortcut dialog (x-for over visibleKeymap). Never hardcode a key
|
||||
// binding in the handler or a row in the dialog — add it here.
|
||||
//
|
||||
// Hardware media keys are a separate layer: they ride the existing
|
||||
// MediaSession action handlers even when the tab is unfocused. The
|
||||
// Media* entries below cover browsers that ALSO deliver them as
|
||||
// in-page keydown events; they resolve to the same methods the
|
||||
// MediaSession handlers call (togglePlay / stepChapter).
|
||||
|
||||
export type KeyAction =
|
||||
| "toggle-play"
|
||||
| "skip-back"
|
||||
| "skip-forward"
|
||||
| "prev-chapter"
|
||||
| "next-chapter"
|
||||
| "bookmark"
|
||||
| "speed-up"
|
||||
| "speed-down"
|
||||
| "volume-up"
|
||||
| "volume-down"
|
||||
| "toggle-mute"
|
||||
| "cycle-skip-up"
|
||||
| "cycle-skip-down"
|
||||
| "toggle-help"
|
||||
| "escape"
|
||||
| "media-toggle-play"
|
||||
| "media-play"
|
||||
| "media-pause"
|
||||
| "media-next"
|
||||
| "media-prev";
|
||||
|
||||
export interface KeySpec {
|
||||
// e.key value to match. Single characters are matched
|
||||
// case-insensitively by resolveShortcut; multi-character key names
|
||||
// ("Enter", "ArrowLeft", "MediaPlayPause") must match verbatim.
|
||||
key: string;
|
||||
// Requires the Alt modifier. Alt-qualified entries disambiguate the
|
||||
// plain binding (Alt+L = speed up while L = skip forward).
|
||||
alt?: boolean;
|
||||
// Dialog badge label, e.g. "Space", "←", "Alt + →".
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface KeyActionSpec {
|
||||
action: KeyAction;
|
||||
// Dialog row label.
|
||||
label: string;
|
||||
description: string;
|
||||
keys: readonly KeySpec[];
|
||||
// Media-key entries are resolved but not shown in the dialog —
|
||||
// hardware keys, not something a keyboard user types.
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const KEYMAP: readonly KeyActionSpec[] = [
|
||||
{
|
||||
action: "toggle-play",
|
||||
label: "Play / pause",
|
||||
description: "Play or pause (only when no control is focused)",
|
||||
keys: [
|
||||
{ key: " ", label: "Space" },
|
||||
{ key: "Enter", label: "Enter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "skip-back",
|
||||
label: "Skip back",
|
||||
description: "Skip back by the skip duration",
|
||||
keys: [
|
||||
{ key: "h", label: "H" },
|
||||
{ key: "ArrowLeft", label: "←" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "skip-forward",
|
||||
label: "Skip forward",
|
||||
description: "Skip forward by the skip duration",
|
||||
keys: [
|
||||
{ key: "l", label: "L" },
|
||||
{ key: "ArrowRight", label: "→" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "prev-chapter",
|
||||
label: "Previous chapter",
|
||||
description: "Previous chapter",
|
||||
keys: [
|
||||
{ key: "k", label: "K" },
|
||||
{ key: "PageUp", label: "PgUp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "next-chapter",
|
||||
label: "Next chapter",
|
||||
description: "Next chapter",
|
||||
keys: [
|
||||
{ key: "j", label: "J" },
|
||||
{ key: "PageDown", label: "PgDn" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "bookmark",
|
||||
label: "Bookmark",
|
||||
description: "Bookmark this position — Enter saves, Shift+Enter newline",
|
||||
keys: [{ key: "b", label: "B" }],
|
||||
},
|
||||
{
|
||||
action: "speed-up",
|
||||
label: "Speed up",
|
||||
description: "Step playback speed up",
|
||||
keys: [
|
||||
{ key: "ArrowRight", alt: true, label: "Alt + →" },
|
||||
{ key: "l", alt: true, label: "Alt + L" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "speed-down",
|
||||
label: "Speed down",
|
||||
description: "Step playback speed down",
|
||||
keys: [
|
||||
{ key: "ArrowLeft", alt: true, label: "Alt + ←" },
|
||||
{ key: "h", alt: true, label: "Alt + H" },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "volume-up",
|
||||
label: "Volume up",
|
||||
description: "Volume up",
|
||||
keys: [{ key: "ArrowUp", label: "↑" }],
|
||||
},
|
||||
{
|
||||
action: "volume-down",
|
||||
label: "Volume down",
|
||||
description: "Volume down",
|
||||
keys: [{ key: "ArrowDown", label: "↓" }],
|
||||
},
|
||||
{
|
||||
action: "toggle-mute",
|
||||
label: "Mute",
|
||||
description: "Mute or unmute",
|
||||
keys: [{ key: "m", label: "M" }],
|
||||
},
|
||||
{
|
||||
action: "cycle-skip-up",
|
||||
label: "Longer skip",
|
||||
description: "Cycle the skip duration up",
|
||||
keys: [{ key: "]", label: "]" }],
|
||||
},
|
||||
{
|
||||
action: "cycle-skip-down",
|
||||
label: "Shorter skip",
|
||||
description: "Cycle the skip duration down",
|
||||
keys: [{ key: "[", label: "[" }],
|
||||
},
|
||||
{
|
||||
action: "toggle-help",
|
||||
label: "Shortcuts",
|
||||
description: "Show or hide this dialog",
|
||||
keys: [{ key: "?", label: "?" }],
|
||||
},
|
||||
{
|
||||
action: "escape",
|
||||
label: "Close",
|
||||
description: "Close dialog, cancel bookmark, close panels",
|
||||
keys: [{ key: "Escape", label: "Esc" }],
|
||||
},
|
||||
// Media keys: same actions the MediaSession handlers expose.
|
||||
{
|
||||
action: "media-toggle-play",
|
||||
label: "Media play/pause key",
|
||||
description: "Hardware media play/pause",
|
||||
keys: [{ key: "MediaPlayPause", label: "Media ⏯" }],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
action: "media-play",
|
||||
label: "Media play key",
|
||||
description: "Hardware media play",
|
||||
keys: [{ key: "MediaPlay", label: "Media ▶" }],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
action: "media-pause",
|
||||
label: "Media pause/stop key",
|
||||
description: "Hardware media pause or stop",
|
||||
keys: [
|
||||
{ key: "MediaPause", label: "Media ⏸" },
|
||||
{ key: "MediaStop", label: "Media ⏹" },
|
||||
],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
action: "media-next",
|
||||
label: "Media next key",
|
||||
description: "Hardware media next (chapter step, like the notification)",
|
||||
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
action: "media-prev",
|
||||
label: "Media previous key",
|
||||
description: "Hardware media previous (chapter step, like the notification)",
|
||||
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
// visibleKeymap filters out the hardware-media rows for the dialog.
|
||||
export function visibleKeymap(): KeyActionSpec[] {
|
||||
return KEYMAP.filter((spec) => !spec.hidden);
|
||||
}
|
||||
|
||||
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
|
||||
// when nothing claims it. Single characters match case-insensitively;
|
||||
// Alt-qualified bindings take precedence order for free: a plain key
|
||||
// never matches an alt binding and vice versa, so L and Alt+L are
|
||||
// distinct by construction.
|
||||
export function resolveShortcut(key: string, altKey: boolean): KeyAction | null {
|
||||
const normalized = key.length === 1 ? key.toLowerCase() : key;
|
||||
for (const spec of KEYMAP) {
|
||||
for (const k of spec.keys) {
|
||||
if (k.key === normalized && (k.alt ?? false) === altKey) return spec.action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SPEEDS, normalizeSpeed, stepSpeed } from "./audio";
|
||||
import { SPEEDS, normalizeSpeed, stepSpeed, SKIP_SECONDS, cycleSkip } from "./audio";
|
||||
|
||||
describe("SPEEDS", () => {
|
||||
it("is the agreed list from the player plan (0.5 through 3)", () => {
|
||||
@@ -53,3 +53,21 @@ describe("stepSpeed", () => {
|
||||
expect(stepSpeed(2.2, -1)).toBe(0.75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SKIP_SECONDS + cycleSkip", () => {
|
||||
it("is the agreed skip list", () => {
|
||||
expect(SKIP_SECONDS).toEqual([1, 5, 10, 15, 30]);
|
||||
});
|
||||
|
||||
it("cycles forward and backward, clamping at the ends", () => {
|
||||
expect(cycleSkip(10, 1)).toBe(15);
|
||||
expect(cycleSkip(10, -1)).toBe(5);
|
||||
expect(cycleSkip(1, -1)).toBe(1);
|
||||
expect(cycleSkip(30, 1)).toBe(30);
|
||||
});
|
||||
|
||||
it("anchors unknown currents at 10s before stepping", () => {
|
||||
expect(cycleSkip(7, 1)).toBe(15);
|
||||
expect(cycleSkip(7, -1)).toBe(5);
|
||||
});
|
||||
});
|
||||
+172
-15
@@ -15,6 +15,7 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { renderNoteHtml } from "./markdown";
|
||||
import { showToast } from "./toast";
|
||||
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
|
||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||
|
||||
@@ -74,6 +75,19 @@ export function stepSpeed(current: number, delta: 1 | -1): number {
|
||||
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
|
||||
}
|
||||
|
||||
// Skip durations — ONE source (same pattern as SPEEDS). The templ
|
||||
// dropdown iterates it through the component's `skipChoices` property,
|
||||
// and [ / ] keyboard cycling walks it through cycleSkip.
|
||||
export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30];
|
||||
|
||||
// cycleSkip moves through SKIP_SECONDS by delta, clamping at both
|
||||
// ends; an unknown current anchors at 10s (the historical default).
|
||||
export function cycleSkip(current: number, delta: 1 | -1): number {
|
||||
const idx = SKIP_SECONDS.indexOf(current);
|
||||
const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx;
|
||||
return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))];
|
||||
}
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 30_000;
|
||||
|
||||
interface AudioConfig {
|
||||
@@ -141,6 +155,14 @@ Alpine.data("audioReader", () => ({
|
||||
skipOpen: false,
|
||||
speedOpen: false,
|
||||
volumeOpen: false,
|
||||
skipChoices: SKIP_SECONDS,
|
||||
|
||||
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
|
||||
// brief OSD confirming keyboard-driven volume/mute changes.
|
||||
helpOpen: false,
|
||||
osdVisible: false,
|
||||
osdText: "",
|
||||
osdTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
|
||||
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
|
||||
// on unmute (spec: icon shows level arcs, crossed when muted).
|
||||
@@ -272,21 +294,11 @@ Alpine.data("audioReader", () => ({
|
||||
if (document.visibilityState === "hidden") this.pushProgress(true);
|
||||
});
|
||||
|
||||
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
this.togglePlay();
|
||||
} else if (e.code === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
this.skip(-1);
|
||||
} else if (e.code === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
this.skip(1);
|
||||
}
|
||||
});
|
||||
// Keyboard shortcuts (plan §2): one dispatcher resolving through
|
||||
// the declarative audio-keys.ts keymap. Hardware media keys ride
|
||||
// the MediaSession handlers above; the Media* keydown codes land
|
||||
// here and call the SAME methods.
|
||||
document.addEventListener("keydown", (e) => this.handleKeydown(e));
|
||||
},
|
||||
|
||||
audio(): HTMLAudioElement | null {
|
||||
@@ -560,6 +572,151 @@ Alpine.data("audioReader", () => ({
|
||||
this.bookmarkCaptureOpen = true;
|
||||
},
|
||||
|
||||
// Keyboard B: open the capture pill and put the cursor in the note
|
||||
// box (user's explicit flow — Enter then saves, even when blank).
|
||||
openBookmarkCapture() {
|
||||
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
|
||||
window.setTimeout(() => {
|
||||
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
|
||||
}, 0);
|
||||
},
|
||||
|
||||
// ---- keyboard dispatcher (audio-keys.ts) ----
|
||||
|
||||
visibleKeymap() {
|
||||
return visibleKeymap();
|
||||
},
|
||||
|
||||
// Escape closes the top-most open surface — even while typing, since
|
||||
// canceling the bookmark note mid-sentence is the expected Esc.
|
||||
// Returns true when something actually closed.
|
||||
closeTopSurface(): boolean {
|
||||
if (this.helpOpen) {
|
||||
this.helpOpen = false;
|
||||
return true;
|
||||
}
|
||||
if (this.bookmarkCaptureOpen) {
|
||||
this.bookmarkCaptureOpen = false;
|
||||
return true;
|
||||
}
|
||||
if (this.editingBookmarkId !== "") {
|
||||
this.editingBookmarkId = "";
|
||||
return true;
|
||||
}
|
||||
if (this.chaptersOpen) {
|
||||
this.chaptersOpen = false;
|
||||
return true;
|
||||
}
|
||||
if (this.bookmarksOpen) {
|
||||
this.bookmarksOpen = false;
|
||||
return true;
|
||||
}
|
||||
if (this.skipOpen || this.speedOpen || this.volumeOpen) {
|
||||
this.skipOpen = false;
|
||||
this.speedOpen = false;
|
||||
this.volumeOpen = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
showOsd(text: string) {
|
||||
this.osdText = text;
|
||||
this.osdVisible = true;
|
||||
if (this.osdTimer) clearTimeout(this.osdTimer);
|
||||
this.osdTimer = setTimeout(() => {
|
||||
this.osdVisible = false;
|
||||
}, 1200);
|
||||
},
|
||||
|
||||
handleKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
|
||||
|
||||
// Escape first: cancel the top-most surface even while typing.
|
||||
if (e.key === "Escape") {
|
||||
if (this.closeTopSurface()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing in any input (bookmark notes!) never triggers shortcuts.
|
||||
if (typing) return;
|
||||
|
||||
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
|
||||
if (!action) return;
|
||||
|
||||
// Space/Enter activate a focused control — never hijack them for
|
||||
// play/pause when the user is keyboard-driving a button.
|
||||
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
switch (action) {
|
||||
case "toggle-play":
|
||||
case "media-toggle-play":
|
||||
this.togglePlay();
|
||||
break;
|
||||
case "media-play":
|
||||
if (!this.playing) this.togglePlay();
|
||||
break;
|
||||
case "media-pause":
|
||||
if (this.playing) this.togglePlay();
|
||||
break;
|
||||
case "skip-back":
|
||||
this.skip(-1);
|
||||
break;
|
||||
case "skip-forward":
|
||||
this.skip(1);
|
||||
break;
|
||||
case "prev-chapter":
|
||||
case "media-prev":
|
||||
this.stepChapter(-1);
|
||||
break;
|
||||
case "next-chapter":
|
||||
case "media-next":
|
||||
this.stepChapter(1);
|
||||
break;
|
||||
case "bookmark":
|
||||
this.openBookmarkCapture();
|
||||
break;
|
||||
case "speed-up":
|
||||
this.setSpeed(stepSpeed(this.speed, 1));
|
||||
break;
|
||||
case "speed-down":
|
||||
this.setSpeed(stepSpeed(this.speed, -1));
|
||||
break;
|
||||
case "volume-up":
|
||||
case "volume-down": {
|
||||
const step = action === "volume-up" ? 0.05 : -0.05;
|
||||
const next = Math.round((Number(this.volume) + step) * 100) / 100;
|
||||
this.setVolume(next);
|
||||
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
|
||||
break;
|
||||
}
|
||||
case "toggle-mute":
|
||||
this.toggleMute();
|
||||
this.showOsd(this.muted ? "Muted" : "Unmuted");
|
||||
break;
|
||||
case "cycle-skip-up":
|
||||
this.setSkip(cycleSkip(this.skipSeconds, 1));
|
||||
break;
|
||||
case "cycle-skip-down":
|
||||
this.setSkip(cycleSkip(this.skipSeconds, -1));
|
||||
break;
|
||||
case "toggle-help":
|
||||
this.helpOpen = !this.helpOpen;
|
||||
break;
|
||||
case "escape":
|
||||
this.closeTopSurface();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async saveBookmark() {
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
|
||||
Reference in new issue
Block a user