Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
56 lines
5.0 KiB
JavaScript
56 lines
5.0 KiB
JavaScript
import { state } from "./core.js";
|
|
import { KEYS, hash32 } from "./covers.js";
|
|
/* ---------- the library (IndexedDB) ---------- */
|
|
export const db = {
|
|
open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); },
|
|
async put(t, store = "tracks") { if (store === "tapes" && t.id === "kept") return; const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(t); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
|
|
async all(store = "tracks") {
|
|
const d = await this.open();
|
|
const rows = await new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result.sort((a, b) => (b.last || b.made) - (a.last || a.made))); r.onerror = () => no(r.error); });
|
|
if (store !== "tapes") return rows;
|
|
// KEPT is a tape that writes itself: every kept track, oldest first, always at the front of the strip
|
|
const kept = await new Promise((ok, no) => { const r = d.transaction("tracks").objectStore("tracks").getAll(); r.onsuccess = () => ok(r.result.filter((t) => t.kept).sort((a, b) => a.made - b.made).map((t) => t.id)); r.onerror = () => no(r.error); });
|
|
return [{ id: "kept", name: "KEPT", ids: kept, made: 0, last: Date.now(), virtual: true }, ...rows];
|
|
},
|
|
async get(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).get(id); r.onsuccess = () => ok(r.result || null); r.onerror = () => no(r.error); }); },
|
|
async del(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).delete(id); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
|
|
};
|
|
|
|
/* ---------- stations: a seed, its taste, and what it has played; resumable from the seed screen ---------- */
|
|
export async function saveStation() {
|
|
if (!state.stationId) return;
|
|
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }));
|
|
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", djSpeed: state.djSpeed || 1, djPersona: state.djPersona || "", mode: state.mode || "music", welcomed: !!state.welcomed }, "stations");
|
|
}
|
|
|
|
const KEEP_UNKEPT = 40;
|
|
// every rendered track is saved so the station can be scrolled back through; ❤️ pins one, the rest roll off after KEEP_UNKEPT
|
|
export async function remember(job, kept) {
|
|
const s = job.spec; if (!job.blob) return;
|
|
s.id ||= s.made + "-" + hash32(s.title);
|
|
const old = await db.get(s.id);
|
|
await db.put({ id: s.id, spec: s, blob: job.blob, made: s.made, kept: kept || (old && old.kept) || false, plays: (old && old.plays) || s.plays || 0 });
|
|
const loose = (await db.all()).filter((t) => !t.kept).slice(KEEP_UNKEPT);
|
|
for (const t of loose) await db.del(t.id);
|
|
}
|
|
|
|
|
|
/* the ledger: one line per cook, play and reaction, in localStorage (the track store prunes, this doesn't; capped at
|
|
3,000 lines). stats.html reads it. Nothing leaves the device. */
|
|
export function logEvent(kind, d = {}) {
|
|
try { const log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); log.push({ t: Date.now(), k: kind, ...d }); while (log.length > 3000) log.shift(); localStorage.setItem("gandora.log", JSON.stringify(log)); } catch {}
|
|
}
|
|
|
|
export const tapeById = async (id) => id === "kept" ? (await db.all("tapes"))[0] : db.get(id, "tapes");
|
|
export async function tapeRows(tp) { return (await Promise.all(tp.ids.map((id) => db.get(id)))).filter(Boolean); }
|
|
/* a tape's cover: a sheet blended from its tracks. The key is the one most of them share (the cover's hue), tempo and
|
|
energy are averages, the tags are everyone's, the seed is the tape's name so each tape keeps its own shape. */
|
|
export function tapeSpec(tp, rows) {
|
|
const specs = rows.map((t) => t.spec);
|
|
const keys = {}; for (const s of specs) keys[s.key] = (keys[s.key] || 0) + 1;
|
|
const key = Object.keys(keys).sort((a, b) => keys[b] - keys[a])[0] || KEYS[hash32(tp.name) % 12];
|
|
const avg = (f, d) => specs.length ? specs.reduce((a, s) => a + (+f(s) || d), 0) / specs.length : d;
|
|
return { seed: "tape:" + (tp.id === "kept" ? "kept" : tp.name), title: tp.name, tags: specs.map((s) => s.tags).join(", ").slice(0, 600), bpm: Math.round(avg((s) => s.bpm, 100)), key, energy: avg((s) => s.energy, 0.5),
|
|
look: specs.length ? (specs[specs.length - 1].look || state.look) : "rings", palette: specs.length > 1 ? "tri" : "duo" };
|
|
}
|