feat: Adding audio player + music to play
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// 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()); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user