Files
stemify/web/app.js
T
cdnutterandClaude Opus 5.5 9299fee9a0 Web player served by stemserve
Plex browsing, stem mixer with presets, shuffle, repeat all/one, playback
speed, queue, waveform seek. stemserve serves web/ at / without the token.
stemserve image now builds from the repo root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:30:02 -07:00

853 lines
41 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. Browses Plex, plays stems from stemserve, no build step.
// Each stem is its own <audio> element so speed changes keep pitch; a tick keeps them lined up.
const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const shuffled = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const ICON = {
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',
next: '<svg viewBox="0 0 24 24"><rect x="17.5" y="5" width="2.5" height="14" rx="1"/><path d="M4 5.7v12.6a1 1 0 0 0 1.55.83L15 12.83a1 1 0 0 0 0-1.66l-9.45-6.3A1 1 0 0 0 4 5.7z"/></svg>',
shuffle: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4V8h-2.2c-1 0-1.9.5-2.5 1.3L7.6 16c-.9 1.3-2.4 2-4 2H2v-2h1.6c.9 0 1.8-.5 2.3-1.2l4.7-6.7C11.5 6.8 13 6 14.8 6H17V3zM2 6h1.6c1.6 0 3.1.8 4 2l.9 1.3-1.2 1.7-1.4-1.9A2.8 2.8 0 0 0 3.6 8H2V6zm12.3 9.7c.6.8 1.5 1.3 2.5 1.3H17v-3l4 4-4 4v-3h-.2c-1.7 0-3.3-.8-4.2-2.2l-.6-.8 1.2-1.7.1.4z"/></svg>',
repeat: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/></svg>',
repeat1: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/><path d="M11.2 9.6l1.8-.9h1.2v6.6h-1.6v-4.9l-1.4.6z"/></svg>',
mixer: '<svg viewBox="0 0 24 24"><path d="M6 3h2v6.2a3 3 0 0 1 0 5.6V21H6v-6.2a3 3 0 0 1 0-5.6V3zm10 0h2v2.2a3 3 0 0 1 0 5.6V21h-2V10.8a3 3 0 0 1 0-5.6V3zM7 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>',
queue: '<svg viewBox="0 0 24 24"><path d="M3 5h14v2H3zm0 6h14v2H3zm0 6h9v2H3zm13-1.5v-1.5l6 3.5-6 3.5V17z"/></svg>',
busy: '<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2.2A6.8 6.8 0 1 1 12 5.2V3z"/></svg>',
};
// ---- settings --------------------------------------------------------------------------
const KEY = "stemify-web";
const S = Object.assign({
plex: null, // {url, token, name}
section: null,
stemUrl: "", // empty = same origin as this page
stemToken: "",
fmt: "opus160",
volume: 0.85, speed: 1, shuffle: false, repeat: "off",
mix: {}, // stem name -> {level, muted, solo}; kept across tracks
albumSort: "titleSort",
}, load(KEY) || {});
if (!S.clientId) S.clientId = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2);
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
let saveTimer;
function save() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(KEY, JSON.stringify(S));
localStorage.setItem(KEY + "-queue", JSON.stringify({ items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }));
} catch {}
}, 300);
}
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
// ---- Plex ------------------------------------------------------------------------------
const plexHeaders = token => ({
Accept: "application/json",
"X-Plex-Product": "Stemify Web",
"X-Plex-Client-Identifier": S.clientId,
...(token ? { "X-Plex-Token": token } : {}),
});
async function px(path, params = {}) {
if (!S.plex) throw new Error("Not signed in to Plex");
const u = new URL(S.plex.url + path);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
if (!r.ok) throw new Error(`Plex ${r.status}`);
return (await r.json()).MediaContainer || {};
}
const plexImg = (thumb, size = 300) => thumb && S.plex
? `${S.plex.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${S.plex.token}`
: "";
function plexTrack(m) {
const part = m.Media?.[0]?.Part?.[0];
return {
id: "p" + m.ratingKey, title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
albumKey: m.parentRatingKey, thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
part: part?.key, file: part?.file, index: m.index,
};
}
async function plexSignIn(status) {
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: plexHeaders() })).json();
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
window.open(auth, "_blank", "width=800,height=740");
status("Waiting for Plex sign-in…");
let token = null;
for (let n = 0; n < 150 && !token; n++) {
await new Promise(r => setTimeout(r, 2000));
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: plexHeaders() })).json()).authToken;
}
if (!token) throw new Error("Sign-in timed out");
status("Finding your server…");
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: plexHeaders(token) })).json();
const https = location.protocol === "https:";
for (const srv of res.filter(r => r.provides?.includes("server"))) {
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
for (const c of conns) {
try {
const r = await fetch(`${c.uri}/identity`, { headers: plexHeaders(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
if (r.ok) return { url: c.uri, token: srv.accessToken || token, name: srv.name };
} catch {}
}
}
throw new Error("Signed in, but no Plex server answered from this browser");
}
async function musicSection() {
if (S.section) return S.section;
const secs = (await px("/library/sections")).Directory || [];
S.section = secs.find(d => d.type === "artist")?.key || null;
save();
if (!S.section) throw new Error("No music library on this Plex server");
return S.section;
}
// ---- stemserve -------------------------------------------------------------------------
const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
const stemQuery = extra => {
const q = new URLSearchParams(extra);
if (S.stemToken) q.set("token", S.stemToken);
const s = q.toString();
return s ? "?" + s : "";
};
const keyPath = k => k.split("/").map(encodeURIComponent).join("/");
const fmtParams = () => S.fmt.startsWith("opus") ? { fmt: "opus", br: S.fmt.slice(4) } : {};
// Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's
// suffix identifies a track. Short or duration-mismatched suffixes only count under a root
// learned from an earlier confident match.
const Stems = {
entries: [], bySource: new Map(), roots: [], etag: null, ok: false,
norm(p) {
p = p.normalize("NFC").replaceAll("\\", "/");
const slash = p.lastIndexOf("/"), dot = p.lastIndexOf(".");
if (dot > slash + 1) p = p.slice(0, dot);
return p.toLowerCase();
},
async refresh() {
try {
const r = await fetch(stemBase() + "/api/index" + stemQuery(), { headers: this.etag ? { "If-None-Match": this.etag } : {} });
this.ok = r.ok || r.status === 304;
if (r.status !== 200) return false;
this.etag = r.headers.get("ETag");
this.entries = (await r.json()).tracks || [];
this.bySource = new Map();
for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); }
return true;
} catch { this.ok = false; return false; }
},
find(t) {
if (t.stem) return t.stem;
if (!t.file || !this.entries.length) return null;
const path = this.norm(t.file);
for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; }
const parts = path.split("/");
for (let i = 1; i < parts.length; i++) {
const hit = this.bySource.get(parts.slice(i).join("/"));
if (!hit) continue;
const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000;
if (parts.length - i >= 2 && okDur) {
const root = parts.slice(0, i).join("/") + "/";
if (!this.roots.includes(root)) this.roots.push(root);
return hit;
}
}
return null;
},
url(e, s) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams())}`; },
prepare(e) {
if (e && S.fmt.startsWith("opus")) fetch(`${stemBase()}/api/prepare${stemQuery({ key: e.key, ...fmtParams() })}`, { method: "POST" }).catch(() => {});
},
};
const stemTrack = e => {
const parts = e.key.split("/");
return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e };
};
// ---- mix -------------------------------------------------------------------------------
const unity = () => ({ level: 1, muted: false, solo: false });
const PRESETS = [
["Full Mix", () => ({})],
["Karaoke", n => n.includes("vocals") && { vocals: { muted: true } }],
["A Cappella", n => n.includes("vocals") && { vocals: { solo: true } }],
["No Drums", n => n.includes("drums") && { drums: { muted: true } }],
["Rhythm", n => n.includes("drums") && n.includes("bass") && { drums: { solo: true }, bass: { solo: true } }],
["No Guitar", n => n.includes("guitar") && { guitar: { muted: true } }],
["No Bass", n => n.includes("bass") && { bass: { muted: true } }],
];
const setting = name => ({ ...unity(), ...S.mix[name] });
function stemGain(name, names) {
const s = setting(name), anySolo = names.some(n => setting(n).solo);
return anySolo ? (s.solo ? s.level : 0) : (s.muted ? 0 : s.level);
}
function applyPreset(build, names) {
const m = build(names);
if (!m) return;
S.mix = {};
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
mixChanged();
}
function currentPreset(names) {
const norm = m => JSON.stringify(names.map(n => { const s = { ...unity(), ...m[n] }; return [s.level, s.muted, s.solo]; }));
const now = norm(S.mix);
return PRESETS.find(([, b]) => { const m = b(names); return m && norm(Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { ...unity(), ...v }]))) === now; })?.[0];
}
// ---- audio -----------------------------------------------------------------------------
const E = {
els: [], names: [], gains: [], ctx: null, master: null, graph: false,
want: false, stalled: false, onEnded: null,
audioCtx() {
if (!this.ctx) {
const C = window.AudioContext || window.webkitAudioContext;
if (!C) return null;
this.ctx = new C();
this.master = this.ctx.createGain();
this.master.connect(this.ctx.destination);
this.master.gain.value = S.volume;
}
if (this.ctx.state === "suspended") this.ctx.resume();
return this.ctx;
},
load(sources, startSec, play) {
this.unload();
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
const sameOrigin = sources.every(s => new URL(s.url, location.href).origin === location.origin);
this.graph = sameOrigin && !!this.audioCtx();
for (const s of sources) {
const el = new Audio();
el.preload = "auto";
el.preservesPitch = true;
el.src = s.url;
el.playbackRate = el.defaultPlaybackRate = S.speed;
if (startSec) el.currentTime = startSec;
let gain = null;
if (this.graph) {
gain = this.ctx.createGain();
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
}
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
}
const lead = this.els[0];
lead.addEventListener("ended", () => this.onEnded?.());
lead.addEventListener("durationchange", () => UI.progress());
lead.addEventListener("error", () => { if (this.want) toast("Couldn't load this track"); });
this.applyGains();
this.want = play;
this.stalled = play;
},
unload() {
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
this.els = []; this.names = []; this.gains = [];
},
applyGains() {
const named = this.names.filter(Boolean);
this.els.forEach((el, i) => {
const g = this.names[i] ? stemGain(this.names[i], named) : 1;
if (this.gains[i]) this.gains[i].gain.value = g;
else el.volume = Math.min(1, g * (this.graph ? 1 : S.volume));
});
if (this.master) this.master.gain.value = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); },
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); },
seek(sec) { this.els.forEach(e => { e.currentTime = sec; }); if (this.want) this.stalled = true; UI.progress(); },
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
position() { return this.els[0]?.currentTime || 0; },
duration() { const d = this.els[0]?.duration; return isFinite(d) ? d : (Q.cur()?.duration || 0) / 1000; },
get playing() { return this.want; },
get buffering() { return this.want && this.stalled; },
// Holds everything until every stem can play, then starts them together and keeps followers
// within ~15 ms of the lead by nudging their rate (pitch is preserved, so it's inaudible).
tick() {
if (!this.els.length || !this.want) return;
const lead = this.els[0];
const notReady = this.els.some(e => e.readyState < 3 && !e.ended);
if (notReady) {
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; UI.transport(); }
return;
}
if (this.stalled) {
this.stalled = false;
const t = lead.currentTime;
this.els.forEach(e => { if (e !== lead && Math.abs(e.currentTime - t) > 0.01) e.currentTime = t; e.playbackRate = S.speed; });
Promise.all(this.els.map(e => e.play())).catch(err => {
if (err.name === "NotAllowedError") { this.pause(); UI.transport(); }
});
UI.transport();
return;
}
for (const f of this.els.slice(1)) {
if (f.ended || lead.ended) continue;
const d = f.currentTime - lead.currentTime;
if (Math.abs(d) > 0.25) f.currentTime = lead.currentTime;
else if (Math.abs(d) > 0.015) f.playbackRate = S.speed * (1 - Math.max(-0.06, Math.min(0.06, d * 1.5)));
else if (f.playbackRate !== S.speed) f.playbackRate = S.speed;
}
},
};
setInterval(() => E.tick(), 150);
// ---- queue -----------------------------------------------------------------------------
let uidSeq = Date.now();
const wrap = t => ({ uid: uidSeq++, t });
const Q = {
items: [], orig: null, i: -1,
cur() { return this.items[this.i]?.t; },
playList(tracks, start = 0, shuffle = S.shuffle) {
if (!tracks.length) return;
const all = tracks.map(wrap);
if (shuffle) {
const first = all[start] && start >= 0 ? all[start] : all[Math.floor(Math.random() * all.length)];
this.orig = all;
this.items = [first, ...shuffled(all.filter(x => x !== first))];
this.i = 0;
} else { this.orig = null; this.items = all; this.i = start; }
S.shuffle = shuffle;
this.start(0, true);
},
add(tracks, next = false) {
const w = tracks.map(wrap);
if (next) { this.items.splice(this.i + 1, 0, ...w); this.orig?.push(...w); }
else { this.items.push(...w); this.orig?.push(...w); }
if (this.i < 0) { this.i = 0; this.start(0, false); }
UI.drawer(); save();
toast(tracks.length === 1 ? `Added “${tracks[0].title}”` : `Added ${tracks.length} tracks`);
},
remove(idx) {
if (idx === this.i) return;
const [x] = this.items.splice(idx, 1);
if (this.orig) this.orig = this.orig.filter(o => o !== x);
if (idx < this.i) this.i--;
UI.drawer(); save();
},
jump(idx) { this.i = idx; this.start(0, true); },
start(posSec, play) {
const t = this.cur();
if (!t) { E.unload(); UI.all(); return; }
const e = Stems.find(t);
const srcs = e
? e.stems.map(s => ({ name: s.name, url: Stems.url(e, s) }))
: [{ name: null, url: `${S.plex?.url}${t.part}?X-Plex-Token=${S.plex?.token}` }];
E.load(srcs, posSec, play);
Wave.load(e);
Stems.prepare(e);
const nt = this.items[this.i + 1]?.t;
if (nt) Stems.prepare(Stems.find(nt));
UI.all(); save();
},
next(auto = false) {
if (auto && S.repeat === "one") { E.seek(0); E.play(); return; }
if (this.i + 1 < this.items.length) { this.i++; this.start(0, true); }
else if (S.repeat !== "off" && this.items.length) { this.i = 0; this.start(0, true); }
else if (auto) { E.pause(); E.seek(0); UI.transport(); }
},
prev() {
if (E.position() > 3 || (this.i === 0 && S.repeat === "off")) { E.seek(0); return; }
this.i = this.i > 0 ? this.i - 1 : this.items.length - 1;
this.start(0, E.playing);
},
toggleShuffle() {
if (!S.shuffle) {
this.orig = this.items.slice();
this.items = [...this.items.slice(0, this.i + 1), ...shuffled(this.items.slice(this.i + 1))];
} else if (this.orig) {
const cur = this.items[this.i];
this.items = this.orig;
this.i = Math.max(0, this.items.indexOf(cur));
this.orig = null;
}
S.shuffle = !S.shuffle;
UI.transport(); UI.drawer(); save();
},
cycleRepeat() {
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
UI.transport(); save();
},
};
E.onEnded = () => Q.next(true);
function setSpeed(v) {
S.speed = v;
E.setSpeed(v);
UI.transport(); save();
}
function mixChanged() { E.applyGains(); Wave.draw(); UI.drawer(); save(); }
// ---- waveform --------------------------------------------------------------------------
const Wave = {
peaks: null, key: null, canvas: $("#wave"),
async load(e) {
this.peaks = null; this.key = e?.key || null;
this.draw();
if (!e) return;
try {
const r = await fetch(`${stemBase()}/api/peaks${stemQuery({ key: e.key, n: 300 })}`);
if (!r.ok || this.key !== e.key) return;
this.peaks = (await r.json()).stems;
this.draw();
} catch {}
},
draw() {
const c = this.canvas, dpr = devicePixelRatio || 1;
const 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;
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent");
if (!this.peaks) {
x.fillStyle = "#ffffff22"; x.fillRect(0, h / 2 - 2, w, 4);
x.fillStyle = 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 cx = b * bw;
x.fillStyle = "#ffffff12";
const gh = Math.max(2, ghost * h);
x.fillRect(cx, (h - gh) / 2, bw - 1, gh);
x.fillStyle = b / bars < frac ? accent : "#ffffff40";
const bh = Math.max(2, v * h);
x.fillRect(cx, (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; save(); });
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
}
// ---- player UI -------------------------------------------------------------------------
let drawerMode = null; // "mixer" | "queue"
const UI = {
all() { this.track(); this.transport(); this.progress(); this.drawer(); markPlaying(); },
track() {
const t = Q.cur();
$("#player").classList.toggle("empty", !t);
$("#np-title").textContent = t?.title || "Nothing playing";
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
const art = t ? plexImg(t.thumb, 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: t.thumb ? [{ src: plexImg(t.thumb, 512), sizes: "512x512" }] : [],
}) : null;
}
},
transport() {
const p = $("#b-play");
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
p.classList.toggle("busy", E.buffering);
$("#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);
$("#b-mixer").classList.toggle("on", drawerMode === "mixer");
$("#b-queue").classList.toggle("on", drawerMode === "queue");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
},
progress() {
$("#t-pos").textContent = fmtTime(E.position());
$("#t-dur").textContent = fmtTime(E.duration());
Wave.draw();
const d = E.duration();
if (d && navigator.mediaSession?.setPositionState) {
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
}
},
drawer() {
const d = $("#drawer");
d.hidden = !drawerMode;
if (drawerMode === "mixer") d.innerHTML = mixerHtml();
else if (drawerMode === "queue") d.innerHTML = queueHtml();
this.transport();
},
};
function mixerHtml() {
const names = E.names.filter(Boolean);
if (!names.length) return `<h2>Mixer</h2><p class="note">${Q.cur() ? "This track has no stems yet." : "Play something with stems to mix it."}</p>`;
const cur = currentPreset(names);
return `<h2>Mixer</h2>
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
${names.map(n => {
const s = setting(n), g = stemGain(n, names);
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
}).join("")}
<p class="note">The mix carries over to the next track.</p>`;
}
function queueHtml() {
if (!Q.items.length) return `<h2>Queue</h2><p class="note">Empty.</p>`;
return `<h2>Queue</h2>${Q.items.map((x, i) => `
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}">
<img loading="lazy" ${x.t.thumb ? `src="${esc(plexImg(x.t.thumb, 80))}"` : ""} alt="">
<div class="tx"><div>${esc(x.t.title)}</div><div>${esc(x.t.artist)}</div></div>
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
</div>`).join("")}`;
}
$("#drawer").addEventListener("click", ev => {
const b = ev.target.closest("button, .q-item");
if (!b) return;
const names = E.names.filter(Boolean);
if (b.dataset.preset) applyPreset(PRESETS[+b.dataset.preset][1], names);
else if (b.dataset.mute) { const n = b.dataset.mute; S.mix[n] = { ...setting(n), muted: !setting(n).muted }; mixChanged(); }
else if (b.dataset.solo) { const n = b.dataset.solo; S.mix[n] = { ...setting(n), solo: !setting(n).solo }; mixChanged(); }
else if (b.dataset.qx) Q.remove(+b.dataset.qx);
else if (b.dataset.q) Q.jump(+b.dataset.q);
});
$("#drawer").addEventListener("input", ev => {
const n = ev.target.dataset.level;
if (!n) return;
S.mix[n] = { ...setting(n), level: +ev.target.value };
E.applyGains(); Wave.draw(); save();
});
$("#drawer").addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); });
$("#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-queue").innerHTML = ICON.queue;
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
$("#volume").value = S.volume;
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); UI.transport(); save(); };
const toggleDrawer = m => { drawerMode = drawerMode === m ? null : m; UI.drawer(); };
$("#b-play").onclick = togglePlay;
$("#b-prev").onclick = () => Q.prev();
$("#b-next").onclick = () => Q.next();
$("#b-shuffle").onclick = () => Q.toggleShuffle();
$("#b-repeat").onclick = () => Q.cycleRepeat();
$("#b-mixer").onclick = () => toggleDrawer("mixer");
$("#b-queue").onclick = () => toggleDrawer("queue");
$("#speed").onchange = ev => setSpeed(+ev.target.value);
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler("play", () => { E.play(); UI.transport(); });
ms.setActionHandler("pause", () => { E.pause(); UI.transport(); });
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.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 === "q") toggleDrawer("queue");
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
});
(function frame() {
if (E.playing) {
$("#t-pos").textContent = fmtTime(E.position());
Wave.draw();
}
requestAnimationFrame(frame);
})();
setInterval(() => { if (E.playing) { UI.progress(); save(); } }, 5000);
function toast(msg) {
let t = $("#toast");
if (!t) {
t = document.createElement("div");
t.id = "toast";
t.style.cssText = "position:fixed;left:50%;bottom:calc(var(--player-h) + 16px);transform:translateX(-50%);background:var(--raised);padding:9px 16px;border-radius:999px;box-shadow:0 8px 30px #0008;z-index:9;transition:opacity .3s";
document.body.append(t);
}
t.textContent = msg;
t.style.opacity = 1;
clearTimeout(t._h);
t._h = setTimeout(() => { t.style.opacity = 0; }, 2200);
}
// ---- views -----------------------------------------------------------------------------
const view = $("#view");
let lists = {}; // track lists rendered in the current view, by id
let routeSeq = 0;
function markPlaying() {
const id = Q.cur()?.id;
view.querySelectorAll("tr[data-id]").forEach(r => r.classList.toggle("playing", r.dataset.id === id));
}
function card(href, img, t, s, round = false) {
return `<a class="card ${round ? "round" : ""}" href="${href}">${img ? `<img loading="lazy" src="${esc(img)}" alt="">` : `<div class="ph"></div>`}
<div class="t">${esc(t)}</div><div class="s">${esc(s || "")}</div></a>`;
}
function trackTable(listId, tracks, { numbers = false, album = true } = {}) {
lists[listId] = tracks;
return `<table class="tracks"><tbody>${tracks.map((t, i) => `
<tr data-list="${listId}" data-i="${i}" data-id="${esc(t.id)}">
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
<td class="ti">${esc(t.title)}${Stems.find(t) ? '<span class="stem-dot">STEMS</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
<td class="d">${fmtTime(t.duration / 1000)}</td>
<td><button class="add" data-add="${i}" title="Add to queue (shift: play next)">+</button></td>
</tr>`).join("")}</tbody></table>`;
}
view.addEventListener("click", ev => {
const add = ev.target.closest("[data-add]");
const row = ev.target.closest("tr[data-list]");
if (add && row) { Q.add([lists[row.dataset.list][+add.dataset.add]], ev.shiftKey); return; }
if (row) { Q.playList(lists[row.dataset.list], +row.dataset.i, false); return; }
const act = ev.target.closest("[data-play]");
if (act) {
const tracks = lists[act.dataset.play];
if (act.dataset.mode === "queue") Q.add(tracks);
else Q.playList(tracks, act.dataset.mode === "shuffle" ? -1 : 0, act.dataset.mode === "shuffle");
}
});
const playButtons = 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="queue">${ICON.queue} Add to queue</button></div>`;
function needPlex() {
view.innerHTML = `<h1>Welcome</h1><p class="sub">Sign in to Plex in <a href="#/settings" style="color:var(--accent)">Settings</a> to browse your library, or open <a href="#/stems" style="color:var(--accent)">Stems only</a>.</p>`;
}
// Grid of Plex items, loaded 120 at a time as you scroll.
async function pagedGrid(seq, path, params, cardFn) {
const grid = $(".grid", view), more = document.createElement("div");
more.className = "more";
grid.after(more);
let start = 0, total = Infinity, busy = false;
const next = async () => {
if (busy || start >= total || seq !== routeSeq) return;
busy = true;
const mc = await px(path, { ...params, "X-Plex-Container-Start": start, "X-Plex-Container-Size": 120 });
if (seq !== routeSeq) return;
total = mc.totalSize ?? mc.size ?? 0;
const items = mc.Metadata || [];
start += items.length;
if (!items.length) total = start;
grid.insertAdjacentHTML("beforeend", items.map(cardFn).join(""));
if (!start) grid.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
busy = false;
if (more.getBoundingClientRect().top < innerHeight * 1.5) next();
};
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
await next();
}
const albumCard = m => card(`#/album/${m.ratingKey}`, plexImg(m.thumb), m.title, [m.parentTitle, m.year].filter(Boolean).join(" · "));
const routes = {
async albums(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Albums</h1>
<select id="sort">${[["titleSort", "Title"], ["addedAt:desc", "Recently added"], ["year:desc", "Year"], ["artist.titleSort", "Artist"]]
.map(([v, l]) => `<option value="${v}" ${S.albumSort === v ? "selected" : ""}>${l}</option>`).join("")}</select></div><div class="grid"></div>`;
$("#sort").onchange = ev => { S.albumSort = ev.target.value; save(); route(); };
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 9, sort: S.albumSort }, albumCard);
},
async artists(seq) {
if (!S.plex) return needPlex();
const sec = await musicSection();
view.innerHTML = `<div class="bar"><h1>Artists</h1></div><div class="grid"></div>`;
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 8, sort: "titleSort" },
m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true));
},
async playlists() {
if (!S.plex) return needPlex();
const pls = (await px("/playlists", { playlistType: "audio" })).Metadata || [];
view.innerHTML = `<div class="bar"><h1>Playlists</h1></div>` + (pls.length
? `<div class="grid">${pls.map(p => card(`#/playlist/${p.ratingKey}`, plexImg(p.composite || p.thumb), p.title, `${p.leafCount} tracks`)).join("")}</div>`
: `<p class="empty-msg">No music playlists.</p>`);
},
async album(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {}, tracks = (kids.Metadata || []).map(plexTrack);
const stems = tracks.filter(t => Stems.find(t)).length;
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Album</div><h1>${esc(a.title)}</h1>
<div class="sub"><a href="#/artist/${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${tracks.length} tracks${stems ? ` · ${stems} with stems` : ""}</div>
${playButtons("album")}</div></div>${trackTable("album", tracks, { numbers: true, album: false })}`;
},
async artist(seq, key) {
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
const a = info.Metadata?.[0] || {};
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="" style="border-radius:50%">
<div class="meta"><div class="kind">Artist</div><h1>${esc(a.title)}</h1>${playButtons("artist")}</div></div>
<h2>Albums</h2><div class="grid">${(kids.Metadata || []).map(albumCard).join("")}</div>`;
lists.artist = [];
const all = await px(`/library/metadata/${key}/allLeaves`);
if (seq === routeSeq) lists.artist = (all.Metadata || []).map(plexTrack);
},
async playlist(seq, key) {
const [info, items] = await Promise.all([px(`/playlists/${key}`), px(`/playlists/${key}/items`)]);
const p = info.Metadata?.[0] || {}, tracks = (items.Metadata || []).map(plexTrack);
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(p.composite || p.thumb, 420))}" alt="">
<div class="meta"><div class="kind">Playlist</div><h1>${esc(p.title)}</h1><div class="sub">${tracks.length} tracks</div>${playButtons("playlist")}</div></div>
${trackTable("playlist", tracks)}`;
},
async search(seq, q) {
if (!S.plex) return needPlex();
const hubs = (await px("/hubs/search", { query: q, limit: 12, sectionId: await musicSection() })).Hub || [];
const hub = t => hubs.find(h => h.type === t)?.Metadata || [];
const artists = hub("artist"), albums = hub("album"), tracks = hub("track").map(plexTrack);
view.innerHTML = `<div class="bar"><h1>“${esc(q)}”</h1></div>` +
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true)).join("")}</div>` : "") +
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
(tracks.length ? `<h2>Songs</h2>${trackTable("search", tracks)}` : "") +
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
},
async stems(seq) {
if (!Stems.entries.length) await Stems.refresh();
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
view.innerHTML = `<div class="bar"><h1>Stems only</h1><input type="text" id="sf" placeholder="Filter ${all.length} tracks"></div><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>` : "");
markPlaying();
};
$("#sf").oninput = render;
render();
},
async settings() {
const info = await fetch(stemBase() + "/api/info" + stemQuery()).then(r => r.ok ? r.json() : null).catch(() => null);
let secs = [];
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form">
<p class="note">${S.plex ? `Connected to <b>${esc(S.plex.name || S.plex.url)}</b> at ${esc(S.plex.url)}` : "Not signed in."}</p>
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400" value="${esc(S.plex?.url || "")}"></label>
<label>Token<input type="password" id="ptok" value="${esc(S.plex?.token || "")}"></label>
<div class="row"><button class="btn" id="psave">Save</button></div></div></details>
</div>
<h2>Stem server</h2><div class="form">
<p class="note">${info ? `stemserve v${info.version}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : "Can't reach 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>
<label>Stream format<select id="fmt">${[["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]
.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
<div class="row"><button class="btn" id="ssave">Save</button></div>
</div>
<h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · Q queue</p>`;
$("#signin").onclick = async () => {
const st = $("#sstatus");
try { S.plex = await plexSignIn(m => { st.textContent = m; }); S.section = null; save(); route(); }
catch (e) { st.textContent = e.message; }
};
if ($("#signout")) $("#signout").onclick = () => { S.plex = null; S.section = null; save(); route(); };
if ($("#sec")) $("#sec").onchange = ev => { S.section = ev.target.value; save(); };
$("#psave").onclick = () => {
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
S.plex = url && token ? { url, token, name: "" } : null; S.section = null; save(); route();
};
$("#ssave").onclick = async () => {
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); S.fmt = $("#fmt").value;
Stems.etag = null; save(); await Stems.refresh(); route();
};
},
};
async function route() {
const seq = ++routeSeq;
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
const arg = decodeURIComponent(rest.join("/"));
document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name));
lists = {};
view.scrollTop = 0;
try { await (routes[name] || routes.albums)(seq, arg); }
catch (e) { if (seq === routeSeq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
if (seq === routeSeq) markPlaying();
}
addEventListener("hashchange", route);
$("#search").onsubmit = ev => {
ev.preventDefault();
const q = ev.target.q.value.trim();
if (q) location.hash = "#/search/" + encodeURIComponent(q);
};
// ---- start -----------------------------------------------------------------------------
(async () => {
await Promise.race([Stems.refresh(), new Promise(r => setTimeout(r, 4000))]);
const saved = load(KEY + "-queue");
if (saved?.items?.length) {
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
uidSeq = Math.max(uidSeq, ...saved.items.map(x => x.uid + 1));
if (Q.orig) Q.orig = Q.orig.map(o => Q.items.find(x => x.uid === o.uid) || o);
Q.start(saved.pos || 0, false);
}
UI.all();
route();
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
})();