// 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, uuid } 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, setting, stemGain, presets, allPresets, presetLabel, applyPreset, currentPreset, nextPreset, savePreset, deletePreset, movePreset, restorePresets, deleteAllCustom, toggleLock,
mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline, Radio } 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();
}
// ---- stem filter (Stems Only / No Stems) ---------------------------------------------------------------------
// 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 applyStemFilter() {
document.body.classList.toggle("stems-only", S.stemFilter === "stems");
document.body.classList.toggle("no-stems", S.stemFilter === "nostems");
$$("[data-stemfilter]").forEach(b => b.classList.toggle("on", S.stemFilter === b.dataset.stemfilter));
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 st = Radio.station;
$("#player").classList.toggle("radio", !!st);
if (st) {
$("#player").classList.remove("empty");
$("#np-title").textContent = st.name;
$("#np-sub").textContent = "Web Radio";
$("#np-art").removeAttribute("src");
document.title = `${st.name} · Stemify`;
if ("mediaSession" in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: st.name, artist: "Web Radio" });
return;
}
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"), radio = !!Radio.station, playing = radio ? Radio.playing : E.playing, busy = !radio && E.buffering;
p.innerHTML = busy ? ICON.busy : playing ? ICON.pause : ICON.play;
p.classList.toggle("busy", busy);
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 = 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 && !Radio.station && 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 = `${["mixer", "lyrics", "queue"].map(k => `${k[0].toUpperCase() + k.slice(1)} `).join("")}
× `;
d.innerHTML = tabs + `
${drawer === "mixer" ? mixerHtml() : drawer === "queue" ? queueHtml() : lyricsShell()}
`;
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 = () => `
Karaoke
${[["off", "No vocals"], ["soft", "Soft vocals"], ["on", "Vocals on"]].map(([m, l]) => `${l} `).join("")}
${S.karaokeMode === "soft" ? `
Soft vocals: ${S.softDb} dB ` : ""}
`;
function mixerHtml() {
const names = mixNames();
if (!names.length) return `${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."}
`;
const cur = currentPreset(names), key = E.entry.key, saved = songMixes[key];
const lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset);
const savedNote = saved ? (sameMix(saved, S.mix, names) ? "Using this song's saved mix." : "Changed from this song's saved mix.") : "";
return karaokeHtml() + `
${presets().map(p => `${lock?.id === p.id ? ICON.lock : ""}${esc(presetLabel(p.name))} `).join("")}+
${names.map(n => {
const s = setting(n), g = stemGain(n, names);
return `${esc(n)}
M
S
`;
}).join("")}
${saved ? "Update saved mix" : "Save mix for this song"}
${saved ? `Forget ` : ""}
Add to stem playlist…
${savedNote} ${S.karaoke ? "Karaoke is on, so saved mixes are ignored until you turn it off." : lock ? `${esc(presetLabel(lock.name))} is locked, so every song starts with it. Long press it to unlock.`
: (S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix.") + " Long press a preset to lock it."}
`;
}
function queueHtml() {
if (!Q.items.length) return `Empty.
`;
const left = Q.items.length - Q.i - 1;
return `${plural(left, "track")} up next Clear
${Q.items.map((x, i) => `
${artOf(x.t, 80) ? `
` : `
`}
${esc(x.t.title)}${x.mix ? ' MIX ' : ""}
${esc(x.t.artist)}
${i === Q.i ? "" : `
× `}
`).join("")}`;
}
const lyricsShell = () => `${mixNames().includes("vocals") ? karaokeHtml() : ""}
Looking for lyrics…
`;
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) => `${esc(l.text) || "♪"}
`).join("") : esc(d.plain || "").replace(/\n/g, " ");
}
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(currentPreset(names)?.id === ds.preset ? allPresets().find(p => p.id === "b:Full Mix") : presets().find(p => p.id === ds.preset), names);
else if ("newpreset" in ds) newPreset();
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);
});
// Long press (or right-click) a preset chip to lock it.
{
let timer = 0, fired = false;
const lock = id => { fired = true; clearTimeout(timer); toggleLock(id, mixNames()); };
d$.addEventListener("pointerdown", ev => {
fired = false;
const b = ev.target.closest("[data-preset]");
if (b && !ev.button) timer = setTimeout(() => lock(b.dataset.preset), 550);
});
for (const e of ["pointerup", "pointerleave", "pointercancel"]) d$.addEventListener(e, () => clearTimeout(timer));
d$.addEventListener("contextmenu", ev => {
const b = ev.target.closest("[data-preset]");
if (!b) return;
ev.preventDefault();
if (!fired) lock(b.dataset.preset);
});
d$.addEventListener("click", ev => { if (fired && ev.target.closest("[data-preset]")) { ev.stopImmediatePropagation(); fired = false; } }, true);
}
function newPreset() {
const n = prompt("Name this preset");
if (n == null) return;
const err = savePreset(n, S.mix);
toast(err || `Saved “${n.trim()}”`);
}
{
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 => `${v}× `).join("");
$("#volume").value = S.volume;
const togglePlay = () => { if (Radio.station) return Radio.toggle(); if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); };
$("#b-play").onclick = togglePlay;
$("#b-prev").onclick = () => Q.prev();
{
// Hold Next to skip the rest of the album.
const nb = $("#b-next");
let timer = 0, held = false;
nb.addEventListener("pointerdown", ev => { held = false; if (!ev.button) timer = setTimeout(() => { held = true; Q.skipAlbum(); }, 600); });
for (const e of ["pointerup", "pointerleave", "pointercancel"]) nb.addEventListener(e, () => clearTimeout(timer));
nb.addEventListener("contextmenu", ev => ev.preventDefault());
nb.onclick = () => { if (held) held = false; else 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(); };
function applyBar() {
$("#speed").style.display = S.bar.speed ? "" : "none";
for (const k of ["mixer", "lyrics", "queue"]) $(`#b-${k}`).style.display = S.bar[k] ? "" : "none";
}
$(".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", () => Radio.station ? Radio.playing || Radio.toggle() : E.play());
ms.setActionHandler("pause", () => Radio.station ? Radio.playing && Radio.toggle() : 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 === "p") nextPreset(mixNames());
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("radio", () => { UI.track(); UI.transport(); const el = $("#stations"); if (el) el.innerHTML = stationsHtml(); });
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 'STEMS ';
const s = Live.state(t);
return s ? `${{ queued: "QUEUED", stemming: "STEMMING", failed: "FAILED" }[s]} ` : "";
};
// 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 ? ` 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 === "-" ? " " : `${esc(it.label)} `).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, group = null) => saveOffline(tracks, m => m && toast(m, 120000), group);
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, group = null) {
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, group)) },
]);
}
// ---- views --------------------------------------------------------------------------------------
let lists = {}, listItems = {}, seq = 0, kick = null, genreCache = null;
const visible = tracks => S.stemFilter ? tracks.filter(t => !!Stems.find(t.t || t) === (S.stemFilter === "stems")) : 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 `
${src ? ` ` : `
`}${esc(t)}
${esc(s)}
`;
}
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 } = {}) => `
${numbers ? t.index ?? i + 1 : i + 1}
${esc(t.title)}${badge(t)} ${mix ? 'MIX ' : ""}${album ? `${esc([t.artist, t.album].filter(Boolean).join(" · "))} ` : ""}
${Offline.has(t.id) ? ICON.download : ""}
${fmtTime(t.duration / 1000)}
+ ${ICON.more}
`;
function trackTable(listId, tracks, opts = {}) {
lists[listId] = tracks;
if (!tracks.length) return `Nothing here.
`;
return `${tracks.map((t, i) => rowHtml(listId, t, i, { ...opts, mix: listItems[listId]?.[i]?.mix })).join("")}
None of these have stems yet.
All of these have stems.
`;
}
const stemsChip = () => `${[["stems", "Stems Only"], ["nostems", "No Stems"]].map(([v, l]) =>
`${l} `).join("")}
`;
function sortControl(screen) {
const st = sortState(screen);
return `${st.opts.map((o, i) => `${o[0]} `).join("")}
${st.random ? `${ICON.shuffle} `
: `${st.desc ? st.o[4] : st.o[3]} `}
`;
}
const bar = (title, { sort, filter = true, extra = "" } = {}) =>
`
${esc(title)} ${extra}${filter ? stemsChip() : ""}${sort ? sortControl(sort) : ""}`;
const actions = id => `
${ICON.play} Play
${ICON.shuffle} Shuffle
Stem Now
${ICON.more}
`;
const hero = (src, kind, title, sub, id, round) => `${src ? `
` : `
`}
`;
function needPlex() {
view.innerHTML = `Welcome Add a Plex server in Settings to browse your library, or browse the stems on their own.
`;
}
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 = `Nothing here.
`;
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);
// Downloads by the album or playlist they came from, newest first. Single songs go under Songs.
function dlGroups(recs) {
const by = new Map();
for (const r of recs) {
const k = r.group?.href || "";
if (!by.has(k)) by.set(k, { g: r.group, recs: [] });
by.get(k).recs.push(r);
}
const gs = [...by.values()].sort((a, b) => !a.g - !b.g);
return gs.map(({ g, recs }, n) => {
if (g) recs.sort((a, b) => a.saved - b.saved);
return `
${plural(recs.length, "track")}, ${fmtBytes(recs.reduce((a, r) => a + r.size, 0))}
Remove ${trackTable("dl:" + n, recs.map(r => r.track))} `;
}).join("");
}
// Albums whose original release date falls today (or within three days) in an earlier year.
function birthdays(albums) {
const now = new Date(), y = now.getFullYear(), today = new Date(y, now.getMonth(), now.getDate());
const leap = new Date(y, 1, 29).getMonth() === 1, onDay = [], week = [];
for (const m of albums) {
const d = m.originallyAvailableAt || "";
if (d.length < 10) continue;
const ry = +d.slice(0, 4), mo = +d.slice(5, 7) - 1;
let da = +d.slice(8, 10);
if (!ry || ry >= y) continue;
if (mo === 1 && da === 29 && !leap) da = 28;
const when = new Date(y, mo, da), diff = Math.round((when - today) / 864e5), years = y - ry;
if (!diff) onDay.push({ m, years });
else if (Math.abs(diff) <= 3) week.push({ m, years, when, diff });
}
onDay.sort((a, b) => b.years - a.years);
week.sort((a, b) => a.diff - b.diff || b.years - a.years);
return { onDay, week };
}
const bdayCard = ({ m, years, when, diff }) => card({
href: `#/album/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, kind: "album", srv: m._srv, key: m.ratingKey,
s: when ? `${diff < 0 ? "Turned" : "Turns"} ${years} · ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `${m.parentTitle} · ${years === 1 ? "1 year ago" : `${years} years ago`}`,
});
const shelf = cards => `${cards.join("")}
`;
const stationsHtml = () => S.stations.length ? `${S.stations.map(st => {
const on = Radio.station?.id === st.id, playing = on && Radio.playing;
return `
${playing ? ICON.pause : ICON.play}
${esc(st.name)} ${esc(st.url)}
Rename Delete `;
}).join("")}
` : `No stations yet. Add one with its stream URL.
`;
const routes = {
async home(my) {
const srcs = sources();
if (!srcs.length) return needPlex();
const today = new Date().toLocaleDateString(undefined, { month: "long", day: "numeric" });
view.innerHTML = `Home
On This Day ${esc(today)}
Loading…
Stations
Library Radio Random songs from your whole library
Random Album Radio Ten random albums, each played in order
`;
const fill = (id, html) => { const el = $(id); if (el && my === seq) { el.querySelector(".note").outerHTML = html; marks(); } };
const fail = id => e => fill(id, `${esc(e.message)}
`);
await Promise.all([
new Pager(srcs, secPath, { type: 9, sort: "lastViewedAt:desc", "viewCount>>": 0 }, 24).next(24)
.then(ms => fill("#h-recent", ms.length ? shelf(ms.map(albumCard)) : `Nothing played yet.
`), fail("#h-recent")),
new Pager(srcs, secPath, { type: 10, sort: "addedAt:desc" }, 10).next(10)
.then(ms => my === seq && fill("#h-added", trackTable("added", ms.map(m => track(m._srv, m)))), fail("#h-added")),
everything(srcs, secPath, { type: 9 }).then(all => {
const { onDay, week } = birthdays(all);
fill("#h-otd", (onDay.length ? shelf(onDay.map(bdayCard)) : `${week.length ? "No album birthdays today. Coming up and just past:" : "No albums in your library came out this week in any year."}
`)
+ (onDay.length && week.length ? `Coming up and just past
` : "") + (week.length ? shelf(week.map(bdayCard)) : ""));
}, fail("#h-otd")),
]);
},
radio() {
view.innerHTML = bar("Web Radio", { filter: false }) + `${stationsHtml()}
Add a station `;
},
async albums(my) {
const srcs = sources();
if (!srcs.length) return needPlex();
view.innerHTML = bar("Albums", { sort: "albums" }) + `
`;
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" }) + `
`;
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: `${ICON.shuffle} Shuffle all ` })
+ `No songs with stems loaded yet. Scroll for more.
Every song loaded so far has stems. Scroll for more.
`;
lists.songs = [];
const size = S.stemFilter ? 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 }) + `Loading…
`;
const list = await genres();
if (my !== seq) return;
count(list.length, "genre");
view.innerHTML = bar("Genres", { filter: false }) + (list.length ? `` : `No genres.
`);
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 = `${esc(title)} No albums in this genre.
`; return; }
view.innerHTML = bar(g.title, { sort: "albums", extra: `${ICON.shuffle} Shuffle ` }) + `
`;
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 })
+ `
Stem playlists + New `
+ (playlists.length ? `${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("")}
`
: `Stem playlists remember a mix for each song. Right-click any song to add it.
`)
+ `Plex playlists ${liveServers().length ? "Loading…" : "No Plex server set up."}
`;
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 ? `${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("")}
`
: `No music playlists.
`;
},
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,
`${esc(a.parentTitle)} ${a.year ? " · " + a.year : ""} · ${plural(tracks.length, "track")}${stems ? ` · ${stems} with stems` : ""}`, "album")
+ ` ${stemsChip()}${sortControl("album")}
` + 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)
+ `
Albums ${stemsChip()}${albums.map(albumCard).join("")}
Songs ${sortControl("artist")}`;
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")
+ ` ${stemsChip()}${sortControl("playlist")}
` + trackTable("playlist", tracks);
},
async stemplaylist(my, id) {
const p = playlists.find(x => x.id === id);
if (!p) { view.innerHTML = `Not found This stem playlist was deleted.
`; 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
· Rename · Delete `, lid)
+ ` ${stemsChip()}
` + 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 ? `Artists ${artists.map(artistCard).join("")}
` : "") +
(albums.length ? `Albums ${albums.map(albumCard).join("")}
` : "") +
(tracks.length ? `
Songs ${sortControl("search")}${trackTable("search", tracks)}` : "") +
(!artists.length && !albums.length && !tracks.length ? `No results.
` : "");
},
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 ? `Remove all ` : "" })
+ `${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."}
`
+ (recs.length ? dlGroups(recs) : `Nothing downloaded. Right-click a song or album and pick Download for offline.
`);
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: ` ` }) + `
`;
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)) : `${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}
`)
+ (hits.length > 400 ? `Showing 400 of ${hits.length}. Filter to narrow it down.
` : "");
};
$("#sf").oninput = render;
render();
},
settings: renderSettings,
};
async function route() {
const my = ++seq;
const [, name = "home", ...rest] = (location.hash || "#/home").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.home)(my, arg); }
catch (e) { if (my === seq) view.innerHTML = `Something went wrong ${esc(e.message)}
`; }
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 (ds.stemfilter) { S.stemFilter = S.stemFilter === ds.stemfilter ? "" : ds.stemfilter; save(); applyStemFilter(); 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 ? "Still loading…" : S.stemFilter === "nostems" ? "All of these have stems" : "None of these have stems yet");
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] || [], { title: $(".hero h1", view)?.textContent || "", href: location.hash }); }
if (ds.station) {
try {
let ts;
if (ds.station === "library") ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m));
else ts = (await Promise.all((await new Pager(sources(), secPath, { type: 9, sort: "random" }, 10).next(10)).map(m => leaves(m._srv, m.ratingKey)))).flat();
return ts.length ? Q.playList(ts, 0, false) : toast("Nothing to play");
} catch { return toast("Couldn't start station"); }
}
if ("rdadd" in ds) {
const name = $("#rname").value.trim(), url = $("#rurl").value.trim();
if (!name || !/^https?:\/\//i.test(url)) return toast("Need a name and an http(s) stream URL");
S.stations.push({ id: uuid(), name, url }); save();
return route();
}
if (ds.radio) { const st = S.stations.find(x => x.id === ds.radio); return Radio.station?.id === st.id && Radio.playing ? Radio.stop() : Radio.play(st); }
if (ds.rdrename) {
const st = S.stations.find(x => x.id === ds.rdrename), n = prompt("Rename", st.name);
if (n?.trim()) { st.name = n.trim(); save(); if (Radio.station?.id === st.id) Radio.station.name = st.name; UI.track(); route(); }
return;
}
if (ds.rddelete) {
if (!confirm("Delete this station?")) return;
if (Radio.station?.id === ds.rddelete) Radio.stop();
S.stations = S.stations.filter(x => x.id !== ds.rddelete); save();
return route();
}
if (ds.dlgroup) {
const ts = lists["dl:" + ds.dlgroup] || [];
if (confirm(`Remove ${plural(ts.length, "download")}?`)) { for (const t of ts) await Offline.remove(t.id); emit("offline"); route(); }
return;
}
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), { title: $(".t", c).textContent, href: c.getAttribute("href") }); }
});
// ---- settings -----------------------------------------------------------------------------------
const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["stemopts", "Stems"], ["presets", "Stem Editor"], ["karaoke", "Karaoke"], ["look", "Appearance"],
["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"], ["about", "About"]];
const BAR = [["speed", "Speed"], ["mixer", "Mixer"], ["lyrics", "Lyrics"], ["queue", "Queue"]];
function presetEditor() {
const ps = presets(), lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset);
return `Locked
${lock ? `${ICON.lock}${esc(presetLabel(lock.name))} Unlock
` : `Nothing locked.
`}
Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.
Mixer Bar ${ps.map((p, i) => `
${esc(presetLabel(p.name))}${p.custom ? ' CUSTOM ' : ""}
${ICON.lock}
↑
↓
×
`).join("")}
Stems
Save the current mix as a preset
Restore Deleted Presets
Delete All Custom Presets
`;
}
on("presets", () => { const el = $("#pedit"); if (el) el.innerHTML = presetEditor(); });
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 `${esc(srv.name || srv.url)}
${srv.env ? "through this stemserve (PLEX_URL)" : esc(srv.url)}
Refresh
${srv.env ? "" : `Remove `}
${dup ? `
Same Plex server as ${esc(dup.name)}, which is used instead.
`
: (srv.sections || []).length ? `
${srv.sections.map(sec => ` ${esc(sec.title)} `).join("")}
`
: `
No music libraries found. Try Refresh.
`}
`;
}
function renderSettings() {
const theme = THEMES[S.theme] || THEMES.amber;
view.innerHTML = `${SECTIONS.map(([id, l]) => `${l} `).join("")}
Plex
Turn on the music libraries you want. Everything that's on is browsed as one library.
${S.servers.map(serverHtml).join("") || `
No servers yet.
`}
Sign in with Plex
Add a server by URL and token
Stem Editor ${presetEditor()}
Offline
${plural(Offline.ids.size, "track")} downloaded. Manage downloads
${isSecureContext && navigator.storage?.persist ? `Ask the browser to keep downloads
` : `Over plain HTTP the browser may clear downloads when it's short on space.
`}
Keys Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · L lyrics · Q queue · K karaoke · P next preset · hold Next to skip the album · right-click a song or album for more
About
Stemify
Your Plex and Jellyfin music, split into stems you can mix.
Chris Nutter Stemify WebUI and the iOS app
Kevin Viers stemd and the Android app
GitHub
Buy Me a Coffee: Coming soon
Stemify is free. A coffee goes straight to the person you pick.
`;
// 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");
const v = $("#about-ver");
if (v && info) v.textContent = `stemserve v${info.version}`;
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; if (!S.keepMix && S.karaoke) setKaraoke(false); save(); };
const ahead = v => { S.precache = Math.max(0, Math.min(50, Math.round(+v || 0))); $("#precache").value = $("#precachen").value = S.precache; save(); };
$("#precache").oninput = ev => ahead(ev.target.value);
$("#precachen").onchange = ev => ahead(ev.target.value);
$("#acapella").onchange = ev => { S.acapella = ev.target.checked; save(); emit("presets"); emit("mix"); };
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], [data-plock], [data-pmove], [data-pdel], [data-padd], [data-prestore], [data-pclear]");
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(); }
else if (ds.plock) toggleLock(ds.plock, mixNames());
else if (ds.pmove) movePreset(ds.pmove, +ds.pdir);
else if (ds.pdel) { if (ds.pdel.startsWith("b:") || confirm("Delete this preset?")) deletePreset(ds.pdel); }
else if ("padd" in ds) newPreset();
else if ("prestore" in ds) restorePresets();
else if ("pclear" in ds && confirm("Delete every custom preset?")) deleteAllCustom();
});
sv.addEventListener("change", ev => {
const bk = ev.target.dataset.bar;
if (bk) { S.bar = { ...S.bar, [bk]: ev.target.checked }; save(); applyBar(); return; }
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();
applyStemFilter();
applyBar();
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);
})();