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 ? '' : ''; } $("#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 = '
+
new tape'; 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