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

+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>',
};