Web: live Stem Now status that tracks every queued song (albums too), right-click menus with Stem Now, Songs and Genres pages, a Stems Only filter in place of the sidebar section, Random sort, several pooled Plex servers and libraries, themes plus custom CSS, a reorganised settings page that loads without a refresh, saved mixes, karaoke and synced lyrics, stem playlists and offline downloads. iOS: Random in every sort menu, a Stems Only toggle (sort menu and Settings), and several music libraries pooled from the signed-in server. Settings now persists karaoke and speed across launches. stemd writes a status file that stemserve serves at /api/queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
937 lines
57 KiB
JavaScript
937 lines
57 KiB
JavaScript
// Stemify web player: views, the player bar, drawers (mixer, lyrics, queue), the context
|
||
// menu and settings. Playback lives in player.js, Plex in plex.js, stemserve in stems.js.
|
||
|
||
import { $, $$, esc, fmtTime, fmtBytes, plural, on, emit, toast, ICON, store } from "./util.js";
|
||
import { S, save, songMixes, saveSongMix, forgetSongMix, playlists, savePlaylists, newPlaylist, addToPlaylist, Offline } from "./store.js";
|
||
import { px, img, track, sources, liveServers, server, refreshServer, addServer, manualServer, signIn, sortState, pickSort, comparator, sortTracks, Pager, everything } from "./plex.js";
|
||
import { Stems, Live, stemNow, stemTrack, stemBase, api } from "./stems.js";
|
||
import { E, Q, PRESETS, setting, stemGain, applyPreset, currentPreset, mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline } from "./player.js";
|
||
import { lyricsFor, lineAt } from "./lyrics.js";
|
||
|
||
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
|
||
const view = $("#view");
|
||
|
||
// ---- appearance -------------------------------------------------------------------------------
|
||
|
||
const THEMES = {
|
||
amber: { bg: "#0d0e12", panel: "#15171d", raised: "#1d2028", line: "#262a33", text: "#eceef3", muted: "#8a8f9c", accent: "#f2b544" },
|
||
ocean: { bg: "#0a1018", panel: "#101a26", raised: "#172434", line: "#1f2f42", text: "#e6eef7", muted: "#8698ad", accent: "#4cc2ff" },
|
||
rose: { bg: "#120c10", panel: "#1b1218", raised: "#251922", line: "#33222e", text: "#f5e9f0", muted: "#a48b99", accent: "#ff6f9f" },
|
||
forest: { bg: "#0b110d", panel: "#111a14", raised: "#18241c", line: "#223127", text: "#e8f2ea", muted: "#88a08e", accent: "#62d38a" },
|
||
mono: { bg: "#0e0e0e", panel: "#161616", raised: "#202020", line: "#2c2c2c", text: "#f0f0f0", muted: "#8c8c8c", accent: "#f0f0f0" },
|
||
oled: { bg: "#000000", panel: "#000000", raised: "#141414", line: "#1e1e1e", text: "#f2f2f2", muted: "#8a8a8a", accent: "#f2b544" },
|
||
light: { bg: "#f6f6f3", panel: "#ffffff", raised: "#ebeae5", line: "#dddbd4", text: "#18181b", muted: "#6b6b73", accent: "#c27a06", light: true },
|
||
};
|
||
const inkFor = hex => {
|
||
const n = parseInt(hex.slice(1), 16), l = (0.299 * (n >> 16) + 0.587 * (n >> 8 & 255) + 0.114 * (n & 255)) / 255;
|
||
return l > 0.6 ? "#141414" : "#ffffff";
|
||
};
|
||
function applyTheme() {
|
||
const t = THEMES[S.theme] || THEMES.amber, a = S.accent || t.accent;
|
||
$("#theme").textContent = `:root{--bg:${t.bg};--panel:${t.panel};--raised:${t.raised};--line:${t.line};--text:${t.text};--muted:${t.muted};` +
|
||
`--accent:${a};--accent-ink:${inkFor(a)};--glass:${t.panel}ee;--hover:${t.text}0d;--shadow:${t.light ? "#0003" : "#0009"};` +
|
||
`--wave:${t.text}44;--wave-ghost:${t.text}14;color-scheme:${t.light ? "light" : "dark"}}`;
|
||
$("#custom-css").textContent = S.css || "";
|
||
$('meta[name="theme-color"]').content = t.bg;
|
||
Wave.colors();
|
||
}
|
||
|
||
// ---- stems-only filter ------------------------------------------------------------------------
|
||
// Tracks know whether they have stems. Album and artist cards are matched by folder name
|
||
// (stems are stored as Artist/Album/Track), which is close enough for a filter.
|
||
|
||
const nameKey = s => (s || "").toLowerCase().replace(/\s*[([][^)\]]*[)\]]/g, "").replace(/[^\p{L}\p{N}]+/gu, "");
|
||
let stemSetCache = null;
|
||
function stemSets() {
|
||
if (!stemSetCache) {
|
||
stemSetCache = { album: new Set(), artist: new Set() };
|
||
for (const e of Stems.entries) {
|
||
const p = e.key.split("/");
|
||
stemSetCache.album.add(nameKey(p.at(-2)));
|
||
stemSetCache.artist.add(nameKey(p.at(-3)));
|
||
}
|
||
}
|
||
return stemSetCache;
|
||
}
|
||
const cardHasStems = (kind, title) => stemSets()[kind]?.has(nameKey(title)) ?? true;
|
||
|
||
function applyStemsOnly() {
|
||
document.body.classList.toggle("stems-only", S.stemsOnly);
|
||
$$("[data-stemsonly]").forEach(b => b.classList.toggle("on", S.stemsOnly));
|
||
kick?.();
|
||
}
|
||
|
||
// ---- waveform ---------------------------------------------------------------------------------
|
||
|
||
const Wave = {
|
||
peaks: null, key: null, canvas: $("#wave"), c: {},
|
||
colors() {
|
||
const cs = getComputedStyle(document.documentElement), v = n => cs.getPropertyValue(n).trim();
|
||
this.c = { accent: v("--accent"), bar: v("--wave"), ghost: v("--wave-ghost") };
|
||
this.draw();
|
||
},
|
||
async load(t) {
|
||
const e = t && Stems.find(t);
|
||
this.peaks = null; this.key = t?.id || null;
|
||
this.draw();
|
||
if (!t) return;
|
||
const key = this.key;
|
||
let peaks = null;
|
||
if (Offline.has(t.id)) peaks = (await Offline.get(t.id).catch(() => null))?.peaks;
|
||
else if (e) peaks = (await fetch(api("/api/peaks", { key: e.key, n: 300 })).then(r => r.ok ? r.json() : null).catch(() => null))?.stems;
|
||
if (this.key === key && peaks) { this.peaks = peaks; this.draw(); }
|
||
},
|
||
draw() {
|
||
const c = this.canvas, dpr = devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight;
|
||
if (!w) return;
|
||
if (c.width !== w * dpr) { c.width = w * dpr; c.height = h * dpr; }
|
||
const x = c.getContext("2d");
|
||
x.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
x.clearRect(0, 0, w, h);
|
||
const dur = E.duration(), frac = dur ? Math.min(1, E.position() / dur) : 0;
|
||
if (E.loading) {
|
||
x.fillStyle = this.c.ghost; x.fillRect(0, h / 2 - 2, w, 4);
|
||
x.fillStyle = this.c.bar; x.fillRect(0, h / 2 - 2, w * E.loadFrac, 4);
|
||
return;
|
||
}
|
||
if (!this.peaks) {
|
||
x.fillStyle = this.c.ghost; x.fillRect(0, h / 2 - 2, w, 4);
|
||
x.fillStyle = this.c.accent; x.fillRect(0, h / 2 - 2, w * frac, 4);
|
||
return;
|
||
}
|
||
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
|
||
const full = new Float32Array(n), mixed = new Float32Array(n);
|
||
for (const nm of names) {
|
||
const g = stemGain(nm, names);
|
||
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
|
||
}
|
||
const max = Math.sqrt(Math.max(...full)) || 1;
|
||
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
|
||
for (let b = 0; b < bars; b++) {
|
||
const i = Math.floor(b / bars * n);
|
||
const v = Math.sqrt(mixed[i]) / max, ghost = Math.sqrt(full[i]) / max;
|
||
const gh = Math.max(2, ghost * h), bh = Math.max(2, v * h);
|
||
x.fillStyle = this.c.ghost; x.fillRect(b * bw, (h - gh) / 2, bw - 1, gh);
|
||
x.fillStyle = b / bars < frac ? this.c.accent : this.c.bar; x.fillRect(b * bw, (h - bh) / 2, bw - 1, bh);
|
||
}
|
||
},
|
||
};
|
||
{
|
||
let drag = false;
|
||
const at = ev => { const r = Wave.canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) * E.duration(); };
|
||
Wave.canvas.addEventListener("pointerdown", ev => { drag = true; Wave.canvas.setPointerCapture(ev.pointerId); E.seek(at(ev)); });
|
||
Wave.canvas.addEventListener("pointermove", ev => { if (drag) E.seek(at(ev)); });
|
||
Wave.canvas.addEventListener("pointerup", () => { drag = false; saveQueue(); });
|
||
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
|
||
}
|
||
|
||
// ---- player bar and drawers -------------------------------------------------------------------
|
||
|
||
let drawer = null; // "mixer" | "lyrics" | "queue"
|
||
const Lyr = { id: null, data: null, idx: -1 };
|
||
const artOf = (t, size) => t?.srv ? img(t.srv, t.thumb, size) : "";
|
||
|
||
const UI = {
|
||
all() { this.track(); this.transport(); this.progress(); this.drawer(); marks(); },
|
||
track() {
|
||
const t = Q.cur();
|
||
$("#player").classList.toggle("empty", !t);
|
||
$("#np-title").textContent = t?.title || "Nothing playing";
|
||
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
|
||
const art = artOf(t, 120);
|
||
if (art) $("#np-art").src = art; else $("#np-art").removeAttribute("src");
|
||
document.title = t ? `${t.title} · Stemify` : "Stemify";
|
||
if ("mediaSession" in navigator) {
|
||
navigator.mediaSession.metadata = t ? new MediaMetadata({
|
||
title: t.title, artist: t.artist, album: t.album, artwork: art ? [{ src: artOf(t, 512), sizes: "512x512" }] : [],
|
||
}) : null;
|
||
}
|
||
},
|
||
transport() {
|
||
const p = $("#b-play");
|
||
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
|
||
p.classList.toggle("busy", E.buffering);
|
||
p.title = E.loading ? `Loading stems ${Math.round(E.loadFrac * 100)}%` : "Play (Space)";
|
||
$("#b-shuffle").classList.toggle("on", S.shuffle);
|
||
$("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat;
|
||
$("#b-repeat").classList.toggle("on", S.repeat !== "off");
|
||
$("#b-repeat").title = `Repeat: ${S.repeat} (R)`;
|
||
$("#speed").value = String(S.speed);
|
||
$("#speed").classList.toggle("on", S.speed !== 1);
|
||
for (const d of ["mixer", "lyrics", "queue"]) $(`#b-${d}`).classList.toggle("on", drawer === d);
|
||
$("#b-mixer").classList.toggle("dot", S.karaoke);
|
||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
|
||
},
|
||
progress() {
|
||
$("#t-pos").textContent = E.loading ? `${Math.round(E.loadFrac * 100)}%` : fmtTime(E.position());
|
||
$("#t-dur").textContent = fmtTime(E.duration());
|
||
Wave.draw();
|
||
const d = E.duration();
|
||
if (d && navigator.mediaSession?.setPositionState) {
|
||
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
|
||
}
|
||
},
|
||
drawer() {
|
||
const d = $("#drawer");
|
||
d.hidden = !drawer;
|
||
if (drawer) {
|
||
const tabs = `<nav class="tabs">${["mixer", "lyrics", "queue"].map(k => `<button data-drawer="${k}" class="${drawer === k ? "on" : ""}">${k[0].toUpperCase() + k.slice(1)}</button>`).join("")}
|
||
<button class="x" data-drawer="" title="Close">×</button></nav>`;
|
||
d.innerHTML = tabs + `<div id="dbody">${drawer === "mixer" ? mixerHtml() : drawer === "queue" ? queueHtml() : lyricsShell()}</div>`;
|
||
if (drawer === "lyrics") loadLyrics();
|
||
if (drawer === "queue") $(".q-item.cur", d)?.scrollIntoView({ block: "center" });
|
||
}
|
||
this.transport();
|
||
},
|
||
};
|
||
const toggleDrawer = m => { drawer = drawer === m || !m ? null : m; UI.drawer(); };
|
||
|
||
const mixNames = () => E.entry ? E.entry.stems.map(s => s.name).filter(n => E.names.includes(n)) : [];
|
||
const karaokeHtml = () => `<div class="kara">
|
||
<button class="chip ${S.karaoke ? "on" : ""}" data-kara title="Karaoke (K)">Karaoke</button>
|
||
<div class="seg">${[["off", "No vocals"], ["soft", "Soft vocals"], ["on", "Vocals on"]].map(([m, l]) => `<button class="${S.karaokeMode === m ? "on" : ""}" data-kmode="${m}">${l}</button>`).join("")}</div>
|
||
${S.karaokeMode === "soft" ? `<label class="note">Soft vocals: <b id="softdb">${S.softDb} dB</b><input type="range" min="-30" max="-3" step="1" value="${S.softDb}" data-softdb></label>` : ""}</div>`;
|
||
|
||
function mixerHtml() {
|
||
const names = mixNames();
|
||
if (!names.length) return `<p class="note">${E.loading ? "Loading stems…" : Q.cur() ? "This track has no stems yet. Right-click it and pick Stem Now." : "Play something with stems to mix it."}</p>`;
|
||
const cur = currentPreset(names), key = E.entry.key, saved = songMixes[key];
|
||
const savedNote = saved ? (sameMix(saved, S.mix, names) ? "Using this song's saved mix." : "Changed from this song's saved mix.") : "";
|
||
return karaokeHtml() + `
|
||
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
|
||
${names.map(n => {
|
||
const s = setting(n), g = stemGain(n, names);
|
||
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
|
||
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
|
||
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
|
||
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
|
||
}).join("")}
|
||
<div class="row mixrow">
|
||
<button class="btn small" data-savemix>${saved ? "Update saved mix" : "Save mix for this song"}</button>
|
||
${saved ? `<button class="btn small" data-forgetmix>Forget</button>` : ""}
|
||
<button class="btn small" data-mixtopl>Add to stem playlist…</button>
|
||
</div>
|
||
<p class="note">${savedNote} ${S.karaoke ? "Karaoke is on, so saved mixes are ignored until you turn it off." : S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix."}</p>`;
|
||
}
|
||
|
||
function queueHtml() {
|
||
if (!Q.items.length) return `<p class="note">Empty.</p>`;
|
||
const left = Q.items.length - Q.i - 1;
|
||
return `<div class="row"><span class="note">${plural(left, "track")} up next</span><button class="btn small" data-qclear>Clear</button></div>
|
||
${Q.items.map((x, i) => `
|
||
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}" draggable="true">
|
||
${artOf(x.t, 80) ? `<img loading="lazy" src="${esc(artOf(x.t, 80))}" alt="">` : `<div class="ph"></div>`}
|
||
<div class="tx"><div>${esc(x.t.title)}${x.mix ? ' <span class="tag">MIX</span>' : ""}</div><div>${esc(x.t.artist)}</div></div>
|
||
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
|
||
</div>`).join("")}`;
|
||
}
|
||
|
||
const lyricsShell = () => `<div id="lyrhead">${mixNames().includes("vocals") ? karaokeHtml() : ""}</div><div id="lyr" class="note">Looking for lyrics…</div>`;
|
||
async function loadLyrics() {
|
||
const t = Q.cur(), box = $("#lyr");
|
||
Lyr.idx = -1;
|
||
if (!t) { box.textContent = "Nothing playing."; return; }
|
||
if (Lyr.id !== t.id) { Lyr.id = t.id; Lyr.data = null; Lyr.data = await lyricsFor(t).catch(() => null); }
|
||
if (Lyr.id !== t.id || drawer !== "lyrics" || !$("#lyr")) return;
|
||
const d = Lyr.data, el = $("#lyr");
|
||
if (!d) { el.textContent = "No lyrics found on LRCLIB."; return; }
|
||
if (d.instrumental) { el.textContent = "Instrumental."; return; }
|
||
el.className = d.lines ? "synced" : "plain";
|
||
el.innerHTML = d.lines ? d.lines.map((l, i) => `<p data-li="${i}">${esc(l.text) || "♪"}</p>`).join("") : esc(d.plain || "").replace(/\n/g, "<br>");
|
||
}
|
||
function tickLyrics() {
|
||
const lines = Lyr.data?.lines;
|
||
if (drawer !== "lyrics" || !lines || Lyr.id !== Q.cur()?.id) return;
|
||
const i = lineAt(lines, E.position());
|
||
if (i === Lyr.idx) return;
|
||
Lyr.idx = i;
|
||
$$("#lyr p.on").forEach(p => p.classList.remove("on"));
|
||
const p = $(`#lyr p[data-li="${i}"]`);
|
||
if (p) { p.classList.add("on"); p.scrollIntoView({ block: "center", behavior: "smooth" }); }
|
||
}
|
||
|
||
const d$ = $("#drawer");
|
||
d$.addEventListener("click", ev => {
|
||
const b = ev.target.closest("button, .q-item, #lyr p");
|
||
if (!b) return;
|
||
const names = mixNames(), ds = b.dataset;
|
||
if (ds.drawer != null) toggleDrawer(ds.drawer || null);
|
||
else if (ds.preset) applyPreset(PRESETS[+ds.preset][1], names);
|
||
else if (ds.mute) { S.mix[ds.mute] = { ...setting(ds.mute), muted: !setting(ds.mute).muted }; mixChanged(); }
|
||
else if (ds.solo) { S.mix[ds.solo] = { ...setting(ds.solo), solo: !setting(ds.solo).solo }; mixChanged(); }
|
||
else if ("kara" in ds) setKaraoke(!S.karaoke);
|
||
else if (ds.kmode) setKaraokeMode(ds.kmode);
|
||
else if ("savemix" in ds) { saveSongMix(E.entry.key, S.mix); toast("Saved the mix for this song"); UI.drawer(); }
|
||
else if ("forgetmix" in ds) { forgetSongMix(E.entry.key); UI.drawer(); }
|
||
else if ("mixtopl" in ds) { const r = b.getBoundingClientRect(); pickPlaylist(r.left, r.bottom, [Q.cur()], S.mix); }
|
||
else if ("qclear" in ds) Q.clear();
|
||
else if (ds.qx) Q.remove(+ds.qx);
|
||
else if (ds.q) Q.jump(+ds.q);
|
||
else if (ds.li) E.seek(Lyr.data.lines[+ds.li].t);
|
||
});
|
||
d$.addEventListener("input", ev => {
|
||
const ds = ev.target.dataset;
|
||
if (ds.level) { S.mix[ds.level] = { ...setting(ds.level), level: +ev.target.value }; E.applyGains(); Wave.draw(); save(); }
|
||
if (ds.softdb) $("#softdb").textContent = ev.target.value + " dB";
|
||
});
|
||
d$.addEventListener("change", ev => {
|
||
if (ev.target.dataset.level) UI.drawer();
|
||
if (ev.target.dataset.softdb) setKaraokeMode("soft", +ev.target.value);
|
||
});
|
||
{
|
||
let from = -1;
|
||
d$.addEventListener("dragstart", ev => { from = +ev.target.closest("[data-q]")?.dataset.q; });
|
||
d$.addEventListener("dragover", ev => { if (ev.target.closest("[data-q]")) ev.preventDefault(); });
|
||
d$.addEventListener("drop", ev => { const to = ev.target.closest("[data-q]"); if (to && from >= 0) { ev.preventDefault(); Q.move(from, +to.dataset.q); } from = -1; });
|
||
}
|
||
|
||
$("#b-play").innerHTML = ICON.play;
|
||
$("#b-prev").innerHTML = ICON.prev;
|
||
$("#b-next").innerHTML = ICON.next;
|
||
$("#b-shuffle").innerHTML = ICON.shuffle;
|
||
$("#b-mixer").innerHTML = ICON.mixer;
|
||
$("#b-lyrics").innerHTML = ICON.lyrics;
|
||
$("#b-queue").innerHTML = ICON.queue;
|
||
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
|
||
$("#volume").value = S.volume;
|
||
|
||
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); };
|
||
$("#b-play").onclick = togglePlay;
|
||
$("#b-prev").onclick = () => Q.prev();
|
||
$("#b-next").onclick = () => Q.next();
|
||
$("#b-shuffle").onclick = () => Q.toggleShuffle();
|
||
$("#b-repeat").onclick = () => Q.cycleRepeat();
|
||
$("#b-mixer").onclick = () => toggleDrawer("mixer");
|
||
$("#b-lyrics").onclick = () => toggleDrawer("lyrics");
|
||
$("#b-queue").onclick = () => toggleDrawer("queue");
|
||
$("#speed").onchange = ev => setSpeed(+ev.target.value);
|
||
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
|
||
$(".np").onclick = () => { const t = Q.cur(); if (t?.albumKey) location.hash = `#/album/${t.srv}:${t.albumKey}`; };
|
||
|
||
if ("mediaSession" in navigator) {
|
||
const ms = navigator.mediaSession;
|
||
ms.setActionHandler("play", () => E.play());
|
||
ms.setActionHandler("pause", () => E.pause());
|
||
ms.setActionHandler("previoustrack", () => Q.prev());
|
||
ms.setActionHandler("nexttrack", () => Q.next());
|
||
try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {}
|
||
}
|
||
|
||
document.addEventListener("keydown", ev => {
|
||
if (ev.key === "Escape") return Menu.close();
|
||
if (ev.target.closest("input, select, textarea") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||
const k = ev.key.toLowerCase();
|
||
if (k === " ") { ev.preventDefault(); togglePlay(); }
|
||
else if (k === "arrowright") E.seek(Math.min(E.duration(), E.position() + (ev.shiftKey ? 30 : 5)));
|
||
else if (k === "arrowleft") E.seek(Math.max(0, E.position() - (ev.shiftKey ? 30 : 5)));
|
||
else if (k === "s") Q.toggleShuffle();
|
||
else if (k === "r") Q.cycleRepeat();
|
||
else if (k === "m") toggleDrawer("mixer");
|
||
else if (k === "l") toggleDrawer("lyrics");
|
||
else if (k === "q") toggleDrawer("queue");
|
||
else if (k === "k") setKaraoke(!S.karaoke);
|
||
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
|
||
});
|
||
|
||
(function frame() {
|
||
if (E.playing && !E.loading) { $("#t-pos").textContent = fmtTime(E.position()); Wave.draw(); tickLyrics(); }
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
setInterval(() => { if (E.playing) { UI.progress(); saveQueue(); } }, 5000);
|
||
|
||
on("track", () => { UI.track(); UI.transport(); UI.progress(); Wave.load(Q.cur()); marks(); if (drawer) UI.drawer(); });
|
||
on("transport", () => UI.transport());
|
||
on("progress", () => UI.progress());
|
||
on("loading", () => { UI.transport(); UI.progress(); });
|
||
on("attached", () => { UI.progress(); if (drawer === "mixer") UI.drawer(); });
|
||
on("queue", () => { if (drawer === "queue") UI.drawer(); });
|
||
on("mix", () => {
|
||
Wave.draw(); UI.transport();
|
||
if (drawer === "mixer") UI.drawer();
|
||
else if (drawer === "lyrics" && $("#lyrhead")) $("#lyrhead").innerHTML = mixNames().includes("vocals") ? karaokeHtml() : "";
|
||
});
|
||
|
||
// ---- live stem status ---------------------------------------------------------------------------
|
||
|
||
const base = p => (p || "").split("/").pop().replace(/\.[^.]+$/, "");
|
||
const badge = t => {
|
||
if (Stems.find(t)) return '<span class="tag stems">STEMS</span>';
|
||
const s = Live.state(t);
|
||
return s ? `<span class="tag ${s}">${{ queued: "QUEUED", stemming: "STEMMING", failed: "FAILED" }[s]}</span>` : "";
|
||
};
|
||
// Refreshes playing, stem and download markers in whatever is on screen.
|
||
function marks() {
|
||
const id = Q.cur()?.id;
|
||
for (const r of $$("tr[data-list]", view)) {
|
||
const t = lists[r.dataset.list]?.[+r.dataset.i];
|
||
if (!t) continue;
|
||
r.classList.toggle("playing", t.id === id);
|
||
r.classList.toggle("nostem", !Stems.find(t));
|
||
const b = $(".badge", r), html = badge(t);
|
||
if (b.innerHTML !== html) b.innerHTML = html;
|
||
$(".off", r).innerHTML = Offline.has(t.id) ? ICON.download : "";
|
||
}
|
||
for (const c of $$(".card[data-nk]", view)) c.classList.toggle("nostem", !stemSets()[c.dataset.kind]?.has(c.dataset.nk));
|
||
}
|
||
function liveStatus() {
|
||
const el = $("#live"), n = Live.pending.size;
|
||
el.hidden = !Live.busy;
|
||
el.innerHTML = Live.current ? `<span class="pulse"></span>Stemming “${esc(base(Live.current))}”${n ? ` · ${n} queued` : ""}` : n ? `${n} queued for stems` : "";
|
||
}
|
||
on("live", () => { marks(); liveStatus(); });
|
||
on("stems", () => { stemSetCache = null; marks(); });
|
||
on("offline", () => marks());
|
||
on("newstems", fresh => {
|
||
toast(fresh.length === 1 ? `Stems ready: ${base(fresh[0].key)}` : `Stems ready for ${fresh.length} tracks`);
|
||
const t = Q.cur();
|
||
if (t && !E.entry && !E.loading && Stems.find(t)) Q.reload();
|
||
});
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden) Live.poll(); });
|
||
|
||
// ---- context menu -------------------------------------------------------------------------------
|
||
|
||
const Menu = {
|
||
el: null,
|
||
open(x, y, items) {
|
||
this.close();
|
||
items = items.filter(Boolean);
|
||
const m = document.createElement("div");
|
||
m.id = "menu";
|
||
m.innerHTML = items.map((it, i) => it === "-" ? "<hr>" : `<button data-mi="${i}" ${it.run ? "" : "disabled"}>${esc(it.label)}</button>`).join("");
|
||
document.body.append(m);
|
||
const r = m.getBoundingClientRect();
|
||
m.style.left = Math.max(8, Math.min(x, innerWidth - r.width - 8)) + "px";
|
||
m.style.top = Math.max(8, Math.min(y, innerHeight - r.height - 8)) + "px";
|
||
m.onclick = ev => { const b = ev.target.closest("[data-mi]"); if (b) { const it = items[+b.dataset.mi]; this.close(); it.run(); } };
|
||
this.el = m;
|
||
},
|
||
close() { this.el?.remove(); this.el = null; },
|
||
};
|
||
addEventListener("pointerdown", ev => { if (Menu.el && !Menu.el.contains(ev.target)) Menu.close(); }, true);
|
||
addEventListener("blur", () => Menu.close());
|
||
view.addEventListener("scroll", () => Menu.close(), { passive: true });
|
||
|
||
const download = tracks => saveOffline(tracks, m => m && toast(m, 120000));
|
||
function stemItem(tracks) {
|
||
const need = tracks.filter(t => !Stems.find(t));
|
||
if (!need.length) return { label: tracks.length === 1 ? "Has stems" : "All have stems" };
|
||
if (tracks.length === 1) {
|
||
const s = Live.state(tracks[0]);
|
||
if (s === "stemming") return { label: "Stemming now…" };
|
||
if (s === "queued") return { label: "Queued for stems" };
|
||
if (!tracks[0].file) return { label: "Stem Now (no file path)" };
|
||
}
|
||
return { label: tracks.length === 1 ? "Stem Now" : `Stem Now (${need.length})`, run: () => stemNow(tracks) };
|
||
}
|
||
function pickPlaylist(x, y, tracks, mix = null) {
|
||
const add = p => { addToPlaylist(p, tracks, mix); toast(`Added to “${p.title}”${mix ? " with this mix" : ""}`); };
|
||
Menu.open(x, y, [
|
||
{ label: "New stem playlist…", run: () => { const n = prompt("Stem playlist name"); if (n?.trim()) add(newPlaylist(n.trim())); } },
|
||
playlists.length && "-",
|
||
...playlists.map(p => ({ label: p.title, run: () => add(p) })),
|
||
]);
|
||
}
|
||
function trackMenu(x, y, listId, i) {
|
||
const t = lists[listId][i], pl = listId.startsWith("spl:") && playlists.find(p => p.id === listId.slice(4));
|
||
Menu.open(x, y, [
|
||
{ label: "Play", run: () => playFrom(listId, i) },
|
||
{ label: "Play next", run: () => Q.add([listItems[listId]?.[i] || t], true) },
|
||
{ label: "Add to queue", run: () => Q.add([listItems[listId]?.[i] || t]) },
|
||
"-",
|
||
stemItem([t]),
|
||
{ label: "Add to stem playlist…", run: () => pickPlaylist(x, y, [t]) },
|
||
Offline.has(t.id) ? { label: "Remove download", run: () => Offline.remove(t.id).then(() => emit("offline")) }
|
||
: { label: "Download for offline", run: () => download([t]) },
|
||
(t.albumKey || t.artistKey) && "-",
|
||
t.albumKey && { label: "Go to album", run: () => { location.hash = `#/album/${t.srv}:${t.albumKey}`; } },
|
||
t.artistKey && { label: "Go to artist", run: () => { location.hash = `#/artist/${t.srv}:${t.artistKey}`; } },
|
||
pl && "-",
|
||
pl && Q.cur()?.id === t.id && E.entry && { label: "Use the current mix here", run: () => { pl.items[i].mix = JSON.parse(JSON.stringify(S.mix)); savePlaylists(); route(); } },
|
||
pl && pl.items[i].mix && { label: "Clear this item's mix", run: () => { pl.items[i].mix = null; savePlaylists(); route(); } },
|
||
pl && { label: "Remove from playlist", run: () => { pl.items.splice(i, 1); pl.updated = Date.now(); savePlaylists(); route(); } },
|
||
]);
|
||
}
|
||
async function leaves(srv, key) { return ((await px(srv, `/library/metadata/${key}/allLeaves`)).Metadata || []).map(m => track(srv, m)); }
|
||
function listMenu(x, y, get) {
|
||
const run = fn => async () => { try { fn(visible(await get())); } catch (e) { toast(e.message); } };
|
||
Menu.open(x, y, [
|
||
{ label: "Play", run: run(ts => Q.playList(ts, 0, false)) },
|
||
{ label: "Shuffle", run: run(ts => Q.playList(ts, -1, true)) },
|
||
{ label: "Play next", run: run(ts => Q.add(ts, true)) },
|
||
{ label: "Add to queue", run: run(ts => Q.add(ts)) },
|
||
"-",
|
||
{ label: "Stem Now", run: run(ts => stemNow(ts)) },
|
||
{ label: "Add to stem playlist…", run: run(ts => pickPlaylist(x, y, ts)) },
|
||
{ label: "Download for offline", run: run(ts => download(ts)) },
|
||
]);
|
||
}
|
||
|
||
// ---- views --------------------------------------------------------------------------------------
|
||
|
||
let lists = {}, listItems = {}, seq = 0, kick = null, genreCache = null;
|
||
const visible = tracks => S.stemsOnly ? tracks.filter(t => (t.t || t) && Stems.find(t.t || t)) : tracks;
|
||
function playFrom(listId, i) {
|
||
const all = listItems[listId] || lists[listId], pick = all[i];
|
||
const vis = visible(all);
|
||
Q.playList(vis.includes(pick) ? vis : all, Math.max(0, (vis.includes(pick) ? vis : all).indexOf(pick)), false);
|
||
}
|
||
|
||
function card({ href, img: src, t, s = "", round, kind, srv, key }) {
|
||
const nk = kind === "album" || kind === "artist" ? nameKey(t) : "";
|
||
const hide = nk && !stemSets()[kind].has(nk);
|
||
return `<a class="card ${round ? "round" : ""} ${hide ? "nostem" : ""}" href="${href}" ${kind ? `data-kind="${kind}" data-srv="${esc(srv)}" data-key="${esc(key)}"` : ""} ${nk ? `data-nk="${esc(nk)}"` : ""}>
|
||
${src ? `<img loading="lazy" src="${esc(src)}" alt="">` : `<div class="ph"></div>`}<div class="t">${esc(t)}</div><div class="s">${esc(s)}</div></a>`;
|
||
}
|
||
const albumCard = m => card({ href: `#/album/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, s: [m.parentTitle, m.year].filter(Boolean).join(" · "), kind: "album", srv: m._srv, key: m.ratingKey });
|
||
const artistCard = m => card({ href: `#/artist/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, round: true, kind: "artist", srv: m._srv, key: m.ratingKey });
|
||
|
||
const rowHtml = (listId, t, i, { numbers, album = true, mix } = {}) => `
|
||
<tr data-list="${listId}" data-i="${i}" class="${Stems.find(t) ? "" : "nostem"} ${Q.cur()?.id === t.id ? "playing" : ""}">
|
||
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
|
||
<td class="ti">${esc(t.title)}<span class="badge">${badge(t)}</span>${mix ? '<span class="tag">MIX</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
|
||
<td class="off">${Offline.has(t.id) ? ICON.download : ""}</td>
|
||
<td class="d">${fmtTime(t.duration / 1000)}</td>
|
||
<td class="acts"><button data-add title="Add to queue (shift: play next)">+</button><button data-menu title="More">${ICON.more}</button></td>
|
||
</tr>`;
|
||
function trackTable(listId, tracks, opts = {}) {
|
||
lists[listId] = tracks;
|
||
if (!tracks.length) return `<p class="empty-msg">Nothing here.</p>`;
|
||
return `<table class="tracks"><tbody>${tracks.map((t, i) => rowHtml(listId, t, i, { ...opts, mix: listItems[listId]?.[i]?.mix })).join("")}</tbody></table>
|
||
<p class="empty-msg only-stems">None of these have stems yet.</p>`;
|
||
}
|
||
|
||
const stemsChip = () => `<button class="chip ${S.stemsOnly ? "on" : ""}" data-stemsonly title="Only show songs with stems">Stems only</button>`;
|
||
function sortControl(screen) {
|
||
const st = sortState(screen);
|
||
return `<div class="sort"><select data-sort="${screen}" title="Sort">${st.opts.map((o, i) => `<option value="${i}" ${i === st.i ? "selected" : ""}>${o[0]}</option>`).join("")}</select>
|
||
${st.random ? `<button class="chip" data-reshuffle title="Shuffle again">${ICON.shuffle}</button>`
|
||
: `<button class="chip" data-sortdir="${screen}" title="Flip order">${st.desc ? st.o[4] : st.o[3]}</button>`}</div>`;
|
||
}
|
||
const bar = (title, { sort, filter = true, extra = "" } = {}) =>
|
||
`<div class="bar"><h1>${esc(title)}</h1><span class="sub" id="count"></span><span class="grow"></span>${extra}${filter ? stemsChip() : ""}${sort ? sortControl(sort) : ""}</div>`;
|
||
const actions = id => `<div class="actions">
|
||
<button class="btn primary" data-play="${id}">${ICON.play} Play</button>
|
||
<button class="btn" data-play="${id}" data-mode="shuffle">${ICON.shuffle} Shuffle</button>
|
||
<button class="btn" data-play="${id}" data-mode="stem">Stem Now</button>
|
||
<button class="btn icon" data-listmenu="${id}" title="More">${ICON.more}</button></div>`;
|
||
const hero = (src, kind, title, sub, id, round) => `<div class="hero">${src ? `<img src="${esc(src)}" alt="" ${round ? 'class="round"' : ""}>` : `<div class="ph ${round ? "round" : ""}"></div>`}
|
||
<div class="meta"><div class="kind">${kind}</div><h1>${esc(title)}</h1><div class="sub">${sub}</div>${id ? actions(id) : ""}</div></div>`;
|
||
|
||
function needPlex() {
|
||
view.innerHTML = `<h1>Welcome</h1><p class="sub">Add a Plex server in <a class="link" href="#/settings">Settings</a> to browse your library, or <a class="link" href="#/stems">browse the stems</a> on their own.</p>`;
|
||
}
|
||
const splitKey = arg => { const i = arg.lastIndexOf(":"); return [arg.slice(0, i), arg.slice(i + 1)]; };
|
||
const count = (n, w) => { const c = $("#count"); if (c) c.textContent = n ? plural(n, w) : ""; };
|
||
|
||
// Appends pages from a Pager as you scroll. `html(items, offset)` renders a page.
|
||
async function paged(mySeq, target, pager, size, html, word) {
|
||
const more = document.createElement("div");
|
||
more.className = "more";
|
||
(target.closest("table") || target).after(more);
|
||
let busy = false, shown = 0;
|
||
const next = async () => {
|
||
if (busy || pager.done || mySeq !== seq) return;
|
||
busy = true;
|
||
let items;
|
||
try { items = await pager.next(size); }
|
||
catch (e) { more.textContent = e.message; busy = false; return; }
|
||
if (mySeq !== seq) return;
|
||
target.insertAdjacentHTML("beforeend", html(items, shown));
|
||
shown += items.length;
|
||
count(pager.total, word);
|
||
busy = false;
|
||
if (pager.done && !shown) more.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
|
||
else if (!pager.done && more.getBoundingClientRect().top < innerHeight * 1.5) next();
|
||
};
|
||
kick = next;
|
||
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
|
||
await next();
|
||
}
|
||
const secPath = s => `/library/sections/${s.sec}/all`;
|
||
|
||
async function genres() {
|
||
if (genreCache && Date.now() - genreCache.at < 10 * 60e3) return genreCache.list;
|
||
const by = new Map();
|
||
for (const g of await everything(sources(), s => `/library/sections/${s.sec}/genre`, { type: 9 })) {
|
||
const k = g.title.toLowerCase();
|
||
if (!by.has(k)) by.set(k, { title: g.title, srcs: [] });
|
||
const src = sources().find(s => s.srv.id === g._srv && s.sec === g._sec);
|
||
by.get(k).srcs.push({ ...src, params: { genre: g.key.match(/(\d+)\D*$/)?.[1] ?? g.key } });
|
||
}
|
||
genreCache = { at: Date.now(), list: [...by.values()].sort((a, b) => a.title.localeCompare(b.title)) };
|
||
return genreCache.list;
|
||
}
|
||
const hue = s => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7);
|
||
|
||
const routes = {
|
||
async albums(my) {
|
||
const srcs = sources();
|
||
if (!srcs.length) return needPlex();
|
||
view.innerHTML = bar("Albums", { sort: "albums" }) + `<div class="grid"></div>`;
|
||
await paged(my, $(".grid", view), new Pager(srcs, secPath, { type: 9, sort: sortState("albums").param }), 120, ms => ms.map(albumCard).join(""), "album");
|
||
},
|
||
async artists(my) {
|
||
const srcs = sources();
|
||
if (!srcs.length) return needPlex();
|
||
view.innerHTML = bar("Artists", { sort: "artists" }) + `<div class="grid"></div>`;
|
||
await paged(my, $(".grid", view), new Pager(srcs, secPath, { type: 8, sort: sortState("artists").param }), 120, ms => ms.map(artistCard).join(""), "artist");
|
||
},
|
||
async songs(my) {
|
||
const srcs = sources();
|
||
if (!srcs.length) return needPlex();
|
||
view.innerHTML = bar("Songs", { sort: "songs", extra: `<button class="chip" data-shuffleall>${ICON.shuffle} Shuffle all</button>` })
|
||
+ `<table class="tracks"><tbody></tbody></table><p class="empty-msg only-stems">No songs with stems loaded yet. Scroll for more.</p>`;
|
||
lists.songs = [];
|
||
const size = S.stemsOnly ? 1000 : 150;
|
||
await paged(my, $("tbody", view), new Pager(srcs, secPath, { type: 10, sort: sortState("songs").param }, size), size, (ms, off) => {
|
||
const ts = ms.map(m => track(m._srv, m));
|
||
lists.songs.push(...ts);
|
||
return ts.map((t, i) => rowHtml("songs", t, off + i)).join("");
|
||
}, "song");
|
||
},
|
||
async genres(my) {
|
||
if (!sources().length) return needPlex();
|
||
view.innerHTML = bar("Genres", { filter: false }) + `<p class="note">Loading…</p>`;
|
||
const list = await genres();
|
||
if (my !== seq) return;
|
||
count(list.length, "genre");
|
||
view.innerHTML = bar("Genres", { filter: false }) + (list.length ? `<div class="grid genres">${list.map(g =>
|
||
`<a class="genre" style="--h:${hue(g.title)}" href="#/genre/${encodeURIComponent(g.title)}">${esc(g.title)}</a>`).join("")}</div>` : `<p class="empty-msg">No genres.</p>`);
|
||
count(list.length, "genre");
|
||
},
|
||
async genre(my, title) {
|
||
const g = (await genres()).find(x => x.title === title);
|
||
if (my !== seq) return;
|
||
if (!g) { view.innerHTML = `<h1>${esc(title)}</h1><p class="empty-msg">No albums in this genre.</p>`; return; }
|
||
view.innerHTML = bar(g.title, { sort: "albums", extra: `<button class="chip" data-genreplay="shuffle">${ICON.shuffle} Shuffle</button>` }) + `<div class="grid"></div>`;
|
||
view.dataset.genre = g.title;
|
||
await paged(my, $(".grid", view), new Pager(g.srcs, secPath, { type: 9, sort: sortState("albums").param }), 120, ms => ms.map(albumCard).join(""), "album");
|
||
},
|
||
async playlists(my) {
|
||
const st = sortState("playlists");
|
||
view.innerHTML = bar("Playlists", { sort: "playlists", filter: false })
|
||
+ `<div class="bar sm"><h2>Stem playlists</h2><button class="chip" data-newpl>+ New</button></div>`
|
||
+ (playlists.length ? `<div class="grid">${playlists.map(p => card({ href: `#/stemplaylist/${p.id}`, img: artOf(p.items[0]?.track, 300), t: p.title, s: plural(p.items.length, "track") })).join("")}</div>`
|
||
: `<p class="note">Stem playlists remember a mix for each song. Right-click any song to add it.</p>`)
|
||
+ `<h2>Plex playlists</h2><div id="plexpl"><p class="note">${liveServers().length ? "Loading…" : "No Plex server set up."}</p></div>`;
|
||
if (!liveServers().length) return;
|
||
let pls = await everything(liveServers().map(srv => ({ srv })), "/playlists", { playlistType: "audio" });
|
||
if (my !== seq) return;
|
||
pls = st.random ? pls.sort(() => Math.random() - 0.5) : pls.sort(comparator(st.param));
|
||
count(pls.length + playlists.length, "playlist");
|
||
$("#plexpl").innerHTML = pls.length ? `<div class="grid">${pls.map(p => card({ href: `#/playlist/${p._srv}:${p.ratingKey}`, img: img(p._srv, p.composite || p.thumb), t: p.title, s: plural(p.leafCount || 0, "track") })).join("")}</div>`
|
||
: `<p class="empty-msg">No music playlists.</p>`;
|
||
},
|
||
async album(my, arg) {
|
||
const [srv, key] = splitKey(arg);
|
||
const [info, kids] = await Promise.all([px(srv, `/library/metadata/${key}`), px(srv, `/library/metadata/${key}/children`)]);
|
||
if (my !== seq) return;
|
||
const a = info.Metadata?.[0] || {}, tracks = sortTracks((kids.Metadata || []).map(m => track(srv, m)), sortState("album"));
|
||
const stems = tracks.filter(t => Stems.find(t)).length;
|
||
view.innerHTML = hero(img(srv, a.thumb, 420), "Album", a.title,
|
||
`<a href="#/artist/${srv}:${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${plural(tracks.length, "track")}${stems ? ` · ${stems} with stems` : ""}`, "album")
|
||
+ `<div class="bar sm"><span class="grow"></span>${stemsChip()}${sortControl("album")}</div>` + trackTable("album", tracks, { numbers: !sortState("album").o[1], album: false });
|
||
},
|
||
async artist(my, arg) {
|
||
const [srv, key] = splitKey(arg);
|
||
const [info, kids] = await Promise.all([px(srv, `/library/metadata/${key}`), px(srv, `/library/metadata/${key}/children`)]);
|
||
if (my !== seq) return;
|
||
const a = info.Metadata?.[0] || {}, albums = (kids.Metadata || []).map(m => (m._srv = srv, m));
|
||
view.innerHTML = hero(img(srv, a.thumb, 420), "Artist", a.title, plural(albums.length, "album"), "artist", true)
|
||
+ `<div class="bar sm"><h2>Albums</h2><span class="grow"></span>${stemsChip()}</div><div class="grid">${albums.map(albumCard).join("")}</div>
|
||
<div class="bar sm"><h2>Songs</h2><span class="grow"></span>${sortControl("artist")}</div><div id="artist-songs"><p class="note">Loading…</p></div>`;
|
||
const tracks = sortTracks(await leaves(srv, key), sortState("artist"));
|
||
if (my !== seq) return;
|
||
$("#artist-songs").innerHTML = trackTable("artist", tracks);
|
||
},
|
||
async playlist(my, arg) {
|
||
const [srv, key] = splitKey(arg);
|
||
const [info, items] = await Promise.all([px(srv, `/playlists/${key}`), px(srv, `/playlists/${key}/items`)]);
|
||
if (my !== seq) return;
|
||
const p = info.Metadata?.[0] || {}, tracks = sortTracks((items.Metadata || []).map(m => track(srv, m)), sortState("playlist"));
|
||
view.innerHTML = hero(img(srv, p.composite || p.thumb, 420), "Playlist", p.title, plural(tracks.length, "track"), "playlist")
|
||
+ `<div class="bar sm"><span class="grow"></span>${stemsChip()}${sortControl("playlist")}</div>` + trackTable("playlist", tracks);
|
||
},
|
||
async stemplaylist(my, id) {
|
||
const p = playlists.find(x => x.id === id);
|
||
if (!p) { view.innerHTML = `<h1>Not found</h1><p class="sub">This stem playlist was deleted.</p>`; return; }
|
||
const lid = "spl:" + id;
|
||
listItems[lid] = p.items.map(x => ({ t: x.track, mix: x.mix }));
|
||
view.innerHTML = hero(artOf(p.items[0]?.track, 420), "Stem playlist", p.title,
|
||
`${plural(p.items.length, "track")} · ${p.items.filter(x => x.mix).length} with their own mix
|
||
· <button class="link" data-plrename="${id}">Rename</button> · <button class="link" data-pldelete="${id}">Delete</button>`, lid)
|
||
+ `<div class="bar sm"><span class="grow"></span>${stemsChip()}</div>` + trackTable(lid, p.items.map(x => x.track));
|
||
},
|
||
async search(my, q) {
|
||
const srcs = sources();
|
||
if (!srcs.length) return needPlex();
|
||
const hubs = (await Promise.all(srcs.map(s => px(s.srv, "/hubs/search", { query: q, limit: 12, sectionId: s.sec })
|
||
.then(mc => (mc.Hub || []).map(h => ({ ...h, Metadata: (h.Metadata || []).map(m => (m._srv = s.srv.id, m)) }))).catch(() => [])))).flat();
|
||
if (my !== seq) return;
|
||
const hub = t => hubs.filter(h => h.type === t).flatMap(h => h.Metadata);
|
||
const artists = hub("artist"), albums = hub("album"), tracks = sortTracks(hub("track").map(m => track(m._srv, m)), sortState("search"));
|
||
view.innerHTML = bar(`“${q}”`) +
|
||
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(artistCard).join("")}</div>` : "") +
|
||
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
|
||
(tracks.length ? `<div class="bar sm"><h2>Songs</h2><span class="grow"></span>${sortControl("search")}</div>${trackTable("search", tracks)}` : "") +
|
||
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
|
||
},
|
||
async downloads(my) {
|
||
const recs = (await Offline.all().catch(() => [])).sort((a, b) => b.saved - a.saved);
|
||
if (my !== seq) return;
|
||
const total = recs.reduce((a, r) => a + r.size, 0);
|
||
const est = await navigator.storage?.estimate?.().catch(() => null);
|
||
view.innerHTML = bar("Downloads", { filter: false, extra: recs.length ? `<button class="chip" data-dlclear>Remove all</button>` : "" })
|
||
+ `<p class="note">${plural(recs.length, "track")}, ${fmtBytes(total)} stored in this browser${est?.quota ? ` (${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} used)` : ""}.
|
||
Downloads play without the network. ${isSecureContext ? "" : "This page isn't on HTTPS, so the browser may clear them when space runs low."}</p>`
|
||
+ (recs.length ? trackTable("downloads", recs.map(r => r.track)) : `<p class="empty-msg">Nothing downloaded. Right-click a song or album and pick Download for offline.</p>`);
|
||
count(recs.length, "track");
|
||
},
|
||
async stems(my) {
|
||
if (!Stems.entries.length) await Stems.refresh();
|
||
if (my !== seq) return;
|
||
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
|
||
view.innerHTML = bar("All stems", { filter: false, extra: `<input type="text" id="sf" placeholder="Filter ${all.length} tracks">` }) + `<div id="sl"></div>`;
|
||
const render = () => {
|
||
const f = $("#sf").value.toLowerCase();
|
||
const hits = f ? all.filter(t => t.stem.key.toLowerCase().includes(f)) : all;
|
||
$("#sl").innerHTML = (hits.length ? trackTable("stems", hits.slice(0, 400)) : `<p class="empty-msg">${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}</p>`)
|
||
+ (hits.length > 400 ? `<p class="note">Showing 400 of ${hits.length}. Filter to narrow it down.</p>` : "");
|
||
};
|
||
$("#sf").oninput = render;
|
||
render();
|
||
},
|
||
settings: renderSettings,
|
||
};
|
||
|
||
async function route() {
|
||
const my = ++seq;
|
||
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
|
||
const arg = decodeURIComponent(rest.join("/"));
|
||
$$("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name || (a.dataset.nav === "genres" && name === "genre") || (a.dataset.nav === "playlists" && /playlist$/.test(name))));
|
||
lists = {}; listItems = {}; kick = null;
|
||
delete view.dataset.genre;
|
||
view.scrollTop = 0;
|
||
Menu.close();
|
||
try { await (routes[name] || routes.albums)(my, arg); }
|
||
catch (e) { if (my === seq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
|
||
if (my === seq) marks();
|
||
}
|
||
addEventListener("hashchange", route);
|
||
$("#search").onsubmit = ev => {
|
||
ev.preventDefault();
|
||
const q = ev.target.q.value.trim();
|
||
if (q) location.hash = "#/search/" + encodeURIComponent(q);
|
||
};
|
||
|
||
view.addEventListener("click", async ev => {
|
||
const el = ev.target.closest("button, tr[data-list]");
|
||
if (!el) return;
|
||
const ds = el.dataset, row = ev.target.closest("tr[data-list]");
|
||
if ("stemsonly" in ds) { S.stemsOnly = !S.stemsOnly; save(); applyStemsOnly(); return; }
|
||
if ("sortdir" in ds) { pickSort(ds.sortdir, sortState(ds.sortdir).i); return route(); }
|
||
if ("reshuffle" in ds) return route();
|
||
if ("add" in ds && row) return Q.add([listItems[row.dataset.list]?.[+row.dataset.i] || lists[row.dataset.list][+row.dataset.i]], ev.shiftKey);
|
||
if ("menu" in ds && row) { const r = el.getBoundingClientRect(); return trackMenu(r.left, r.bottom, row.dataset.list, +row.dataset.i); }
|
||
if (el === row) return playFrom(row.dataset.list, +row.dataset.i);
|
||
if (ds.play) {
|
||
const all = listItems[ds.play] || lists[ds.play] || [], tracks = visible(all);
|
||
if (ds.mode === "stem") return stemNow(all.map(x => x.t || x));
|
||
if (!tracks.length) return toast(all.length ? "None of these have stems yet" : "Still loading…");
|
||
return Q.playList(tracks, ds.mode === "shuffle" ? -1 : 0, ds.mode === "shuffle");
|
||
}
|
||
if (ds.listmenu) { const r = el.getBoundingClientRect(); return listMenu(r.left, r.bottom, async () => listItems[ds.listmenu] || lists[ds.listmenu] || []); }
|
||
if ("shuffleall" in ds) {
|
||
const ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m));
|
||
return Q.playList(visible(ts), -1, true);
|
||
}
|
||
if (ds.genreplay) {
|
||
const g = (await genres()).find(x => x.title === view.dataset.genre);
|
||
const albums = await new Pager(g.srcs, secPath, { type: 9, sort: "random" }, 12).next(12);
|
||
const ts = (await Promise.all(albums.map(m => leaves(m._srv, m.ratingKey)))).flat();
|
||
return Q.playList(visible(ts), -1, true);
|
||
}
|
||
if ("newpl" in ds) { const n = prompt("Stem playlist name"); if (n?.trim()) { newPlaylist(n.trim()); route(); } return; }
|
||
if (ds.plrename) { const p = playlists.find(x => x.id === ds.plrename), n = prompt("Rename", p.title); if (n?.trim()) { p.title = n.trim(); savePlaylists(); route(); } return; }
|
||
if (ds.pldelete) { if (confirm("Delete this stem playlist?")) { playlists.splice(playlists.findIndex(x => x.id === ds.pldelete), 1); savePlaylists(); location.hash = "#/playlists"; } return; }
|
||
if ("dlclear" in ds) { if (confirm("Remove every download?")) { for (const id of [...Offline.ids]) await Offline.remove(id); emit("offline"); route(); } }
|
||
});
|
||
view.addEventListener("change", ev => {
|
||
const s = ev.target.dataset.sort;
|
||
if (s) { pickSort(s, +ev.target.value); route(); }
|
||
});
|
||
view.addEventListener("contextmenu", ev => {
|
||
const row = ev.target.closest("tr[data-list]"), c = ev.target.closest(".card[data-kind]");
|
||
if (row) { ev.preventDefault(); trackMenu(ev.clientX, ev.clientY, row.dataset.list, +row.dataset.i); }
|
||
else if (c) { ev.preventDefault(); listMenu(ev.clientX, ev.clientY, () => leaves(c.dataset.srv, c.dataset.key)); }
|
||
});
|
||
|
||
// ---- settings -----------------------------------------------------------------------------------
|
||
|
||
const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["playback", "Playback"], ["karaoke", "Karaoke"], ["look", "Appearance"], ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"]];
|
||
const FORMATS = [["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]];
|
||
|
||
function serverHtml(srv) {
|
||
const dup = srv.mid && S.servers.find(o => o !== srv && o.mid === srv.mid && S.servers.indexOf(o) < S.servers.indexOf(srv));
|
||
return `<div class="srv"><div class="row"><b>${esc(srv.name || srv.url)}</b>
|
||
<span class="note">${srv.env ? "through this stemserve (PLEX_URL)" : esc(srv.url)}</span><span class="grow"></span>
|
||
<button class="btn small" data-refsrv="${esc(srv.id)}">Refresh</button>
|
||
${srv.env ? "" : `<button class="btn small" data-rmsrv="${esc(srv.id)}">Remove</button>`}</div>
|
||
${dup ? `<p class="note">Same Plex server as ${esc(dup.name)}, which is used instead.</p>`
|
||
: (srv.sections || []).length ? `<div class="secs">${srv.sections.map(sec => `<label class="check"><input type="checkbox" data-sec="${esc(srv.id)}|${esc(sec.key)}" ${sec.on ? "checked" : ""}>${esc(sec.title)}</label>`).join("")}</div>`
|
||
: `<p class="note">No music libraries found. Try Refresh.</p>`}</div>`;
|
||
}
|
||
|
||
function renderSettings() {
|
||
const theme = THEMES[S.theme] || THEMES.amber;
|
||
view.innerHTML = `<div class="settings"><nav class="toc">${SECTIONS.map(([id, l]) => `<a href="#/settings" data-jump="${id}">${l}</a>`).join("")}</nav><div class="panes">
|
||
<section id="set-plex"><h2>Plex</h2>
|
||
<p class="note">Turn on the music libraries you want. Everything that's on is browsed as one library.</p>
|
||
<div id="srvs">${S.servers.map(serverHtml).join("") || `<p class="note">No servers yet.</p>`}</div>
|
||
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button><span class="note" id="sstatus"></span></div>
|
||
<details><summary class="note">Add a server by URL and token</summary><div class="form">
|
||
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400"></label>
|
||
<label>Token<input type="password" id="ptok"></label>
|
||
<div class="row"><button class="btn" id="padd">Add server</button></div></div></details>
|
||
</section>
|
||
<section id="set-stems"><h2>Stem server</h2><div class="form">
|
||
<p class="note" id="stem-info">Checking stemserve…</p>
|
||
<label>URL (blank = this page's server)<input type="url" id="surl" placeholder="${esc(location.origin)}" value="${esc(S.stemUrl)}"></label>
|
||
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
|
||
<div class="row"><button class="btn" id="ssave">Save and reconnect</button></div></div>
|
||
</section>
|
||
<section id="set-playback"><h2>Playback</h2><div class="form">
|
||
<label>Stem format<select id="fmt">${FORMATS.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
|
||
<p class="note">Stems are downloaded whole before a song starts. Opus 160 is about 1 MB per stem-minute; FLAC is around eight times that.</p>
|
||
<label class="check"><input type="checkbox" id="keepmix" ${S.keepMix ? "checked" : ""}>Keep the mix when the next song has no saved mix</label>
|
||
<div class="row"><span class="note">${plural(Object.keys(songMixes).length, "saved song mix")}</span>${Object.keys(songMixes).length ? `<button class="btn small" id="forgetall">Forget all</button>` : ""}</div></div>
|
||
</section>
|
||
<section id="set-karaoke"><h2>Karaoke</h2><div class="form">
|
||
<label class="check"><input type="checkbox" id="kon" ${S.karaoke ? "checked" : ""}>Karaoke on (K)</label>
|
||
<label>Vocals in karaoke<select id="kmode">${[["off", "Off"], ["soft", "Soft"], ["on", "Left on (lyrics only)"]].map(([v, l]) => `<option value="${v}" ${S.karaokeMode === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
|
||
<label>Soft vocal level: <span id="kdbv">${S.softDb} dB</span><input type="range" id="kdb" min="-30" max="-3" step="1" value="${S.softDb}"></label>
|
||
<p class="note">Synced lyrics come from LRCLIB, shown in the Lyrics drawer (L).</p></div>
|
||
</section>
|
||
<section id="set-look"><h2>Appearance</h2><div class="form">
|
||
<div class="themes">${Object.entries(THEMES).map(([k, t]) => `<button class="swatch ${S.theme === k ? "on" : ""}" data-theme="${k}" style="background:${t.bg};color:${t.text};border-color:${t.line}"><i style="background:${t.accent}"></i>${k[0].toUpperCase() + k.slice(1)}</button>`).join("")}</div>
|
||
<div class="row"><label class="check">Accent <input type="color" id="accent" value="${S.accent || theme.accent}"></label>${S.accent ? `<button class="btn small" id="accentreset">Use the theme's</button>` : ""}</div>
|
||
<label>Custom CSS<textarea id="css" rows="8" spellcheck="false" placeholder=":root { --accent: #7cf; --radius: 4px; }\n.card img { border-radius: 0; }">${esc(S.css)}</textarea></label>
|
||
<p class="note">Applied live, after the theme. Variables: --bg --panel --raised --line --text --muted --accent --accent-ink --radius --wave --wave-ghost.</p></div>
|
||
</section>
|
||
<section id="set-offline"><h2>Offline</h2>
|
||
<p class="note" id="storage-info">${plural(Offline.ids.size, "track")} downloaded. <a class="link" href="#/downloads">Manage downloads</a></p>
|
||
${isSecureContext && navigator.storage?.persist ? `<div class="row"><button class="btn small" id="persist">Ask the browser to keep downloads</button></div>` : `<p class="note">Over plain HTTP the browser may clear downloads when it's short on space.</p>`}
|
||
</section>
|
||
<section id="set-backup"><h2>Backup</h2>
|
||
<p class="note">Settings, saved mixes and stem playlists. The file includes your Plex tokens.</p>
|
||
<div class="row"><button class="btn" id="export">Export</button><label class="btn">Import<input type="file" id="import" accept="application/json" hidden></label></div>
|
||
</section>
|
||
<section id="set-keys"><h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · L lyrics · Q queue · K karaoke · right-click a song or album for more</p></section>
|
||
</div></div>`;
|
||
|
||
// Slow bits fill in after the page is up.
|
||
fetch(api("/api/info"), { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null).then(info => {
|
||
const el = $("#stem-info");
|
||
if (el) el.textContent = info ? `stemserve v${info.version} at ${stemBase()}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : `Can't reach stemserve at ${stemBase()}.`;
|
||
});
|
||
navigator.storage?.estimate?.().then(e => { const el = $("#storage-info"); if (el && e?.quota) el.insertAdjacentText("afterbegin", `${fmtBytes(e.usage)} of ${fmtBytes(e.quota)} used. `); }).catch(() => {});
|
||
|
||
const again = () => { if (location.hash.startsWith("#/settings")) renderSettings(); };
|
||
const plexChanged = () => { genreCache = null; save(); };
|
||
$("#signin").onclick = async () => {
|
||
const st = $("#sstatus");
|
||
try { const ok = await signIn(m => { st.textContent = m; }); plexChanged(); toast(`Added ${plural(ok.length, "server")}`); again(); }
|
||
catch (e) { st.textContent = e.message; }
|
||
};
|
||
$("#padd").onclick = async () => {
|
||
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
|
||
if (!url || !token) return toast("Need both a URL and a token");
|
||
const srv = addServer(manualServer(url, token));
|
||
try { await refreshServer(srv); } catch (e) { toast(e.message); }
|
||
plexChanged(); again();
|
||
};
|
||
$("#ssave").onclick = async () => {
|
||
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim();
|
||
Stems.etag = null; save(); await Stems.refresh(); Live.poll(); again();
|
||
};
|
||
$("#fmt").onchange = ev => { S.fmt = ev.target.value; save(); };
|
||
$("#keepmix").onchange = ev => { S.keepMix = ev.target.checked; save(); };
|
||
if ($("#forgetall")) $("#forgetall").onclick = () => { for (const k of Object.keys(songMixes)) forgetSongMix(k); again(); };
|
||
$("#kon").onchange = ev => setKaraoke(ev.target.checked);
|
||
$("#kmode").onchange = ev => setKaraokeMode(ev.target.value);
|
||
$("#kdb").oninput = ev => { $("#kdbv").textContent = ev.target.value + " dB"; };
|
||
$("#kdb").onchange = ev => setKaraokeMode(S.karaokeMode, +ev.target.value);
|
||
$("#accent").oninput = ev => { S.accent = ev.target.value; applyTheme(); save(); };
|
||
if ($("#accentreset")) $("#accentreset").onclick = () => { S.accent = ""; applyTheme(); save(); again(); };
|
||
let cssTimer;
|
||
$("#css").oninput = ev => { clearTimeout(cssTimer); cssTimer = setTimeout(() => { S.css = ev.target.value; applyTheme(); save(); }, 250); };
|
||
if ($("#persist")) $("#persist").onclick = async () => toast(await navigator.storage.persist() ? "Downloads will be kept" : "The browser said no");
|
||
$("#export").onclick = () => {
|
||
const blob = new Blob([JSON.stringify({ stemify: 1, settings: S, mixes: songMixes, playlists }, null, 2)], { type: "application/json" });
|
||
const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "stemify-web.json" });
|
||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
};
|
||
$("#import").onchange = async ev => {
|
||
try {
|
||
const j = JSON.parse(await ev.target.files[0].text());
|
||
if (!j.stemify) throw new Error("Not a Stemify backup");
|
||
store("stemify-web", { ...j.settings, clientId: S.clientId });
|
||
if (j.mixes) store("stemify-web-mixes", j.mixes);
|
||
if (j.playlists) store("stemify-web-playlists", j.playlists);
|
||
location.reload();
|
||
} catch (e) { toast(e.message); }
|
||
};
|
||
const sv = $(".settings");
|
||
sv.addEventListener("click", async ev => {
|
||
const b = ev.target.closest("[data-jump], [data-theme], [data-refsrv], [data-rmsrv]");
|
||
if (!b) return;
|
||
const ds = b.dataset;
|
||
if (ds.jump) { ev.preventDefault(); $("#set-" + ds.jump).scrollIntoView({ behavior: "smooth" }); }
|
||
else if (ds.theme) { S.theme = ds.theme; S.accent = ""; applyTheme(); save(); again(); }
|
||
else if (ds.refsrv) { try { await refreshServer(server(ds.refsrv)); toast("Refreshed"); } catch (e) { toast(e.message); } plexChanged(); again(); }
|
||
else if (ds.rmsrv && confirm("Remove this server?")) { S.servers = S.servers.filter(s => s.id !== ds.rmsrv); plexChanged(); again(); }
|
||
});
|
||
sv.addEventListener("change", ev => {
|
||
const k = ev.target.dataset.sec;
|
||
if (!k) return;
|
||
const [id, key] = k.split("|"), sec = server(id)?.sections.find(s => s.key === key);
|
||
if (sec) { sec.on = ev.target.checked; plexChanged(); }
|
||
});
|
||
}
|
||
on("mix", () => { if ($("#kon")) $("#kon").checked = S.karaoke; });
|
||
|
||
// ---- start --------------------------------------------------------------------------------------
|
||
|
||
// The server's own PLEX_URL / PLEX_TOKEN show up as a server proxied through stemserve, and a
|
||
// local stemserve is tried when this page's server has none.
|
||
async function connect() {
|
||
const ok = await Stems.refresh();
|
||
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
|
||
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
|
||
try {
|
||
const r = await fetch(u + "/api/info", { signal: AbortSignal.timeout(1500) });
|
||
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
|
||
} catch {}
|
||
}
|
||
}
|
||
try {
|
||
const c = await fetch(api("/api/config"), { signal: AbortSignal.timeout(4000) }).then(r => r.ok ? r.json() : null);
|
||
if (c?.plex) addServer({ id: "env", env: true, token: c.plex.token, url: stemBase() + "/plex" });
|
||
else if (c) S.servers = S.servers.filter(s => !s.env);
|
||
} catch {}
|
||
await Promise.all(S.servers.filter(s => !s.sections?.length || !s.mid).map(s => refreshServer(s).catch(() => {})));
|
||
save();
|
||
}
|
||
|
||
(async () => {
|
||
applyTheme();
|
||
applyStemsOnly();
|
||
await Offline.init();
|
||
await Promise.race([connect(), new Promise(r => setTimeout(r, 5000))]);
|
||
Q.restore();
|
||
UI.all();
|
||
route();
|
||
Live.poll();
|
||
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
|
||
})();
|