173 lines
15 KiB
JavaScript
173 lines
15 KiB
JavaScript
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
|