Files
site/ui/player.js
T

271 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });