Files
site/store.js
T

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" };
}