Files
site/ui/sheet.js
T

144 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 */