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
|
||||||
+1
-1263
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
import { $, asDataUrl, state, toast } from "./core.js";
|
||||||
|
import { wavBlob } from "./render.js";
|
||||||
|
import { askMash } from "./dj.js";
|
||||||
|
import { makeJob } from "./app.js";
|
||||||
|
const MASH_MAX = 3, MASH_SR = 22050;
|
||||||
|
async function mashReference(blobs, seconds) {
|
||||||
|
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null;
|
||||||
|
const probe = new AC(1, 1, MASH_SR);
|
||||||
|
const bufs = [];
|
||||||
|
for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} }
|
||||||
|
if (bufs.length < 2) return null;
|
||||||
|
const per = seconds / bufs.length, fade = 2, total = seconds;
|
||||||
|
const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR);
|
||||||
|
let at = 0;
|
||||||
|
bufs.forEach((buf, i) => {
|
||||||
|
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||||
|
const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination);
|
||||||
|
const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade);
|
||||||
|
const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat
|
||||||
|
g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade);
|
||||||
|
g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len);
|
||||||
|
src.start(start, from, len); at = start + len;
|
||||||
|
});
|
||||||
|
const out = await ctx.startRendering();
|
||||||
|
const ch = out.getChannelData(0), pcm = new Int16Array(ch.length);
|
||||||
|
for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767;
|
||||||
|
return asDataUrl(wavBlob(pcm, MASH_SR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mashup(rows) {
|
||||||
|
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
|
||||||
|
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
|
||||||
|
if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; }
|
||||||
|
toast(`Mashing ${rows.length} tracks — it goes next.`, 4000);
|
||||||
|
try {
|
||||||
|
const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]);
|
||||||
|
if (ref) spec.refAudio = ref;
|
||||||
|
const job = makeJob(spec);
|
||||||
|
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob;
|
||||||
|
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP";
|
||||||
|
} catch (e) { toast(e.message, 5000); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { drawCover, hash32, prng } from "./covers.js";
|
||||||
|
import { $, asDataUrl, mmss, ids, state, toast } from "./core.js";
|
||||||
|
import { wavBlob } from "./render.js";
|
||||||
|
import { drawTicks, drawTrail, keepLabel, nowPlaying, setPlayIcon, showLyrics, showTags } from "./ui/player.js";
|
||||||
|
import { db, logEvent, remember, saveStation, tapeById } from "./store.js";
|
||||||
|
import { cookNext, makeJob } from "./app.js";
|
||||||
|
import { playTape } from "./ui/library.js";
|
||||||
|
import { askDJ } from "./dj.js";
|
||||||
|
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
|
||||||
|
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
|
||||||
|
so the first real track can start itself when the render lands, however long that takes */
|
||||||
|
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
|
||||||
|
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
|
||||||
|
export const crackle = { el: null, on: false };
|
||||||
|
export function makeCrackle(seconds = 12, gain = 1) {
|
||||||
|
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
|
||||||
|
let lp = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss, around -16 dBFS
|
||||||
|
mix[i] += lp * 1.6;
|
||||||
|
if (rnd() < 0.0015) { const len = 20 + Math.floor(rnd() * 80), amp = 0.3 + rnd() * 0.5; for (let k = 0; k < len && i + k < n; k++) mix[i + k] += amp * (1 - k / len) * (rnd() * 2 - 1); } // a pop
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i] * gain)) * 32767;
|
||||||
|
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||||||
|
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||||||
|
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||||||
|
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||||||
|
new Int16Array(buf, 44).set(out);
|
||||||
|
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = Math.min(parseFloat(localStorage.getItem("gandora.bed") || "0.18"), localStorage.getItem("gandora.bed2") ? 1 : 0.18); localStorage.setItem("gandora.bed2", "1"); el.preload = "auto";
|
||||||
|
el.mix = mix; el.sr = sr; // kept for the Web Audio bed
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
/* the bed through Web Audio: a looping buffer has no seam (an <audio> loop pauses at the join, badly on Safari).
|
||||||
|
This is not the music's path, so the iOS rules about the player don't apply; if the context can't start, the
|
||||||
|
element plays instead. */
|
||||||
|
const bedAC = { ctx: null, gain: null, src: null, buf: null };
|
||||||
|
function bedStart(base) {
|
||||||
|
if (state.phase === "playing" && (state.playing || state.playPending || !state.audio.paused)) return false; // never over the music
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false;
|
||||||
|
try {
|
||||||
|
bedAC.ctx ||= new AC();
|
||||||
|
if (bedAC.ctx.state !== "running") bedAC.ctx.resume().catch(() => {});
|
||||||
|
if (bedAC.ctx.state !== "running") return false;
|
||||||
|
if (!bedAC.buf) { const el = crackle.el; bedAC.buf = bedAC.ctx.createBuffer(1, el.mix.length, el.sr); bedAC.buf.getChannelData(0).set(el.mix.map((v) => Math.max(-1, Math.min(1, v)))); }
|
||||||
|
bedStop(0);
|
||||||
|
const src = bedAC.ctx.createBufferSource(); src.buffer = bedAC.buf; src.loop = true;
|
||||||
|
const gain = bedAC.ctx.createGain(); const t = bedAC.ctx.currentTime;
|
||||||
|
gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(base, t + 0.6);
|
||||||
|
src.connect(gain).connect(bedAC.ctx.destination); src.start();
|
||||||
|
bedAC.src = src; bedAC.gain = gain;
|
||||||
|
try { crackle.el.pause(); } catch {}
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
function bedStop(fade = 0.4) {
|
||||||
|
const { ctx, gain, src } = bedAC; if (!src) return;
|
||||||
|
bedAC.src = null; bedAC.gain = null;
|
||||||
|
try { const t = ctx.currentTime; gain.gain.cancelScheduledValues(t); gain.gain.setValueAtTime(gain.gain.value, t); gain.gain.linearRampToValueAtTime(0, t + fade); src.stop(t + fade + 0.05); } catch {}
|
||||||
|
}
|
||||||
|
export function bedLevel(v) { if (bedAC.gain && bedAC.ctx) { const t = bedAC.ctx.currentTime; bedAC.gain.gain.cancelScheduledValues(t); bedAC.gain.gain.setValueAtTime(v, t); } }
|
||||||
|
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
|
||||||
|
export function bed() {
|
||||||
|
clearTimeout(crackle.timer);
|
||||||
|
crackle.timer = setTimeout(() => {
|
||||||
|
// on unless music is actually running or the player itself is holding with the crackle; the unlock clip is neither
|
||||||
|
const music = state.phase === "playing" && (state.playing || !state.audio.paused || state.playPending) && state.audio.src !== SILENCE;
|
||||||
|
const want = !music && state.phase !== "holding";
|
||||||
|
if (want) crackleOn(); else crackleOff();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
export const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
|
||||||
|
function crackleOn() {
|
||||||
|
if (crackle.on || state.holding || bedOff()) return;
|
||||||
|
crackle.el ||= makeCrackle(); crackle.on = true;
|
||||||
|
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
|
||||||
|
if (bedStart(base)) { crackle.via = "ac"; return; }
|
||||||
|
crackle.via = "el"; crackle.el.volume = 0; crackle.el.currentTime = 0;
|
||||||
|
crackle.el.play().catch(() => { crackle.on = false; clearInterval(crackle.ramp); }); // refused without a gesture: the first tap calls again
|
||||||
|
clearInterval(crackle.ramp);
|
||||||
|
crackle.ramp = setInterval(() => { const k = Math.min(1, (performance.now() - t0) / 600); if (!crackle.on) { clearInterval(crackle.ramp); return; } crackle.el.volume = base * k; if (k >= 1) clearInterval(crackle.ramp); }, 40);
|
||||||
|
}
|
||||||
|
export function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
|
||||||
|
/* 50 ms of silence as a wav, built here rather than pasted */
|
||||||
|
|
||||||
|
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
|
||||||
|
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
|
||||||
|
function prime() {
|
||||||
|
if (state.needTap && state.current && state.current.blob) { // a resume that autoplay refused: this tap starts it
|
||||||
|
state.needTap = false; $("#cover").classList.remove("tap"); state.tapStarted = performance.now();
|
||||||
|
state.audio.play().then(() => { state.playing = true; state.primed = true; crackleOff(); setPlayIcon(); vizStart(); }).catch(() => { state.needTap = true; $("#cover").classList.add("tap"); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!crackle.on) bed();
|
||||||
|
if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return;
|
||||||
|
try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {}
|
||||||
|
try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {}
|
||||||
|
}
|
||||||
|
for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true });
|
||||||
|
setTimeout(bed, 400); // the room tone, from the start where the browser lets it
|
||||||
|
/* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when
|
||||||
|
the screen locks (and obeys the mute switch). So on iOS the cover pulses to the beat from the sheet instead,
|
||||||
|
and the music stays a plain <audio> element that keeps playing in the background. */
|
||||||
|
const IOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
|
||||||
|
function vizBeat() {
|
||||||
|
cancelAnimationFrame(viz.raf);
|
||||||
|
const canvas = $("#cover canvas"), t0 = performance.now(), rnd = prng(hash32(state.current.spec.title || ""));
|
||||||
|
const frame = () => {
|
||||||
|
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||||||
|
const spec = state.current.spec, t = (performance.now() - t0) / 1000, bps = (+spec.bpm || 100) / 60, en = +spec.energy || 0.5;
|
||||||
|
const beat = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps)), 6); // a kick on every beat
|
||||||
|
const bar = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps / 4)), 2); // and a swell on the bar
|
||||||
|
const bass = Math.min(1, (0.25 + 0.75 * beat) * (0.5 + 0.5 * en)), mid = 0.3 + 0.3 * bar * en, high = 0.2 + 0.25 * Math.pow(Math.max(0, Math.sin(2 * Math.PI * t * bps * 2)), 4) * en;
|
||||||
|
drawCover(canvas, spec, { pulse: { bass, mid, high, t } });
|
||||||
|
viz.raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
viz.raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
function vizStart() {
|
||||||
|
if (IOS || state.beatViz) return vizBeat();
|
||||||
|
// the player is only wired through the analyser once the context is actually running: a suspended context
|
||||||
|
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
|
||||||
|
try {
|
||||||
|
viz.ctx ||= new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (viz.ctx.state !== "running") { viz.ctx.resume().then(() => { if (viz.ctx.state === "running" && state.playing) vizStart(); }).catch(() => {}); if (viz.ctx.state !== "running") return; }
|
||||||
|
if (!viz.an) {
|
||||||
|
const src = viz.ctx.createMediaElementSource(state.audio);
|
||||||
|
viz.an = viz.ctx.createAnalyser(); viz.an.fftSize = 512; viz.an.smoothingTimeConstant = 0.7;
|
||||||
|
src.connect(viz.an); viz.an.connect(viz.ctx.destination);
|
||||||
|
viz.data = new Uint8Array(viz.an.frequencyBinCount);
|
||||||
|
}
|
||||||
|
} catch { return; }
|
||||||
|
cancelAnimationFrame(viz.raf);
|
||||||
|
const canvas = $("#cover canvas"), t0 = performance.now();
|
||||||
|
const band = (a, b) => { let s = 0; for (let i = a; i < b; i++) s += viz.data[i]; return s / (b - a) / 255; };
|
||||||
|
const frame = () => {
|
||||||
|
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||||||
|
viz.an.getByteFrequencyData(viz.data);
|
||||||
|
const raw = { bass: band(1, 8), mid: band(8, 40), high: band(40, 120) };
|
||||||
|
for (const k in raw) viz.smooth[k] += (raw[k] - viz.smooth[k]) * (raw[k] > viz.smooth[k] ? 0.5 : 0.12); // fast attack, slow decay
|
||||||
|
const s = viz.smooth;
|
||||||
|
drawCover(canvas, state.current.spec, { pulse: { bass: Math.min(1, s.bass * 1.4), mid: s.mid, high: Math.min(1, s.high * 2.5), t: (performance.now() - t0) / 1000 } });
|
||||||
|
viz.raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
viz.raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- the player: one small state machine ----------
|
||||||
|
state.phase is one of
|
||||||
|
idle nothing on air: no station, or the station stopped
|
||||||
|
cooking state.current is on screen and its render hasn't landed; the player is silent, the room tone plays beside it
|
||||||
|
holding the player itself loops the crackle (a locked phone keeps the session only while the element stays busy);
|
||||||
|
a track that lands meanwhile queues behind it (state.pendingJob) and takes over when the crackle's pass ends
|
||||||
|
playing a track's audio is in the player (pause is not a phase: state.playing says whether it runs)
|
||||||
|
ended the track ran out; inside the event the next source starts or the hold begins, then advance() picks on
|
||||||
|
The transitions are the functions below (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) and
|
||||||
|
only they set state.phase or the player's src. The one exception is prime(): the unlock clip, which is not a track.
|
||||||
|
The order inside `ended` matters: a source that must survive a locked screen starts synchronously there; the
|
||||||
|
bookkeeping comes after. And nothing awaits between setting src and play() except the render itself (a database read
|
||||||
|
there has lost the user activation before). */
|
||||||
|
state.phase = "idle";
|
||||||
|
Object.defineProperty(state, "holding", { get: () => state.phase === "holding" }); // the old flag, now read-only
|
||||||
|
const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob));
|
||||||
|
/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */
|
||||||
|
function setSource(src, loop) {
|
||||||
|
const old = state.audio.src;
|
||||||
|
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src) && !(crackle.hold && old === crackle.hold.src)) URL.revokeObjectURL(old);
|
||||||
|
state.audio.src = src; state.audio.loop = loop;
|
||||||
|
if (state.holdVol != null && !(crackle.hold && src === crackle.hold.src)) { state.audio.volume = state.holdVol; state.holdVol = null; } // the hold ran at room-tone level
|
||||||
|
}
|
||||||
|
/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track).
|
||||||
|
A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended`
|
||||||
|
(job.preplayed) is already playing and keeps that phase. */
|
||||||
|
function toCooking(job) {
|
||||||
|
const spec = job.spec;
|
||||||
|
state.current = job;
|
||||||
|
if (!job.preplayed && state.phase !== "holding") state.phase = "cooking";
|
||||||
|
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
|
||||||
|
keepLabel();
|
||||||
|
for (const t of $("#bar").querySelectorAll("em")) t.remove();
|
||||||
|
drawTicks(spec, spec.seconds || 120);
|
||||||
|
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
|
||||||
|
$("#lyric").textContent = spec.line || "";
|
||||||
|
showTags(spec);
|
||||||
|
showLyrics(spec);
|
||||||
|
drawCover($("#cover canvas"), spec);
|
||||||
|
if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; }
|
||||||
|
$("#cover").classList.add("cooking"); $("#bar i").style.width = "0";
|
||||||
|
bed(); // the needle is down while it cooks
|
||||||
|
}
|
||||||
|
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
|
||||||
|
function toHolding() {
|
||||||
|
try {
|
||||||
|
// the level is baked into the sample: iOS ignores an element's volume. OFF holds on four seconds of nothing (its own
|
||||||
|
// URL, not SILENCE, so `ended` still fires and hands over).
|
||||||
|
const lvl = bedOff() ? 0 : Math.round((crackle.base ?? 0.18) * 100) / 100;
|
||||||
|
if (!crackle.hold || crackle.hold.lvl !== lvl) { crackle.hold = lvl ? makeCrackle(4, lvl) : Object.assign(new Audio(URL.createObjectURL(wavBlob(new Int16Array(8000 * 4), 8000))), { loop: true }); crackle.hold.lvl = lvl; }
|
||||||
|
crackleOff(); setSource(crackle.hold.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
|
||||||
|
}
|
||||||
|
/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and
|
||||||
|
handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */
|
||||||
|
function queueBehindHold(job) {
|
||||||
|
return new Promise((r) => { srcOf(job); state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
|
||||||
|
}
|
||||||
|
/* → playing, inside `ended`: the crackle's pass is over and the queued track takes its place, synchronously */
|
||||||
|
function handOver(job) {
|
||||||
|
state.pendingJob = null;
|
||||||
|
const go = state.onHoldEnd; state.onHoldEnd = null;
|
||||||
|
if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending
|
||||||
|
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); } catch {}
|
||||||
|
if (go) go();
|
||||||
|
}
|
||||||
|
/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right
|
||||||
|
here; playJob then finds it preplayed and only does the bookkeeping */
|
||||||
|
function startNow(job) {
|
||||||
|
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with
|
||||||
|
no gesture yet) means TAP TO PLAY: the source stays loaded and prime() starts it on the next tap. */
|
||||||
|
async function toPlaying(job) {
|
||||||
|
if (!job.preplayed) { setSource(srcOf(job), !!state.loop); state.pendingJob = null; }
|
||||||
|
state.phase = "playing";
|
||||||
|
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
|
||||||
|
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
|
||||||
|
}
|
||||||
|
/* play() inside the swap: the playing flag follows the promise, so a refusal (iOS after a lock, after another element
|
||||||
|
played) shows TAP TO PLAY instead of a playing UI with no sound; the bed stays off while the answer is pending */
|
||||||
|
function playNow(job) {
|
||||||
|
state.playPending = true;
|
||||||
|
let p; try { p = state.audio.play(); } catch (e) { p = Promise.reject(e); }
|
||||||
|
Promise.resolve(p).then(() => { state.playPending = false; if (state.current === job) { state.playing = true; state.needTap = false; $("#cover").classList.remove("tap"); setPlayIcon(); } })
|
||||||
|
.catch(() => { state.playPending = false; if (state.current === job || !state.current) { state.playing = false; state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); setPlayIcon(); bed(); } });
|
||||||
|
}
|
||||||
|
/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what
|
||||||
|
follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count
|
||||||
|
as "queued ahead": advance() decides those, so nothing is started here. */
|
||||||
|
function toEnded() {
|
||||||
|
state.phase = "ended"; state.playing = false; setPlayIcon(); vizStop();
|
||||||
|
const job = state.nextJob;
|
||||||
|
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
|
||||||
|
if (!queued) { if (job && job.blob) startNow(job); else toHolding(); }
|
||||||
|
advance();
|
||||||
|
}
|
||||||
|
/* → idle: the station stops (a change of station, a deleted station) */
|
||||||
|
export function toIdle() {
|
||||||
|
state.audio.pause(); state.playing = false; setPlayIcon();
|
||||||
|
state.current = null; state.nextJob = null; state.pendingJob = null; state.phase = "idle";
|
||||||
|
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
|
||||||
|
}
|
||||||
|
/* a job through the machine: show it, wait for its render, start it, then the bookkeeping */
|
||||||
|
export async function playJob(job) {
|
||||||
|
const spec = job.spec;
|
||||||
|
toCooking(job);
|
||||||
|
await job.done;
|
||||||
|
if (state.current !== job) return; // skipped while cooking
|
||||||
|
$("#cover").classList.remove("cooking");
|
||||||
|
if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; }
|
||||||
|
if (state.phase === "holding" && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
|
||||||
|
await queueBehindHold(job);
|
||||||
|
if (state.current !== job) return;
|
||||||
|
}
|
||||||
|
spec.id ||= spec.made + "-" + hash32(spec.title);
|
||||||
|
if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back
|
||||||
|
await toPlaying(job);
|
||||||
|
nowPlaying(spec); document.title = `${spec.title} · g*ndora`;
|
||||||
|
cookNext(); // (no-op if already cooking)
|
||||||
|
// the bookkeeping, after the music is already going
|
||||||
|
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
|
||||||
|
(async () => {
|
||||||
|
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
|
||||||
|
if (job.fileInto) { const tp = await tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
|
||||||
|
drawTrail().catch(() => {});
|
||||||
|
})().catch(() => {});
|
||||||
|
}
|
||||||
|
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
|
||||||
|
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
|
||||||
|
|
||||||
|
async function step(dir) {
|
||||||
|
const i = state.pos + dir; if (i < 0 || i >= state.recent.length) return false;
|
||||||
|
const row = await db.get(state.recent[i]); if (!row) return false;
|
||||||
|
state.pos = i; state.audio.pause();
|
||||||
|
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
|
||||||
|
never one of the last few heard; null when the library is too thin */
|
||||||
|
async function shuffled() {
|
||||||
|
const rows = (await db.all()).filter((t) => t.spec.stationId === state.stationId && t.blob && !t.spec.break);
|
||||||
|
const avoid = new Set(state.recent.slice(-4));
|
||||||
|
const pool = rows.filter((t) => !avoid.has(t.id)); if (pool.length < 3) return null;
|
||||||
|
const net = typeof brainNow === "function" ? brainNow() : null;
|
||||||
|
const w = pool.map((t) => Math.exp(3 * (net ? net.predict(t.spec) : 0.5)) * (t.kept ? 2 : 1) * (1 + 0.3 * Math.log1p(t.plays || 0)));
|
||||||
|
let r = Math.random() * w.reduce((a, b) => a + b, 0);
|
||||||
|
for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; }
|
||||||
|
return pool[pool.length - 1];
|
||||||
|
}
|
||||||
|
/* what plays next, in order: the tape → a shared tape still rendering → forward in the session → the remix/after
|
||||||
|
slot → shuffle (maybe) → the cooked next → or cook one now. Called after toEnded() and on skip. */
|
||||||
|
export async function advance() {
|
||||||
|
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
|
||||||
|
if (state.tape) {
|
||||||
|
const tp = await tapeById(state.tape.id);
|
||||||
|
if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); }
|
||||||
|
state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station.");
|
||||||
|
}
|
||||||
|
// a shared tape still rendering: its next track comes before anything fresh
|
||||||
|
if (state.tapeQueue && state.tapeQueue.length && !state.nextJob) {
|
||||||
|
const t = state.tapeQueue.shift(); const spec = { ...t, seed: t.seed || state.seed, made: Date.now() };
|
||||||
|
state.nextJob = makeJob(spec); state.nextJob.fileInto = state.sharedTapeId;
|
||||||
|
}
|
||||||
|
if (await step(+1)) return; // forward through the session first, then cook new
|
||||||
|
if (!state.nextJob && state.after) { state.nextJob = state.after; state.after = null; }
|
||||||
|
if (state.shuffle && Math.random() < 0.5) {
|
||||||
|
const t = await shuffled();
|
||||||
|
if (t) { state.audio.pause(); await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; }
|
||||||
|
}
|
||||||
|
bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs
|
||||||
|
const job = state.nextJob || await cookNext();
|
||||||
|
state.nextJob = null; $("#next").textContent = "";
|
||||||
|
if (!job) { crackleOff(); return; }
|
||||||
|
await playJob(job);
|
||||||
|
cookNext();
|
||||||
|
}
|
||||||
|
/* the player's events. `ended` is the only one that moves the machine; the rest keep state.playing honest. */
|
||||||
|
state.audio.addEventListener("ended", () => {
|
||||||
|
if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air
|
||||||
|
if (state.phase === "holding") { if (state.pendingJob) handOver(state.pendingJob); return; } // the crackle's pass ran out: hand over, or (loop is on, so rarely) nothing waits
|
||||||
|
toEnded();
|
||||||
|
});
|
||||||
|
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); });
|
||||||
|
state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } });
|
||||||
|
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
|
||||||
|
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
|
||||||
|
/* scrubbing: tap or drag along the bar; the readout shows where you'd land */
|
||||||
|
(function () {
|
||||||
|
const bar = $("#bar"), fill = $("#bar i"), tip = $("#bar b");
|
||||||
|
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||||
|
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };
|
||||||
|
let down = false;
|
||||||
|
const show = (f) => { fill.style.width = f * 100 + "%"; tip.style.left = f * 100 + "%"; tip.textContent = state.audio.duration ? `${mmss(f * state.audio.duration)} / ${mmss(state.audio.duration)}` : ""; };
|
||||||
|
bar.addEventListener("pointerdown", (e) => { if (!state.audio.duration || !state.current || !state.current.blob) return; down = true; bar.classList.add("drag"); bar.setPointerCapture(e.pointerId); show(frac(e)); e.preventDefault(); });
|
||||||
|
bar.addEventListener("pointermove", (e) => { if (down) show(frac(e)); });
|
||||||
|
const end = (e) => { if (!down) return; down = false; bar.classList.remove("drag"); const f = frac(e); state.audio.currentTime = f * state.audio.duration; show(f); };
|
||||||
|
bar.addEventListener("pointerup", end); bar.addEventListener("pointercancel", end);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if ("mediaSession" in navigator) {
|
||||||
|
const ms = navigator.mediaSession;
|
||||||
|
try {
|
||||||
|
ms.setActionHandler("play", () => $("#play").click());
|
||||||
|
ms.setActionHandler("pause", () => $("#play").click());
|
||||||
|
ms.setActionHandler("previoustrack", () => $("#backTrack").click());
|
||||||
|
ms.setActionHandler("nexttrack", () => $("#skip").click());
|
||||||
|
} catch {}
|
||||||
|
state.audio.addEventListener("play", () => { ms.playbackState = "playing"; });
|
||||||
|
state.audio.addEventListener("pause", () => { ms.playbackState = "paused"; });
|
||||||
|
}
|
||||||
|
/* updates: an installed app has no address bar to refresh from, so the page checks for a new worker whenever it
|
||||||
|
comes back to the foreground (and every ten minutes) and offers a reload when one has taken over */
|
||||||
|
|
||||||
|
$("#play").addEventListener("click", async () => {
|
||||||
|
if (!state.audio.src) return;
|
||||||
|
if (state.tapStarted && performance.now() - state.tapStarted < 800) { state.tapStarted = 0; setPlayIcon(); return; } // this tap already started it
|
||||||
|
if (state.needTap) { prime(); return; }
|
||||||
|
if (state.playing) { state.audio.pause(); state.playing = false; vizStop(); } else { try { await state.audio.play(); state.playing = true; vizStart(); } catch {} }
|
||||||
|
setPlayIcon();
|
||||||
|
});
|
||||||
|
$("#skip").addEventListener("click", () => { state.audio.pause(); state.playing = false; setPlayIcon(); if (state.current) state.current.spec.reaction = (state.current.spec.reaction || "") + " [skipped]"; advance(); });
|
||||||
|
$("#remix").addEventListener("click", async () => {
|
||||||
|
const cur = state.current && state.current.spec; if (!cur) return;
|
||||||
|
state.remixOf = cur; toast("Remixing — it goes next.");
|
||||||
|
try {
|
||||||
|
const spec = await askDJ(); spec.remixOf = cur.id;
|
||||||
|
// the renderer hears the original, which also fixes the length: when the listener asked for shorter/longer, let the length win
|
||||||
|
if (state.current && state.current.blob && Math.abs((spec.seconds || 120) - (cur.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.current.blob);
|
||||||
|
const job = makeJob(spec);
|
||||||
|
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
|
||||||
|
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";
|
||||||
|
} catch (e) { toast(e.message, 5000); }
|
||||||
|
finally { state.remixOf = null; }
|
||||||
|
});
|
||||||
|
$("#shuffle").addEventListener("click", () => { state.shuffle = !state.shuffle; $("#shuffle").classList.toggle("on", state.shuffle); toast(state.shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh."); });
|
||||||
|
$("#loop").addEventListener("click", () => { state.audio.loop = !state.audio.loop; state.loop = state.audio.loop; $("#loop").classList.toggle("on", state.audio.loop); toast(state.audio.loop ? "Repeating this track." : "Back to the station."); });
|
||||||
|
$("#prev").addEventListener("click", () => { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } });
|
||||||
|
$("#backTrack").addEventListener("click", async () => { if (!(await step(-1))) toast("That's the first track of this session."); });
|
||||||
|
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
|
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
|
||||||
const REV = "be3bce8aac3e";
|
const REV = "be3bce8aac3e";
|
||||||
const SHELL = "gandora-shell-" + REV;
|
const SHELL = "gandora-shell-" + REV;
|
||||||
const FILES = ["/", "/index.html", "/core.js", "/covers.js", "/dj.js", "/render.js", "/store.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
|
const FILES = ["/", "/index.html", "/app.js", "/core.js", "/covers.js", "/dj.js", "/mashup.js", "/player.js", "/render.js", "/store.js", "/ui/library.js", "/ui/player.js", "/ui/sheet.js", "/ui/taste.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
|
||||||
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
|
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
|
||||||
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
|
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
|
||||||
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
|
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
import { db, tapeById, tapeRows, tapeSpec } from "../store.js";
|
||||||
|
import { $, ids, state, toast } from "../core.js";
|
||||||
|
import { KEYS, drawCover, hash32 } from "../covers.js";
|
||||||
|
import { b64u, resumeStation, stationFrom } from "../app.js";
|
||||||
|
import { stationSheet } from "../ui/sheet.js";
|
||||||
|
import { keepLabel, reactionEmoji } from "../ui/player.js";
|
||||||
|
import { playJob } from "../player.js";
|
||||||
|
import { mashup } from "../mashup.js";
|
||||||
|
export async function listStations() {
|
||||||
|
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
|
||||||
|
box.classList.toggle("hidden", !list.length);
|
||||||
|
for (const s of list) {
|
||||||
|
const d = document.createElement("div"); d.className = "s" + (s.id === state.stationId ? " on" : "");
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 144;
|
||||||
|
drawCover(c, { seed: s.seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(s.seed) % 12], energy: 0.5 }, { lit: 0.9 });
|
||||||
|
d.append(c, Object.assign(document.createElement("b"), { textContent: s.name || s.seed }), Object.assign(document.createElement("small"), { textContent: `${s.played || 0} tracks` }));
|
||||||
|
d.addEventListener("click", () => resumeStation(s));
|
||||||
|
const dots = Object.assign(document.createElement("button"), { className: "dots", textContent: "⋯", title: "rename, reset or delete" });
|
||||||
|
dots.addEventListener("click", (e) => { e.stopPropagation(); stationSheet(s.id); });
|
||||||
|
d.append(dots);
|
||||||
|
row.append(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
|
||||||
|
|
||||||
|
export function libNow() {
|
||||||
|
const bar = $("#libNow"); if (!bar) return;
|
||||||
|
const cur = state.current && state.current.spec;
|
||||||
|
for (const d of document.querySelectorAll("#libGrid .t")) { const on = !!(cur && d.dataset.id === cur.id); d.classList.toggle("now", on); d.classList.toggle("paused", on && !state.playing); }
|
||||||
|
if (!cur) { bar.classList.add("hidden"); return; }
|
||||||
|
bar.classList.remove("hidden");
|
||||||
|
drawCover(bar.querySelector("canvas"), cur); bar.querySelector("b").textContent = cur.title; bar.querySelector("small").textContent = (state.station || cur.seed || "").toUpperCase();
|
||||||
|
bar.querySelector("#libPlay svg").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||||||
|
}
|
||||||
|
$("#libPlay").addEventListener("click", () => $("#play").click());
|
||||||
|
$("#libOpen").addEventListener("click", () => { $("#lib").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); });
|
||||||
|
|
||||||
|
|
||||||
|
$("#libBtn").addEventListener("click", async () => {
|
||||||
|
const list = await db.all(); const g = $("#libGrid"); g.innerHTML = "";
|
||||||
|
$("#libEmpty").classList.toggle("hidden", list.length > 0);
|
||||||
|
const cur = state.current && state.current.spec;
|
||||||
|
const tile = (t, inKept = false) => {
|
||||||
|
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
|
||||||
|
const art = document.createElement("div"); art.className = "art";
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec); art.append(c);
|
||||||
|
if (t.kept && !inKept) art.append(Object.assign(document.createElement("span"), { className: "kept", textContent: "KEPT" }));
|
||||||
|
const re = reactionEmoji(t.spec.reaction).trim();
|
||||||
|
if (re) art.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
|
||||||
|
const addT = Object.assign(document.createElement("button"), { className: "addTape", textContent: "+", title: "add to a tape" });
|
||||||
|
addT.addEventListener("click", (e) => { e.stopPropagation(); pickTape(t); });
|
||||||
|
const fromT = Object.assign(document.createElement("button"), { className: "fromT", textContent: "*", title: "start a station from this track" });
|
||||||
|
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
|
||||||
|
art.append(addT, fromT);
|
||||||
|
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
|
||||||
|
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) { d.classList.add("now"); if (!state.playing) d.classList.add("paused"); }
|
||||||
|
d.addEventListener("click", () => {
|
||||||
|
// the library stays open; the track plays underneath, and the bar at the bottom follows it
|
||||||
|
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
|
||||||
|
const job = { id: ++ids.seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }; state.seed = t.spec.seed; $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); state.audio.pause(); playJob(job);
|
||||||
|
});
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
// kept first, then everything played, grouped by station (channel), newest station first
|
||||||
|
const stations = await db.all("stations"); const byId = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||||
|
const head = (text, seed, count) => {
|
||||||
|
const h = document.createElement("h3"); h.style.gridColumn = "1/-1";
|
||||||
|
if (seed) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }); h.append(c); }
|
||||||
|
h.append(Object.assign(document.createElement("span"), { textContent: text }));
|
||||||
|
if (count != null) h.append(Object.assign(document.createElement("small"), { textContent: `· ${count}` }));
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
// tapes first: a strip of mixtapes, and a way to start one
|
||||||
|
const tapes = await db.all("tapes");
|
||||||
|
g.append(head("TAPES", "", tapes.length));
|
||||||
|
const strip = document.createElement("div"); strip.id = "tapeRow";
|
||||||
|
for (const tp of tapes) {
|
||||||
|
const s = document.createElement("div"); s.className = "s";
|
||||||
|
const tc = document.createElement("div"); tc.className = "tc one";
|
||||||
|
const rows = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean);
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 180; drawCover(c, tapeSpec(tp, rows)); tc.append(c);
|
||||||
|
s.append(tc, Object.assign(document.createElement("b"), { textContent: tp.name }), Object.assign(document.createElement("small"), { textContent: `${tp.ids.length} tracks` }));
|
||||||
|
s.addEventListener("click", () => openTape(tp.id));
|
||||||
|
strip.append(s);
|
||||||
|
}
|
||||||
|
const nw = document.createElement("div"); nw.className = "s new"; nw.innerHTML = '<div class="tc">+</div><b>new tape</b>';
|
||||||
|
nw.addEventListener("click", async () => { const name = prompt("Name the tape:", ""); if (!name) return; const tp = { id: "tape-" + Date.now(), name: name.trim().slice(0, 60), ids: [], made: Date.now(), last: Date.now() }; await db.put(tp, "tapes"); openTape(tp.id); });
|
||||||
|
strip.append(nw); g.append(strip);
|
||||||
|
const kept = list.filter((t) => t.kept);
|
||||||
|
if (kept.length) { g.append(head("KEPT", "", kept.length)); for (const t of kept) g.append(tile(t, true)); }
|
||||||
|
const groups = new Map();
|
||||||
|
for (const t of list) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); }
|
||||||
|
for (const [k, rows] of groups) {
|
||||||
|
const s = byId[k]; const name = (s && s.name) || rows[0].spec.seed, seed = (s && s.seed) || rows[0].spec.seed;
|
||||||
|
g.append(head(name.toUpperCase(), seed, rows.length));
|
||||||
|
for (const t of rows) g.append(tile(t));
|
||||||
|
}
|
||||||
|
libNow();
|
||||||
|
$("#libCost").textContent = `~$${state.cost.toFixed(2)} of music rendered from this browser · replays from the library are free`;
|
||||||
|
$("#lib").classList.remove("hidden");
|
||||||
|
});
|
||||||
|
$("#libClose").addEventListener("click", () => $("#lib").classList.add("hidden"));
|
||||||
|
|
||||||
|
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
|
||||||
|
async function pickTape(track) {
|
||||||
|
const p = $("#tapePick"); p.innerHTML = "";
|
||||||
|
const tapes = await db.all("tapes");
|
||||||
|
p.append(Object.assign(document.createElement("h4"), { textContent: "ADD “" + track.spec.title.toUpperCase() + "” TO" }));
|
||||||
|
const add = async (tp) => { if (tp.virtual) { const row = await db.get(track.id); if (row && !row.kept) { row.kept = true; await db.put(row); keepLabel().catch(() => {}); } } else if (!tp.ids.includes(track.id)) { tp.ids.push(track.id); tp.last = Date.now(); await db.put(tp, "tapes"); } p.classList.add("hidden"); toast(`On “${tp.name}”.`); };
|
||||||
|
for (const tp of tapes) { const b = document.createElement("button"); b.textContent = `${tp.name} · ${tp.ids.length}`; b.addEventListener("click", () => add(tp)); p.append(b); }
|
||||||
|
const inp = document.createElement("input"); inp.placeholder = "new tape…"; inp.maxLength = 60;
|
||||||
|
inp.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const name = inp.value.trim(); if (!name) return; const tp = { id: "tape-" + Date.now(), name, ids: [], made: Date.now(), last: Date.now() }; await add(tp); });
|
||||||
|
const close = document.createElement("button"); close.textContent = "cancel"; close.addEventListener("click", () => p.classList.add("hidden"));
|
||||||
|
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
state.tape = null; // { id, ids, i, loop } while a tape is playing
|
||||||
|
/* one tape by id. KEPT is virtual (db.all("tapes") writes it from the kept tracks), so it is the one that needs the list */
|
||||||
|
|
||||||
|
async function openTape(id) {
|
||||||
|
const tp = await tapeById(id); if (!tp) return;
|
||||||
|
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
|
||||||
|
$("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual);
|
||||||
|
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
|
||||||
|
drawCover($("#tapeCover canvas"), tapeSpec(tp, rows));
|
||||||
|
$("#tapeEmpty").classList.toggle("hidden", rows.length > 0);
|
||||||
|
$("#tapeLoop").classList.toggle("go", !!(state.tape && state.tape.id === id && state.tape.loop));
|
||||||
|
rows.forEach((t, i) => {
|
||||||
|
const r = document.createElement("div"); r.className = "row" + (state.current && state.current.spec.id === t.id ? " on" : "");
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec);
|
||||||
|
const m = document.createElement("div"); m.className = "m"; m.append(Object.assign(document.createElement("b"), { textContent: `${i + 1}. ${t.spec.title}` }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
|
||||||
|
const play = () => playTape(tp, i);
|
||||||
|
c.addEventListener("click", play); m.addEventListener("click", play);
|
||||||
|
const ctl = document.createElement("div"); ctl.className = "ctl";
|
||||||
|
const mv = async (d) => { const j = i + d; if (j < 0 || j >= tp.ids.length) return; [tp.ids[i], tp.ids[j]] = [tp.ids[j], tp.ids[i]]; await db.put(tp, "tapes"); openTape(id); };
|
||||||
|
const up = Object.assign(document.createElement("button"), { textContent: "▲", title: "earlier" }), dn = Object.assign(document.createElement("button"), { textContent: "▼", title: "later" }), rm = Object.assign(document.createElement("button"), { textContent: "✕", title: "remove from tape" });
|
||||||
|
up.addEventListener("click", () => mv(-1)); dn.addEventListener("click", () => mv(1));
|
||||||
|
rm.addEventListener("click", async () => { if (tp.virtual) { const row = await db.get(tp.ids[i]); if (row) { row.kept = false; await db.put(row); } } else { tp.ids.splice(i, 1); await db.put(tp, "tapes"); } openTape(id); });
|
||||||
|
ctl.append(up, dn, rm); r.append(c, m, ctl); list.append(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function playTape(tp, i, loop) {
|
||||||
|
const rows = await tapeRows(tp); if (!rows.length) return;
|
||||||
|
i = ((i % rows.length) + rows.length) % rows.length;
|
||||||
|
state.tape = { id: tp.id, ids: rows.map((t) => t.id), i, loop: loop ?? (state.tape && state.tape.id === tp.id ? state.tape.loop : false) };
|
||||||
|
$("#tape").classList.add("hidden"); $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
|
||||||
|
$("#stationName").textContent = "📼 " + tp.name;
|
||||||
|
const t = rows[i]; state.audio.pause();
|
||||||
|
await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
|
||||||
|
}
|
||||||
|
$("#tapePlay").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (tp) playTape(tp, 0); });
|
||||||
|
$("#tapeLoop").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
|
||||||
|
$("#tapeName").addEventListener("change", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
|
||||||
|
$("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); });
|
||||||
|
/* mashups: up to three tracks in, one out. The DJ writes a sheet that fuses theirs; the renderer is handed the
|
||||||
|
tracks themselves, cut and crossfaded into one reference (OfflineAudioContext, off the playback path, so iOS is
|
||||||
|
fine), and the mash is the station's next track. */
|
||||||
|
|
||||||
|
$("#tapeMash").addEventListener("click", async () => {
|
||||||
|
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
|
||||||
|
const rows = await tapeRows(tp);
|
||||||
|
$("#tape").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden");
|
||||||
|
mashup(rows);
|
||||||
|
});
|
||||||
|
$("#tapeShare").addEventListener("click", async () => {
|
||||||
|
const tp = await tapeById($("#tape").dataset.id); if (!tp) return;
|
||||||
|
const rows = await tapeRows(tp);
|
||||||
|
const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) };
|
||||||
|
const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`;
|
||||||
|
if (navigator.share) { try { await navigator.share({ title: tp.name, text: `${tp.name} · a gandora mixtape, ${rows.length} tracks`, url }); return; } catch {} }
|
||||||
|
try { await navigator.clipboard.writeText(url); toast("Tape link copied. It renders each track for whoever opens it."); } catch { prompt("Copy this link:", url); }
|
||||||
|
});
|
||||||
|
$("#tapeClose").addEventListener("click", () => $("#tape").classList.add("hidden"));
|
||||||
|
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
import { $, hooks, mmss, ids, state, toast } from "../core.js";
|
||||||
|
import { db, logEvent, remember, saveStation } from "../store.js";
|
||||||
|
import { drawCover } from "../covers.js";
|
||||||
|
import { playJob } from "../player.js";
|
||||||
|
import { libNow, listStations } from "../ui/library.js";
|
||||||
|
import { cookNext, shareLink } from "../app.js";
|
||||||
|
let trailIds = [];
|
||||||
|
export async function drawTrail() {
|
||||||
|
const el = $("#trail"); if (!el) return;
|
||||||
|
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
|
||||||
|
trailIds = rows.map((t) => t.id);
|
||||||
|
const cur = state.current && state.current.spec && state.current.spec.id;
|
||||||
|
if (trailIds.length < 2) { el.textContent = ""; return; }
|
||||||
|
el.textContent = "";
|
||||||
|
for (const t of rows) {
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 120;
|
||||||
|
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
|
||||||
|
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
|
||||||
|
el.append(c);
|
||||||
|
}
|
||||||
|
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
|
||||||
|
$("#trail").addEventListener("click", async (e) => {
|
||||||
|
e.stopPropagation(); // the cover underneath flips to lyrics on click
|
||||||
|
const c = e.target.closest("canvas"); if (!c) return;
|
||||||
|
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
|
||||||
|
const row = await db.get(id); if (!row) return;
|
||||||
|
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
|
||||||
|
state.audio.pause();
|
||||||
|
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||||||
|
});
|
||||||
|
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
|
||||||
|
|
||||||
|
export function setPlayIcon() {
|
||||||
|
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||||||
|
libNow();
|
||||||
|
}
|
||||||
|
/* the library's own now-playing: ring the tile, keep the bottom bar current */
|
||||||
|
|
||||||
|
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
|
||||||
|
const EMOJI_MEANING = {
|
||||||
|
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound; the listener loves this one" },
|
||||||
|
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
|
||||||
|
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
|
||||||
|
"🚫": { avoid: "anything like the current track" },
|
||||||
|
};
|
||||||
|
/* where in the track a reaction landed: the second, and the lyric section playing then (sections assumed evenly spread) */
|
||||||
|
function sectionAt(spec, t) {
|
||||||
|
const marks = [...String(spec.lyrics || "").matchAll(/\[([a-z -]+)\]/gi)].map((m) => m[1].toLowerCase());
|
||||||
|
if (!marks.length) return "";
|
||||||
|
const dur = state.audio.duration || spec.seconds || 120;
|
||||||
|
return marks[Math.min(marks.length - 1, Math.floor(t / dur * marks.length))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markReaction(spec, emoji, t) {
|
||||||
|
const dur = state.audio.duration || spec.seconds || 120;
|
||||||
|
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
|
||||||
|
if (state.current.spec === spec) drawTicks(spec, dur);
|
||||||
|
}
|
||||||
|
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
|
||||||
|
export function drawTicks(spec, dur) {
|
||||||
|
const bar = $("#bar"); if (!bar) return;
|
||||||
|
for (const t of bar.querySelectorAll("em")) t.remove();
|
||||||
|
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
|
||||||
|
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
|
||||||
|
let below = false, prevAt = -Infinity;
|
||||||
|
for (const g of groups) {
|
||||||
|
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
|
||||||
|
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
|
||||||
|
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
|
||||||
|
tick.classList.toggle("below", below);
|
||||||
|
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
|
||||||
|
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
|
||||||
|
bar.append(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function react(emoji, text) {
|
||||||
|
const cur = state.current && state.current.spec; if (!cur) return;
|
||||||
|
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
|
||||||
|
const at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
|
||||||
|
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
|
||||||
|
if (emoji) markReaction(cur, emoji, at);
|
||||||
|
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
|
||||||
|
if (m) {
|
||||||
|
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
|
||||||
|
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
|
||||||
|
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
|
||||||
|
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`;
|
||||||
|
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
|
||||||
|
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
for (const p of parts) {
|
||||||
|
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||||||
|
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||||||
|
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
|
||||||
|
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
|
||||||
|
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
|
||||||
|
const later = !!state.nextJob;
|
||||||
|
if (!later) cookNext();
|
||||||
|
saveStation().catch(() => {});
|
||||||
|
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
|
||||||
|
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
|
||||||
|
if (hooks.reacted) hooks.reacted(emoji, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
|
||||||
|
export const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
|
||||||
|
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
|
||||||
|
export function showTags(spec) {
|
||||||
|
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
|
||||||
|
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
|
||||||
|
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
|
||||||
|
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
|
||||||
|
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
|
||||||
|
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.mashOf ? "mashup" : spec.remixOf ? "remix" : "";
|
||||||
|
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
|
||||||
|
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
|
||||||
|
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
|
||||||
|
if (!state.tagsOpen) return;
|
||||||
|
for (const tag of [...tags, ...extra]) {
|
||||||
|
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
|
||||||
|
b.addEventListener("click", () => tagTap(spec, tag, b));
|
||||||
|
list.append(b);
|
||||||
|
}
|
||||||
|
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
|
||||||
|
add.addEventListener("click", () => {
|
||||||
|
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
|
||||||
|
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
|
||||||
|
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
|
||||||
|
inp.addEventListener("blur", done);
|
||||||
|
});
|
||||||
|
list.append(add);
|
||||||
|
}
|
||||||
|
function tagTap(spec, tag, btn, force) {
|
||||||
|
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
|
||||||
|
const t = tag.replace(/\s+/g, "_");
|
||||||
|
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
|
||||||
|
else {
|
||||||
|
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
|
||||||
|
const note = `the tag ${JSON.stringify(tag)}`;
|
||||||
|
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
|
||||||
|
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
|
||||||
|
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
|
||||||
|
}
|
||||||
|
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
|
||||||
|
showTags(spec);
|
||||||
|
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
|
||||||
|
saveStation().catch(() => {});
|
||||||
|
if (!state.nextJob) cookNext();
|
||||||
|
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
|
||||||
|
}
|
||||||
|
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
|
||||||
|
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
|
||||||
|
export function showLyrics(spec) {
|
||||||
|
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
|
||||||
|
p.innerHTML = ""; p.classList.toggle("none", !has);
|
||||||
|
if (!has) { p.textContent = "instrumental"; return; }
|
||||||
|
for (const line of spec.lyrics.split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
||||||
|
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
|
||||||
|
else p.append(document.createTextNode(line + "\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleLyrics() {
|
||||||
|
state.lyricsOpen = !state.lyricsOpen;
|
||||||
|
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
|
||||||
|
$("#lyricsBtn").textContent = state.lyricsOpen ? "⇄ COVER" : "⇄ LYRICS";
|
||||||
|
}
|
||||||
|
$("#lyricsBtn").addEventListener("click", toggleLyrics);
|
||||||
|
$("#cover").addEventListener("click", toggleLyrics);
|
||||||
|
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
|
||||||
|
export async function keepLabel() {
|
||||||
|
const b = $("#keep"), cur = state.current && state.current.spec;
|
||||||
|
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP"; return; }
|
||||||
|
const row = await db.get(cur.id);
|
||||||
|
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP";
|
||||||
|
$("#share").disabled = false;
|
||||||
|
}
|
||||||
|
$("#share").addEventListener("click", async () => {
|
||||||
|
const url = shareLink(); if (!url) return;
|
||||||
|
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
|
||||||
|
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
|
||||||
|
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
|
||||||
|
});
|
||||||
|
$("#keep").addEventListener("click", async () => {
|
||||||
|
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) { toast("Still cooking."); return; }
|
||||||
|
const row = await db.get(cur.id);
|
||||||
|
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
|
||||||
|
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
|
||||||
|
});
|
||||||
|
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
|
||||||
|
$("#back").addEventListener("click", () => {
|
||||||
|
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
|
||||||
|
const cur = state.current && state.current.spec;
|
||||||
|
$("#nowRow").classList.toggle("hidden", !cur);
|
||||||
|
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
|
||||||
|
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
|
||||||
|
listStations().catch(() => {});
|
||||||
|
});
|
||||||
|
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
|
||||||
|
/* lock screen / media keys: title, station, the cover, and back · pause · next */
|
||||||
|
export function nowPlaying(spec) {
|
||||||
|
if (!("mediaSession" in navigator)) return;
|
||||||
|
try {
|
||||||
|
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
|
||||||
|
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_SETS = {
|
||||||
|
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
|
||||||
|
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
|
||||||
|
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
|
||||||
|
};
|
||||||
|
const EMOJI_KEY = "gandora.emoji";
|
||||||
|
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
|
||||||
|
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); localStorage.setItem(EMOJI_KEY + ".last", e); }
|
||||||
|
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
|
||||||
|
const EMOJI_TRAY = 7;
|
||||||
|
function showEmojiRow() {
|
||||||
|
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
|
||||||
|
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
|
||||||
|
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
|
||||||
|
const ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
|
||||||
|
const last = localStorage.getItem(EMOJI_KEY + ".last");
|
||||||
|
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
|
||||||
|
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
|
||||||
|
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
|
||||||
|
}
|
||||||
|
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); setTimeout(showEmojiRow, 350); });
|
||||||
|
function openPicker() {
|
||||||
|
const p = $("#picker"); p.innerHTML = "";
|
||||||
|
const top = document.createElement("div"); top.className = "top";
|
||||||
|
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
|
||||||
|
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
|
||||||
|
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
|
||||||
|
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
|
||||||
|
close.addEventListener("click", () => p.classList.add("hidden"));
|
||||||
|
top.append(inp, go, close);
|
||||||
|
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
|
||||||
|
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
|
||||||
|
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
|
||||||
|
for (const [name, list] of Object.entries(sets)) {
|
||||||
|
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
|
||||||
|
const g = document.createElement("div"); g.className = "g";
|
||||||
|
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
|
||||||
|
p.append(g);
|
||||||
|
}
|
||||||
|
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
|
||||||
|
p.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
|
||||||
|
showEmojiRow();
|
||||||
|
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
|
||||||
|
|
||||||
|
/* key box */
|
||||||
|
|
||||||
|
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
|
||||||
|
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
|
||||||
|
$("#scrim").addEventListener("click", closeSheets);
|
||||||
|
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
|
||||||
|
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
|
||||||
+143
@@ -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 */
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
import { $, state, toast } from "../core.js";
|
||||||
|
import { KEYS, drawCover, hash32, keyHue } from "../covers.js";
|
||||||
|
import { db } from "../store.js";
|
||||||
|
import { listStations } from "../ui/library.js";
|
||||||
|
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
|
||||||
|
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
|
||||||
|
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
|
||||||
|
export function reactionWeight(spec) {
|
||||||
|
const r = String(spec.reaction || "");
|
||||||
|
let w = 0;
|
||||||
|
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
|
||||||
|
if (/\[skipped\]/.test(r)) w -= 1.5;
|
||||||
|
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
export function drawTaste(rows, title) {
|
||||||
|
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
|
||||||
|
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
|
||||||
|
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
|
||||||
|
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
|
||||||
|
ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H);
|
||||||
|
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
|
||||||
|
const cell = 18, sig = 90;
|
||||||
|
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
|
||||||
|
let like = 0, heard = 0;
|
||||||
|
for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; }
|
||||||
|
if (heard < 0.02) continue;
|
||||||
|
const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said
|
||||||
|
const a = Math.min(0.5, heard * 0.3);
|
||||||
|
if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral
|
||||||
|
else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`;
|
||||||
|
ctx.fillRect(gx, gy, cell, cell);
|
||||||
|
}
|
||||||
|
// axes
|
||||||
|
ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
|
||||||
|
ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
|
||||||
|
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
|
||||||
|
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
|
||||||
|
// the points: colour is the key, size is how much was said about it, ring warm or cold
|
||||||
|
for (const p of pts) {
|
||||||
|
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35);
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
|
||||||
|
if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
|
||||||
|
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
|
||||||
|
}
|
||||||
|
const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length;
|
||||||
|
const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0;
|
||||||
|
const liked = pts.filter((p) => p.w > 0);
|
||||||
|
const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null;
|
||||||
|
const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null;
|
||||||
|
const minors = liked.filter((p) => p.minor).length;
|
||||||
|
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
|
||||||
|
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears.");
|
||||||
|
}
|
||||||
|
export async function openTaste() {
|
||||||
|
const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||||
|
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
|
||||||
|
const groups = [["all", "ALL", "", rows]];
|
||||||
|
const seen = new Map();
|
||||||
|
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
|
||||||
|
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
|
||||||
|
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
|
||||||
|
const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); };
|
||||||
|
for (const g of groups) {
|
||||||
|
const b = document.createElement("button"); b.dataset.k = g[0];
|
||||||
|
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
|
||||||
|
b.append(document.createTextNode(g[1] + " · " + g[3].length));
|
||||||
|
b.addEventListener("click", () => { pick = g; show(); });
|
||||||
|
tabs.append(b);
|
||||||
|
}
|
||||||
|
show(); $("#taste").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
$("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message)));
|
||||||
|
$("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden"));
|
||||||
|
|
||||||
|
listStations().catch(() => {});
|
||||||
|
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed
|
||||||
Reference in new issue
Block a user