Files
stemify/web/app.js
T
rootandClaude Opus 5.5 7cdfbef160 Web player overhaul; iOS random sort, stems-only filter, pooled libraries
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>
2026-10-20 18:29:45 -07:00

937 lines
57 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.
// 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);
})();