Files
stemify/web/app.js
T

1177 lines
74 KiB
JavaScript
Raw Permalink 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, 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;
// The envelope only changes with the song or the mix, not every redraw.
const gains = names.map(nm => stemGain(nm, names)), gk = gains.join();
if (this.env?.peaks !== this.peaks || this.env.gk !== gk) {
const full = new Float32Array(n), mixed = new Float32Array(n);
names.forEach((nm, k) => {
const g = gains[k];
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
});
this.env = { peaks: this.peaks, gk, full, mixed, max: Math.sqrt(Math.max(...full)) || 1 };
}
const { full, mixed, max } = this.env;
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);
}
},
// Redraws during playback only when the played part has grown by a bar (or a pixel without peaks).
follow() {
const w = this.canvas.clientWidth, dur = E.duration();
const steps = this.peaks ? Math.max(20, Math.floor(w / 3)) : w;
const at = dur ? Math.floor(Math.min(1, E.position() / dur) * steps) : 0;
if (at === this.at) return;
this.at = at;
this.draw();
},
};
{
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 = `<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 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() + `
<div class="presets">${presets().map(p => `<button class="chip ${cur?.id === p.id ? "on" : ""} ${lock?.id === p.id ? "locked" : ""}" data-preset="${esc(p.id)}" ${p.build(names) ? "" : "disabled"}
title="Tap again for Full Mix. Long press to ${lock?.id === p.id ? "unlock" : "lock"}.">${lock?.id === p.id ? ICON.lock : ""}${esc(presetLabel(p.name))}</button>`).join("")}<button class="chip" data-newpreset title="Save this mix as a preset">+</button></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." : 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."}</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(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 => `<option value="${v}">${v}×</option>`).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]); }
});
// The clock, waveform and lyrics follow playback 5 times a second instead of every frame:
// none of them change faster than that, and a 120 Hz loop keeps phones warm.
setInterval(() => {
if (document.hidden || !E.playing || E.loading) return;
const t = fmtTime(E.position()), el = $("#t-pos");
if (el.textContent !== t) el.textContent = t;
Wave.follow(); tickLyrics();
}, 200);
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 '<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, 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 `<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><p class="empty-msg only-nostems">All of these have stems.</p>`;
}
const stemsChip = () => `<div class="seg stemfilter">${[["stems", "Stems Only"], ["nostems", "No Stems"]].map(([v, l]) =>
`<button class="${S.stemFilter === v ? "on" : ""}" data-stemfilter="${v}">${l}</button>`).join("")}</div>`;
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);
// 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 `<section class="dlgroup"><div class="bar sm"><h2>${g ? `<a href="${esc(g.href)}">${esc(g.title)}</a>` : "Songs"}</h2>
<span class="sub">${plural(recs.length, "track")}, ${fmtBytes(recs.reduce((a, r) => a + r.size, 0))}</span><span class="grow"></span>
<button class="chip" data-dlgroup="${n}">Remove</button></div>${trackTable("dl:" + n, recs.map(r => r.track))}</section>`;
}).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 => `<div class="shelf ${cards.length > 4 ? "two" : ""}">${cards.join("")}</div>`;
const stationsHtml = () => S.stations.length ? `<div class="stationlist">${S.stations.map(st => {
const on = Radio.station?.id === st.id, playing = on && Radio.playing;
return `<div class="stn ${on ? "on" : ""}"><button class="btn icon ${playing ? "primary" : ""}" data-radio="${esc(st.id)}" title="${playing ? "Stop" : "Play"}">${playing ? ICON.pause : ICON.play}</button>
<div class="tx"><b>${esc(st.name)}</b><span class="note">${esc(st.url)}</span></div>
<button class="btn small" data-rdrename="${esc(st.id)}">Rename</button><button class="btn small" data-rddelete="${esc(st.id)}">Delete</button></div>`;
}).join("")}</div>` : `<p class="empty-msg">No stations yet. Add one with its stream URL.</p>`;
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 = `<h1>Home</h1>
<section id="h-recent"><h2>Recent Plays</h2><p class="note">Loading…</p></section>
<section id="h-added"><h2>Recently Added</h2><p class="note">Loading…</p></section>
<section id="h-otd"><h2>On This Day</h2><p class="sub">${esc(today)}</p><p class="note">Loading…</p></section>
<section><h2>Stations</h2><div class="stations">
<button class="station" data-station="library"><b>Library Radio</b><span>Random songs from your whole library</span></button>
<button class="station" data-station="albums"><b>Random Album Radio</b><span>Ten random albums, each played in order</span></button></div></section>`;
const fill = (id, html) => { const el = $(id); if (el && my === seq) { el.querySelector(".note").outerHTML = html; marks(); } };
const fail = id => e => fill(id, `<p class="note">${esc(e.message)}</p>`);
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)) : `<p class="note">Nothing played yet.</p>`), 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)) : `<p class="note">${week.length ? "No album birthdays today. Coming up and just past:" : "No albums in your library came out this week in any year."}</p>`)
+ (onDay.length && week.length ? `<p class="note">Coming up and just past</p>` : "") + (week.length ? shelf(week.map(bdayCard)) : ""));
}, fail("#h-otd")),
]);
},
radio() {
view.innerHTML = bar("Web Radio", { filter: false }) + `<div id="stations">${stationsHtml()}</div>
<h2>Add a station</h2><div class="form">
<label>Name<input type="text" id="rname" placeholder="KEXP"></label>
<label>Stream URL<input type="url" id="rurl" placeholder="https://example.com/stream.mp3"></label>
<div class="row"><button class="btn primary" data-rdadd>Add station</button></div>
<p class="note">Any stream the browser can play, like MP3, AAC or Ogg. Playing a station pauses the queue, and playing the queue stops the station.</p></div>`;
},
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><p class="empty-msg only-nostems">Every song loaded so far has stems. Scroll for more.</p>`;
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 }) + `<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 ? dlGroups(recs) : `<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 = "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 = `<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 (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 `<h3>Locked</h3>
${lock ? `<div class="prow"><span class="pn">${ICON.lock}${esc(presetLabel(lock.name))}</span><span class="grow"></span><button class="btn small" data-plock="${esc(lock.id)}">Unlock</button></div>` : `<p class="note">Nothing locked.</p>`}
<p class="note">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.</p>
<h3>Mixer Bar</h3><div class="plist">${ps.map((p, i) => `<div class="prow"><span class="pn">${esc(presetLabel(p.name))}${p.custom ? ' <span class="tag">CUSTOM</span>' : ""}</span><span class="grow"></span>
<button class="btn small ${lock?.id === p.id ? "on" : ""}" data-plock="${esc(p.id)}" title="${lock?.id === p.id ? "Unlock" : "Lock"}">${ICON.lock}</button>
<button class="btn small" data-pmove="${esc(p.id)}" data-pdir="-1" ${i ? "" : "disabled"} title="Move up">↑</button>
<button class="btn small" data-pmove="${esc(p.id)}" data-pdir="1" ${i < ps.length - 1 ? "" : "disabled"} title="Move down">↓</button>
<button class="btn small" data-pdel="${esc(p.id)}" ${p.id === "b:Full Mix" ? "disabled" : ""} title="Delete">×</button></div>`).join("")}</div>
<h3>Stems</h3><div class="row">
<button class="btn small" data-padd ${mixNames().length ? "" : "disabled"} title="Play a song with stems to save its mix">Save the current mix as a preset</button>
<button class="btn small" data-prestore ${S.hiddenPresets.length ? "" : "disabled"}>Restore Deleted Presets</button>
<button class="btn small" data-pclear ${S.customPresets.length ? "" : "disabled"}>Delete All Custom Presets</button></div>`;
}
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 `<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-stemopts"><h2>Stems</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>Cache Ahead<div class="row"><input type="range" id="precache" min="0" max="50" step="1" value="${S.precache}"><input type="number" id="precachen" min="0" max="50" value="${S.precache}"> songs</div></label>
<p class="note">How many upcoming songs load their stems in the background. Each one is held in memory, so keep it low on phones. FLAC loads one at most.</p>
<label class="check"><input type="checkbox" id="keepmix" ${S.keepMix ? "checked" : ""}>Keep Stem Mix Between Songs</label>
<p class="note">Songs without a saved mix keep the current mix. When it's off they start at full mix, and karaoke turns off.</p>
<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-presets"><h2>Stem Editor</h2><div id="pedit">${presetEditor()}</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 class="check"><input type="checkbox" id="acapella" ${S.acapella ? "checked" : ""}>Spell the vocals-only preset “Acapella”</label>
<div class="row"><span class="note">Player bar</span>${BAR.map(([k, l]) => `<label class="check"><input type="checkbox" data-bar="${k}" ${S.bar[k] ? "checked" : ""}>${l}</label>`).join("")}</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 · P next preset · hold Next to skip the album · right-click a song or album for more</p></section>
<section id="set-about"><h2>About</h2>
<p><b>Stemify</b> <span class="note" id="about-ver"></span></p>
<p class="note">Your Plex and Jellyfin music, split into stems you can mix.</p>
<div class="people">
<div class="person"><b>Chris Nutter</b><span class="note">Stemify WebUI and the iOS app</span><div class="row">
<a class="btn small" href="https://iamchrisama.com" target="_blank" rel="noopener">iamchrisama.com</a>
<a class="btn small" href="https://buymeacoffee.com/cdnutter" target="_blank" rel="noopener">Buy Me a Coffee</a></div></div>
<div class="person"><b>Kevin Viers</b><span class="note">stemd and the Android app</span><div class="row">
<a class="btn small" href="https://github.com/kevinViers/" target="_blank" rel="noopener">GitHub</a>
<a class="btn small" href="https://venmo.com/code?user_id=2910252785729536546&amp;created=1792897467" target="_blank" rel="noopener">Venmo</a></div></div>
</div>
<p class="note">Stemify is free. A tip goes straight to the person you pick.</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");
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(() => { if (!document.hidden) Stems.refresh(); }, 5 * 60 * 1000);
})();