Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
390 lines
30 KiB
JavaScript
390 lines
30 KiB
JavaScript
import { drawCover, hash32, prng } from "./covers.js";
|
||
import { $, asDataUrl, mmss, ids, state, toast } from "./core.js";
|
||
import { wavBlob } from "./render.js";
|
||
import { drawTicks, drawTrail, keepLabel, nowPlaying, setPlayIcon, showLyrics, showTags } from "./ui/player.js";
|
||
import { db, logEvent, remember, saveStation, tapeById } from "./store.js";
|
||
import { cookNext, makeJob } from "./app.js";
|
||
import { playTape } from "./ui/library.js";
|
||
import { askDJ } from "./dj.js";
|
||
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
|
||
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
|
||
so the first real track can start itself when the render lands, however long that takes */
|
||
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
|
||
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
|
||
export const crackle = { el: null, on: false };
|
||
export function makeCrackle(seconds = 12, gain = 1) {
|
||
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
|
||
let lp = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss, around -16 dBFS
|
||
mix[i] += lp * 1.6;
|
||
if (rnd() < 0.0015) { const len = 20 + Math.floor(rnd() * 80), amp = 0.3 + rnd() * 0.5; for (let k = 0; k < len && i + k < n; k++) mix[i + k] += amp * (1 - k / len) * (rnd() * 2 - 1); } // a pop
|
||
}
|
||
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i] * gain)) * 32767;
|
||
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||
new Int16Array(buf, 44).set(out);
|
||
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = Math.min(parseFloat(localStorage.getItem("gandora.bed") || "0.18"), localStorage.getItem("gandora.bed2") ? 1 : 0.18); localStorage.setItem("gandora.bed2", "1"); el.preload = "auto";
|
||
el.mix = mix; el.sr = sr; // kept for the Web Audio bed
|
||
return el;
|
||
}
|
||
/* the bed through Web Audio: a looping buffer has no seam (an <audio> loop pauses at the join, badly on Safari).
|
||
This is not the music's path, so the iOS rules about the player don't apply; if the context can't start, the
|
||
element plays instead. */
|
||
const bedAC = { ctx: null, gain: null, src: null, buf: null };
|
||
function bedStart(base) {
|
||
if (state.phase === "playing" && (state.playing || state.playPending || !state.audio.paused)) return false; // never over the music
|
||
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false;
|
||
try {
|
||
bedAC.ctx ||= new AC();
|
||
if (bedAC.ctx.state !== "running") bedAC.ctx.resume().catch(() => {});
|
||
if (bedAC.ctx.state !== "running") return false;
|
||
if (!bedAC.buf) { const el = crackle.el; bedAC.buf = bedAC.ctx.createBuffer(1, el.mix.length, el.sr); bedAC.buf.getChannelData(0).set(el.mix.map((v) => Math.max(-1, Math.min(1, v)))); }
|
||
bedStop(0);
|
||
const src = bedAC.ctx.createBufferSource(); src.buffer = bedAC.buf; src.loop = true;
|
||
const gain = bedAC.ctx.createGain(); const t = bedAC.ctx.currentTime;
|
||
gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(base, t + 0.6);
|
||
src.connect(gain).connect(bedAC.ctx.destination); src.start();
|
||
bedAC.src = src; bedAC.gain = gain;
|
||
try { crackle.el.pause(); } catch {}
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
function bedStop(fade = 0.4) {
|
||
const { ctx, gain, src } = bedAC; if (!src) return;
|
||
bedAC.src = null; bedAC.gain = null;
|
||
try { const t = ctx.currentTime; gain.gain.cancelScheduledValues(t); gain.gain.setValueAtTime(gain.gain.value, t); gain.gain.linearRampToValueAtTime(0, t + fade); src.stop(t + fade + 0.05); } catch {}
|
||
}
|
||
export function bedLevel(v) { if (bedAC.gain && bedAC.ctx) { const t = bedAC.ctx.currentTime; bedAC.gain.gain.cancelScheduledValues(t); bedAC.gain.gain.setValueAtTime(v, t); } }
|
||
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
|
||
export function bed() {
|
||
clearTimeout(crackle.timer);
|
||
crackle.timer = setTimeout(() => {
|
||
// on unless music is actually running or the player itself is holding with the crackle; the unlock clip is neither
|
||
const music = state.phase === "playing" && (state.playing || !state.audio.paused || state.playPending) && state.audio.src !== SILENCE;
|
||
const want = !music && state.phase !== "holding";
|
||
if (want) crackleOn(); else crackleOff();
|
||
}, 300);
|
||
}
|
||
export const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
|
||
function crackleOn() {
|
||
if (crackle.on || state.holding || bedOff()) return;
|
||
crackle.el ||= makeCrackle(); crackle.on = true;
|
||
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
|
||
if (bedStart(base)) { crackle.via = "ac"; return; }
|
||
crackle.via = "el"; crackle.el.volume = 0; crackle.el.currentTime = 0;
|
||
crackle.el.play().catch(() => { crackle.on = false; clearInterval(crackle.ramp); }); // refused without a gesture: the first tap calls again
|
||
clearInterval(crackle.ramp);
|
||
crackle.ramp = setInterval(() => { const k = Math.min(1, (performance.now() - t0) / 600); if (!crackle.on) { clearInterval(crackle.ramp); return; } crackle.el.volume = base * k; if (k >= 1) clearInterval(crackle.ramp); }, 40);
|
||
}
|
||
export function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
|
||
/* 50 ms of silence as a wav, built here rather than pasted */
|
||
|
||
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
|
||
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
|
||
function prime() {
|
||
if (state.needTap && state.current && state.current.blob) { // a resume that autoplay refused: this tap starts it
|
||
state.needTap = false; $("#cover").classList.remove("tap"); state.tapStarted = performance.now();
|
||
state.audio.play().then(() => { state.playing = true; state.primed = true; crackleOff(); setPlayIcon(); vizStart(); }).catch(() => { state.needTap = true; $("#cover").classList.add("tap"); });
|
||
return;
|
||
}
|
||
if (!crackle.on) bed();
|
||
if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return;
|
||
try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {}
|
||
try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {}
|
||
}
|
||
for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true });
|
||
setTimeout(bed, 400); // the room tone, from the start where the browser lets it
|
||
/* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when
|
||
the screen locks (and obeys the mute switch). So on iOS the cover pulses to the beat from the sheet instead,
|
||
and the music stays a plain <audio> element that keeps playing in the background. */
|
||
const IOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
|
||
function vizBeat() {
|
||
cancelAnimationFrame(viz.raf);
|
||
const canvas = $("#cover canvas"), t0 = performance.now(), rnd = prng(hash32(state.current.spec.title || ""));
|
||
const frame = () => {
|
||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||
const spec = state.current.spec, t = (performance.now() - t0) / 1000, bps = (+spec.bpm || 100) / 60, en = +spec.energy || 0.5;
|
||
const beat = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps)), 6); // a kick on every beat
|
||
const bar = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps / 4)), 2); // and a swell on the bar
|
||
const bass = Math.min(1, (0.25 + 0.75 * beat) * (0.5 + 0.5 * en)), mid = 0.3 + 0.3 * bar * en, high = 0.2 + 0.25 * Math.pow(Math.max(0, Math.sin(2 * Math.PI * t * bps * 2)), 4) * en;
|
||
drawCover(canvas, spec, { pulse: { bass, mid, high, t } });
|
||
viz.raf = requestAnimationFrame(frame);
|
||
};
|
||
viz.raf = requestAnimationFrame(frame);
|
||
}
|
||
function vizStart() {
|
||
if (IOS || state.beatViz) return vizBeat();
|
||
// the player is only wired through the analyser once the context is actually running: a suspended context
|
||
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
|
||
try {
|
||
viz.ctx ||= new (window.AudioContext || window.webkitAudioContext)();
|
||
if (viz.ctx.state !== "running") { viz.ctx.resume().then(() => { if (viz.ctx.state === "running" && state.playing) vizStart(); }).catch(() => {}); if (viz.ctx.state !== "running") return; }
|
||
if (!viz.an) {
|
||
const src = viz.ctx.createMediaElementSource(state.audio);
|
||
viz.an = viz.ctx.createAnalyser(); viz.an.fftSize = 512; viz.an.smoothingTimeConstant = 0.7;
|
||
src.connect(viz.an); viz.an.connect(viz.ctx.destination);
|
||
viz.data = new Uint8Array(viz.an.frequencyBinCount);
|
||
}
|
||
} catch { return; }
|
||
cancelAnimationFrame(viz.raf);
|
||
const canvas = $("#cover canvas"), t0 = performance.now();
|
||
const band = (a, b) => { let s = 0; for (let i = a; i < b; i++) s += viz.data[i]; return s / (b - a) / 255; };
|
||
const frame = () => {
|
||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||
viz.an.getByteFrequencyData(viz.data);
|
||
const raw = { bass: band(1, 8), mid: band(8, 40), high: band(40, 120) };
|
||
for (const k in raw) viz.smooth[k] += (raw[k] - viz.smooth[k]) * (raw[k] > viz.smooth[k] ? 0.5 : 0.12); // fast attack, slow decay
|
||
const s = viz.smooth;
|
||
drawCover(canvas, state.current.spec, { pulse: { bass: Math.min(1, s.bass * 1.4), mid: s.mid, high: Math.min(1, s.high * 2.5), t: (performance.now() - t0) / 1000 } });
|
||
viz.raf = requestAnimationFrame(frame);
|
||
};
|
||
viz.raf = requestAnimationFrame(frame);
|
||
}
|
||
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
|
||
|
||
|
||
/* ---------- the player: one small state machine ----------
|
||
state.phase is one of
|
||
idle nothing on air: no station, or the station stopped
|
||
cooking state.current is on screen and its render hasn't landed; the player is silent, the room tone plays beside it
|
||
holding the player itself loops the crackle (a locked phone keeps the session only while the element stays busy);
|
||
a track that lands meanwhile queues behind it (state.pendingJob) and takes over when the crackle's pass ends
|
||
playing a track's audio is in the player (pause is not a phase: state.playing says whether it runs)
|
||
ended the track ran out; inside the event the next source starts or the hold begins, then advance() picks on
|
||
The transitions are the functions below (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) and
|
||
only they set state.phase or the player's src. The one exception is prime(): the unlock clip, which is not a track.
|
||
The order inside `ended` matters: a source that must survive a locked screen starts synchronously there; the
|
||
bookkeeping comes after. And nothing awaits between setting src and play() except the render itself (a database read
|
||
there has lost the user activation before). */
|
||
state.phase = "idle";
|
||
Object.defineProperty(state, "holding", { get: () => state.phase === "holding" }); // the old flag, now read-only
|
||
const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob));
|
||
/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */
|
||
function setSource(src, loop) {
|
||
const old = state.audio.src;
|
||
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src) && !(crackle.hold && old === crackle.hold.src)) URL.revokeObjectURL(old);
|
||
state.audio.src = src; state.audio.loop = loop;
|
||
if (state.holdVol != null && !(crackle.hold && src === crackle.hold.src)) { state.audio.volume = state.holdVol; state.holdVol = null; } // the hold ran at room-tone level
|
||
}
|
||
/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track).
|
||
A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended`
|
||
(job.preplayed) is already playing and keeps that phase. */
|
||
function toCooking(job) {
|
||
const spec = job.spec;
|
||
state.current = job;
|
||
if (!job.preplayed && state.phase !== "holding") state.phase = "cooking";
|
||
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
|
||
keepLabel();
|
||
for (const t of $("#bar").querySelectorAll("em")) t.remove();
|
||
drawTicks(spec, spec.seconds || 120);
|
||
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
|
||
$("#lyric").textContent = spec.line || "";
|
||
showTags(spec);
|
||
showLyrics(spec);
|
||
drawCover($("#cover canvas"), spec);
|
||
if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; }
|
||
$("#cover").classList.add("cooking"); $("#bar i").style.width = "0";
|
||
bed(); // the needle is down while it cooks
|
||
}
|
||
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
|
||
function toHolding() {
|
||
try {
|
||
// the level is baked into the sample: iOS ignores an element's volume. OFF holds on four seconds of nothing (its own
|
||
// URL, not SILENCE, so `ended` still fires and hands over).
|
||
const lvl = bedOff() ? 0 : Math.round((crackle.base ?? 0.18) * 100) / 100;
|
||
if (!crackle.hold || crackle.hold.lvl !== lvl) { crackle.hold = lvl ? makeCrackle(4, lvl) : Object.assign(new Audio(URL.createObjectURL(wavBlob(new Int16Array(8000 * 4), 8000))), { loop: true }); crackle.hold.lvl = lvl; }
|
||
crackleOff(); setSource(crackle.hold.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
|
||
}
|
||
/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and
|
||
handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */
|
||
function queueBehindHold(job) {
|
||
return new Promise((r) => { srcOf(job); state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
|
||
}
|
||
/* → playing, inside `ended`: the crackle's pass is over and the queued track takes its place, synchronously */
|
||
function handOver(job) {
|
||
state.pendingJob = null;
|
||
const go = state.onHoldEnd; state.onHoldEnd = null;
|
||
if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending
|
||
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); } catch {}
|
||
if (go) go();
|
||
}
|
||
/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right
|
||
here; playJob then finds it preplayed and only does the bookkeeping */
|
||
function startNow(job) {
|
||
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); return true; }
|
||
catch { return false; }
|
||
}
|
||
/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with
|
||
no gesture yet) means TAP TO PLAY: the source stays loaded and prime() starts it on the next tap. */
|
||
async function toPlaying(job) {
|
||
if (!job.preplayed) { setSource(srcOf(job), !!state.loop); state.pendingJob = null; }
|
||
state.phase = "playing";
|
||
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
|
||
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
|
||
}
|
||
/* play() inside the swap: the playing flag follows the promise, so a refusal (iOS after a lock, after another element
|
||
played) shows TAP TO PLAY instead of a playing UI with no sound; the bed stays off while the answer is pending */
|
||
function playNow(job) {
|
||
state.playPending = true;
|
||
let p; try { p = state.audio.play(); } catch (e) { p = Promise.reject(e); }
|
||
Promise.resolve(p).then(() => { state.playPending = false; if (state.current === job) { state.playing = true; state.needTap = false; $("#cover").classList.remove("tap"); setPlayIcon(); } })
|
||
.catch(() => { state.playPending = false; if (state.current === job || !state.current) { state.playing = false; state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); setPlayIcon(); bed(); } });
|
||
}
|
||
/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what
|
||
follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count
|
||
as "queued ahead": advance() decides those, so nothing is started here. */
|
||
function toEnded() {
|
||
state.phase = "ended"; state.playing = false; setPlayIcon(); vizStop();
|
||
const job = state.nextJob;
|
||
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
|
||
if (!queued) { if (job && job.blob) startNow(job); else toHolding(); }
|
||
advance();
|
||
}
|
||
/* → idle: the station stops (a change of station, a deleted station) */
|
||
export function toIdle() {
|
||
state.audio.pause(); state.playing = false; setPlayIcon();
|
||
state.current = null; state.nextJob = null; state.pendingJob = null; state.phase = "idle";
|
||
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
|
||
}
|
||
/* a job through the machine: show it, wait for its render, start it, then the bookkeeping */
|
||
export async function playJob(job) {
|
||
const spec = job.spec;
|
||
toCooking(job);
|
||
await job.done;
|
||
if (state.current !== job) return; // skipped while cooking
|
||
$("#cover").classList.remove("cooking");
|
||
if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; }
|
||
if (state.phase === "holding" && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
|
||
await queueBehindHold(job);
|
||
if (state.current !== job) return;
|
||
}
|
||
spec.id ||= spec.made + "-" + hash32(spec.title);
|
||
if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back
|
||
await toPlaying(job);
|
||
nowPlaying(spec); document.title = `${spec.title} · g*ndora`;
|
||
cookNext(); // (no-op if already cooking)
|
||
// the bookkeeping, after the music is already going
|
||
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
|
||
(async () => {
|
||
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
|
||
if (job.fileInto) { const tp = await tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
|
||
drawTrail().catch(() => {});
|
||
})().catch(() => {});
|
||
}
|
||
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
|
||
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
|
||
|
||
async function step(dir) {
|
||
const i = state.pos + dir; if (i < 0 || i >= state.recent.length) return false;
|
||
const row = await db.get(state.recent[i]); if (!row) return false;
|
||
state.pos = i; state.audio.pause();
|
||
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||
return true;
|
||
}
|
||
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
|
||
never one of the last few heard; null when the library is too thin */
|
||
async function shuffled() {
|
||
const rows = (await db.all()).filter((t) => t.spec.stationId === state.stationId && t.blob && !t.spec.break);
|
||
const avoid = new Set(state.recent.slice(-4));
|
||
const pool = rows.filter((t) => !avoid.has(t.id)); if (pool.length < 3) return null;
|
||
const net = typeof brainNow === "function" ? brainNow() : null;
|
||
const w = pool.map((t) => Math.exp(3 * (net ? net.predict(t.spec) : 0.5)) * (t.kept ? 2 : 1) * (1 + 0.3 * Math.log1p(t.plays || 0)));
|
||
let r = Math.random() * w.reduce((a, b) => a + b, 0);
|
||
for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; }
|
||
return pool[pool.length - 1];
|
||
}
|
||
/* what plays next, in order: the tape → a shared tape still rendering → forward in the session → the remix/after
|
||
slot → shuffle (maybe) → the cooked next → or cook one now. Called after toEnded() and on skip. */
|
||
export async function advance() {
|
||
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
|
||
if (state.tape) {
|
||
const tp = await tapeById(state.tape.id);
|
||
if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); }
|
||
state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station.");
|
||
}
|
||
// a shared tape still rendering: its next track comes before anything fresh
|
||
if (state.tapeQueue && state.tapeQueue.length && !state.nextJob) {
|
||
const t = state.tapeQueue.shift(); const spec = { ...t, seed: t.seed || state.seed, made: Date.now() };
|
||
state.nextJob = makeJob(spec); state.nextJob.fileInto = state.sharedTapeId;
|
||
}
|
||
if (await step(+1)) return; // forward through the session first, then cook new
|
||
if (!state.nextJob && state.after) { state.nextJob = state.after; state.after = null; }
|
||
if (state.shuffle && Math.random() < 0.5) {
|
||
const t = await shuffled();
|
||
if (t) { state.audio.pause(); await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; }
|
||
}
|
||
bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs
|
||
const job = state.nextJob || await cookNext();
|
||
state.nextJob = null; $("#next").textContent = "";
|
||
if (!job) { crackleOff(); return; }
|
||
await playJob(job);
|
||
cookNext();
|
||
}
|
||
/* the player's events. `ended` is the only one that moves the machine; the rest keep state.playing honest. */
|
||
state.audio.addEventListener("ended", () => {
|
||
if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air
|
||
if (state.phase === "holding") { if (state.pendingJob) handOver(state.pendingJob); return; } // the crackle's pass ran out: hand over, or (loop is on, so rarely) nothing waits
|
||
toEnded();
|
||
});
|
||
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); });
|
||
state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } });
|
||
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
|
||
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
|
||
/* scrubbing: tap or drag along the bar; the readout shows where you'd land */
|
||
(function () {
|
||
const bar = $("#bar"), fill = $("#bar i"), tip = $("#bar b");
|
||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };
|
||
let down = false;
|
||
const show = (f) => { fill.style.width = f * 100 + "%"; tip.style.left = f * 100 + "%"; tip.textContent = state.audio.duration ? `${mmss(f * state.audio.duration)} / ${mmss(state.audio.duration)}` : ""; };
|
||
bar.addEventListener("pointerdown", (e) => { if (!state.audio.duration || !state.current || !state.current.blob) return; down = true; bar.classList.add("drag"); bar.setPointerCapture(e.pointerId); show(frac(e)); e.preventDefault(); });
|
||
bar.addEventListener("pointermove", (e) => { if (down) show(frac(e)); });
|
||
const end = (e) => { if (!down) return; down = false; bar.classList.remove("drag"); const f = frac(e); state.audio.currentTime = f * state.audio.duration; show(f); };
|
||
bar.addEventListener("pointerup", end); bar.addEventListener("pointercancel", end);
|
||
})();
|
||
|
||
if ("mediaSession" in navigator) {
|
||
const ms = navigator.mediaSession;
|
||
try {
|
||
ms.setActionHandler("play", () => $("#play").click());
|
||
ms.setActionHandler("pause", () => $("#play").click());
|
||
ms.setActionHandler("previoustrack", () => $("#backTrack").click());
|
||
ms.setActionHandler("nexttrack", () => $("#skip").click());
|
||
} catch {}
|
||
state.audio.addEventListener("play", () => { ms.playbackState = "playing"; });
|
||
state.audio.addEventListener("pause", () => { ms.playbackState = "paused"; });
|
||
}
|
||
/* updates: an installed app has no address bar to refresh from, so the page checks for a new worker whenever it
|
||
comes back to the foreground (and every ten minutes) and offers a reload when one has taken over */
|
||
|
||
$("#play").addEventListener("click", async () => {
|
||
if (!state.audio.src) return;
|
||
if (state.tapStarted && performance.now() - state.tapStarted < 800) { state.tapStarted = 0; setPlayIcon(); return; } // this tap already started it
|
||
if (state.needTap) { prime(); return; }
|
||
if (state.playing) { state.audio.pause(); state.playing = false; vizStop(); } else { try { await state.audio.play(); state.playing = true; vizStart(); } catch {} }
|
||
setPlayIcon();
|
||
});
|
||
$("#skip").addEventListener("click", () => { state.audio.pause(); state.playing = false; setPlayIcon(); if (state.current) state.current.spec.reaction = (state.current.spec.reaction || "") + " [skipped]"; advance(); });
|
||
$("#remix").addEventListener("click", async () => {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
state.remixOf = cur; toast("Remixing — it goes next.");
|
||
try {
|
||
const spec = await askDJ(); spec.remixOf = cur.id;
|
||
// the renderer hears the original, which also fixes the length: when the listener asked for shorter/longer, let the length win
|
||
if (state.current && state.current.blob && Math.abs((spec.seconds || 120) - (cur.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.current.blob);
|
||
const job = makeJob(spec);
|
||
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
|
||
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";
|
||
} catch (e) { toast(e.message, 5000); }
|
||
finally { state.remixOf = null; }
|
||
});
|
||
$("#shuffle").addEventListener("click", () => { state.shuffle = !state.shuffle; $("#shuffle").classList.toggle("on", state.shuffle); toast(state.shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh."); });
|
||
$("#loop").addEventListener("click", () => { state.audio.loop = !state.audio.loop; state.loop = state.audio.loop; $("#loop").classList.toggle("on", state.audio.loop); toast(state.audio.loop ? "Repeating this track." : "Back to the station."); });
|
||
$("#prev").addEventListener("click", () => { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } });
|
||
$("#backTrack").addEventListener("click", async () => { if (!(await step(-1))) toast("That's the first track of this session."); });
|
||
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
|