Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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);
|
|
});
|
|
});
|