diff --git a/app.js b/app.js new file mode 100644 index 0000000..8aefd4c --- /dev/null +++ b/app.js @@ -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 diff --git a/index.html b/index.html index 9304984..a141bb5 100644 --- a/index.html +++ b/index.html @@ -399,1269 +399,7 @@ input,textarea{font:inherit;color:inherit}