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