split 4/4: player.js (the state machine and the one <audio>, moved as-is), ui/player.js, ui/library.js, ui/sheet.js, ui/taste.js, mashup.js and app.js (boot, window.g, brain.js globals); index.html is markup + CSS + two script tags

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
This commit is contained in:
Claude Fable 5.1 committed 2026-10-21 22:31:34 +00:00
1 parent cc6c971423
commit 479e110f5e
9 files changed
+1297 -1264

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
import { $, LS, hooks, ids, state, toast } from "./core.js";
import { BREAKS, BREAK_EVERY, MODES, askBreak, askDJ, modeFromSeed } from "./dj.js";
import { advance, crackle, playJob, toIdle } from "./player.js";
import { KEYS, drawCover, hash32, keyHue, prng } from "./covers.js";
import { db, logEvent, saveStation } from "./store.js";
import { keepLabel, reactionEmoji } from "./ui/player.js";
import { noiseSpec, renderTrack, setFetcher, toneSpec, wavBlob } from "./render.js";
import { drawTaste, openTaste, reactionWeight } from "./ui/taste.js";
export const b64u = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64u = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4))));
export function shareLink() {
const s = state.current && state.current.spec; if (!s) return "";
const t = { seed: s.seed, station: state.station, title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "",
rseed: s.rseed, seconds: s.seconds, meter: s.meter, look: s.look || state.look, palette: s.palette || state.palette };
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
}
export function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
async function openStation(seed, opts = {}) { await openStationNow(seed, opts); if (hooks.stationOpened) hooks.stationOpened(seed, opts); }
async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", djSpeed = 1, djPersona = "", mode, welcomed = false } = {}) {
state.mode = MODES.includes(mode) ? mode : modeFromSeed(seed);
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.djSpeed = djSpeed || 1; state.djPersona = djPersona; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
if (state.stationId !== id) { toIdle(); state.recent = []; state.pos = -1; state.tape = null; }
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
$("#stationName").textContent = name || seed;
$("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
setUrl();
if (!state.current) {
if (first) { first.seed = seed; first.made = Date.now(); state.nextJob = makeJob(first); drawCover($("#cover canvas"), first, { lit: 0.6 }); $("#title").textContent = first.title; saveStation().catch(() => {}); advance(); return; }
drawCover($("#cover canvas"), { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }, { lit: 0.6 });
$("#title").textContent = "tuning…"; $("#tags").textContent = ""; $("#lyric").textContent = ""; keepLabel();
saveStation().catch(() => {});
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
const have = id ? (await db.all()).filter((t) => t.spec.stationId === id) : [];
if (have.length) { const t = have[0]; await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true }); state.recent = [t.id]; state.pos = 0; return; }
advance();
}
}
export function resumeStation(s) { openStation(s.seed, s); }
/* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ
continues from it. The track is copied into the new station so the original stays where it was. */
export async function stationFrom(t) {
const s = t.spec; if (!t.blob) { toast("That one hasn't finished cooking."); return; }
const id = `s-${Date.now()}-${hash32(s.title + s.tags)}`;
const seed = `like "${s.title}": ${s.tags.split(",").map((x) => x.trim()).filter(Boolean).slice(0, 5).join(", ")}`;
const copy = { ...s, id: s.id + "-" + hash32(id), stationId: id, seed, made: Date.now(), reaction: "", plays: 0 };
delete copy.station;
await db.put({ id: copy.id, spec: copy, blob: t.blob, made: copy.made, kept: false, plays: 0 });
state.stationId = null; // so openStation treats it as a change of station
await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] },
history: [copy], played: 0, look: s.look, palette: s.palette });
state.refFor = { blob: t.blob, left: 2, seconds: s.seconds || 120 }; // the next two tracks are rendered with the source as reference
toast(`New station from “${s.title}”.`, 3000);
}
export async function stationFromCurrent() {
const cur = state.current && state.current.spec; if (!cur) return;
const row = (await db.get(cur.id)) || (state.current.blob ? { spec: cur, blob: state.current.blob } : null);
if (!row) { toast("That one hasn't finished cooking."); return; }
stationFrom(row);
}
/* ---------- the station loop ---------- */
export function makeJob(spec) {
if (hooks.jobMade) hooks.jobMade(spec);
const job = { id: ++ids.seq, spec, blob: null, err: null, done: null };
job.done = (async () => {
const t0 = Date.now();
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
const t = `COOKING ${el}s${exp ? " / ~" + exp + "s" : ""}${hedged ? " · SECOND OVEN LIT" : ""}`;
if (state.current === job) $("#cover .veil").textContent = t;
else if (state.nextJob === job && state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · " + t;
}); }
catch (e) { job.err = e; }
logEvent("cook", { ms: Date.now() - t0, ok: !job.err, err: job.err ? String(job.err.message || job.err).slice(0, 80) : undefined, cost: spec.cost || 0, r: spec.renderer || (spec.noise ? "device:noise" : spec.tone ? "device:tone" : ""), s: spec.seconds || 0,
mode: state.mode || "music", st: state.station || state.seed || "", brk: spec.break || undefined, fb: spec.fallback || undefined, title: spec.title });
})();
return job;
}
let cooking = null;
/* DJ intermissions: every BREAK_EVERY slots the DJ takes a 30 s break in the station's own sound — a station ident, a
recap of what just played, a tease of what's coming, a shout-out to the listener, or (now and then) the pro word.
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
export function cookNext() {
if (state.nextJob) return Promise.resolve(state.nextJob);
if (cooking) return cooking;
cooking = (async () => {
try {
const real = state.history.filter((t) => !t.break).length;
const welcome = !state.welcomed && real >= 3 && !state.history.some((t) => t.break === "welcome"); // the DJ pops on once the brain does
const local = state.mode === "noise" || state.mode === "tones";
const due = !local && (welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3)); // three real tracks first: that's when the brain wakes up too
const kind = welcome ? "welcome" : BREAKS[(state.breaks || 0) % BREAKS.length];
const spec = state.mode === "noise" ? noiseSpec() : state.mode === "tones" ? toneSpec() : due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
if (spec.break) { state.sinceAd = 0; state.breaks = (state.breaks || 0) + 1; } else state.sinceAd = (state.sinceAd || 0) + 1;
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.break ? `NEXT · ${spec.break.toUpperCase()} · ` : "NEXT · ") + spec.title.toUpperCase(); return state.nextJob; }
catch (e) { toast(e.message, 5000); return null; }
finally { cooking = null; }
})();
return cooking;
}
/* ---------- UI wiring ---------- */
$("#seedIn").addEventListener("input", (e) => {
const v = e.target.value.trim();
const c = $("#seedCover"); if (!v) { c.classList.remove("on"); return; }
c.classList.add("on"); drawCover(c.querySelector("canvas"), { seed: v, title: "", tags: "", bpm: 100, key: KEYS[hash32(v) % 12], energy: 0.5 });
$("#seedHint").textContent = "↵ to start the station";
});
$("#seedForm").addEventListener("submit", (e) => {
e.preventDefault();
const v = $("#seedIn").value.trim(); if (!v) return;
openStation(v); // a new seed is a new station
});
/* tags as controls: tap a tag for more of it, again for less, again to let it go; + adds one of your own.
Each tap is a reaction (+word / -word) the DJ and the taste net both see. */
function showUpdate() { const u = $("#upd"); if (u) u.classList.remove("hidden"); }
if ("serviceWorker" in navigator && location.protocol === "https:") {
navigator.serviceWorker.register("sw.js").then((reg) => {
const check = () => reg.update().catch(() => {});
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
reg.addEventListener("updatefound", () => { const nw = reg.installing; if (!nw) return; nw.addEventListener("statechange", () => { if (nw.state === "activated" && navigator.serviceWorker.controller) showUpdate(); }); });
}).catch(() => {});
}
$("#upd").addEventListener("click", () => location.reload());
for (const b of document.querySelectorAll("button.reload")) b.addEventListener("click", () => location.reload());
/* light / dark: follows the system until the toggle is used, then remembered */
const sysDark = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme() {
const saved = localStorage.getItem("gandora.theme");
const t = saved || (sysDark.matches ? "dark" : "light");
if (saved) document.documentElement.dataset.theme = saved; else delete document.documentElement.dataset.theme;
const m = document.querySelector('meta[name="theme-color"]'); if (m) m.content = t === "dark" ? "#0b0b0e" : "#f4f4f6";
for (const b of document.querySelectorAll("button.theme")) b.textContent = t === "dark" ? "LIGHT" : "DARK";
if (typeof drawTaste === "function" && $("#taste") && !$("#taste").classList.contains("hidden")) { try { openTaste(); } catch {} }
}
for (const b of document.querySelectorAll("button.theme")) b.addEventListener("click", () => {
const cur = document.documentElement.dataset.theme || (sysDark.matches ? "dark" : "light");
localStorage.setItem("gandora.theme", cur === "dark" ? "light" : "dark"); applyTheme();
});
sysDark.addEventListener("change", applyTheme);
applyTheme();
function openKey() { $("#keyIn").value = state.key; $("#djIn").value = state.dj; $("#keyBox").classList.remove("hidden"); $("#keyIn").focus(); }
$("#keyBtn").addEventListener("click", openKey);
if (new URLSearchParams(location.search).has("key")) $("#keyBtn").classList.remove("hidden"); // key box is parked for now
$("#keyCancel").addEventListener("click", () => $("#keyBox").classList.add("hidden"));
$("#keySave").addEventListener("click", () => {
state.key = $("#keyIn").value.trim(); state.dj = $("#djIn").value.trim() || "claude-haiku-5-5";
localStorage.setItem(LS.key, state.key); localStorage.setItem(LS.dj, state.dj);
$("#keyBox").classList.add("hidden"); keyLabel(); $("#seedIn").focus();
});
/* sheets close on a tap outside them */
function keyLabel() { $("#keyBtn").textContent = state.key ? "your venice key ✓" : "shared budget · add a venice key"; }
keyLabel();
/* library */
const sharedTape = new URLSearchParams(location.search).get("tape");
if (sharedTape) {
(async () => {
try {
const t = JSON.parse(unb64u(sharedTape)); if (!t || !Array.isArray(t.tracks) || !t.tracks.length) throw 0;
const tp = { id: "tape-" + Date.now(), name: String(t.name || "shared tape").slice(0, 60), ids: [], pending: t.tracks, made: Date.now(), last: Date.now() };
await db.put(tp, "tapes");
// render the tracks in order as the station's next ones, and file each into the tape as it lands
state.sharedTapeId = tp.id; state.tapeQueue = t.tracks.slice();
openStation(t.tracks[0].seed, { name: t.tracks[0].station || "" });
toast(`Tape “${tp.name}”: ${t.tracks.length} tracks to render, one by one.`);
} catch { toast("That link didn't carry a tape."); }
})();
}
$("#libBtn2").addEventListener("click", () => $("#libBtn").click());
$("#tasteBtn2").addEventListener("click", () => $("#tasteBtn").click());
const sharedT = new URLSearchParams(location.search).get("t");
if (sharedT) {
try { const t = JSON.parse(unb64u(sharedT)); if (t && t.seed && t.title) { openStation(t.seed, { name: t.station || "", look: t.look, palette: t.palette, first: { title: t.title, tags: t.tags, lyrics: t.lyrics || "", bpm: t.bpm, key: t.key, energy: t.energy, line: t.line || "", station: t.station || "", rseed: t.rseed, seconds: t.seconds, meter: t.meter, look: t.look, palette: t.palette } }); } }
catch { toast("That link didn't carry a track."); }
} else if (location.hash.length > 1 && !sharedTape) {
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
const seed = decodeURIComponent(location.hash.slice(1)).replace(/\+/g, " ");
db.all("stations").then((list) => {
const s = list.find((x) => x.seed === seed);
if (s) resumeStation(s);
else { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); }
}).catch(() => { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); });
}
/* what the page shares with classic scripts loaded after it (brain.js, the harness fragments): window.g for tooling,
and the exact names brain.js uses today, as globals. Everything else stays inside the modules. */
window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } };
Object.assign(window, { state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji });
Object.defineProperty(window, "seq", { get: () => ids.seq, set: (v) => { ids.seq = v; } }); // brain.js does ++ids.seq
+1 -1263
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
import { $, asDataUrl, state, toast } from "./core.js";
import { wavBlob } from "./render.js";
import { askMash } from "./dj.js";
import { makeJob } from "./app.js";
const MASH_MAX = 3, MASH_SR = 22050;
async function mashReference(blobs, seconds) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null;
const probe = new AC(1, 1, MASH_SR);
const bufs = [];
for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} }
if (bufs.length < 2) return null;
const per = seconds / bufs.length, fade = 2, total = seconds;
const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR);
let at = 0;
bufs.forEach((buf, i) => {
const src = ctx.createBufferSource(); src.buffer = buf;
const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination);
const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade);
const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat
g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade);
g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len);
src.start(start, from, len); at = start + len;
});
const out = await ctx.startRendering();
const ch = out.getChannelData(0), pcm = new Int16Array(ch.length);
for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767;
return asDataUrl(wavBlob(pcm, MASH_SR));
}
export async function mashup(rows) {
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; }
toast(`Mashing ${rows.length} tracks — it goes next.`, 4000);
try {
const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]);
if (ref) spec.refAudio = ref;
const job = makeJob(spec);
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob;
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP";
} catch (e) { toast(e.message, 5000); }
}
+389
View File
@@ -0,0 +1,389 @@
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 */
+1 -1
View File
@@ -3,7 +3,7 @@
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker. // REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
const REV = "be3bce8aac3e"; const REV = "be3bce8aac3e";
const SHELL = "gandora-shell-" + REV; const SHELL = "gandora-shell-" + REV;
const FILES = ["/", "/index.html", "/core.js", "/covers.js", "/dj.js", "/render.js", "/store.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"]; const FILES = ["/", "/index.html", "/app.js", "/core.js", "/covers.js", "/dj.js", "/mashup.js", "/player.js", "/render.js", "/store.js", "/ui/library.js", "/ui/player.js", "/ui/sheet.js", "/ui/taste.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); }); self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); }); self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched // same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
+173
View File
@@ -0,0 +1,173 @@
import { db, tapeById, tapeRows, tapeSpec } from "../store.js";
import { $, ids, state, toast } from "../core.js";
import { KEYS, drawCover, hash32 } from "../covers.js";
import { b64u, resumeStation, stationFrom } from "../app.js";
import { stationSheet } from "../ui/sheet.js";
import { keepLabel, reactionEmoji } from "../ui/player.js";
import { playJob } from "../player.js";
import { mashup } from "../mashup.js";
export async function listStations() {
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
box.classList.toggle("hidden", !list.length);
for (const s of list) {
const d = document.createElement("div"); d.className = "s" + (s.id === state.stationId ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 144;
drawCover(c, { seed: s.seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(s.seed) % 12], energy: 0.5 }, { lit: 0.9 });
d.append(c, Object.assign(document.createElement("b"), { textContent: s.name || s.seed }), Object.assign(document.createElement("small"), { textContent: `${s.played || 0} tracks` }));
d.addEventListener("click", () => resumeStation(s));
const dots = Object.assign(document.createElement("button"), { className: "dots", textContent: "⋯", title: "rename, reset or delete" });
dots.addEventListener("click", (e) => { e.stopPropagation(); stationSheet(s.id); });
d.append(dots);
row.append(d);
}
}
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
export function libNow() {
const bar = $("#libNow"); if (!bar) return;
const cur = state.current && state.current.spec;
for (const d of document.querySelectorAll("#libGrid .t")) { const on = !!(cur && d.dataset.id === cur.id); d.classList.toggle("now", on); d.classList.toggle("paused", on && !state.playing); }
if (!cur) { bar.classList.add("hidden"); return; }
bar.classList.remove("hidden");
drawCover(bar.querySelector("canvas"), cur); bar.querySelector("b").textContent = cur.title; bar.querySelector("small").textContent = (state.station || cur.seed || "").toUpperCase();
bar.querySelector("#libPlay svg").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
}
$("#libPlay").addEventListener("click", () => $("#play").click());
$("#libOpen").addEventListener("click", () => { $("#lib").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); });
$("#libBtn").addEventListener("click", async () => {
const list = await db.all(); const g = $("#libGrid"); g.innerHTML = "";
$("#libEmpty").classList.toggle("hidden", list.length > 0);
const cur = state.current && state.current.spec;
const tile = (t, inKept = false) => {
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
const art = document.createElement("div"); art.className = "art";
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec); art.append(c);
if (t.kept && !inKept) art.append(Object.assign(document.createElement("span"), { className: "kept", textContent: "KEPT" }));
const re = reactionEmoji(t.spec.reaction).trim();
if (re) art.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
const addT = Object.assign(document.createElement("button"), { className: "addTape", textContent: "+", title: "add to a tape" });
addT.addEventListener("click", (e) => { e.stopPropagation(); pickTape(t); });
const fromT = Object.assign(document.createElement("button"), { className: "fromT", textContent: "*", title: "start a station from this track" });
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
art.append(addT, fromT);
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) { d.classList.add("now"); if (!state.playing) d.classList.add("paused"); }
d.addEventListener("click", () => {
// the library stays open; the track plays underneath, and the bar at the bottom follows it
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
const job = { id: ++ids.seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }; state.seed = t.spec.seed; $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); state.audio.pause(); playJob(job);
});
return d;
};
// kept first, then everything played, grouped by station (channel), newest station first
const stations = await db.all("stations"); const byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const head = (text, seed, count) => {
const h = document.createElement("h3"); h.style.gridColumn = "1/-1";
if (seed) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }); h.append(c); }
h.append(Object.assign(document.createElement("span"), { textContent: text }));
if (count != null) h.append(Object.assign(document.createElement("small"), { textContent: `· ${count}` }));
return h;
};
// tapes first: a strip of mixtapes, and a way to start one
const tapes = await db.all("tapes");
g.append(head("TAPES", "", tapes.length));
const strip = document.createElement("div"); strip.id = "tapeRow";
for (const tp of tapes) {
const s = document.createElement("div"); s.className = "s";
const tc = document.createElement("div"); tc.className = "tc one";
const rows = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean);
const c = document.createElement("canvas"); c.width = c.height = 180; drawCover(c, tapeSpec(tp, rows)); tc.append(c);
s.append(tc, Object.assign(document.createElement("b"), { textContent: tp.name }), Object.assign(document.createElement("small"), { textContent: `${tp.ids.length} tracks` }));
s.addEventListener("click", () => openTape(tp.id));
strip.append(s);
}
const nw = document.createElement("div"); nw.className = "s new"; nw.innerHTML = '<div class="tc">+</div><b>new tape</b>';
nw.addEventListener("click", async () => { const name = prompt("Name the tape:", ""); if (!name) return; const tp = { id: "tape-" + Date.now(), name: name.trim().slice(0, 60), ids: [], made: Date.now(), last: Date.now() }; await db.put(tp, "tapes"); openTape(tp.id); });
strip.append(nw); g.append(strip);
const kept = list.filter((t) => t.kept);
if (kept.length) { g.append(head("KEPT", "", kept.length)); for (const t of kept) g.append(tile(t, true)); }
const groups = new Map();
for (const t of list) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); }
for (const [k, rows] of groups) {
const s = byId[k]; const name = (s && s.name) || rows[0].spec.seed, seed = (s && s.seed) || rows[0].spec.seed;
g.append(head(name.toUpperCase(), seed, rows.length));
for (const t of rows) g.append(tile(t));
}
libNow();
$("#libCost").textContent = `~$${state.cost.toFixed(2)} of music rendered from this browser · replays from the library are free`;
$("#lib").classList.remove("hidden");
});
$("#libClose").addEventListener("click", () => $("#lib").classList.add("hidden"));
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
async function pickTape(track) {
const p = $("#tapePick"); p.innerHTML = "";
const tapes = await db.all("tapes");
p.append(Object.assign(document.createElement("h4"), { textContent: "ADD “" + track.spec.title.toUpperCase() + "” TO" }));
const add = async (tp) => { if (tp.virtual) { const row = await db.get(track.id); if (row && !row.kept) { row.kept = true; await db.put(row); keepLabel().catch(() => {}); } } else if (!tp.ids.includes(track.id)) { tp.ids.push(track.id); tp.last = Date.now(); await db.put(tp, "tapes"); } p.classList.add("hidden"); toast(`On “${tp.name}”.`); };
for (const tp of tapes) { const b = document.createElement("button"); b.textContent = `${tp.name} · ${tp.ids.length}`; b.addEventListener("click", () => add(tp)); p.append(b); }
const inp = document.createElement("input"); inp.placeholder = "new tape…"; inp.maxLength = 60;
inp.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const name = inp.value.trim(); if (!name) return; const tp = { id: "tape-" + Date.now(), name, ids: [], made: Date.now(), last: Date.now() }; await add(tp); });
const close = document.createElement("button"); close.textContent = "cancel"; close.addEventListener("click", () => p.classList.add("hidden"));
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
}
state.tape = null; // { id, ids, i, loop } while a tape is playing
/* one tape by id. KEPT is virtual (db.all("tapes") writes it from the kept tracks), so it is the one that needs the list */
async function openTape(id) {
const tp = await tapeById(id); if (!tp) return;
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
$("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual);
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
drawCover($("#tapeCover canvas"), tapeSpec(tp, rows));
$("#tapeEmpty").classList.toggle("hidden", rows.length > 0);
$("#tapeLoop").classList.toggle("go", !!(state.tape && state.tape.id === id && state.tape.loop));
rows.forEach((t, i) => {
const r = document.createElement("div"); r.className = "row" + (state.current && state.current.spec.id === t.id ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec);
const m = document.createElement("div"); m.className = "m"; m.append(Object.assign(document.createElement("b"), { textContent: `${i + 1}. ${t.spec.title}` }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
const play = () => playTape(tp, i);
c.addEventListener("click", play); m.addEventListener("click", play);
const ctl = document.createElement("div"); ctl.className = "ctl";
const mv = async (d) => { const j = i + d; if (j < 0 || j >= tp.ids.length) return; [tp.ids[i], tp.ids[j]] = [tp.ids[j], tp.ids[i]]; await db.put(tp, "tapes"); openTape(id); };
const up = Object.assign(document.createElement("button"), { textContent: "▲", title: "earlier" }), dn = Object.assign(document.createElement("button"), { textContent: "▼", title: "later" }), rm = Object.assign(document.createElement("button"), { textContent: "✕", title: "remove from tape" });
up.addEventListener("click", () => mv(-1)); dn.addEventListener("click", () => mv(1));
rm.addEventListener("click", async () => { if (tp.virtual) { const row = await db.get(tp.ids[i]); if (row) { row.kept = false; await db.put(row); } } else { tp.ids.splice(i, 1); await db.put(tp, "tapes"); } openTape(id); });
ctl.append(up, dn, rm); r.append(c, m, ctl); list.append(r);
});
}
export async function playTape(tp, i, loop) {
const rows = await tapeRows(tp); if (!rows.length) return;
i = ((i % rows.length) + rows.length) % rows.length;
state.tape = { id: tp.id, ids: rows.map((t) => t.id), i, loop: loop ?? (state.tape && state.tape.id === tp.id ? state.tape.loop : false) };
$("#tape").classList.add("hidden"); $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
$("#stationName").textContent = "📼 " + tp.name;
const t = rows[i]; state.audio.pause();
await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
}
$("#tapePlay").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (tp) playTape(tp, 0); });
$("#tapeLoop").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
$("#tapeName").addEventListener("change", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
$("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); });
/* mashups: up to three tracks in, one out. The DJ writes a sheet that fuses theirs; the renderer is handed the
tracks themselves, cut and crossfaded into one reference (OfflineAudioContext, off the playback path, so iOS is
fine), and the mash is the station's next track. */
$("#tapeMash").addEventListener("click", async () => {
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
const rows = await tapeRows(tp);
$("#tape").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden");
mashup(rows);
});
$("#tapeShare").addEventListener("click", async () => {
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
const rows = await tapeRows(tp);
const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) };
const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`;
if (navigator.share) { try { await navigator.share({ title: tp.name, text: `${tp.name} · a gandora mixtape, ${rows.length} tracks`, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Tape link copied. It renders each track for whoever opens it."); } catch { prompt("Copy this link:", url); }
});
$("#tapeClose").addEventListener("click", () => $("#tape").classList.add("hidden"));
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
+270
View File
@@ -0,0 +1,270 @@
import { $, hooks, mmss, ids, state, toast } from "../core.js";
import { db, logEvent, remember, saveStation } from "../store.js";
import { drawCover } from "../covers.js";
import { playJob } from "../player.js";
import { libNow, listStations } from "../ui/library.js";
import { cookNext, shareLink } from "../app.js";
let trailIds = [];
export async function drawTrail() {
const el = $("#trail"); if (!el) return;
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
trailIds = rows.map((t) => t.id);
const cur = state.current && state.current.spec && state.current.spec.id;
if (trailIds.length < 2) { el.textContent = ""; return; }
el.textContent = "";
for (const t of rows) {
const c = document.createElement("canvas"); c.width = c.height = 120;
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
el.append(c);
}
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
}
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
$("#trail").addEventListener("click", async (e) => {
e.stopPropagation(); // the cover underneath flips to lyrics on click
const c = e.target.closest("canvas"); if (!c) return;
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
const row = await db.get(id); if (!row) return;
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
state.audio.pause();
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
});
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
export function setPlayIcon() {
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
libNow();
}
/* the library's own now-playing: ring the tile, keep the bottom bar current */
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
const EMOJI_MEANING = {
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound; the listener loves this one" },
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
"🚫": { avoid: "anything like the current track" },
};
/* where in the track a reaction landed: the second, and the lyric section playing then (sections assumed evenly spread) */
function sectionAt(spec, t) {
const marks = [...String(spec.lyrics || "").matchAll(/\[([a-z -]+)\]/gi)].map((m) => m[1].toLowerCase());
if (!marks.length) return "";
const dur = state.audio.duration || spec.seconds || 120;
return marks[Math.min(marks.length - 1, Math.floor(t / dur * marks.length))];
}
function markReaction(spec, emoji, t) {
const dur = state.audio.duration || spec.seconds || 120;
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
if (state.current.spec === spec) drawTicks(spec, dur);
}
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
export function drawTicks(spec, dur) {
const bar = $("#bar"); if (!bar) return;
for (const t of bar.querySelectorAll("em")) t.remove();
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
let below = false, prevAt = -Infinity;
for (const g of groups) {
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
tick.classList.toggle("below", below);
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
bar.append(tick);
}
}
async function react(emoji, text) {
const cur = state.current && state.current.spec; if (!cur) return;
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
const at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
if (emoji) markReaction(cur, emoji, at);
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
if (m) {
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
}
if (text) {
state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`;
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
for (const p of parts) {
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
}
}
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
const later = !!state.nextJob;
if (!later) cookNext();
saveStation().catch(() => {});
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
if (hooks.reacted) hooks.reacted(emoji, text);
}
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
export const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
export function showTags(spec) {
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.mashOf ? "mashup" : spec.remixOf ? "remix" : "";
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
if (!state.tagsOpen) return;
for (const tag of [...tags, ...extra]) {
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
b.addEventListener("click", () => tagTap(spec, tag, b));
list.append(b);
}
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
add.addEventListener("click", () => {
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
inp.addEventListener("blur", done);
});
list.append(add);
}
function tagTap(spec, tag, btn, force) {
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
const t = tag.replace(/\s+/g, "_");
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
else {
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
const note = `the tag ${JSON.stringify(tag)}`;
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
}
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
showTags(spec);
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
saveStation().catch(() => {});
if (!state.nextJob) cookNext();
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
}
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
export function showLyrics(spec) {
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
p.innerHTML = ""; p.classList.toggle("none", !has);
if (!has) { p.textContent = "instrumental"; return; }
for (const line of spec.lyrics.split(/\r?\n/)) {
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
else p.append(document.createTextNode(line + "\n"));
}
}
function toggleLyrics() {
state.lyricsOpen = !state.lyricsOpen;
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
$("#lyricsBtn").textContent = state.lyricsOpen ? "⇄ COVER" : "⇄ LYRICS";
}
$("#lyricsBtn").addEventListener("click", toggleLyrics);
$("#cover").addEventListener("click", toggleLyrics);
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
export async function keepLabel() {
const b = $("#keep"), cur = state.current && state.current.spec;
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP"; return; }
const row = await db.get(cur.id);
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP";
$("#share").disabled = false;
}
$("#share").addEventListener("click", async () => {
const url = shareLink(); if (!url) return;
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
});
$("#keep").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) { toast("Still cooking."); return; }
const row = await db.get(cur.id);
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
});
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
$("#back").addEventListener("click", () => {
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
const cur = state.current && state.current.spec;
$("#nowRow").classList.toggle("hidden", !cur);
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
listStations().catch(() => {});
});
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
/* lock screen / media keys: title, station, the cover, and back · pause · next */
export function nowPlaying(spec) {
if (!("mediaSession" in navigator)) return;
try {
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
} catch {}
}
const EMOJI_SETS = {
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
};
const EMOJI_KEY = "gandora.emoji";
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); localStorage.setItem(EMOJI_KEY + ".last", e); }
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
const EMOJI_TRAY = 7;
function showEmojiRow() {
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
const ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
const last = localStorage.getItem(EMOJI_KEY + ".last");
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
}
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); setTimeout(showEmojiRow, 350); });
function openPicker() {
const p = $("#picker"); p.innerHTML = "";
const top = document.createElement("div"); top.className = "top";
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
close.addEventListener("click", () => p.classList.add("hidden"));
top.append(inp, go, close);
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
for (const [name, list] of Object.entries(sets)) {
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
const g = document.createElement("div"); g.className = "g";
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
p.append(g);
}
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
p.classList.remove("hidden");
}
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
showEmojiRow();
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
/* key box */
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
$("#scrim").addEventListener("click", closeSheets);
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
+143
View File
@@ -0,0 +1,143 @@
import { db } from "../store.js";
import { $, state, toast } from "../core.js";
import { LOOKS, PALETTES, drawCover } from "../covers.js";
import { MODES, PERSONAS, VOICES, speak, voiceLabel } from "../dj.js";
import { bed, bedLevel, bedOff, crackle, crackleOff, makeCrackle, toIdle } from "../player.js";
import { setUrl, stationFromCurrent } from "../app.js";
import { listStations } from "../ui/library.js";
export async function stationSheet(id) {
const s = await db.get(id, "stations"); if (!s) return;
let p = $("#stationSheet"); p.innerHTML = ""; p.scrollTop = 0;
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close");
x.addEventListener("click", () => p.classList.add("hidden")); p.append(x);
const name = document.createElement("input"); name.value = s.name || ""; name.placeholder = s.seed; name.maxLength = 60;
p.append(Object.assign(document.createElement("label"), { textContent: "name" }), name, Object.assign(document.createElement("label"), { textContent: "seed · " + s.seed + " · " + (s.played || 0) + " tracks" }));
const lookRow = document.createElement("div"); lookRow.className = "row"; lookRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "look" }));
for (const lk of LOOKS) {
const b = Object.assign(document.createElement("button"), { textContent: lk.toUpperCase(), className: (s.look || "rings") === lk ? "go" : "" });
b.addEventListener("click", async () => {
s.look = lk; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.look = lk; await db.put(t); }
if (state.stationId === id) { state.look = lk; if (state.current) { state.current.spec.look = lk; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.look = lk; }
for (const x of lookRow.children) x.className = x.textContent === lk.toUpperCase() ? "go" : "";
toast(`Look: ${lk}.`);
});
lookRow.append(b);
}
p.append(lookRow);
const palRow = document.createElement("div"); palRow.className = "row"; palRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "palette" }));
for (const pm of PALETTES) {
const b = Object.assign(document.createElement("button"), { textContent: pm.toUpperCase(), className: (s.palette || "duo") === pm ? "go" : "" });
b.addEventListener("click", async () => {
s.palette = pm; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.palette = pm; await db.put(t); }
if (state.stationId === id) { state.palette = pm; if (state.current) { state.current.spec.palette = pm; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.palette = pm; }
for (const x of palRow.children) x.className = x.textContent === pm.toUpperCase() ? "go" : "";
toast(`Palette: ${pm}.`);
});
palRow.append(b);
}
p.append(palRow);
const modeRow = document.createElement("div"); modeRow.className = "row"; modeRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "mode" }));
for (const md of MODES) {
const b = Object.assign(document.createElement("button"), { textContent: md.toUpperCase(), className: (s.mode || "music") === md ? "go" : "", title: { music: "songs", freestyle: "an MC off the dome over a beat", asmr: "spoken only, no music", noise: "brown, pink, rain, white, crackle: made here, free", tones: "binaural beats, solfeggio, isochronic: made here, free" }[md] });
b.addEventListener("click", async () => {
s.mode = md; await db.put(s, "stations");
if (state.stationId === id) { state.mode = md; if (state.nextJob && !state.nextJob.blob) state.nextJob = null; }
for (const x of modeRow.children) x.className = x.textContent === md.toUpperCase() ? "go" : "";
toast(`Mode: ${md}. Takes effect from the next track.`);
});
modeRow.append(b);
}
p.append(modeRow);
// the DJ: name, voice, speed, persona, folded away unless something is set
const dj = document.createElement("details"); dj.open = false;
dj.append(Object.assign(document.createElement("summary"), { textContent: "dj" + (s.djName ? " · " + s.djName : "") + (s.djVoice ? " · " + voiceLabel(s.djVoice) : "") }));
const pAll = p, pDj = dj; p = dj;
const preRow = document.createElement("div"); preRow.className = "row"; preRow.style.marginTop = "4px";
const djName = document.createElement("input"); djName.value = s.djName || ""; djName.placeholder = "name (the DJ picks one at the welcome if blank)"; djName.maxLength = 40;
const djPersona = document.createElement("input"); djPersona.value = s.djPersona || ""; djPersona.placeholder = "persona: how they talk, e.g. whispery ASMR host"; djPersona.maxLength = 240; djPersona.style.marginTop = "6px";
const voiceRow = document.createElement("div"); voiceRow.className = "row"; voiceRow.style.marginTop = "6px";
let voice = s.djVoice || "", speed = s.djSpeed || 1;
const paintVoices = () => { for (const b of voiceRow.children) b.className = b.dataset.v === voice ? "go" : ""; };
for (const v of VOICES) {
const b = Object.assign(document.createElement("button"), { textContent: voiceLabel(v).toUpperCase(), title: "tap to hear " + v }); b.dataset.v = v;
b.addEventListener("click", async () => {
voice = v; paintVoices();
if (crackle.preview) { try { crackle.preview.pause(); } catch {} crackle.preview = null; }
const mine = crackle.previewSeq = (crackle.previewSeq || 0) + 1;
try { const was = { v: state.djVoice, sp: state.djSpeed }; state.djVoice = v; state.djSpeed = speed;
const blob = await speak({ lyrics: `Hi, this is ${djName.value.trim() || "your DJ"}, and you're listening to ${s.name || s.seed}.` });
state.djVoice = was.v; state.djSpeed = was.sp;
if (mine !== crackle.previewSeq) return; // a later tap won
if (crackle.preview) { try { crackle.preview.pause(); } catch {} }
const a = crackle.preview = new Audio(URL.createObjectURL(blob)); a.volume = state.audio.volume; a.play().catch(() => {}); } catch { toast("Couldn't fetch the voice."); }
});
voiceRow.append(b);
}
paintVoices();
for (const pr of PERSONAS) {
const b = Object.assign(document.createElement("button"), { textContent: pr.id.toUpperCase(), title: pr.persona });
b.addEventListener("click", () => { djName.value = pr.name; djPersona.value = pr.persona; voice = pr.voice; speed = pr.speed; spd.value = Math.round(speed * 100); spdOut.textContent = spd.value + "%"; paintVoices(); });
preRow.append(b);
}
const spdRow = document.createElement("div"); spdRow.className = "sl";
const spd = document.createElement("input"); spd.type = "range"; spd.min = 60; spd.max = 150; spd.value = Math.round(speed * 100);
const spdOut = Object.assign(document.createElement("span"), { textContent: spd.value + "%" });
spd.addEventListener("input", () => { speed = spd.value / 100; spdOut.textContent = spd.value + "%"; });
spdRow.append(Object.assign(document.createElement("label"), { textContent: "speed" }), spd, spdOut);
p.append(preRow, djName, djPersona, voiceRow, spdRow);
p = pAll; p.append(pDj);
const applyDj = async () => {
s.djName = djName.value.trim().slice(0, 40); s.djPersona = djPersona.value.trim().slice(0, 240); s.djVoice = voice; s.djSpeed = speed;
if (state.stationId === id) { state.djName = s.djName; state.djPersona = s.djPersona; state.djVoice = s.djVoice; state.djSpeed = s.djSpeed; }
};
// volume and room tone, for everything (not just this station)
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; bedLevel(v); }]]) {
const sl = document.createElement("div"); sl.className = "sl";
const r = document.createElement("input"); r.type = "range"; r.min = 0; r.max = 100; r.value = Math.round(get() * 100);
const out = Object.assign(document.createElement("span"), { textContent: r.value + "%" });
r.addEventListener("input", () => { set(r.value / 100); out.textContent = r.value + "%"; localStorage.setItem(key, String(r.value / 100)); });
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out);
if (key === "gandora.bed") {
const off = Object.assign(document.createElement("button"), { textContent: bedOff() ? "OFF" : "ON", title: "the crackle between tracks" });
off.style.cssText = "padding:6px 10px;border-radius:999px;border:1px solid var(--line);font:600 10px var(--font);letter-spacing:.12em";
off.addEventListener("click", () => { const now = !bedOff(); localStorage.setItem("gandora.bedoff", now ? "1" : "0"); off.textContent = now ? "OFF" : "ON"; if (now) crackleOff(); else bed(); toast(now ? "Room tone off." : "Room tone on."); });
sl.append(off);
}
p.append(sl);
}
if (state.stationId === id) {
const more = document.createElement("div"); more.className = "row"; more.style.marginTop = "10px"; more.style.justifyContent = "flex-start";
const fromT = Object.assign(document.createElement("button"), { textContent: "* STATION FROM THIS TRACK", title: "a new station grown from the track playing now" });
fromT.disabled = !(state.current && state.current.blob && !state.current.spec.break);
fromT.addEventListener("click", () => { p.classList.add("hidden"); stationFromCurrent(); });
const shareS = Object.assign(document.createElement("button"), { textContent: "↗ SHARE STATION", title: "a link that opens this station's seed" });
shareS.addEventListener("click", async () => {
const url = `${location.origin}/#${encodeURIComponent(s.seed).replace(/%20/g, "+")}`;
try { if (navigator.share) await navigator.share({ title: `${s.name || s.seed} · gandora`, url }); else { await navigator.clipboard.writeText(url); toast("Station link copied."); } } catch {}
});
more.append(fromT, shareS); p.append(more);
}
const row = document.createElement("div"); row.className = "row";
const save = Object.assign(document.createElement("button"), { className: "go", textContent: "SAVE" });
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await applyDj(); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Saved."); });
const reset = Object.assign(document.createElement("button"), { textContent: "RESET TASTE" });
reset.addEventListener("click", async () => { if (!confirm("Forget what this station has learned? Its tracks stay.")) return; s.taste = { like: [], avoid: [], notes: [] }; s.history = []; await db.put(s, "stations"); if (state.stationId === id) { state.taste = s.taste; state.history = []; } p.classList.add("hidden"); toast("Taste reset."); });
const del = Object.assign(document.createElement("button"), { className: "bad", textContent: "DELETE STATION" });
del.addEventListener("click", async () => {
if (!confirm(`Delete “${s.name || s.seed}” and its unkept tracks? Kept tracks stay in the library.`)) return;
for (const t of (await db.all()).filter((t) => t.spec.stationId === id && !t.kept)) await db.del(t.id);
await db.del(id, "stations");
if (state.stationId === id) { toIdle(); state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); }
p.classList.add("hidden"); listStations().catch(() => {}); toast("Station deleted.");
});
row.append(save, reset, del); p.append(row); p.classList.remove("hidden"); name.focus({ preventScroll: true });
}
$("#stationName").addEventListener("click", () => { if (state.stationId && !state.tape) stationSheet(state.stationId); });
/* links: the URL follows the station (#seed); a share link carries the current track's sheet music (?t=) so the same
song opens and renders for whoever follows it, even with nothing stored anywhere */
+78
View File
@@ -0,0 +1,78 @@
import { $, state, toast } from "../core.js";
import { KEYS, drawCover, hash32, keyHue } from "../covers.js";
import { db } from "../store.js";
import { listStations } from "../ui/library.js";
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
export function reactionWeight(spec) {
const r = String(spec.reaction || "");
let w = 0;
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
if (/\[skipped\]/.test(r)) w -= 1.5;
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
return w;
}
export function drawTaste(rows, title) {
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H);
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
const cell = 18, sig = 90;
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
let like = 0, heard = 0;
for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; }
if (heard < 0.02) continue;
const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said
const a = Math.min(0.5, heard * 0.3);
if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral
else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`;
ctx.fillRect(gx, gy, cell, cell);
}
// axes
ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
// the points: colour is the key, size is how much was said about it, ring warm or cold
for (const p of pts) {
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
}
const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length;
const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0;
const liked = pts.filter((p) => p.w > 0);
const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null;
const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null;
const minors = liked.filter((p) => p.minor).length;
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears.");
}
export async function openTaste() {
const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
const groups = [["all", "ALL", "", rows]];
const seen = new Map();
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); };
for (const g of groups) {
const b = document.createElement("button"); b.dataset.k = g[0];
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
b.append(document.createTextNode(g[1] + " · " + g[3].length));
b.addEventListener("click", () => { pick = g; show(); });
tabs.append(b);
}
show(); $("#taste").classList.remove("hidden");
}
$("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message)));
$("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden"));
listStations().catch(() => {});
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed