Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
271 lines
20 KiB
JavaScript
271 lines
20 KiB
JavaScript
import { $, hooks, mmss, ids, state, toast } from "../core.js";
|
||
import { db, logEvent, remember, saveStation } from "../store.js";
|
||
import { drawCover } from "../covers.js";
|
||
import { playJob } from "../player.js";
|
||
import { libNow, listStations } from "../ui/library.js";
|
||
import { cookNext, shareLink } from "../app.js";
|
||
let trailIds = [];
|
||
export async function drawTrail() {
|
||
const el = $("#trail"); if (!el) return;
|
||
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
|
||
trailIds = rows.map((t) => t.id);
|
||
const cur = state.current && state.current.spec && state.current.spec.id;
|
||
if (trailIds.length < 2) { el.textContent = ""; return; }
|
||
el.textContent = "";
|
||
for (const t of rows) {
|
||
const c = document.createElement("canvas"); c.width = c.height = 120;
|
||
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
|
||
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
|
||
el.append(c);
|
||
}
|
||
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
|
||
}
|
||
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
|
||
$("#trail").addEventListener("click", async (e) => {
|
||
e.stopPropagation(); // the cover underneath flips to lyrics on click
|
||
const c = e.target.closest("canvas"); if (!c) return;
|
||
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
|
||
const row = await db.get(id); if (!row) return;
|
||
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
|
||
state.audio.pause();
|
||
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||
});
|
||
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
|
||
|
||
export function setPlayIcon() {
|
||
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||
libNow();
|
||
}
|
||
/* the library's own now-playing: ring the tile, keep the bottom bar current */
|
||
|
||
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
|
||
const EMOJI_MEANING = {
|
||
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound; the listener loves this one" },
|
||
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
|
||
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
|
||
"🚫": { avoid: "anything like the current track" },
|
||
};
|
||
/* where in the track a reaction landed: the second, and the lyric section playing then (sections assumed evenly spread) */
|
||
function sectionAt(spec, t) {
|
||
const marks = [...String(spec.lyrics || "").matchAll(/\[([a-z -]+)\]/gi)].map((m) => m[1].toLowerCase());
|
||
if (!marks.length) return "";
|
||
const dur = state.audio.duration || spec.seconds || 120;
|
||
return marks[Math.min(marks.length - 1, Math.floor(t / dur * marks.length))];
|
||
}
|
||
|
||
function markReaction(spec, emoji, t) {
|
||
const dur = state.audio.duration || spec.seconds || 120;
|
||
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
|
||
if (state.current.spec === spec) drawTicks(spec, dur);
|
||
}
|
||
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
|
||
export function drawTicks(spec, dur) {
|
||
const bar = $("#bar"); if (!bar) return;
|
||
for (const t of bar.querySelectorAll("em")) t.remove();
|
||
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
|
||
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
|
||
let below = false, prevAt = -Infinity;
|
||
for (const g of groups) {
|
||
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
|
||
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
|
||
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
|
||
tick.classList.toggle("below", below);
|
||
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
|
||
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
|
||
bar.append(tick);
|
||
}
|
||
}
|
||
async function react(emoji, text) {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
|
||
const at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
|
||
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
|
||
if (emoji) markReaction(cur, emoji, at);
|
||
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
|
||
if (m) {
|
||
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
|
||
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
|
||
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
|
||
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
|
||
}
|
||
if (text) {
|
||
state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`;
|
||
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
|
||
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
|
||
for (const p of parts) {
|
||
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
|
||
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
|
||
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
|
||
}
|
||
}
|
||
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
|
||
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
|
||
const later = !!state.nextJob;
|
||
if (!later) cookNext();
|
||
saveStation().catch(() => {});
|
||
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
|
||
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
|
||
if (hooks.reacted) hooks.reacted(emoji, text);
|
||
}
|
||
|
||
|
||
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
|
||
export const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
|
||
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
|
||
export function showTags(spec) {
|
||
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
|
||
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
|
||
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
|
||
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
|
||
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
|
||
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.mashOf ? "mashup" : spec.remixOf ? "remix" : "";
|
||
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
|
||
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
|
||
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
|
||
if (!state.tagsOpen) return;
|
||
for (const tag of [...tags, ...extra]) {
|
||
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
|
||
b.addEventListener("click", () => tagTap(spec, tag, b));
|
||
list.append(b);
|
||
}
|
||
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
|
||
add.addEventListener("click", () => {
|
||
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
|
||
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
|
||
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
|
||
inp.addEventListener("blur", done);
|
||
});
|
||
list.append(add);
|
||
}
|
||
function tagTap(spec, tag, btn, force) {
|
||
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
|
||
const t = tag.replace(/\s+/g, "_");
|
||
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
|
||
else {
|
||
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
|
||
const note = `the tag ${JSON.stringify(tag)}`;
|
||
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
|
||
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
|
||
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
|
||
}
|
||
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
|
||
showTags(spec);
|
||
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
|
||
saveStation().catch(() => {});
|
||
if (!state.nextJob) cookNext();
|
||
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
|
||
}
|
||
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
|
||
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
|
||
export function showLyrics(spec) {
|
||
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
|
||
p.innerHTML = ""; p.classList.toggle("none", !has);
|
||
if (!has) { p.textContent = "instrumental"; return; }
|
||
for (const line of spec.lyrics.split(/\r?\n/)) {
|
||
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
||
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
|
||
else p.append(document.createTextNode(line + "\n"));
|
||
}
|
||
}
|
||
function toggleLyrics() {
|
||
state.lyricsOpen = !state.lyricsOpen;
|
||
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
|
||
$("#lyricsBtn").textContent = state.lyricsOpen ? "⇄ COVER" : "⇄ LYRICS";
|
||
}
|
||
$("#lyricsBtn").addEventListener("click", toggleLyrics);
|
||
$("#cover").addEventListener("click", toggleLyrics);
|
||
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
|
||
export async function keepLabel() {
|
||
const b = $("#keep"), cur = state.current && state.current.spec;
|
||
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP"; return; }
|
||
const row = await db.get(cur.id);
|
||
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP";
|
||
$("#share").disabled = false;
|
||
}
|
||
$("#share").addEventListener("click", async () => {
|
||
const url = shareLink(); if (!url) return;
|
||
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
|
||
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
|
||
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
|
||
});
|
||
$("#keep").addEventListener("click", async () => {
|
||
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) { toast("Still cooking."); return; }
|
||
const row = await db.get(cur.id);
|
||
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
|
||
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
|
||
});
|
||
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
|
||
$("#back").addEventListener("click", () => {
|
||
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
|
||
const cur = state.current && state.current.spec;
|
||
$("#nowRow").classList.toggle("hidden", !cur);
|
||
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
|
||
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
|
||
listStations().catch(() => {});
|
||
});
|
||
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
|
||
/* lock screen / media keys: title, station, the cover, and back · pause · next */
|
||
export function nowPlaying(spec) {
|
||
if (!("mediaSession" in navigator)) return;
|
||
try {
|
||
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
|
||
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
|
||
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
|
||
} catch {}
|
||
}
|
||
|
||
const EMOJI_SETS = {
|
||
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
|
||
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
|
||
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
|
||
};
|
||
const EMOJI_KEY = "gandora.emoji";
|
||
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
|
||
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); localStorage.setItem(EMOJI_KEY + ".last", e); }
|
||
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
|
||
const EMOJI_TRAY = 7;
|
||
function showEmojiRow() {
|
||
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
|
||
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
|
||
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
|
||
const ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
|
||
const last = localStorage.getItem(EMOJI_KEY + ".last");
|
||
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
|
||
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
|
||
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
|
||
}
|
||
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); setTimeout(showEmojiRow, 350); });
|
||
function openPicker() {
|
||
const p = $("#picker"); p.innerHTML = "";
|
||
const top = document.createElement("div"); top.className = "top";
|
||
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
|
||
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
|
||
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
|
||
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
|
||
close.addEventListener("click", () => p.classList.add("hidden"));
|
||
top.append(inp, go, close);
|
||
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
|
||
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
|
||
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
|
||
for (const [name, list] of Object.entries(sets)) {
|
||
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
|
||
const g = document.createElement("div"); g.className = "g";
|
||
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
|
||
p.append(g);
|
||
}
|
||
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
|
||
p.classList.remove("hidden");
|
||
}
|
||
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
|
||
showEmojiRow();
|
||
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
|
||
|
||
/* key box */
|
||
|
||
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
|
||
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
|
||
$("#scrim").addEventListener("click", closeSheets);
|
||
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
|
||
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
|