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

+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 */