-
Notifications
You must be signed in to change notification settings - Fork 4
Fix media keys after HTML5→WebAudio crossover #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
7402ce8
71433b4
00e0bc2
5730812
9be256e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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=='; | ||
|
|
||
| export class Queue implements TrackQueueRef { | ||
| private _tracks: Track[] = []; | ||
| private readonly _actor; | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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(); | ||
|
|
@@ -297,6 +313,8 @@ export class Queue implements TrackQueueRef { | |
| } | ||
|
|
||
| destroy(): void { | ||
| this._stopMediaSessionAnchor(); | ||
| this._mediaSessionAnchor = null; | ||
|
Comment on lines
345
to
+347
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an application repeatedly creates, plays, and destroys queues, each call to Useful? React with 👍 / 👎. |
||
| for (const track of this._tracks) track.destroy(); | ||
| this._tracks = []; | ||
| this._actor.stop(); | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. |
||
| } | ||
| if (this._mediaSessionAnchor.paused) { | ||
| this._mediaSessionAnchor.play().catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| private _stopMediaSessionAnchor(): void { | ||
| if (this._mediaSessionAnchor && !this._mediaSessionAnchor.paused) { | ||
| this._mediaSessionAnchor.pause(); | ||
| } | ||
| } | ||
| } | ||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
loopdoes 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 👍 / 👎.