Web player overhaul; iOS random sort, stems-only filter, pooled libraries

Web: live Stem Now status that tracks every queued song (albums too),
right-click menus with Stem Now, Songs and Genres pages, a Stems Only
filter in place of the sidebar section, Random sort, several pooled
Plex servers and libraries, themes plus custom CSS, a reorganised
settings page that loads without a refresh, saved mixes, karaoke and
synced lyrics, stem playlists and offline downloads.

iOS: Random in every sort menu, a Stems Only toggle (sort menu and
Settings), and several music libraries pooled from the signed-in server.
Settings now persists karaoke and speed across launches.

stemd writes a status file that stemserve serves at /api/queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-20 18:29:45 -07:00
1 parent 48faa6682f
commit 7cdfbef160
16 files changed
+2006 -730

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
// Playback: the mix, karaoke, the stem engine and the queue.
// Stems are downloaded whole as Blobs and played from blob: URLs. Six streaming <audio>
// elements used to hold all six of Chrome's connections to stemserve, so nothing else on the
// page (settings, the Plex proxy) could load until they finished.
import { S, save, songMixes, Offline } from "./store.js";
import { Stems, api } from "./stems.js";
import { partUrl } from "./plex.js";
import { emit, load, store, shuffled, toast, clone } from "./util.js";
// ---- mix -------------------------------------------------------------------------------------
export const unity = () => ({ level: 1, muted: false, solo: false });
export 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 } }],
];
export const setting = name => ({ ...unity(), ...S.mix[name] });
export function stemGain(name, names, mix = S.mix) {
const s = n => ({ ...unity(), ...mix[n] });
const me = s(name), anySolo = names.some(n => s(n).solo);
return anySolo ? (me.solo ? me.level : 0) : (me.muted ? 0 : me.level);
}
const normMix = (m, names) => JSON.stringify(names.map(n => { const s = { ...unity(), ...m?.[n] }; return [+s.level.toFixed(3), s.muted, s.solo]; }));
export const sameMix = (a, b, names) => normMix(a, names) === normMix(b, names);
export 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();
}
export const currentPreset = names => PRESETS.find(([, b]) => { const m = b(names); return m && sameMix(m, S.mix, names); })?.[0];
export function mixChanged() { E.applyGains(); emit("mix"); save(); }
// Karaoke: vocals on, soft (default -12 dB) or off. The iOS app does the same.
export function karaokeMix() {
if (S.karaokeMode === "on") return {};
if (S.karaokeMode === "soft") return { vocals: { ...unity(), level: Math.pow(10, S.softDb / 20) } };
return { vocals: { ...unity(), muted: true } };
}
export function setKaraoke(on) {
const names = E.names.filter(Boolean);
if (on && !S.karaoke) { S.preKaraokeMix = clone(S.mix); S.mix = karaokeMix(); }
else if (!on && S.karaoke) {
if (sameMix(S.mix, karaokeMix(), names.length ? names : ["vocals"])) {
const key = E.entry?.key;
S.mix = clone((key && songMixes[key]) || S.preKaraokeMix || {});
}
delete S.preKaraokeMix;
}
S.karaoke = on;
mixChanged();
}
export function setKaraokeMode(mode, softDb) {
const wasKaraoke = S.karaoke && sameMix(S.mix, karaokeMix(), ["vocals"]);
S.karaokeMode = mode;
if (softDb != null) S.softDb = softDb;
if (wasKaraoke) S.mix = karaokeMix();
mixChanged();
}
// The mix a track starts with: karaoke, the queue item's own mix (stem playlists), the song's
// saved mix, or whatever was playing before.
function startMix(item, entry) {
if (S.karaoke) return karaokeMix();
return clone(item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {}));
}
// ---- fetching stems ---------------------------------------------------------------------------
// Downloads every stem of `entry` as a Blob, `conc` at a time, reporting the overall fraction.
export async function fetchStems(entry, fmt, { conc = 4, signal, onProgress } = {}) {
const got = new Array(entry.stems.length).fill(0), sizes = new Array(entry.stems.length).fill(0);
const report = () => {
const total = sizes.reduce((a, b) => a + b, 0);
onProgress?.(total ? got.reduce((a, b) => a + b, 0) / total : 0);
};
const one = async i => {
const r = await fetch(Stems.url(entry, entry.stems[i], fmt), { signal });
if (!r.ok) throw new Error(`stem ${r.status}`);
sizes[i] = +r.headers.get("Content-Length") || entry.stems[i].size || 0;
const reader = r.body.getReader(), chunks = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); got[i] += value.length; report();
}
return new Blob(chunks, { type: r.headers.get("Content-Type") || "audio/flac" });
};
Stems.prepare(entry, fmt);
const out = {}, todo = entry.stems.map((_, i) => i);
await Promise.all(Array.from({ length: Math.min(conc, todo.length) }, async () => {
while (todo.length) { const i = todo.shift(); out[entry.stems[i].name] = await one(i); }
}));
return out;
}
// One prefetched track, so the next song starts instantly. Kept to Opus (FLAC is too big).
const Pre = {
key: null, promise: null, ctl: null,
get(entry) { return this.key === `${entry.key}|${S.fmt}` ? this.promise : null; },
start(entry) {
const k = `${entry.key}|${S.fmt}`;
if (this.key === k) return;
this.ctl?.abort();
this.key = k; this.ctl = new AbortController();
this.promise = fetchStems(entry, S.fmt, { conc: 2, signal: this.ctl.signal });
this.promise.catch(() => { if (this.key === k) this.key = null; });
},
clear() { this.ctl?.abort(); this.key = this.promise = null; },
};
// ---- engine -----------------------------------------------------------------------------------
export const E = {
els: [], names: [], gains: [], urls: [], ctx: null, master: null,
want: false, stalled: false, loading: false, loadFrac: 0, pendingPos: 0,
entry: null, offline: null, ctl: null, gen: 0,
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;
},
// Loads a track: its stems (offline copy, prefetched, or downloaded now) or the Plex file.
async load(t, entry, startSec, play) {
this.unload();
const gen = ++this.gen;
this.want = play; this.stalled = play; this.pendingPos = startSec || 0;
this.entry = entry; this.offline = null;
const rec = await Offline.get(t.id).catch(() => null);
if (gen !== this.gen) return;
let blobs = null;
if (rec) { this.offline = rec; this.entry = entry = rec.entry; blobs = rec.blobs; }
else if (entry) {
this.loading = true; this.loadFrac = 0; emit("loading");
this.ctl = new AbortController();
try {
blobs = await (Pre.get(entry) || fetchStems(entry, S.fmt, {
signal: this.ctl.signal, onProgress: f => { if (gen === this.gen) { this.loadFrac = f; emit("loading"); } },
}));
} catch (e) {
if (gen !== this.gen) return;
this.loading = false; emit("loading");
if (e.name !== "AbortError") toast("Couldn't load the stems: " + e.message);
return;
}
if (gen !== this.gen) return;
this.loading = false; emit("loading");
}
let sources;
if (blobs?.file) sources = [{ name: null, url: URL.createObjectURL(blobs.file), blob: true }];
else if (blobs) {
const order = entry.stems.map(s => s.name);
sources = Object.entries(blobs).sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
.map(([name, b]) => ({ name, url: URL.createObjectURL(b), blob: true }));
}
else {
const url = partUrl(t);
if (!url) { toast("Can't play this: its Plex server isn't set up here"); this.want = false; emit("transport"); return; }
sources = [{ name: null, url }];
}
this.attach(sources);
},
attach(sources) {
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
const graph = sources.every(s => s.blob || new URL(s.url, location.href).origin === location.origin) && !!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 (this.pendingPos) el.currentTime = this.pendingPos;
let gain = null;
if (graph) {
gain = this.ctx.createGain();
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
}
if (s.blob) this.urls.push(s.url);
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
}
this.graph = graph;
const lead = this.els[0];
lead.addEventListener("ended", () => this.onEnded?.());
lead.addEventListener("durationchange", () => emit("progress"));
lead.addEventListener("error", () => { if (this.want) toast("Couldn't play this track"); });
this.applyGains();
emit("attached");
},
unload() {
this.gen++;
this.ctl?.abort(); this.ctl = null;
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
for (const u of this.urls) URL.revokeObjectURL(u);
this.els = []; this.names = []; this.gains = []; this.urls = [];
this.loading = false;
},
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 * S.volume);
});
if (this.master) this.master.gain.value = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); },
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); emit("transport"); },
seek(sec) {
this.pendingPos = sec;
this.els.forEach(e => { e.currentTime = sec; });
if (this.want) this.stalled = true;
emit("progress");
},
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
position() { return this.els[0] ? this.els[0].currentTime : this.pendingPos; },
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 || this.loading); },
get ready() { return this.els.length > 0 && !this.loading; },
// 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];
if (this.els.some(e => e.readyState < 3 && !e.ended)) {
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; emit("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(); });
emit("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);
export function setSpeed(v) { S.speed = v; E.setSpeed(v); emit("transport"); save(); }
// ---- queue ------------------------------------------------------------------------------------
const QKEY = "stemify-web-queue";
let uidSeq = Date.now();
const wrap = (t, mix = null) => ({ uid: uidSeq++, t, mix });
let qTimer;
export function saveQueue() {
clearTimeout(qTimer);
qTimer = setTimeout(() => store(QKEY, { items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }), 300);
}
export const Q = {
items: [], orig: null, i: -1,
cur() { return this.items[this.i]?.t; },
curItem() { return this.items[this.i]; },
// tracks: plain tracks, or {t, mix} items from a stem playlist.
playList(tracks, start = 0, shuffle = S.shuffle) {
if (!tracks.length) return;
const all = tracks.map(x => x.t ? wrap(x.t, x.mix) : wrap(x));
if (shuffle) {
const first = start >= 0 && all[start] ? 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(x => x.t ? wrap(x.t, x.mix) : wrap(x));
if (next) this.items.splice(this.i + 1, 0, ...w); else this.items.push(...w);
this.orig?.push(...w);
if (this.i < 0) { this.i = 0; this.start(0, false); }
emit("queue"); saveQueue(); this.prepareNext();
toast(tracks.length === 1 ? `${next ? "Playing next" : "Added"}: “${(tracks[0].t || tracks[0]).title}”` : `${next ? "Playing next" : "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--;
emit("queue"); saveQueue();
},
move(from, to) {
if (from === to) return;
const cur = this.items[this.i];
const [x] = this.items.splice(from, 1);
this.items.splice(to, 0, x);
this.i = this.items.indexOf(cur);
emit("queue"); saveQueue();
},
clear() {
this.items = this.items.slice(this.i, this.i + 1); this.orig = null; this.i = this.items.length ? 0 : -1;
emit("queue"); saveQueue();
},
jump(idx) { this.i = idx; this.start(0, true); },
start(posSec, play) {
const it = this.curItem();
if (!it) { E.unload(); emit("track"); return; }
const e = Stems.find(it.t);
if (e) S.mix = startMix(it, e);
E.load(it.t, e, posSec, play).then(() => this.prepareNext());
emit("track"); saveQueue(); save();
},
// Reloads the current track in place, e.g. when it just got stems.
reload() { if (this.cur()) this.start(E.position(), E.playing); },
// Fetch the next track only once this one is playing and fully loaded, so they don't compete.
prepareNext() {
clearInterval(this.nextTimer);
const nt = this.items[this.i + 1]?.t;
if (!nt) return;
const ready = () => E.playing && !E.buffering && E.ready
&& E.els.every(el => el.duration > 0 && el.buffered.length && el.buffered.end(el.buffered.length - 1) >= el.duration - 0.5);
this.nextTimer = setInterval(() => {
if (!ready()) return;
clearInterval(this.nextTimer);
const e = Stems.find(nt);
if (!e || Offline.has(nt.id)) return;
if (S.fmt.startsWith("opus")) Pre.start(e); else Stems.prepare(e);
}, 1000);
},
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); }
},
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;
Pre.clear(); this.prepareNext();
emit("transport"); emit("queue"); saveQueue(); save();
},
cycleRepeat() {
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
emit("transport"); save();
},
restore() {
const saved = load(QKEY);
if (!saved?.items?.length) return;
// Queues saved before multiple servers have ids like "p123" and no server.
const fix = x => {
if (x.t.id?.startsWith("p") && !x.t.srv && S.servers[0]) {
x.t.srv = S.servers[0].id; x.t.key = x.t.id.slice(1); x.t.id = `p${x.t.srv}:${x.t.key}`;
}
return x;
};
this.items = saved.items.map(fix);
this.i = Math.min(Math.max(0, saved.i), this.items.length - 1);
uidSeq = Math.max(uidSeq, ...this.items.map(x => x.uid + 1));
this.orig = saved.orig ? saved.orig.map(o => this.items.find(x => x.uid === o.uid) || fix(o)) : null;
this.start(saved.pos || 0, false);
},
};
E.onEnded = () => Q.next(true);
// ---- offline ----------------------------------------------------------------------------------
export async function saveOffline(tracks, onStatus) {
let n = 0;
for (const t of tracks) {
if (Offline.has(t.id)) continue;
onStatus?.(`Downloading ${++n} of ${tracks.length}: ${t.title}`);
try {
const entry = Stems.find(t);
let blobs, peaks = null;
if (entry) {
blobs = await fetchStems(entry, S.fmt, { conc: 2 });
peaks = await fetch(api("/api/peaks", { key: entry.key, n: 300 })).then(r => r.ok ? r.json() : null).catch(() => null);
} else {
const url = partUrl(t);
if (!url) continue;
const r = await fetch(url);
if (!r.ok) throw new Error(`Plex ${r.status}`);
blobs = { file: await r.blob() };
}
const size = Object.values(blobs).reduce((a, b) => a + b.size, 0);
await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, saved: Date.now() });
} catch (e) { toast(`Couldn't download “${t.title}”: ${e.message}`); }
}
emit("offline");
onStatus?.("");
if (n) toast(n === 1 ? "Downloaded for offline" : `Downloaded ${n} tracks for offline`);
}