Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/Queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import type { GaplessOptions, AddTrackOptions, TrackInfo, TrackMetadata, Playbac

const MAX_SCHEDULE_LOOKAHEAD = 5;

// Minimal silent WAV: 44-byte header + 2 bytes of silence (1 sample, mono, 16-bit, 44100 Hz).
// Looped on a hidden <audio> element to keep the browser's media session anchor alive
// after the real track crosses over to Web Audio (Chrome/Safari stop routing media
// keys once every <audio>/<video> element is paused).
const SILENT_WAV_DATA_URI =
'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA==';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Give the media anchor a controllable duration

In Chrome, a media element must have a resource duration of at least five seconds to qualify as a controllable media session. This WAV contains one 44.1 kHz sample, so its duration is about 23 microseconds; setting loop does not change the element's reported duration. Once the real track is paused during WebAudio crossover, Chrome can therefore discard this anchor and hardware media keys remain unavailable. Use a silent asset whose intrinsic duration satisfies the browser threshold.

Useful? React with 👍 / 👎.


export class Queue implements TrackQueueRef {
private _tracks: Track[] = [];
private readonly _actor;
Expand All @@ -37,6 +44,9 @@ export class Queue implements TrackQueueRef {
private _preloadNumTracks: number;
private _playbackRate: number;

/** Silent looping element that keeps the browser's MediaSession anchor alive. */
private _mediaSessionAnchor: HTMLAudioElement | null = null;

/** Index of the next track with a pre-scheduled gapless start, or null. */
private _scheduledNextIndex: number | null = null;

Expand Down Expand Up @@ -177,7 +187,13 @@ export class Queue implements TrackQueueRef {
this._actor = createActor(machine);

this._actor.subscribe((snapshot) => {
updateMediaSessionPlaybackState(snapshot.value === 'playing');
const playing = snapshot.value === 'playing';
updateMediaSessionPlaybackState(playing);
if (playing) {
this._startMediaSessionAnchor();
} else {
this._stopMediaSessionAnchor();
}
});

this._actor.start();
Expand Down Expand Up @@ -297,6 +313,8 @@ export class Queue implements TrackQueueRef {
}

destroy(): void {
this._stopMediaSessionAnchor();
this._mediaSessionAnchor = null;
Comment on lines 345 to +347

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revoke the silent anchor's blob URL on destruction

When an application repeatedly creates, plays, and destroys queues, each call to createSilentWavUrl() registers a new ~80 KB object URL that remains alive until explicitly revoked or the document unloads. Pausing the element and nulling this reference does not release that URL, so long-lived applications accumulate the generated WAV blobs; retain the URL and call URL.revokeObjectURL() during destroy() before clearing the anchor.

Useful? React with 👍 / 👎.

for (const track of this._tracks) track.destroy();
this._tracks = [];
this._actor.stop();
Expand Down Expand Up @@ -544,4 +562,22 @@ export class Queue implements TrackQueueRef {
if (remaining <= 0) return null;
return ctx.currentTime + remaining;
}

private _startMediaSessionAnchor(): void {
if (typeof Audio === 'undefined') return;
if (!this._mediaSessionAnchor) {
this._mediaSessionAnchor = new Audio(SILENT_WAV_DATA_URI);
this._mediaSessionAnchor.loop = true;
this._mediaSessionAnchor.volume = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the silent anchor logically audible

On Chrome and Safari, a zero-volume media element is treated as inaudible and may not acquire the media/audio focus required to become the controllable MediaSession anchor, so the new element can play without restoring hardware media-key routing after the real track crosses to WebAudio. The WAV samples are already silence, so leave the element at a positive/default volume rather than setting volume to zero.

Useful? React with 👍 / 👎.

}
if (this._mediaSessionAnchor.paused) {
this._mediaSessionAnchor.play().catch(() => {});
}
}

private _stopMediaSessionAnchor(): void {
if (this._mediaSessionAnchor && !this._mediaSessionAnchor.paused) {
this._mediaSessionAnchor.pause();
}
}
}
2 changes: 2 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export class MockAudioElement {
duration = NaN;
paused = true;
readyState = 0;
loop = false;
muted = false;

ended = false;

Expand Down
88 changes: 88 additions & 0 deletions tests/unit/media-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { Queue } from '../../src/Queue';
import { mockFetchSuccess, MockAudioElement } from '../setup';

describe('media key support via silent anchor element', () => {
it('creates a silent looping audio element when playback starts', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
await new Promise(r => setTimeout(r, 0));

const anchor = (q as any)._mediaSessionAnchor as MockAudioElement;
expect(anchor).toBeDefined();
expect(anchor.paused).toBe(false);
expect(anchor.loop).toBe(true);
expect(anchor.volume).toBe(0);
});

it('pauses the anchor element when playback pauses', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
await new Promise(r => setTimeout(r, 0));

const anchor = (q as any)._mediaSessionAnchor as MockAudioElement;
expect(anchor.paused).toBe(false);

q.pause();
expect(anchor.paused).toBe(true);
});

it('resumes the anchor element when playback resumes', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
await new Promise(r => setTimeout(r, 0));
q.pause();

const anchor = (q as any)._mediaSessionAnchor as MockAudioElement;
expect(anchor.paused).toBe(true);

q.play();
expect(anchor.paused).toBe(false);
});

it('anchor stays playing after crossover to webaudio', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
for (let i = 0; i < 15; i++) await new Promise(r => setTimeout(r, 0));
await new Promise(r => setTimeout(r, 50));

const tracks = (q as any)._tracks;
expect(tracks[0].machineState).toBe('webaudio');
expect((tracks[0].audio as MockAudioElement).paused).toBe(true);

const anchor = (q as any)._mediaSessionAnchor as MockAudioElement;
expect(anchor.paused).toBe(false);
});

it('reuses the same anchor element across play/pause cycles', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
await new Promise(r => setTimeout(r, 0));
const anchor1 = (q as any)._mediaSessionAnchor;

q.pause();
q.play();
const anchor2 = (q as any)._mediaSessionAnchor;

expect(anchor1).toBe(anchor2);
});

it('cleans up anchor on destroy', async () => {
mockFetchSuccess();
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'] });
q.play();
await new Promise(r => setTimeout(r, 0));

const anchor = (q as any)._mediaSessionAnchor as MockAudioElement;
expect(anchor.paused).toBe(false);

q.destroy();
expect(anchor.paused).toBe(true);
expect((q as any)._mediaSessionAnchor).toBeNull();
});
});