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

+715 -679
View File
File diff suppressed because it is too large. Load diff
+9 -3
View File
@@ -6,16 +6,21 @@
<meta name="theme-color" content="#0d0e12">
<title>Stemify</title>
<link rel="stylesheet" href="style.css">
<style id="theme"></style>
<style id="custom-css"></style>
</head>
<body>
<div id="app">
<aside id="nav">
<div class="brand">stemify</div>
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
<a href="#/albums" data-nav="albums">Albums</a>
<a href="#/artists" data-nav="artists">Artists</a>
<a href="#/songs" data-nav="songs">Songs</a>
<a href="#/genres" data-nav="genres">Genres</a>
<a href="#/playlists" data-nav="playlists">Playlists</a>
<a href="#/stems" data-nav="stems">Stems only</a>
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
<a href="#/downloads" data-nav="downloads">Downloads</a>
<div id="live" hidden></div>
<a href="#/settings" data-nav="settings" class="settings-link">Settings</a>
</aside>
<main id="view"></main>
@@ -41,8 +46,9 @@
</div>
</div>
<div class="right">
<select id="speed" title="Speed"></select>
<select id="speed" title="Speed ([ and ])"></select>
<button id="b-mixer" class="pill" title="Mixer (M)"></button>
<button id="b-lyrics" class="pill" title="Lyrics (L)"></button>
<button id="b-queue" class="pill" title="Queue (Q)"></button>
<input id="volume" type="range" min="0" max="1" step="0.01" title="Volume">
</div>
+46
View File
@@ -0,0 +1,46 @@
// Synced lyrics from LRCLIB, same lookup as the iOS app: exact match first, then a search.
const cache = new Map();
function parse(lrc) {
const out = [];
for (const line of (lrc || "").split("\n")) {
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
if (!stamps.length) continue;
const text = line.replace(/\[[^\]]*\]/g, "").trim();
for (const m of stamps) out.push({ t: +m[1] * 60 + +m[2], text });
}
return out.sort((a, b) => a.t - b.t);
}
// -> {lines: [{t, text}] | null, plain: string | null, instrumental: bool} or null if not found.
export async function lyricsFor(t) {
const id = t.id;
if (cache.has(id)) return cache.get(id);
const p = (async () => {
const q = { track_name: t.title, artist_name: t.artist, album_name: t.album };
if (t.duration) q.duration = Math.round(t.duration / 1000);
const get = async (path, params) => {
const r = await fetch(`https://lrclib.net${path}?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(8000) });
return r.ok ? r.json() : null;
};
let hit = await get("/api/get", q).catch(() => null);
if (!hit) {
const list = await get("/api/search", { track_name: t.title, artist_name: t.artist }).catch(() => null) || [];
hit = list.find(x => x.syncedLyrics) || list[0] || null;
}
if (!hit) return null;
return { lines: hit.syncedLyrics ? parse(hit.syncedLyrics) : null, plain: hit.plainLyrics || null, instrumental: !!hit.instrumental };
})();
cache.set(id, p);
const res = await p;
if (!res) cache.delete(id);
return res;
}
// Index of the line playing at `sec`, or -1 before the first.
export function lineAt(lines, sec) {
let lo = 0, hi = lines.length - 1, ans = -1;
while (lo <= hi) { const mid = (lo + hi) >> 1; if (lines[mid].t <= sec + 0.15) { ans = mid; lo = mid + 1; } else hi = mid - 1; }
return ans;
}
+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`);
}
+235
View File
@@ -0,0 +1,235 @@
// Plex: any number of servers, each with its music sections switched on or off. Lists from
// several sections are merged page by page so they read as one library.
import { S, save } from "./store.js";
import { uuid } from "./util.js";
export const headers = token => ({
Accept: "application/json",
"X-Plex-Product": "Stemify Web",
"X-Plex-Client-Identifier": S.clientId,
...(token ? { "X-Plex-Token": token } : {}),
});
export const server = id => S.servers.find(s => s.id === id);
export async function px(srv, path, params = {}) {
if (typeof srv === "string") srv = server(srv);
if (!srv) throw new Error("That Plex server isn't set up any more");
const u = new URL(srv.url + path, location.href);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: headers(srv.token), signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`Plex ${r.status} from ${srv.name}`);
return (await r.json()).MediaContainer || {};
}
export function img(srvId, thumb, size = 300) {
const s = server(srvId);
return thumb && s
? `${s.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${s.token}`
: "";
}
export function partUrl(t) {
const s = server(t.srv);
return s && t.part ? `${s.url}${t.part}?X-Plex-Token=${s.token}` : null;
}
export function track(srvId, m) {
const part = m.Media?.[0]?.Part?.[0];
return {
id: `p${srvId}:${m.ratingKey}`, srv: srvId, key: m.ratingKey,
title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
albumKey: m.parentRatingKey, artistKey: m.grandparentRatingKey,
thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
part: part?.key, file: part?.file, index: m.index,
meta: { titleSort: m.titleSort, addedAt: m.addedAt, lastViewedAt: m.lastViewedAt, viewCount: m.viewCount, userRating: m.userRating },
};
}
// Sections that are switched on, one per (server, section). A Plex server reachable two ways
// (the env proxy and a sign-in) only counts once.
export function sources() {
const seen = new Set(), out = [];
for (const srv of S.servers) {
if (srv.mid && seen.has(srv.mid)) continue;
if (srv.mid) seen.add(srv.mid);
for (const sec of srv.sections || []) if (sec.on) out.push({ srv, sec: sec.key });
}
return out;
}
export const liveServers = () => [...new Map(sources().map(s => [s.srv.id, s.srv])).values()];
// Fills in the server's identity and music sections. New sections start switched on.
export async function refreshServer(srv) {
const [id, secs] = await Promise.all([px(srv, "/").catch(() => ({})), px(srv, "/library/sections")]);
if (id.machineIdentifier) srv.mid = id.machineIdentifier;
if (id.friendlyName && (!srv.name || srv.name === srv.url)) srv.name = id.friendlyName;
const old = new Map((srv.sections || []).map(s => [s.key, s]));
srv.sections = (secs.Directory || []).filter(d => d.type === "artist")
.map(d => ({ key: String(d.key), title: d.title, on: old.get(String(d.key))?.on ?? true }));
save();
return srv;
}
export function addServer(srv) {
const i = S.servers.findIndex(s => s.id === srv.id || (srv.mid && s.mid === srv.mid && !s.env));
if (i >= 0) S.servers[i] = { ...S.servers[i], ...srv, sections: S.servers[i].sections };
else S.servers.push({ sections: [], ...srv });
save();
return S.servers[i >= 0 ? i : S.servers.length - 1];
}
export const manualServer = (url, token) => ({ id: "m" + uuid().slice(0, 8), url, token, name: url });
// PIN sign-in, then every server on the account that answers from this browser.
export async function signIn(status) {
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: headers() })).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: headers() })).json()).authToken;
}
if (!token) throw new Error("Sign-in timed out");
status("Finding your servers…");
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: headers(token) })).json();
const https = location.protocol === "https:";
const found = await Promise.all(res.filter(r => r.provides?.includes("server")).map(async srv => {
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: headers(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
if (r.ok) return { id: srv.clientIdentifier, mid: srv.clientIdentifier, url: c.uri, token: srv.accessToken || token, name: srv.name };
} catch {}
}
return null;
}));
const ok = found.filter(Boolean);
if (!ok.length) throw new Error("Signed in, but no Plex server answered from this browser");
for (const f of ok) await refreshServer(addServer(f)).catch(() => {});
return ok;
}
// ---- sorting -------------------------------------------------------------------------------
// Same options as the iOS app, plus Random. [title, Plex key, starts descending, up, down].
const AZ = ["A to Z", "Z to A"], NEW = ["Oldest First", "Newest First"], MOST = ["Fewest First", "Most First"];
const RANDOM = ["Random", "random", false, "Shuffled", "Shuffled"];
export const SORTS = {
artists: [["Name", "titleSort", false, ...AZ], ["Date Added", "addedAt", true, ...NEW], ["Last Played", "lastViewedAt", true, ...NEW],
["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"], RANDOM],
albums: [["Title", "titleSort", false, ...AZ], ["Artist", "artist.titleSort,year", false, ...AZ], ["Year", "year", true, ...NEW],
["Release Date", "originallyAvailableAt", true, ...NEW], ["Date Added", "addedAt", true, ...NEW], ["Last Played", "lastViewedAt", true, ...NEW],
["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"], RANDOM],
songs: [["Title", "titleSort", false, ...AZ], ["Artist", "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", false, ...AZ],
["Album", "album.titleSort,album.index,album.id,track.absoluteIndex", false, ...AZ], ["Date Added", "addedAt", true, ...NEW],
["Last Played", "lastViewedAt", true, ...NEW], ["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"],
["Duration", "duration", true, "Shortest First", "Longest First"], RANDOM],
playlists: [["Title", "titleSort", false, ...AZ], ["Date Added", "addedAt", true, ...NEW], ["Last Updated", "updatedAt", true, ...NEW],
["Last Played", "lastViewedAt", true, ...NEW], ["Plays", "viewCount", true, ...MOST], ["Duration", "duration", true, "Shortest First", "Longest First"], RANDOM],
};
// Track lists inside an album, artist, playlist or genre are sorted in the browser.
SORTS.tracks = [["Default", "", false, "In Order", "Reversed"], ...SORTS.songs];
export function sortState(screen) {
const opts = SORTS[screen === "album" || screen === "artist" || screen === "playlist" || screen === "search" ? "tracks" : screen] || SORTS.tracks;
const st = S.sorts[screen] || {};
const i = opts[st.i] ? st.i : 0, o = opts[i];
const desc = o[2] !== !!st.rev;
return { opts, i, o, desc, random: o[1] === "random", param: o[1] === "random" ? "random" : o[1].split(",").map((f, j) => j || !desc ? f : f + ":desc").join(",") };
}
// Picking the current field again flips it, like Plexamp and the iOS app.
export function pickSort(screen, i, dir) {
const st = S.sorts[screen] ||= {};
const opts = sortState(screen).opts;
if (dir != null) st.rev = dir !== opts[st.i ?? 0][2];
else if ((st.i ?? 0) === i) st.rev = !st.rev;
else { st.i = i; st.rev = false; }
save();
}
// Value Plex sorts by, for merging pages from several sections and for sorting in the browser.
const FIELD = {
titleSort: m => m.titleSort || m.title,
"artist.titleSort": m => m.type === "track" ? m.grandparentTitle : m.parentTitle,
"album.titleSort": m => m.parentTitle,
originallyAvailableAt: m => m.originallyAvailableAt || "",
};
const coll = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
export function comparator(param) {
const keys = param.split(",").map(k => { const [f, d] = k.split(":"); return { get: FIELD[f] || (m => m[f]), dir: d === "desc" ? -1 : 1 }; });
return (a, b) => {
for (const { get, dir } of keys) {
let x = get(a), y = get(b);
if (x == null && y == null) continue;
if (typeof x === "number" || typeof y === "number") { x ??= -Infinity; y ??= -Infinity; if (x !== y) return (x < y ? -1 : 1) * dir; }
else { const c = coll.compare(x ?? "", y ?? ""); if (c) return c * dir; }
}
return 0;
};
}
// Sorts plain tracks (from plexTrack) the way a Plex sort key would.
export function sortTracks(tracks, st) {
if (!st.o[1]) return st.desc !== st.o[2] ? tracks.slice().reverse() : tracks;
if (st.random) return tracks.map(t => [Math.random(), t]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
const asMeta = t => ({ type: "track", title: t.title, grandparentTitle: t.artist, parentTitle: t.album, duration: t.duration, ...t.meta });
const cmp = comparator(st.param);
return tracks.slice().sort((a, b) => cmp(asMeta(a), asMeta(b)));
}
// ---- merged paging ----------------------------------------------------------------------------
// Pulls pages from every source and merges them by the sort, or interleaves them for Random.
export class Pager {
constructor(srcs, path, params, size = 120) {
this.cmp = params.sort && params.sort !== "random" ? comparator(params.sort) : null;
this.random = params.sort === "random";
this.st = srcs.map(s => ({ ...s, buf: [], start: 0, total: Infinity }));
this.path = path; this.params = params; this.size = size;
this.total = 0;
}
get done() { return this.st.every(s => !s.buf.length && s.start >= s.total); }
async fill(s) {
if (s.buf.length || s.start >= s.total) return;
const mc = await px(s.srv, typeof this.path === "function" ? this.path(s) : this.path,
{ ...this.params, ...(s.params || {}), "X-Plex-Container-Start": s.start, "X-Plex-Container-Size": this.size });
const items = mc.Metadata || [];
const first = s.total === Infinity;
s.total = items.length ? mc.totalSize ?? mc.size ?? 0 : s.start;
if (first) this.total += s.total;
s.start += items.length;
for (const m of items) m._srv = s.srv.id;
s.buf.push(...items);
}
async next(n = this.size) {
const out = [];
while (out.length < n) {
await Promise.all(this.st.map(s => this.fill(s).catch(e => { s.total = s.start; s.err = e; })));
const live = this.st.filter(s => s.buf.length);
if (!live.length) break;
let pick;
if (live.length === 1) pick = live[0];
else if (this.random) {
let r = Math.random() * live.reduce((a, s) => a + s.buf.length + s.total - s.start, 0);
pick = live.find(s => (r -= s.buf.length + s.total - s.start) < 0) || live[0];
} else pick = this.cmp ? live.reduce((a, s) => this.cmp(s.buf[0], a.buf[0]) < 0 ? s : a) : live[0];
// A single live source can hand over its whole buffer at once.
out.push(...(live.length === 1 ? pick.buf.splice(0, n - out.length) : [pick.buf.shift()]));
}
if (!out.length && this.st.every(s => s.err)) throw this.st[0].err;
return out;
}
}
// Every page of every source, for lists that are only ever shown whole (genres, playlists).
export async function everything(srcs, path, params = {}) {
const per = await Promise.all(srcs.map(async s => {
const mc = await px(s.srv, typeof path === "function" ? path(s) : path, params).catch(() => ({}));
return (mc.Metadata || mc.Directory || []).map(m => (m._srv = s.srv.id, m._sec = s.sec, m));
}));
return per.flat();
}
+122
View File
@@ -0,0 +1,122 @@
// stemserve: the index of tracks that have stems, Stem Now, and the live separation queue.
import { S } from "./store.js";
import { emit, toast } from "./util.js";
export const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
export 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("/");
export const fmtParams = (fmt = S.fmt) => fmt.startsWith("opus") ? { fmt: "opus", br: fmt.slice(4) } : {};
export const api = (path, extra) => stemBase() + path + stemQuery(extra);
// 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.
export const Stems = {
entries: [], bySource: new Map(), byKey: 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(api("/api/index"), { headers: this.etag ? { "If-None-Match": this.etag } : {}, signal: AbortSignal.timeout(15000) });
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(); this.byKey = new Map();
for (const e of this.entries) {
const k = this.norm(e.source);
if (!this.bySource.has(k)) this.bySource.set(k, e);
this.byKey.set(e.key, e);
}
emit("stems");
return true;
} catch { this.ok = false; return false; }
},
find(t) {
if (t.stem) return this.byKey.get(t.stem.key) || 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, fmt) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams(fmt))}`; },
prepare(e, fmt = S.fmt) {
if (e && fmt.startsWith("opus")) fetch(api("/api/prepare", { key: e.key, ...fmtParams(fmt) }), { method: "POST" }).catch(() => {});
},
};
export 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 };
};
// ---- live queue ------------------------------------------------------------------------------
// Polls /api/queue: quickly while stemd has work, slowly otherwise. When stemd finishes a track
// the index is refreshed, and every list on screen updates its badges.
export const Live = {
pending: new Set(), current: null, done: null, failed: new Set(), timer: null,
state(t) {
if (!t.file) return null;
if (this.current && (t.file === this.current || t.file.endsWith("/" + this.current))) return "stemming";
if (this.pending.has(t.file)) return "queued";
if (this.failed.has(t.file)) return "failed";
return null;
},
get busy() { return !!this.current || this.pending.size > 0; },
async poll() {
clearTimeout(this.timer);
try {
const r = await fetch(api("/api/queue"), { cache: "no-store", signal: AbortSignal.timeout(8000) });
if (r.ok) {
const j = await r.json();
const before = new Set(Stems.entries.map(e => e.key));
const changed = j.done !== this.done;
this.pending = new Set(j.pending || []); this.current = j.current || null; this.failed = new Set(j.failed || []);
if (changed && this.done !== null && await Stems.refresh()) {
const fresh = Stems.entries.filter(e => !before.has(e.key));
if (fresh.length) emit("newstems", fresh);
}
this.done = j.done;
emit("live");
}
} catch {}
this.timer = setTimeout(() => this.poll(), this.busy ? 4000 : 30000);
},
};
export async function stemNow(tracks) {
const seen = new Set();
const paths = tracks.filter(t => t.file && !Stems.find(t) && !seen.has(t.file) && seen.add(t.file)).map(t => t.file);
if (!paths.length) return toast(tracks.length === 1 ? "Already has stems" : "These all have stems already");
try {
const r = await fetch(api("/api/stem"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paths }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) return toast(`Couldn't queue: ${j.error || r.status}`);
for (const p of paths) Live.pending.add(p);
emit("live");
toast(`Stemming ${paths.length === 1 ? "1 track" : paths.length + " tracks"} next`);
Live.poll();
} catch (e) { toast("Couldn't queue: " + e.message); }
}
+86
View File
@@ -0,0 +1,86 @@
// Everything the player keeps in the browser: settings, saved song mixes, stem playlists,
// and offline downloads (IndexedDB).
import { load, store, uuid } from "./util.js";
const KEY = "stemify-web";
export const S = Object.assign({
servers: [], // [{id, url, token, name, env?, sections: [{key, title, on}]}]
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}; the live mix
keepMix: true, // carry the mix to the next track when it has no saved mix
karaoke: false, karaokeMode: "off", softDb: -12,
sorts: {}, // screen -> sort value
stemsOnly: false,
theme: "amber", accent: "", css: "",
}, load(KEY) || {});
if (!S.clientId) S.clientId = uuid();
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
// Before multiple servers there was one S.plex and one S.section.
if (S.plex) {
const p = S.plex;
if (!p.env) S.servers.push({ id: "m" + Date.now().toString(36), url: p.url, token: p.token, name: p.name || p.url, sections: S.section ? [{ key: String(S.section), title: "Music", on: true }] : [] });
delete S.plex; delete S.section;
}
if (S.albumSort) { S.sorts.albums = S.albumSort; delete S.albumSort; }
let timer;
export function save() {
clearTimeout(timer);
timer = setTimeout(() => store(KEY, S), 300);
}
// ---- saved song mixes, keyed by the stem entry's key -----------------------------------
const MIX_KEY = KEY + "-mixes";
export const songMixes = load(MIX_KEY) || {};
export function saveSongMix(key, mix) { songMixes[key] = JSON.parse(JSON.stringify(mix)); store(MIX_KEY, songMixes); }
export function forgetSongMix(key) { delete songMixes[key]; store(MIX_KEY, songMixes); }
// ---- stem playlists: tracks plus an optional mix per item ------------------------------
const PL_KEY = KEY + "-playlists";
export const playlists = load(PL_KEY) || [];
export const savePlaylists = () => store(PL_KEY, playlists);
export function newPlaylist(title) {
const p = { id: uuid(), title, items: [], created: Date.now(), updated: Date.now() };
playlists.unshift(p);
savePlaylists();
return p;
}
export function addToPlaylist(p, tracks, mix) {
for (const t of tracks) p.items.push({ uid: uuid(), track: t, mix: mix ? JSON.parse(JSON.stringify(mix)) : null });
p.updated = Date.now();
savePlaylists();
}
// ---- offline downloads -----------------------------------------------------------------
// One record per track: {id, track, entry, fmt, size, peaks, lyrics, blobs: {name: Blob}}.
let dbp;
function db() {
return dbp ||= new Promise((res, rej) => {
const r = indexedDB.open("stemify-web", 1);
r.onupgradeneeded = () => r.result.createObjectStore("tracks", { keyPath: "id" });
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
const req = r => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
async function os(mode) { return (await db()).transaction("tracks", mode).objectStore("tracks"); }
export const Offline = {
ids: new Set(),
async init() { try { this.ids = new Set(await req((await os("readonly")).getAllKeys())); } catch {} },
has: id => Offline.ids.has(id),
async get(id) { return this.ids.has(id) ? req((await os("readonly")).get(id)) : null; },
async all() { return req((await os("readonly")).getAll()); },
async put(rec) { await req((await os("readwrite")).put(rec)); this.ids.add(rec.id); },
async remove(id) { await req((await os("readwrite")).delete(id)); this.ids.delete(id); },
};
+106 -11
View File
@@ -9,12 +9,18 @@
--accent-ink: #1a1306;
--radius: 12px;
--player-h: 96px;
--glass: #15171dee;
--hover: #eceef30d;
--shadow: #0009;
--wave: #eceef344;
--wave-ghost: #eceef314;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; }
button, input, select { font: inherit; color: inherit; }
button, input, select, textarea { font: inherit; color: inherit; }
button:disabled { opacity: .4; cursor: default; }
button { background: none; border: 0; cursor: pointer; padding: 0; }
a { color: inherit; text-decoration: none; }
svg { width: 1em; height: 1em; fill: currentColor; display: block; }
@@ -36,7 +42,7 @@ h2 { font-size: 17px; margin: 28px 0 12px; }
.sub { color: var(--muted); }
.bar { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; flex-wrap: wrap; }
.bar h1 { margin-right: auto; }
select, input[type=text], input[type=url], input[type=password] { background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
select, textarea, input[type=text], input[type=url], input[type=password] { background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; outline: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 18px; }
.card { display: block; min-width: 0; }
@@ -47,7 +53,7 @@ select, input[type=text], input[type=url], input[type=password] { background: va
.card .s { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hero { display: flex; gap: 28px; align-items: flex-end; margin-bottom: 26px; }
.hero img { width: 210px; height: 210px; border-radius: var(--radius); object-fit: cover; background: var(--raised); box-shadow: 0 20px 50px #0008; }
.hero img { width: 210px; height: 210px; border-radius: var(--radius); object-fit: cover; background: var(--raised); box-shadow: 0 20px 50px var(--shadow); }
.hero .meta { min-width: 0; }
.hero .kind { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
@@ -57,28 +63,109 @@ select, input[type=text], input[type=url], input[type=password] { background: va
.tracks { width: 100%; border-collapse: collapse; }
.tracks tr { border-radius: 8px; }
.tracks td { padding: 8px 10px; border-bottom: 1px solid #ffffff08; white-space: nowrap; }
.tracks td { padding: 8px 10px; border-bottom: 1px solid var(--hover); white-space: nowrap; }
.tracks tr:hover td { background: var(--panel); }
.tracks tr.playing td { color: var(--accent); }
.tracks .n { width: 36px; color: var(--muted); text-align: right; }
.tracks .ti { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tracks .ti small { color: var(--muted); margin-left: 8px; }
.tracks .d { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.tracks .add { opacity: 0; color: var(--muted); font-size: 18px; }
.tracks tr:hover .add { opacity: 1; }
.tracks .add:hover { color: var(--text); }
.stem-dot { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--accent); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; margin-left: 8px; vertical-align: 1px; }
.more { display: block; margin: 24px auto; }
.tracks tr { cursor: default; }
.tracks .off { color: var(--accent); width: 20px; padding: 0 2px; font-size: 13px; }
.tracks .acts { width: 1px; padding: 0 4px; }
.tracks .acts button { opacity: 0; color: var(--muted); font-size: 18px; width: 26px; height: 26px; border-radius: 6px; display: inline-grid; place-items: center; vertical-align: middle; }
.tracks tr:hover .acts button { opacity: 1; }
.tracks .acts button:hover { color: var(--text); background: var(--raised); }
.tag { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .05em; color: var(--muted); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; margin-left: 8px; vertical-align: 1px; }
.tag.stems { color: var(--accent); }
.tag.queued { color: var(--muted); }
.tag.stemming { color: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
.tag.failed { color: #e5534b; }
@keyframes pulse { 50% { opacity: .35; } }
.badge:empty { display: none; }
.more { min-height: 1px; margin: 24px auto; color: var(--muted); }
.sub#count { margin-right: auto; }
.bar h1 + .sub { margin-left: -2px; }
.bar.sm { margin: 28px 0 12px; }
.bar.sm h2 { margin: 0; }
.grow { flex: 1; }
.sort { display: flex; gap: 6px; align-items: center; }
.sort select { padding: 5px 8px; font-size: 13px; }
.sort .chip svg { display: inline-block; vertical-align: -2px; }
.chip { display: inline-flex; gap: 6px; align-items: center; }
.link { color: var(--accent); cursor: pointer; }
.btn.small { padding: 5px 12px; font-size: 13px; }
.btn.icon { padding: 9px 12px; }
.hero .ph { width: 210px; height: 210px; border-radius: var(--radius); background: var(--raised); flex: none; }
.hero .round { border-radius: 50%; }
/* Stems-only filter hides rows and cards without stems. */
body.stems-only .tracks tr.nostem, body.stems-only .card.nostem { display: none; }
.only-stems { display: none; }
body.stems-only .tracks:not(:has(tr:not(.nostem))) + .only-stems { display: block; }
.genres { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; }
.genre { display: flex; align-items: flex-end; aspect-ratio: 16 / 9; padding: 14px; border-radius: 10px; font-weight: 700; font-size: 16px; color: #fff;
background: linear-gradient(135deg, hsl(var(--h) 55% 42%), hsl(calc(var(--h) + 40) 60% 24%)); text-shadow: 0 1px 6px #0006; }
.genre:hover { filter: brightness(1.12); }
#live { margin-top: 14px; padding: 8px 10px; border-radius: 8px; background: var(--raised); font-size: 12px; color: var(--muted); line-height: 1.35; }
.pulse { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-right: 6px; animation: pulse 1.4s ease-in-out infinite; }
#menu { position: fixed; z-index: 50; min-width: 200px; padding: 5px; background: var(--raised); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 34px var(--shadow); }
#menu button { display: block; width: 100%; text-align: left; padding: 7px 10px; border-radius: 6px; }
#menu button:not(:disabled):hover { background: var(--accent); color: var(--accent-ink); }
#menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
#toast { position: fixed; left: 50%; bottom: calc(var(--player-h) + 18px); transform: translate(-50%, 10px); z-index: 60; max-width: min(520px, calc(100% - 32px));
background: var(--text); color: var(--bg); padding: 9px 16px; border-radius: 999px; font-weight: 500; font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }
#toast.show { opacity: 1; transform: translate(-50%, 0); }
.empty-msg { color: var(--muted); padding: 40px 0; }
.form { max-width: 520px; display: grid; gap: 14px; }
.form label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
.form label input, .form label select { color: var(--text); font-size: 14px; }
.form .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
.check { display: flex !important; gap: 8px; align-items: center; color: var(--text) !important; font-size: 14px !important; }
textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; resize: vertical; color: var(--text); }
.settings { display: grid; grid-template-columns: 150px 1fr; gap: 32px; align-items: start; }
.toc { position: sticky; top: 0; display: grid; gap: 2px; }
.toc a { padding: 6px 10px; border-radius: 8px; color: var(--muted); }
.toc a:hover { color: var(--text); background: var(--hover); }
.panes section { max-width: 640px; padding-bottom: 26px; margin-bottom: 26px; border-bottom: 1px solid var(--line); scroll-margin-top: 10px; }
.panes section h2 { margin-top: 0; }
.srv { padding: 12px 14px; margin-bottom: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.srv .row { margin: 0; }
.secs { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 10px; }
details summary { cursor: pointer; margin: 12px 0; }
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
.swatch { border: 1px solid; border-radius: 10px; padding: 10px; text-align: left; font-weight: 600; display: flex; gap: 8px; align-items: center; }
.swatch i { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.swatch.on { outline: 2px solid var(--accent); outline-offset: 2px; }
input[type=color] { width: 40px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
.note { color: var(--muted); font-size: 13px; }
#drawer { width: 340px; background: var(--panel); border-left: 1px solid var(--line); overflow-y: auto; padding: 22px 20px; }
#drawer h2 { margin: 0 0 14px; }
#drawer { display: flex; flex-direction: column; padding-top: 12px; }
#drawer[hidden] { display: none; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; position: sticky; top: -12px; background: var(--panel); padding: 6px 0; z-index: 1; }
.tabs button { padding: 6px 12px; border-radius: 8px; color: var(--muted); font-weight: 600; }
.tabs button.on { background: var(--raised); color: var(--text); }
.tabs .x { margin-left: auto; font-size: 20px; }
.kara { display: grid; gap: 10px; margin-bottom: 16px; }
.kara .chip { justify-self: start; }
.seg { display: flex; background: var(--raised); border-radius: 8px; padding: 3px; }
.seg button { flex: 1; padding: 5px 4px; border-radius: 6px; font-size: 12px; color: var(--muted); }
.seg button.on { background: var(--accent); color: var(--accent-ink); }
.kara label { display: grid; gap: 4px; }
.mixrow { margin-top: 16px; }
#lyr.synced p { margin: 0; padding: 7px 4px; font-size: 19px; font-weight: 700; line-height: 1.3; color: var(--muted); opacity: .55; cursor: pointer; transition: opacity .2s, color .2s; }
#lyr.synced p:hover { opacity: .85; }
#lyr.synced p.on { color: var(--text); opacity: 1; }
#lyr.plain { font-size: 15px; line-height: 1.6; color: var(--text); }
.q-item .ph { width: 38px; height: 38px; border-radius: 6px; background: var(--raised); flex: none; }
.q-item[draggable] { cursor: grab; }
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.chip { background: var(--raised); border-radius: 999px; padding: 5px 12px; font-size: 13px; font-weight: 500; }
.chip.on { background: var(--accent); color: var(--accent-ink); }
@@ -102,7 +189,7 @@ select, input[type=text], input[type=url], input[type=password] { background: va
.q-item .x { color: var(--muted); font-size: 18px; padding: 0 4px; opacity: 0; }
.q-item:hover .x { opacity: 1; }
#player { position: fixed; left: 0; right: 0; bottom: 0; height: var(--player-h); background: #101116ee; backdrop-filter: blur(20px);
#player { position: fixed; left: 0; right: 0; bottom: 0; height: var(--player-h); background: var(--glass); backdrop-filter: blur(20px);
border-top: 1px solid var(--line); display: grid; grid-template-columns: minmax(180px, 1fr) minmax(320px, 1.6fr) minmax(180px, 1fr); align-items: center; gap: 20px; padding: 0 20px; }
#player.empty .center, #player.empty .right { opacity: .35; pointer-events: none; }
.np { display: flex; gap: 12px; align-items: center; min-width: 0; }
@@ -126,6 +213,9 @@ select, input[type=text], input[type=url], input[type=password] { background: va
.right { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
.pill { color: var(--muted); font-size: 18px; width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; }
.pill.on { background: var(--raised); color: var(--accent); }
.pill { position: relative; }
.pill.dot::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.np { cursor: pointer; }
#speed { padding: 5px 6px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
#speed.on { color: var(--accent); border-color: var(--accent); }
#volume { width: 96px; }
@@ -138,6 +228,11 @@ input[type=range] { accent-color: var(--accent); }
.brand { padding: 0 8px 0 4px; }
#nav a { white-space: nowrap; }
#search input { margin: 0; width: 120px; }
#live { margin: 0; white-space: nowrap; }
.settings { grid-template-columns: 1fr; }
.toc { position: static; display: flex; flex-wrap: wrap; }
.tracks .acts button { opacity: 1; }
.tracks .ti small { display: block; margin-left: 0; }
.settings-link { margin-top: 0; }
#view { padding: 20px 16px 40px; }
#drawer { position: fixed; inset: auto 0 var(--player-h) 0; width: auto; max-height: 60vh; border-left: 0; border-top: 1px solid var(--line); z-index: 5; }
+49
View File
@@ -0,0 +1,49 @@
// Small helpers shared by the web player's modules.
export const $ = (s, r = document) => r.querySelector(s);
export const $$ = (s, r = document) => [...r.querySelectorAll(s)];
export const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
export const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
export const fmtBytes = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : (n / 1e6).toFixed(n >= 1e8 ? 0 : 1) + " MB";
export const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? "" : "s"}`;
export 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; };
export const clone = o => JSON.parse(JSON.stringify(o ?? null));
// crypto.randomUUID only exists on HTTPS pages, and the player is usually opened over plain HTTP.
export const uuid = () => crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);
export function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
export function store(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }
// Lets the player tell the UI what changed without importing it.
const bus = {};
export const on = (ev, fn) => { (bus[ev] ||= []).push(fn); };
export const emit = (ev, ...args) => { for (const fn of bus[ev] || []) fn(...args); };
export function toast(msg, ms = 2400) {
let t = $("#toast");
if (!t) {
t = document.createElement("div");
t.id = "toast";
document.body.append(t);
}
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove("show"), ms);
}
export 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>',
lyrics: '<svg viewBox="0 0 24 24"><path d="M12 2a4 4 0 0 1 4 4v5a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4zm-7 9h2a5 5 0 0 0 10 0h2a7 7 0 0 1-6 6.93V21h3v2H8v-2h3v-3.07A7 7 0 0 1 5 11z"/></svg>',
more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
download: '<svg viewBox="0 0 24 24"><path d="M11 3h2v10.2l3.6-3.6 1.4 1.4-6 6-6-6 1.4-1.4 3.6 3.6V3zM4 19h16v2H4z"/></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>',
};