Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 15 additions & 18 deletions src/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ export class Track {
seekWebAudio: () => this._seekWebAudio(),
resetHtml5Element: () => {
this.audio.currentTime = 0;
this.audio.muted = false;
if (this._html5GainNode && this.ctx) {
this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime);
this._html5GainNode.gain.setValueAtTime(1, this.ctx.currentTime);

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 Pause the retained element before restoring its gain

When NEXT, PREVIOUS, or GOTO deactivates a track after crossover, the webaudio DEACTIVATE transition calls resetHtml5Element without pauseHtml5, so the retained element is still playing. Unmuting it and restoring _html5GainNode to 1 while resetting its position makes the old track restart audibly alongside the newly selected track; the fallback path has the same problem when muted is cleared.

Useful? React with 👍 / 👎.

}
},
resetTiming: () => {
this._waRefCtxTime = 0;
Expand Down Expand Up @@ -625,25 +630,17 @@ export class Track {
this._html5GainNode.gain.cancelScheduledValues(t0);
this._html5GainNode.gain.setValueAtTime(1, t0);
this._html5GainNode.gain.linearRampToValueAtTime(0, t1);
// Pause the HTML5 element after the fade completes — it stops consuming
// network/decoder resources and the gain is back to silent regardless.
// Reset the gain to 1 afterwards so future plays through this element
// (post-deactivate/reactivate) start at full level.
const ctxRef = this.ctx;
const html5GainRef = this._html5GainNode;
setTimeout(() => {
this.audio.pause();
if (ctxRef && html5GainRef) {
html5GainRef.gain.cancelScheduledValues(ctxRef.currentTime);
html5GainRef.gain.setValueAtTime(1, ctxRef.currentTime);
}
}, CROSSOVER_FADE_SEC * 1000 + 5);
// Keep the HTML5 element playing (silently) after the fade completes.
// Pausing it severs the browser's media-element anchor, causing
// macOS/ChromeOS media keys to stop dispatching MediaSession events
// (the MediaSession API requires an actively-playing <audio>/<video>).
// The gain is already 0 so no sound leaks; the element just provides
// the "active media" signal the OS needs.
} else {
// Fallback: no MediaElementSource path. Silence HTML5 immediately.
const savedVolume = this.audio.volume;
this.audio.volume = 0;
this.audio.pause();
this.audio.volume = savedVolume;
// Fallback: no MediaElementSource path. Mute the element but keep it
// playing so the browser still sees an active media element for media
// key routing.
this.audio.muted = true;
}

this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC);
Expand Down
1 change: 1 addition & 0 deletions src/machines/track.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ export function createTrackMachine(initialContext: TrackContext) {
LOOKAHEAD_REACHED: {
actions: 'setNotifiedLookahead',
},
HTML5_ENDED: {},

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 Transfer the media anchor when the retained element ends

When the retained HTML5 element reaches its natural end, ignoring this event leaves no actively playing media element. Gaplessly scheduled tracks enter webaudio via SCHEDULE_GAPLESS, and already-buffered tracks can enter it directly on PLAY, so neither path calls audio.play() to establish a replacement anchor; under the Chrome/Safari behavior this change is intended to address, media keys therefore stop working at the first track boundary (or earlier after a pause or backward seek lets the HTML5 timeline finish first).

Useful? React with 👍 / 👎.

WEBAUDIO_ENDED: {
target: 'idle',
actions: ['clearIsPlaying', 'stopProgressLoop', 'notifyTrackEnded'],
Expand Down
10 changes: 6 additions & 4 deletions tests/unit/crossover-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import { Queue } from '../../src/Queue';
import { mockFetchSuccess, mockFetchRedirect, MockAudioElement, MockAudioBuffer, advanceTime, MockGainNode } from '../setup';

describe('crossover end-to-end flow', () => {
it('after q.play() + decode, current track crosses over and HTML5 element is paused', async () => {
it('after q.play() + decode, current track crosses over and HTML5 element stays playing (silently) for media key support', async () => {
mockFetchSuccess();
const debug: string[] = [];
const q = new Queue({ tracks: ['a.mp3', 'b.mp3'], onDebug: (m: string) => debug.push(m) });
q.play();
for (let i = 0; i < 15; i++) await new Promise(r => setTimeout(r, 0));
// The HTML5 element is paused after the crossfade completes (~30 ms).
await new Promise(r => setTimeout(r, 50));

const tracks = (q as any)._tracks;
expect(tracks[0].playbackType).toBe('WEBAUDIO');
expect(tracks[0].machineState).toBe('webaudio');
expect(tracks[0].isPlaying).toBe(true);
expect((tracks[0].audio as MockAudioElement).paused).toBe(true);
// HTML5 element stays playing (gain at 0) so the browser keeps routing
// media keys to the MediaSession handlers.
expect((tracks[0].audio as MockAudioElement).paused).toBe(false);

expect(debug.some(m => m.includes('crossoverHtml5ToWebAudio'))).toBe(true);
});
Expand Down Expand Up @@ -106,7 +107,8 @@ describe('crossover end-to-end flow', () => {
expect(tracks[0].playbackType).toBe('WEBAUDIO');

const audio = tracks[0].audio as MockAudioElement;
expect(audio.paused).toBe(true);
// HTML5 element stays playing (silently) for media key support.
expect(audio.paused).toBe(false);

// Two GainNodes should have ramped — the html5GainNode (1→0) and the
// WebAudio fade gain (0→1). Inspect createGain call results.
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/media-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from 'vitest';
import { Queue } from '../../src/Queue';
import { mockFetchSuccess, MockAudioElement, MockGainNode, advanceTime } from '../setup';

describe('media key support after crossover', () => {
it('HTML5 element stays playing after crossover so browser keeps routing media keys', 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].isPlaying).toBe(true);

const audio = tracks[0].audio as MockAudioElement;
expect(audio.paused).toBe(false);
expect(audio.pause.mock.calls.length).toBe(0);
});

it('html5GainNode is ramped to 0 so the still-playing element is silent', 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));

const tracks = (q as any)._tracks;
const ctx = tracks[0].ctx as { createGain: { mock: { results: { value: MockGainNode }[] } } };
const gainNodes = ctx.createGain.mock.results.map(r => r.value);

const html5FadeNode = gainNodes.find(g =>
(g.gain.linearRampToValueAtTime as ReturnType<typeof vi.fn>).mock.calls.some(
(call) => call[0] === 0
)
);
expect(html5FadeNode).toBeDefined();
});

it('HTML5_ENDED in webaudio state does not crash or change track state', 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');

// Simulate the HTML5 element reaching its natural end while WebAudio is active
const audio = tracks[0].audio as MockAudioElement;
audio.onended?.();
await new Promise(r => setTimeout(r, 0));

// Should remain in webaudio state, still playing
expect(tracks[0].machineState).toBe('webaudio');
expect(tracks[0].isPlaying).toBe(true);
});

it('MediaSession play/pause handlers still fire after crossover to webaudio', async () => {
mockFetchSuccess();
const progressCalls: any[] = [];
const q = new Queue({
tracks: ['a.mp3', 'b.mp3'],
onProgress: (info) => progressCalls.push(info),
});
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');

// Simulate media key pause (what the MediaSession handler calls)
q.pause();
expect(tracks[0].isPlaying).toBe(false);

// Simulate media key play
q.play();
expect(tracks[0].isPlaying).toBe(true);
});

it('deactivation resets html5 gain and muted state for reuse', 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');

const audio = tracks[0].audio as MockAudioElement;

// Deactivate the track
tracks[0].deactivate();
expect(tracks[0].machineState).toBe('idle');
expect(audio.muted).toBe(false);
});

it('fallback path (no MediaElementSource) mutes element instead of pausing', async () => {
// Make createMediaElementSource throw to trigger fallback path
const mockCtx = (globalThis as any)._mockAudioContext;
mockCtx.createMediaElementSource = vi.fn(() => {
throw new Error('Simulated: double attach');
});

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');

const audio = tracks[0].audio as MockAudioElement;
// Element should still be playing (not paused), but muted
expect(audio.paused).toBe(false);
expect(audio.muted).toBe(true);
});
});