163 lines
4.4 KiB
JavaScript
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()); },
|
|
};
|
|
}
|