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:
1 parent
cc6c971423
commit
479e110f5e
9 files changed
+1297
-1264
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user