officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes.
192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
|
|
|
// These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two
|
|
// sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real
|
|
// node that is really playing — there is simply a second one nobody is holding. The only way to catch it
|
|
// is to count the nodes that were started.
|
|
|
|
type StartCall = { when: number; offset: number };
|
|
|
|
class FakeSource {
|
|
buffer: AudioBuffer | null = null;
|
|
onended: (() => void) | null = null;
|
|
started: StartCall | null = null;
|
|
stopped = false;
|
|
disconnected = false;
|
|
constructor(private ctx: FakeContext) {}
|
|
connect(): void {}
|
|
disconnect(): void {
|
|
this.disconnected = true;
|
|
}
|
|
start(when = 0, offset = 0): void {
|
|
if (this.started) throw new Error('InvalidStateError: already started');
|
|
this.started = { when, offset };
|
|
this.ctx.started.push(this);
|
|
}
|
|
stop(): void {
|
|
if (!this.started) throw new Error('InvalidStateError: not started');
|
|
this.stopped = true;
|
|
}
|
|
/** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */
|
|
end(): void {
|
|
this.onended?.();
|
|
}
|
|
}
|
|
|
|
class FakeContext {
|
|
currentTime = 0;
|
|
state: 'running' | 'suspended' | 'closed' = 'running';
|
|
destination = {};
|
|
started: FakeSource[] = [];
|
|
createGain() {
|
|
return { gain: { value: 1 }, connect: () => {} };
|
|
}
|
|
createBufferSource() {
|
|
return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource;
|
|
}
|
|
async resume(): Promise<void> {
|
|
this.state = 'running';
|
|
}
|
|
async suspend(): Promise<void> {
|
|
this.state = 'suspended';
|
|
}
|
|
async close(): Promise<void> {
|
|
this.state = 'closed';
|
|
}
|
|
async decodeAudioData(): Promise<AudioBuffer> {
|
|
// Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant".
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
return { duration: 100 } as AudioBuffer;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine
|
|
* always has the next track already started, scheduled at the current track's end. Counting those as
|
|
* audible would make the healthy state look like the bug.
|
|
*/
|
|
const audible = (ctx: FakeContext) =>
|
|
ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime);
|
|
|
|
let ctx: FakeContext;
|
|
const originalFetch = globalThis.fetch;
|
|
const originalCtor = (globalThis as Record<string, unknown>).AudioContext;
|
|
|
|
const QUEUE: EngineTrack[] = [
|
|
{ key: 'a', url: '/stream/a' },
|
|
{ key: 'b', url: '/stream/b' },
|
|
{ key: 'c', url: '/stream/c' },
|
|
];
|
|
|
|
// Let every pending decode + its continuation run.
|
|
const settle = async () => {
|
|
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
|
|
};
|
|
|
|
beforeEach(() => {
|
|
ctx = new FakeContext();
|
|
(globalThis as Record<string, unknown>).AudioContext = function () {
|
|
return ctx;
|
|
};
|
|
globalThis.fetch = (async () => ({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
})) as unknown as typeof fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
(globalThis as Record<string, unknown>).AudioContext = originalCtor;
|
|
});
|
|
|
|
describe('GaplessEngine', () => {
|
|
test('load(autoplay) followed by play() starts the track exactly once', async () => {
|
|
// The host's queue effect and playing effect both fire in the same commit when a queue starts from a
|
|
// paused player. This is that commit, and it used to produce two sources of the same track.
|
|
const engine = new GaplessEngine({});
|
|
engine.load(QUEUE, 0, true);
|
|
engine.play();
|
|
await settle();
|
|
|
|
expect(audible(ctx)).toHaveLength(1);
|
|
engine.destroy();
|
|
});
|
|
|
|
test('repeated play() during the decode does not stack sources', async () => {
|
|
const engine = new GaplessEngine({});
|
|
engine.load(QUEUE, 0, true);
|
|
engine.play();
|
|
engine.play();
|
|
engine.play();
|
|
await settle();
|
|
|
|
expect(audible(ctx)).toHaveLength(1);
|
|
engine.destroy();
|
|
});
|
|
|
|
test('pause then play while still decoding does not stack sources', async () => {
|
|
// The impatient-user path: nothing is audible yet because the file is still downloading, so the play
|
|
// button gets hit again.
|
|
const engine = new GaplessEngine({});
|
|
engine.load(QUEUE, 0, true);
|
|
engine.pause();
|
|
engine.play();
|
|
await settle();
|
|
|
|
expect(audible(ctx)).toHaveLength(1);
|
|
engine.destroy();
|
|
});
|
|
|
|
test('a stale source ending does not advance the queue', async () => {
|
|
// Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index.
|
|
const seen: number[] = [];
|
|
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
|
engine.load(QUEUE, 0, true);
|
|
await settle();
|
|
|
|
const first = audible(ctx)[0]!;
|
|
engine.skipTo(2); // bumps the generation and stops `first`
|
|
await settle();
|
|
first.end(); // the browser still delivers its onended
|
|
|
|
expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index
|
|
engine.destroy();
|
|
});
|
|
|
|
test('switching queues leaves nothing from the old one sounding', async () => {
|
|
const engine = new GaplessEngine({});
|
|
engine.load(QUEUE, 0, true);
|
|
await settle();
|
|
engine.load([{ key: 'z', url: '/stream/z' }], 0, true);
|
|
await settle();
|
|
|
|
expect(audible(ctx)).toHaveLength(1);
|
|
expect(audible(ctx)[0]!.started).not.toBeNull();
|
|
engine.destroy();
|
|
});
|
|
|
|
test('a natural boundary advances the index exactly once', async () => {
|
|
const seen: number[] = [];
|
|
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
|
|
engine.load(QUEUE, 0, true);
|
|
await settle();
|
|
|
|
const cur = audible(ctx)[0]!;
|
|
cur.end();
|
|
await settle();
|
|
|
|
expect(seen).toEqual([1]);
|
|
engine.destroy();
|
|
});
|
|
|
|
test('destroy() silences every source it started', async () => {
|
|
const engine = new GaplessEngine({});
|
|
engine.load(QUEUE, 0, true);
|
|
await settle();
|
|
engine.destroy();
|
|
|
|
expect(audible(ctx)).toHaveLength(0);
|
|
});
|
|
});
|