Files
site/ui/library.js
T

173 lines
15 KiB
JavaScript
Raw Normal View History

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