// Background music: plays the .mp3 files of the music folder (listed by the server at music/index.json) // in random order, like a shuffled tape. No DOM here: the walkman panel drives it and listens to it. // // States: "stopped" (nothing loaded), "paused" (track loaded, silent), "playing". // Browsers refuse to start audio before the first click or key press; start() then waits for it. export const prettyTitle = (file) => { const base = file.replace(/\.mp3$/i, ""); return base.replace(/^\d+[\s._-]*/, "").replace(/_+/g, " ").replace(/\s+/g, " ").trim() || base; }; function shuffled(list) { const a = [...list]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } export function createMusic({ getVolume = () => 0.3, onUserChoice = () => {} } = {}) { const el = new Audio(); el.preload = "none"; let tracks = []; let bag = []; // tracks still to come in this shuffle let history = []; // tracks already played, for "previous" let current = null; let state = "stopped"; let waitingForGesture = false; let failures = 0; let listener = () => {}; const notify = () => listener(info()); const info = () => ({ available: tracks.length > 0, state, title: current ? prettyTitle(current) : "", waitingForGesture }); // The next track of the shuffle, never the one that just played (unless it is the only one). function pick() { if (!bag.length) { bag = shuffled(tracks); if (bag.length > 1 && bag[0] === current) bag.push(bag.shift()); } return bag.shift(); } function setTrack(name) { current = name; el.src = `music/${encodeURIComponent(name)}`; } function applyVolume() { el.volume = Math.max(0, Math.min(1, getVolume())); } async function begin() { applyVolume(); try { await el.play(); state = "playing"; waitingForGesture = false; } catch (err) { // Blocked until the player touches the page, or the file could not be read. if (err?.name === "NotAllowedError") waitingForGesture = true; state = current ? "paused" : "stopped"; } notify(); } el.addEventListener("playing", () => { failures = 0; }); el.addEventListener("ended", () => next()); el.addEventListener("error", () => { // A broken or unreadable file: try another tape, and give up when none plays. failures++; if (failures > tracks.length) { state = "stopped"; notify(); return; } if (current) history.pop(); skip(); }); function skip() { if (!tracks.length) return; setTrack(pick()); void begin(); } async function load() { try { const r = await fetch("music/index.json", { cache: "no-store" }); const data = r.ok ? await r.json() : {}; tracks = Array.isArray(data.tracks) ? data.tracks.filter((n) => typeof n === "string") : []; } catch { tracks = []; } notify(); return tracks.length; } function play() { if (!tracks.length) return; if (state === "paused" && current) { void begin(); } else { if (current) history.push(current); skip(); } onUserChoice(true); } function pause() { if (state !== "playing") return; el.pause(); state = "paused"; waitingForGesture = false; onUserChoice(false); notify(); } function stop() { el.pause(); el.currentTime = 0; state = "stopped"; current = null; waitingForGesture = false; onUserChoice(false); notify(); } function next() { if (!tracks.length) return; if (current) history.push(current); skip(); onUserChoice(true); } // Back to the start of the track, or to the one before when it has barely begun. function prev() { if (!tracks.length) return; if (state === "stopped" || el.currentTime > 3 || !history.length) { if (current) el.currentTime = 0; else skip(); } else { setTrack(history.pop()); void begin(); } onUserChoice(true); } // Called on the first click or key press: starts the music if the player wants it. function unlockedByGesture(wanted) { if (wanted && state === "stopped" && tracks.length) skip(); else if (waitingForGesture && current) void begin(); } return { load, play, pause, stop, next, prev, applyVolume, unlockedByGesture, info, setListener: (fn) => { listener = fn; fn(info()); }, }; }