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