feat(audio): Music plays each track to its end, then a random next (gapless)

Replace the fixed 5-min rotation with end-driven shuffle: each track plays through,
and a short crossfade a few seconds before it ends rolls a random OTHER track
(pickNextIndex, no immediate repeat) — so Music is always playing while selected.
An 'ended' handler is the safety net (short track / missed timeupdate) and a
watchdog resumes an unexpectedly-paused active track. Crossfade injectable via
window.__hefMusicXfadeMs. music-playlist.spec.ts reworked to the end-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-03 06:00:27 -07:00
parent 8c6778230b
commit e666465d62
2 changed files with 83 additions and 53 deletions
+34 -28
View File
@@ -1,57 +1,63 @@
import { test, expect, Page } from "@playwright/test";
// Music is a rotating playlist that crossfades to a random OTHER track on a timer
// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via
// window.__hefMusicRotateMs / __hefMusicXfadeMs before load.
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
// by dispatching its "ended" event (real tracks are minutes long).
async function boot(page: Page, rotateMs: number, xfadeMs: number) {
await page.addInitScript(([r, x]) => {
(window as any).__hefMusicRotateMs = r;
(window as any).__hefMusicXfadeMs = x;
}, [rotateMs, xfadeMs]);
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
async function boot(page: Page, xfadeMs = 300) {
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, xfadeMs);
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
// The track on the louder (active) music element.
async function activeTrack(page: Page): Promise<string> {
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
async function active(page: Page): Promise<{ id: string; url: string }> {
return await page.evaluate(() => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return el.dataset.url || "";
return { id: el.id, url: el.dataset.url || "" };
});
}
// Simulate the active track finishing.
async function endActive(page: Page, id: string) {
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
}
test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => {
await boot(page, 1200, 300);
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
await page.waitForTimeout(200);
const first = await activeTrack(page);
expect(first).toMatch(/audio\/music\/\w+\.mp3/);
// Wait past one rotation (1200ms) + the crossfade (300ms) + buffer.
const first = await active(page);
expect(first.url).toMatch(MUSIC_RE);
await endActive(page, first.id);
await page.waitForFunction((f) => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/");
}, first, { timeout: 6000 });
const second = await activeTrack(page);
expect(second).toMatch(/audio\/music\/\w+\.mp3/);
expect(second).not.toBe(first); // rotated to another track
return (el.dataset.url || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
}, first.url, { timeout: 6000 });
const second = await active(page);
expect(second.url).toMatch(MUSIC_RE);
expect(second.url).not.toBe(first.url); // never an immediate repeat
});
test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => {
await boot(page, 900, 250);
test("Music keeps playing across several track-ends", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
const seen = new Set<string>();
for (let i = 0; i < 3; i++) {
await page.waitForTimeout(1300);
seen.add(await activeTrack(page));
await page.waitForTimeout(400);
const cur = await active(page);
seen.add(cur.url);
await endActive(page, cur.id);
await page.waitForTimeout(400); // let the crossfade settle
}
// Over 3 rotations we should have heard more than one distinct track.
expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1);
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
});