diff --git a/AGENTS.md b/AGENTS.md
index d7e06669..6730b004 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -229,7 +229,7 @@ A file a Sidra window pulls in with a `` needs an entry too. `assets/windo
- Authentication is handled entirely by Apple's web flow; use `persist:sidra` partition for cookie persistence
- Volume sync between MPRIS and MusicKit uses echo suppression to prevent feedback loops. `set Volume` schedules its own `Volume` property emission and pushes the value onto `_pendingVolumes`, a queue bounded by `MAX_PENDING_VOLUMES`. `updateVolume()` matches an incoming value against any entry within `VOLUME_ECHO_TOLERANCE`, then discards that entry and every older one: echoes arrive in order, so an overtaken set left in the queue would suppress a later in-app change. A full queue drops the value being added, never the oldest entry. The oldest is the one the next echo matches, so evicting it makes that echo read as an in-app change and pull the cached volume back to a value the drag left behind, which is the same fault a single slot had; a drag of more than `MAX_PENDING_VOLUMES` sets reached it. An untracked newest value is harmless: its echo matches nothing and writes the level the player has actually reached. A single pending slot lost a drag - the 0.5 and 0.6 echoes of a 0.5, 0.6, 0.7 burst each read as an in-app change and overwrote the cached volume, leaving it one echo behind with nothing to correct it, because the hook's 250 ms poll only reports on change. The setter must emit; without that, only the client that made the change knew the new value. Keep the 2 second safety timeout: it drops the whole queue, so a missed echo self-heals
- The volume listener in `assets/musicKitHook.js` must bind `playbackVolumeDidChange`. That is the only volume event MusicKit publishes: a CDP session against a running Sidra confirmed a `mk.volume` write fires it once and never fires `volumeDidChange`. Binding `volumeDidChange` leaves the listener dead and the 250 ms poll as the only path reporting volume. Keep that poll: nothing has confirmed what the player bar volume control writes, because it is light DOM with no range input and `mk._targetElement` is not exposed on the public instance, so its write path cannot be observed. The listener and the poll are the only two paths, and `lastVolume` stops both reporting one change. The IPC channel the listener sends on stays named `volumeDidChange`; that is Sidra's own channel name, deliberately unrelated to the MusicKit event name, and renaming it touches the preload allowlist and both contract tests
-- Scroll-to-change-volume is one non-passive `wheel` listener on `window` in `assets/musicKitHook.js`, installed beside the `message` listener in the `waitForMK` callback so `__sidraHookInjected` covers it and it installs once. Anything inside `attachToInstance` would re-install on every re-hook. It writes `mk.volume`, which reaches the tray and MPRIS by the existing `volumeDidChange` route, so nothing outside the hook changes and no `sidra:command` channel is needed. The gate is `event.composedPath()` matched on the `chrome-volume` class token, and it must stay a class-token test: a CDP walk of the live DOM found that token on a `div` on `music.apple.com` and on an `amp-chrome-volume` element on `classical.music.apple.com`, so matching the element name works on Classical and silently does nothing on Apple Music. Neither page has an `input[type=range]`. Never write a Svelte scope hash into the selector; those change on any Apple rebuild. `event.ctrlKey` returns early so Ctrl+scroll still zooms, `preventDefault()` fires only when the gate matches, and nothing calls `stopPropagation()` because Apple's own handlers need the event. `deltaY` accumulates and every 100 pixels applies one 5% step, carrying the remainder; 100 is Chromium's default `deltaY` per wheel notch. Do not scale the step by `deltaY` magnitude - Chromium reports a wheel and a touchpad identically as `DOM_DELTA_PIXEL`, so magnitude gives a wheel a full-range jump and a touchpad an invisible nudge. The value is clamped to 0 and 1 and rounded to 2 decimal places, because MusicKit throws on an out-of-range value. The base is read from `window.__sidraHookedMk` on every event, never from a cached local or a captured instance, so a write MusicKit drops self-corrects on the next notch and a replaced singleton is picked up by the same marker the monitor uses. Do not attach the listener to the volume element: it does not exist when the hook runs and is replaced on navigation and service switches, so it would need a `MutationObserver` in the file whose duplicate listeners once cost 180 MiB/s
+- Scroll-to-change-volume puts its non-passive `wheel` listener on the volume control itself, never on `window` or `document`. A non-passive wheel listener on either of those marks the whole document a non-fast-scrollable region: Chromium can no longer scroll on the compositor thread, so every wheel tick waits on the main thread, which Apple Music keeps busy while a list scrolls. That is invisible in review, because the handler only calls `preventDefault()` over the control and is correct in every other respect - the cost comes from the listener existing, not from what it does, and it made scrolling anywhere in the app lag while `music.apple.com` in a browser stayed smooth. `test/musicKitHook.test.ts` pins it: one test asserts no `wheel` registration reaches `window` or `document`, and the volume behaviour tests drive the control directly, so putting it back fails a dozen of them. The control does not exist when the hook runs and is replaced on navigation and service switches, so the binding is resolved lazily by a **passive** `pointerover` listener on `window`, installed beside the `message` listener in the `waitForMK` callback so `__sidraHookInjected` covers it and it installs once; anything inside `attachToInstance` would re-install on every re-hook. A wheel event over the control is always preceded by the pointer arriving there, and `bindVolumeWheel()` removes the listener from the previous control before binding the new one, so a replaced player bar moves the binding rather than accumulating listeners on dead elements. Do not reach for a `MutationObserver`: one in this file previously cost 180 MiB/s. The gate is `closest('.chrome-volume')`, and it must stay a class-token test: a CDP walk of the live DOM found that token on a `div` on `music.apple.com` and on an `amp-chrome-volume` element on `classical.music.apple.com`, so matching the element name works on Classical and silently does nothing on Apple Music. Neither page has an `input[type=range]`. Never write a Svelte scope hash into the selector; those change on any Apple rebuild. It runs on `pointerover` rather than on every wheel tick, which is the other half of the fix: the `composedPath()` it replaced allocated the whole ancestor chain on every event, so the cost grew with DOM depth. `event.ctrlKey` returns early so Ctrl+scroll still zooms, and nothing calls `stopPropagation()` because Apple's own handlers need the event. `deltaY` accumulates and every 100 pixels applies one 5% step, carrying the remainder; 100 is Chromium's default `deltaY` per wheel notch. Do not scale the step by `deltaY` magnitude - Chromium reports a wheel and a touchpad identically as `DOM_DELTA_PIXEL`, so magnitude gives a wheel a full-range jump and a touchpad an invisible nudge. The value is clamped to 0 and 1 and rounded to 2 decimal places, because MusicKit throws on an out-of-range value. The base is read from `window.__sidraHookedMk` on every event, never from a cached local or a captured instance, so a write MusicKit drops self-corrects on the next notch and a replaced singleton is picked up by the same marker the monitor uses. Writing `mk.volume` reaches the tray and MPRIS by the existing `volumeDidChange` route, so nothing outside the hook changes and no `sidra:command` channel is needed
- `Notification.isSupported()` returns `false` in CastLabs Electron even when the platform fully supports notifications; do not gate on it - listen for the `failed` event instead to surface OS-level rejection
- `Notification.show()` blocks the browser UI thread on Linux when nothing owns `org.freedesktop.Notifications`: Electron calls `notify_notification_show()` inline, and libnotify builds its `GDBusProxy` without `DO_NOT_AUTO_START`, so GLib runs `StartServiceByName` in a nested main loop and each attempt waits the 25 second D-Bus activation timeout. Electron queries server capabilities three times before the show, so one notification freezes the window for about 100 seconds (#174). `createNotification()` in `src/notify.ts` is therefore the only place a `Notification` is constructed, and it returns `null` when the gate is closed. `src/notificationDaemon.ts` opens the gate: it answers `NameHasOwner` for that name, which is one round trip and never triggers activation, then watches `NameOwnerChanged` so a daemon started mid-session re-enables notifications without a restart. The gate starts closed on Linux and opens on the first probe reply; off Linux it is open from the start and no bus is opened. `main.ts` calls `initNotificationProbe()` in `app.whenReady()`. Constructing a `Notification` directly anywhere else reopens the freeze
- The `failed` listener in `createNotification()` latches the gate closed on Linux only; macOS and Windows have no `NameOwnerChanged` recovery path, so a latch there would kill notifications for the rest of the session. A daemon that owns the name and then hangs mid-`Notify` still blocks: `NameHasOwner` returns true and Electron waits on `g_dbus_proxy_call_sync` with an infinite timeout. That case cannot be fixed from JavaScript
diff --git a/assets/musicKitHook.js b/assets/musicKitHook.js
index cdd2d2e0..d629cc68 100644
--- a/assets/musicKitHook.js
+++ b/assets/musicKitHook.js
@@ -2,7 +2,7 @@
// This flag persists for the page lifetime and is never cleared. Re-injection
// must not re-run the IIFE: the 5-second monitor inside the hook already
// handles MusicKit instance replacement, and re-running would install a
- // duplicate set of message and wheel listeners.
+ // duplicate set of message and pointerover listeners.
if (window.__sidraHookInjected) return;
window.__sidraHookInjected = true;
@@ -303,7 +303,7 @@
* the monitor will not retry a part-attached instance. Reporting the error
* is all that is left to do, and it must not propagate: the monitor's own
* catch would swallow it, and on the first call it would abort the rest of
- * the hook, including the message and wheel listeners below.
+ * the hook, including the message and pointerover listeners below.
*
* @param {object} mk - The MusicKit.getInstance() singleton
* @returns {void}
@@ -372,28 +372,32 @@
let wheelDelta = 0;
/**
- * Change the volume when the pointer is over the player bar volume control.
- *
- * Both services put the `chrome-volume` class token on the control, on a
- * `div` for music.apple.com and on an `amp-chrome-volume` element for
- * classical.music.apple.com, so the gate matches that token anywhere in the
- * composed path and never the element name, which would work on Classical
- * and silently do nothing on Apple Music. Never write a Svelte scope hash
- * into the selector; those change on any Apple rebuild. Writing mk.volume
- * reaches the main process by the existing playbackVolumeDidChange route.
- *
- * The listener is on window rather than on the control itself, because the
- * control does not exist when the hook runs and is replaced on navigation
- * and service switches.
+ * Matches the player bar volume control by the class token both services
+ * put on it: a `div` on music.apple.com, an `amp-chrome-volume` element on
+ * classical.music.apple.com. It must stay a class-token selector and never
+ * an element name, which would work on Classical and silently do nothing on
+ * Apple Music. Never write a Svelte scope hash into it; those change on any
+ * Apple rebuild.
+ */
+ const VOLUME_SELECTOR = '.chrome-volume';
+
+ /**
+ * The control the wheel listener is currently bound to, or null.
+ * @type {Element | null}
+ */
+ let boundVolumeControl = null;
+
+ /**
+ * Change the volume when the wheel turns over the player bar volume
+ * control. Writing mk.volume reaches the main process by the existing
+ * playbackVolumeDidChange route, so nothing outside the hook changes.
*
* @param {WheelEvent} event - The wheel event
+ * @returns {void}
*/
- window.addEventListener('wheel', (event) => {
+ function onVolumeWheel(event) {
// Ctrl+scroll and pinch are zoom gestures, not volume ones.
if (event.ctrlKey) return;
- const overVolume = event.composedPath()
- .some((target) => target.classList?.contains('chrome-volume'));
- if (!overVolume) return;
// Stop the page scrolling under the control, even when this event only
// accumulates and moves the volume nowhere.
event.preventDefault();
@@ -416,7 +420,55 @@
// and the result is clamped because MusicKit throws outside 0 to 1.
const volume = hookedMk.volume - steps * VOLUME_STEP;
hookedMk.volume = Math.min(1, Math.max(0, Math.round(volume * 100) / 100));
- }, { passive: false });
+ }
+
+ /**
+ * Point the wheel listener at the volume control, moving it off whichever
+ * control it was on before.
+ *
+ * The listener must never go on `window` or `document`: a non-passive wheel
+ * listener there marks the whole document a non-fast-scrollable region, so
+ * Chromium stops scrolling on the compositor thread and every wheel tick
+ * waits on a main thread Apple Music keeps busy. The cost comes from the
+ * listener existing, not from what it does, which is why it survived review
+ * while the handler itself stayed correct. On the control the region is
+ * that control's own box.
+ *
+ * @param {Element} control - The volume control to bind
+ * @returns {void}
+ */
+ function bindVolumeWheel(control) {
+ if (control === boundVolumeControl) return;
+ if (boundVolumeControl) {
+ boundVolumeControl.removeEventListener('wheel', onVolumeWheel);
+ }
+ boundVolumeControl = control;
+ control.addEventListener('wheel', onVolumeWheel, { passive: false });
+ }
+
+ /**
+ * Find the volume control and bind to it when the pointer reaches it.
+ *
+ * The control does not exist when the hook runs and is replaced on
+ * navigation and service switches, so the binding is resolved lazily rather
+ * than once. `pointerover` is what resolves it: a wheel event over the
+ * control is always preceded by the pointer arriving there, and this
+ * listener is passive, so it costs scrolling nothing.
+ *
+ * A MutationObserver would answer the same question and is deliberately not
+ * used: one in this file previously cost 180 MiB/s.
+ *
+ * @param {PointerEvent} event - The pointerover event
+ * @returns {void}
+ */
+ function onPointerOver(event) {
+ const target = event.target;
+ if (typeof target?.closest !== 'function') return;
+ const control = target.closest(VOLUME_SELECTOR);
+ if (control) bindVolumeWheel(control);
+ }
+
+ window.addEventListener('pointerover', onPointerOver, { passive: true });
console.log('[Sidra] MusicKit hooked successfully');
diff --git a/test/musicKitHook.test.ts b/test/musicKitHook.test.ts
index 5ff82042..a2a2b255 100644
--- a/test/musicKitHook.test.ts
+++ b/test/musicKitHook.test.ts
@@ -8,30 +8,92 @@ const hookScript = fs.readFileSync(
'utf-8',
);
-/** An event target in a composed path, carrying the classes given. */
-function element(classes: string) {
+/** A listener registration made on a fake element. */
+interface Registration {
+ type: string;
+ listener: (event: unknown) => void;
+ options?: unknown;
+}
+
+/**
+ * An element in a fake ancestor chain. It carries only what the hook uses:
+ * class tokens, addEventListener/removeEventListener, and closest(), which is
+ * how the hook finds the volume control from the pointer's target.
+ */
+interface FakeElement {
+ tokens: string[];
+ parent: FakeElement | null;
+ registrations: Registration[];
+ classList: { contains(token: string): boolean };
+ addEventListener(type: string, listener: (event: unknown) => void, options?: unknown): void;
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
+ closest(selector: string): FakeElement | null;
+}
+
+function element(classes: string, parent: FakeElement | null): FakeElement {
const tokens = classes.split(' ');
- return { classList: { contains: (token: string) => tokens.includes(token) } };
+ const node: FakeElement = {
+ tokens,
+ parent,
+ registrations: [],
+ classList: { contains: (token: string) => tokens.includes(token) },
+ addEventListener(type, listener, options) {
+ node.registrations.push({ type, listener, options });
+ },
+ removeEventListener(type, listener) {
+ const index = node.registrations.findIndex(
+ (entry) => entry.type === type && entry.listener === listener,
+ );
+ if (index !== -1) node.registrations.splice(index, 1);
+ },
+ // Only class selectors are supported, which is all the hook asks for.
+ closest(selector) {
+ const wanted = selector.replace(/^\./, '');
+ for (let current: FakeElement | null = node; current; current = current.parent) {
+ if (current.tokens.includes(wanted)) return current;
+ }
+ return null;
+ },
+ };
+ return node;
}
/**
- * A composed path built from the outermost-first class lists, ordered as the
- * DOM delivers it and terminated by the window, which carries no classList.
+ * Builds an ancestor chain from the outermost-first class lists and hands back
+ * the innermost element, which is what a pointer or wheel event targets.
*/
-function composedPath(...classes: string[]) {
- return [...classes.map(element), {}];
+function chain(outermost: string, ...rest: string[]): FakeElement {
+ let node = element(outermost, null);
+ for (const entry of rest) node = element(entry, node);
+ return node;
+}
+
+/** The volume control in a chain, for a test that asserts against it directly. */
+function volumeControl(target: FakeElement): FakeElement {
+ const control = target.closest('.chrome-volume');
+ if (!control) throw new Error('this chain carries no chrome-volume element');
+ return control;
+}
+
+/** Every wheel registration on a chain, innermost first, as bubbling sees them. */
+function wheelRegistrations(target: FakeElement): Registration[] {
+ const found: Registration[] = [];
+ for (let node: FakeElement | null = target; node; node = node.parent) {
+ found.push(...node.registrations.filter((entry) => entry.type === 'wheel'));
+ }
+ return found;
}
// The two services differ in the element that carries the chrome-volume token:
// a div on music.apple.com, an amp-chrome-volume element on classical.
-const MUSIC_VOLUME_PATH = composedPath(
- 'chrome-volume__slider', 'chrome-volume', 'chrome-player',
+const musicVolumeTarget = () => chain(
+ 'chrome-player', 'chrome-volume', 'chrome-volume__slider',
);
-const CLASSICAL_VOLUME_PATH = composedPath(
- 'chrome-volume__indicator', 'amp-volume-control', 'chrome-volume',
- 'chrome-player__volume',
+const classicalVolumeTarget = () => chain(
+ 'chrome-player__volume', 'chrome-volume', 'amp-volume-control',
+ 'chrome-volume__indicator',
);
-const NON_VOLUME_PATH = composedPath('chrome-player__button', 'chrome-player');
+const nonVolumeTarget = () => chain('chrome-player', 'chrome-player__button');
/** A MusicKit stand-in, optionally with a volume getter that throws. */
function createMusicKit(
@@ -86,10 +148,12 @@ function createHarness({
const intervals: Array<{ callback: () => void; delay: number }> = [];
const intervalCallbacks: Array<() => void> = [];
const messageListeners: Array<(event: unknown) => void> = [];
- const wheelListeners: Array<{
- listener: (event: unknown) => void;
- options: unknown;
- }> = [];
+ const pointerOverListeners: Array<(event: unknown) => void> = [];
+ // Registrations the hook made on window. The wheel listener must never appear
+ // here: see the non-fast-scrollable region note in the hook. A registration on
+ // document is caught differently - document is not in the vm context, so
+ // reaching for it throws where the hook runs.
+ const globalRegistrations: Registration[] = [];
const musicKitListeners = new Map void>();
const mediaSession = { setPositionState: vi.fn() };
const navigator = navigatorOverrides ?? { mediaSession };
@@ -114,8 +178,9 @@ function createHarness({
listener: (event: unknown) => void,
options?: unknown,
) => {
+ globalRegistrations.push({ type: event, listener, options });
if (event === 'message') messageListeners.push(listener);
- if (event === 'wheel') wheelListeners.push({ listener, options });
+ if (event === 'pointerover') pointerOverListeners.push(listener);
}),
navigator,
};
@@ -169,21 +234,24 @@ function createHarness({
if (!window.AMWrapper) throw new Error('this harness was built without an AMWrapper bridge');
return window.AMWrapper.ipcRenderer.send;
},
- // Sends one wheel event to every registered listener and hands the event
- // back, so a test can read the preventDefault mock off it.
+ // Moves the pointer onto `target`, as the browser does before any wheel
+ // event reaches it. This is what resolves the hook's lazy binding.
+ hoverOver: (target: FakeElement) => {
+ for (const listener of pointerOverListeners) listener({ target });
+ },
+ // Moves the pointer onto `target`, then sends one wheel event up its
+ // ancestor chain, as bubbling delivers it. The event is handed back so a
+ // test can read the preventDefault mock off it.
dispatchWheel: (
- { ctrlKey = false, deltaY, path }:
- { ctrlKey?: boolean; deltaY: number; path: unknown[] },
+ { ctrlKey = false, deltaY, target }:
+ { ctrlKey?: boolean; deltaY: number; target: FakeElement },
) => {
- const event = {
- composedPath: () => path,
- ctrlKey,
- deltaY,
- preventDefault: vi.fn(),
- };
- for (const { listener } of wheelListeners) listener(event);
+ for (const listener of pointerOverListeners) listener({ target });
+ const event = { ctrlKey, deltaY, preventDefault: vi.fn(), target };
+ for (const { listener } of wheelRegistrations(target)) listener(event);
return event;
},
+ globalRegistrations,
mediaSession,
messageListeners,
musicKit,
@@ -213,7 +281,7 @@ function createHarness({
vm.runInContext(hookScript, context);
for (const callback of intervalCallbacks.slice(alreadyRun)) callback();
},
- wheelListeners,
+ pointerOverListeners,
window,
};
}
@@ -491,17 +559,17 @@ describe('musicKitHook', () => {
});
it.each([
- ['music', MUSIC_VOLUME_PATH],
- ['classical', CLASSICAL_VOLUME_PATH],
+ ['music', musicVolumeTarget],
+ ['classical', classicalVolumeTarget],
])('lowers the volume a step when the wheel turns down over the %s volume control', (
_service,
- path,
+ makeTarget,
) => {
const { dispatchWheel, musicKit } = createHarness({
musicKitOverrides: { volume: 0.5 },
});
- const event = dispatchWheel({ deltaY: 100, path });
+ const event = dispatchWheel({ deltaY: 100, target: makeTarget() });
expect(musicKit.volume).toBe(0.45);
expect(event.preventDefault).toHaveBeenCalled();
@@ -512,7 +580,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.5 },
});
- dispatchWheel({ deltaY: -100, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: -100, target: musicVolumeTarget() });
expect(musicKit.volume).toBe(0.55);
});
@@ -522,7 +590,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.5 },
});
- const event = dispatchWheel({ deltaY: 100, path: NON_VOLUME_PATH });
+ const event = dispatchWheel({ deltaY: 100, target: nonVolumeTarget() });
expect(musicKit.volume).toBe(0.5);
expect(event.preventDefault).not.toHaveBeenCalled();
@@ -533,7 +601,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.5 },
});
- const event = dispatchWheel({ ctrlKey: true, deltaY: 100, path: MUSIC_VOLUME_PATH });
+ const event = dispatchWheel({ ctrlKey: true, deltaY: 100, target: musicVolumeTarget() });
expect(musicKit.volume).toBe(0.5);
expect(event.preventDefault).not.toHaveBeenCalled();
@@ -544,7 +612,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.02 },
});
- dispatchWheel({ deltaY: 100, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: 100, target: musicVolumeTarget() });
expect(musicKit.volume).toBe(0);
});
@@ -554,7 +622,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.98 },
});
- dispatchWheel({ deltaY: -100, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: -100, target: musicVolumeTarget() });
expect(musicKit.volume).toBe(1);
});
@@ -566,7 +634,7 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.7 },
});
- dispatchWheel({ deltaY: 100, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: 100, target: musicVolumeTarget() });
expect(musicKit.volume).toBe(0.65);
});
@@ -575,12 +643,13 @@ describe('musicKitHook', () => {
const { dispatchWheel, musicKit } = createHarness({
musicKitOverrides: { volume: 0.5 },
});
+ const target = musicVolumeTarget();
- dispatchWheel({ deltaY: 50, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: 50, target });
expect(musicKit.volume).toBe(0.5);
- dispatchWheel({ deltaY: 50, path: MUSIC_VOLUME_PATH });
+ dispatchWheel({ deltaY: 50, target });
expect(musicKit.volume).toBe(0.45);
});
@@ -590,25 +659,95 @@ describe('musicKitHook', () => {
musicKitOverrides: { volume: 0.5 },
});
- dispatchWheel({ deltaY: -50, path: MUSIC_VOLUME_PATH });
- dispatchWheel({ deltaY: 100, path: MUSIC_VOLUME_PATH });
+ const target = musicVolumeTarget();
+
+ dispatchWheel({ deltaY: -50, target });
+ dispatchWheel({ deltaY: 100, target });
expect(musicKit.volume).toBe(0.45);
});
- it('registers the wheel listener as non-passive so preventDefault works', () => {
- const { wheelListeners } = createHarness();
+ // The listener has to be non-passive to call preventDefault, and that is
+ // exactly why it must sit on the control. A non-passive wheel listener on
+ // window or document marks the whole document a non-fast-scrollable region,
+ // so Chromium stops scrolling on the compositor thread and every wheel tick
+ // waits behind Apple Music's main thread. Nothing else in the suite notices
+ // that, because the handler still behaves correctly while doing it.
+ it('registers no wheel listener on window', () => {
+ const { globalRegistrations } = createHarness();
+
+ expect(globalRegistrations.filter((entry) => entry.type === 'wheel')).toEqual([]);
+ });
+
+ it('registers the wheel listener on the volume control, non-passive', () => {
+ const { hoverOver } = createHarness();
+ const target = musicVolumeTarget();
+
+ hoverOver(target);
+
+ const registrations = wheelRegistrations(target);
+ expect(registrations).toHaveLength(1);
+ expect(registrations[0].options).toEqual({ passive: false });
+ // On the control itself, so the non-fast-scrollable region is its own box.
+ expect(volumeControl(target).registrations).toHaveLength(1);
+ });
+
+ it('registers the pointerover listener as passive so scrolling is unaffected', () => {
+ const { globalRegistrations } = createHarness();
+
+ const pointerOver = globalRegistrations.filter((entry) => entry.type === 'pointerover');
+ expect(pointerOver).toHaveLength(1);
+ expect(pointerOver[0].options).toEqual({ passive: true });
+ });
+
+ it('binds nothing until the pointer reaches the control', () => {
+ const { globalRegistrations } = createHarness();
+ const target = musicVolumeTarget();
+
+ expect(wheelRegistrations(target)).toEqual([]);
+ expect(globalRegistrations.some((entry) => entry.type === 'pointerover')).toBe(true);
+ });
+
+ it('leaves the pointer over a non-volume element unbound', () => {
+ const { hoverOver } = createHarness();
+ const target = nonVolumeTarget();
+
+ hoverOver(target);
+
+ expect(wheelRegistrations(target)).toEqual([]);
+ });
+
+ it('binds once however often the pointer re-enters the same control', () => {
+ const { hoverOver } = createHarness();
+ const target = musicVolumeTarget();
+
+ hoverOver(target);
+ hoverOver(target);
+ hoverOver(target);
+
+ expect(wheelRegistrations(target)).toHaveLength(1);
+ });
+
+ // Apple Music replaces the player bar on navigation and on a service switch,
+ // so the binding has to move rather than accumulate on dead elements.
+ it('moves the listener to a replacement control and releases the old one', () => {
+ const { hoverOver } = createHarness();
+ const first = musicVolumeTarget();
+ const second = musicVolumeTarget();
+
+ hoverOver(first);
+ hoverOver(second);
- expect(wheelListeners).toHaveLength(1);
- expect(wheelListeners[0].options).toEqual({ passive: false });
+ expect(wheelRegistrations(first)).toEqual([]);
+ expect(wheelRegistrations(second)).toHaveLength(1);
});
- it('installs no second wheel listener when the script is injected again', () => {
- const { reinject, wheelListeners } = createHarness();
+ it('installs no second pointerover listener when the script is injected again', () => {
+ const { reinject, pointerOverListeners } = createHarness();
reinject();
- expect(wheelListeners).toHaveLength(1);
+ expect(pointerOverListeners).toHaveLength(1);
});
it('clears media session position state for a radio stream with no duration', () => {