Files

163 lines
4.4 KiB
JavaScript

// 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()); },
};
}