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 ? '' : '';
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 });