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

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