From cb0c242e219d0fe75c3d95a832911cf93caa9032 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 12 Oct 2026 12:26:53 -0700 Subject: [PATCH 1/3] Add opt-in scoped hotkeys enableHotkeys now also accepts "scoped", which only handles hotkeys while focus is within Webamp. This lets embedders with their own keyboard interactions enable hotkeys without Webamp intercepting keys page-wide. Scoped hotkeys attach the existing hotkey listener to Webamp's root DOM node instead of the document. enableHotkeys: true behaves exactly as before. --- .../docs/05_features/01_hotkeys.md | 2 + .../docs/06_API/02_webamp-constructor.md | 6 ++- packages/webamp/js/components/App.tsx | 14 ++++- packages/webamp/js/hotkeys.test.ts | 54 +++++++++++++++++++ packages/webamp/js/hotkeys.ts | 14 +++-- packages/webamp/js/types.ts | 9 +++- packages/webamp/js/webampLazy.tsx | 5 +- 7 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 packages/webamp/js/hotkeys.test.ts diff --git a/packages/webamp-docs/docs/05_features/01_hotkeys.md b/packages/webamp-docs/docs/05_features/01_hotkeys.md index 25f2af28de..ce02eae534 100644 --- a/packages/webamp-docs/docs/05_features/01_hotkeys.md +++ b/packages/webamp-docs/docs/05_features/01_hotkeys.md @@ -16,6 +16,8 @@ const webamp = new Webamp({ webamp.renderWhenReady(document.getElementById("winamp-container")); ``` +By default, hotkeys are handled anywhere in the page. If your page has its own keyboard interactions, pass `enableHotkeys: "scoped"` to only handle hotkeys while focus is within Webamp. + ## Hotkeys | Key Combo | Action | diff --git a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md index 447fe0dbc1..14fdb87de3 100644 --- a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md +++ b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md @@ -126,7 +126,11 @@ In keeping with the original Winamp, **double size mode does not apply to resiza ### `enableHotkeys` -A boolean indicating if global hotkeys should be enabled. **Default:** `false`. +Controls whether hotkeys are enabled. **Default:** `false`. + +- `true`: Hotkeys are handled anywhere in the page. +- `"scoped"`: Hotkeys are only handled while focus is within Webamp. Use this if your page has its own keyboard interactions. +- `false`: Hotkeys are disabled. ```ts const webamp = new Webamp({ diff --git a/packages/webamp/js/components/App.tsx b/packages/webamp/js/components/App.tsx index 31cba88bcc..b8d7028468 100644 --- a/packages/webamp/js/components/App.tsx +++ b/packages/webamp/js/components/App.tsx @@ -6,7 +6,7 @@ import { StrictMode, } from "react"; import ReactDOM from "react-dom"; -import { FilePicker } from "../types"; +import { FilePicker, Dispatch } from "../types"; import { WINDOWS } from "../constants"; import * as Selectors from "../selectors"; import * as Actions from "../actionCreators"; @@ -21,7 +21,8 @@ import EqualizerWindow from "./EqualizerWindow"; import Skin from "./Skin"; import { IMedia } from "../media"; -import { useTypedSelector, useActionCreator } from "../hooks"; +import { useTypedSelector, useActionCreator, useTypedDispatch } from "../hooks"; +import { bindHotkeys } from "../hotkeys"; import Css from "./Css"; // @ts-ignore @@ -31,6 +32,7 @@ interface Props { filePickers: FilePicker[]; media: IMedia; parentDomNode: HTMLElement; + scopedHotkeys: boolean; onMount?: () => void; } @@ -42,6 +44,7 @@ export default function App({ filePickers, onMount, parentDomNode, + scopedHotkeys, }: Props) { const closed = useTypedSelector(Selectors.getClosed); const genWindowsInfo = useTypedSelector(Selectors.getGenWindows); @@ -64,6 +67,13 @@ export default function App({ webampNode.style.zIndex = String(zIndex); }, [webampNode, zIndex]); + const dispatch = useTypedDispatch(); + useEffect(() => { + if (scopedHotkeys) { + return bindHotkeys(dispatch as Dispatch, webampNode); + } + }, [scopedHotkeys, dispatch, webampNode]); + useLayoutEffect(() => { parentDomNode.appendChild(webampNode); return () => { diff --git a/packages/webamp/js/hotkeys.test.ts b/packages/webamp/js/hotkeys.test.ts new file mode 100644 index 0000000000..dec476ac80 --- /dev/null +++ b/packages/webamp/js/hotkeys.test.ts @@ -0,0 +1,54 @@ +import { bindHotkeys } from "./hotkeys"; + +function pressR(target: EventTarget) { + // R toggles repeat. + target.dispatchEvent( + new KeyboardEvent("keydown", { keyCode: 82, bubbles: true }) + ); +} + +describe("bindHotkeys", () => { + let webampNode: HTMLElement; + let button: HTMLElement; + let outside: HTMLElement; + + beforeEach(() => { + webampNode = document.createElement("div"); + button = document.createElement("button"); + webampNode.appendChild(button); + outside = document.createElement("button"); + document.body.append(webampNode, outside); + }); + + afterEach(() => { + webampNode.remove(); + outside.remove(); + }); + + it("handles hotkeys anywhere in the document by default", () => { + const dispatch = vi.fn(); + const unbind = bindHotkeys(dispatch); + pressR(outside); + pressR(button); + unbind(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ type: "TOGGLE_REPEAT" }); + }); + + it("only handles hotkeys within the target when given one", () => { + const dispatch = vi.fn(); + const unbind = bindHotkeys(dispatch, webampNode); + pressR(outside); + expect(dispatch).not.toHaveBeenCalled(); + pressR(button); + expect(dispatch).toHaveBeenCalledWith({ type: "TOGGLE_REPEAT" }); + unbind(); + }); + + it("stops handling hotkeys once unbound", () => { + const dispatch = vi.fn(); + bindHotkeys(dispatch, webampNode)(); + pressR(button); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webamp/js/hotkeys.ts b/packages/webamp/js/hotkeys.ts index 8eae211805..651d719c6c 100644 --- a/packages/webamp/js/hotkeys.ts +++ b/packages/webamp/js/hotkeys.ts @@ -20,7 +20,12 @@ import { Dispatch } from "./types"; const IGNORE_EVENTS_FROM_TAGS = new Set(["input", "textarea", "select"]); -export function bindHotkeys(dispatch: Dispatch): () => void { +// By default hotkeys are handled anywhere in the document. Pass a `target` to +// only handle them when focus is within that element. +export function bindHotkeys( + dispatch: Dispatch, + target: Document | HTMLElement = document +): () => void { let currentPos: number = 0; const trigger = [ 78, // N @@ -148,9 +153,12 @@ export function bindHotkeys(dispatch: Dispatch): () => void { } } }; - document.addEventListener("keydown", listener); + // TypeScript can't pick the right overload for the `Document | HTMLElement` + // union. + const eventListener = listener as EventListener; + target.addEventListener("keydown", eventListener); return () => { - document.removeEventListener("keydown", listener); + target.removeEventListener("keydown", eventListener); }; } diff --git a/packages/webamp/js/types.ts b/packages/webamp/js/types.ts index 9b3879b6c7..21912af853 100644 --- a/packages/webamp/js/types.ts +++ b/packages/webamp/js/types.ts @@ -688,11 +688,16 @@ export interface Options { enableDoubleSizeMode?: boolean; /** - * Should global hotkeys be enabled? + * Should hotkeys be enabled? + * + * - `true`: Hotkeys are handled anywhere in the page. + * - `"scoped"`: Hotkeys are only handled while focus is within Webamp. Useful + * if the page has other keyboard interactions. + * - `false`: Hotkeys are disabled. * * Default: `false` */ - enableHotkeys?: boolean; + enableHotkeys?: boolean | "scoped"; /** * An array of additional file pickers. diff --git a/packages/webamp/js/webampLazy.tsx b/packages/webamp/js/webampLazy.tsx index 86a1cd2234..ffe8b7c2c3 100644 --- a/packages/webamp/js/webampLazy.tsx +++ b/packages/webamp/js/webampLazy.tsx @@ -215,7 +215,9 @@ class Webamp { this.store.dispatch(Actions.setWindowLayout(options.windowLayout)); - if (enableHotkeys) { + // Scoped hotkeys are bound by ``, since they are attached to + // Webamp's DOM node. + if (enableHotkeys === true) { this._disposable.add(bindHotkeys(this.store.dispatch)); } } @@ -556,6 +558,7 @@ class Webamp { From d223f8c7b1451d950c685e14b7b22385cfb82723 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Thu, 15 Oct 2026 09:30:53 -0700 Subject: [PATCH 2/3] Add changelog entry and cite version in docs --- packages/webamp-docs/docs/05_features/01_hotkeys.md | 2 +- packages/webamp-docs/docs/06_API/02_webamp-constructor.md | 2 +- packages/webamp-docs/docs/12_changelog.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/webamp-docs/docs/05_features/01_hotkeys.md b/packages/webamp-docs/docs/05_features/01_hotkeys.md index ce02eae534..2a66d37bb7 100644 --- a/packages/webamp-docs/docs/05_features/01_hotkeys.md +++ b/packages/webamp-docs/docs/05_features/01_hotkeys.md @@ -16,7 +16,7 @@ const webamp = new Webamp({ webamp.renderWhenReady(document.getElementById("winamp-container")); ``` -By default, hotkeys are handled anywhere in the page. If your page has its own keyboard interactions, pass `enableHotkeys: "scoped"` to only handle hotkeys while focus is within Webamp. +By default, hotkeys are handled anywhere in the page. If your page has its own keyboard interactions, pass `enableHotkeys: "scoped"` _(since [Unreleased](../12_changelog.md#unreleased))_ to only handle hotkeys while focus is within Webamp. ## Hotkeys diff --git a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md index 14fdb87de3..f31435dac1 100644 --- a/packages/webamp-docs/docs/06_API/02_webamp-constructor.md +++ b/packages/webamp-docs/docs/06_API/02_webamp-constructor.md @@ -129,7 +129,7 @@ In keeping with the original Winamp, **double size mode does not apply to resiza Controls whether hotkeys are enabled. **Default:** `false`. - `true`: Hotkeys are handled anywhere in the page. -- `"scoped"`: Hotkeys are only handled while focus is within Webamp. Use this if your page has its own keyboard interactions. +- `"scoped"` _(since [Unreleased](../12_changelog.md#unreleased))_: Hotkeys are only handled while focus is within Webamp. Use this if your page has its own keyboard interactions. - `false`: Hotkeys are disabled. ```ts diff --git a/packages/webamp-docs/docs/12_changelog.md b/packages/webamp-docs/docs/12_changelog.md index 9ebe8bba9a..8620549e0c 100644 --- a/packages/webamp-docs/docs/12_changelog.md +++ b/packages/webamp-docs/docs/12_changelog.md @@ -15,6 +15,7 @@ If you want access to the changes in this section before they are officially rel - The Milkdrop window now has a stable id, `#milkdrop-window`, like `#main-window`, `#equalizer-window` and `#playlist-window`. - Added a new Webamp instance method: [`webamp.onMediaError(cb)`](./06_API/03_instance-methods.md#onmediaerrorcb-error--code-number---void---void), which is called when a track fails to load or play. - Added a new Webamp instance method: [`webamp.setTracks(tracks)`](./06_API/03_instance-methods.md#settrackstracks-track-void), which replaces the playlist without starting playback. +- The [`enableHotkeys`](./06_API/02_webamp-constructor.md#enablehotkeys) option now accepts `"scoped"`, which only handles hotkeys while focus is within Webamp. ### Bug Fixes From e69098c9bffbfdc154947cfd11c18632aadb504e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Thu, 15 Oct 2026 09:57:47 -0700 Subject: [PATCH 3/3] Bind all hotkeys in Rather than binding global hotkeys in the constructor and scoped hotkeys in , bind both in , choosing the document or Webamp's DOM node as the target. Hotkeys are unbound when the React root unmounts (on dispose()). This means enableHotkeys: true now takes effect once Webamp renders rather than when it is constructed. --- packages/webamp/js/components/App.tsx | 13 ++++++++----- packages/webamp/js/webampLazy.tsx | 10 +--------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/webamp/js/components/App.tsx b/packages/webamp/js/components/App.tsx index b8d7028468..0c145677db 100644 --- a/packages/webamp/js/components/App.tsx +++ b/packages/webamp/js/components/App.tsx @@ -32,7 +32,7 @@ interface Props { filePickers: FilePicker[]; media: IMedia; parentDomNode: HTMLElement; - scopedHotkeys: boolean; + enableHotkeys: boolean | "scoped"; onMount?: () => void; } @@ -44,7 +44,7 @@ export default function App({ filePickers, onMount, parentDomNode, - scopedHotkeys, + enableHotkeys, }: Props) { const closed = useTypedSelector(Selectors.getClosed); const genWindowsInfo = useTypedSelector(Selectors.getGenWindows); @@ -69,10 +69,13 @@ export default function App({ const dispatch = useTypedDispatch(); useEffect(() => { - if (scopedHotkeys) { - return bindHotkeys(dispatch as Dispatch, webampNode); + if (!enableHotkeys) { + return; } - }, [scopedHotkeys, dispatch, webampNode]); + // Scoped hotkeys only see key events from within Webamp's DOM node. + const target = enableHotkeys === "scoped" ? webampNode : document; + return bindHotkeys(dispatch as Dispatch, target); + }, [enableHotkeys, dispatch, webampNode]); useLayoutEffect(() => { parentDomNode.appendChild(webampNode); diff --git a/packages/webamp/js/webampLazy.tsx b/packages/webamp/js/webampLazy.tsx index ffe8b7c2c3..25ffe2fb65 100644 --- a/packages/webamp/js/webampLazy.tsx +++ b/packages/webamp/js/webampLazy.tsx @@ -20,7 +20,6 @@ import { } from "./types"; import getStore from "./store"; import App from "./components/App"; -import { bindHotkeys } from "./hotkeys"; import Media, { IMedia, IMediaClass } from "./media"; import * as Selectors from "./selectors"; import * as Actions from "./actionCreators"; @@ -83,7 +82,6 @@ class Webamp { initialTracks, initialSkin, availableSkins, - enableHotkeys = false, zIndex, requireJSZip, requireMusicMetadata, @@ -214,12 +212,6 @@ class Webamp { } this.store.dispatch(Actions.setWindowLayout(options.windowLayout)); - - // Scoped hotkeys are bound by ``, since they are attached to - // Webamp's DOM node. - if (enableHotkeys === true) { - this._disposable.add(bindHotkeys(this.store.dispatch)); - } } /** @@ -558,7 +550,7 @@ class Webamp {