Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+157 -27
View File
@@ -28,14 +28,77 @@ export function stemGain(name, names, mix = S.mix) {
}
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);
// Presets in the mixer: the built-ins ("b:Name") and saved custom mixes ("c:Name"), in the
// user's order. Deleting a built-in hides it; Full Mix can't be deleted.
const customPreset = c => ({ id: `c:${c.name}`, name: c.name, custom: true, build: n => Object.keys(c.mix).some(k => n.includes(k)) && c.mix });
export function allPresets() {
const all = [...PRESETS.map(([name, build]) => ({ id: `b:${name}`, name, build })), ...S.customPresets.map(customPreset)];
const rank = (p, i) => { const r = S.presetOrder.indexOf(p.id); return r < 0 ? 1e6 + i : r; };
return all.map((p, i) => [rank(p, i), p]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
}
export const presets = () => allPresets().filter(p => !S.hiddenPresets.includes(p.id));
export const presetLabel = name => S.acapella && name === "A Cappella" ? "Acapella" : name;
const presetsChanged = () => { emit("presets"); emit("mix"); save(); };
export function applyPreset(p, names) {
const m = p?.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 const currentPreset = names => presets().find(p => { const m = p.build(names); return m && sameMix(m, S.mix, names); });
// Steps to the next preset that fits this song (the P key).
export function nextPreset(names) {
const ok = presets().filter(p => p.build(names));
const cur = currentPreset(names);
applyPreset(ok[(ok.indexOf(cur) + 1) % ok.length], names);
}
// Saves `mix` as a custom preset, keeping only stems that differ from the default. Returns an
// error message, or "" when saved.
export function savePreset(name, mix) {
name = name.trim();
if (!name) return "Give the preset a name";
if (PRESETS.some(([n]) => n.toLowerCase() === name.toLowerCase())) return `“${name}” is a built-in preset`;
const kept = {};
for (const [k, v] of Object.entries(mix || {})) {
const st = { ...unity(), ...v };
if (Math.abs(st.level - 1) > 0.001 || st.muted || st.solo) kept[k] = st;
}
if (!Object.keys(kept).length) return "This is the full mix. Change a stem first.";
const i = S.customPresets.findIndex(c => c.name === name);
if (i >= 0) S.customPresets[i].mix = kept; else S.customPresets.push({ name, mix: kept });
presetsChanged();
return "";
}
export function deletePreset(id) {
if (id === "b:Full Mix") return;
if (id.startsWith("b:")) { if (!S.hiddenPresets.includes(id)) S.hiddenPresets.push(id); }
else S.customPresets = S.customPresets.filter(c => `c:${c.name}` !== id);
if (S.lockedPreset === id) S.lockedPreset = "";
S.presetOrder = S.presetOrder.filter(x => x !== id);
presetsChanged();
}
export function movePreset(id, d) {
const order = presets().map(p => p.id), i = order.indexOf(id), j = i + d;
if (i < 0 || j < 0 || j >= order.length) return;
[order[i], order[j]] = [order[j], order[i]];
S.presetOrder = order;
presetsChanged();
}
export function restorePresets() { S.hiddenPresets = []; presetsChanged(); }
export function deleteAllCustom() {
S.customPresets = [];
if (S.lockedPreset.startsWith("c:")) S.lockedPreset = "";
S.presetOrder = S.presetOrder.filter(x => !x.startsWith("c:"));
presetsChanged();
}
// Locking a preset applies it now and to every song after.
export function toggleLock(id, names) {
S.lockedPreset = S.lockedPreset === id ? "" : id;
if (S.lockedPreset) applyPreset(allPresets().find(p => p.id === id), names);
presetsChanged();
}
export function mixChanged() { E.applyGains(); emit("mix"); save(); }
// Karaoke: vocals on, soft (default -12 dB) or off. The iOS app does the same.
@@ -64,11 +127,14 @@ export function setKaraokeMode(mode, 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.
// The mix a track starts with: karaoke (only kept between songs when the mix is), the locked
// preset, 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 && !S.keepMix) { S.karaoke = false; delete S.preKaraokeMix; }
if (S.karaoke) return karaokeMix();
return clone(item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {}));
const lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset)?.build(entry.stems.map(s => s.name));
return clone(lock || item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {}));
}
// ---- fetching stems ---------------------------------------------------------------------------
@@ -100,19 +166,28 @@ export async function fetchStems(entry, fmt, { conc = 4, signal, onProgress } =
return out;
}
// One prefetched track, so the next song starts instantly. Kept to Opus (FLAC is too big).
// Cache Ahead: the next S.precache songs, fetched one at a time and held in memory, so they
// start 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; });
map: new Map(), run: 0, // "key|fmt" -> {promise, ctl}
k: entry => `${entry.key}|${S.fmt}`,
get(entry) { return this.map.get(this.k(entry))?.promise || null; },
// Drops songs no longer coming up and fetches the missing ones in queue order.
async keep(entries) {
const run = ++this.run, want = new Set(entries.map(this.k));
for (const [k, v] of this.map) if (!want.has(k)) { v.ctl.abort(); this.map.delete(k); }
for (const e of entries) {
if (run !== this.run) return;
const k = this.k(e);
let p = this.map.get(k)?.promise;
if (!p) {
const ctl = new AbortController();
p = fetchStems(e, S.fmt, { conc: 2, signal: ctl.signal });
this.map.set(k, { promise: p, ctl });
}
await p.catch(() => { if (this.map.get(k)?.promise === p) this.map.delete(k); });
}
},
clear() { this.ctl?.abort(); this.key = this.promise = null; },
};
// ---- engine -----------------------------------------------------------------------------------
@@ -137,6 +212,7 @@ export const E = {
// Loads a track: its stems (offline copy, prefetched, or downloaded now) or the Plex file.
async load(t, entry, startSec, play) {
if (play) Radio.stop();
this.unload();
const gen = ++this.gen;
this.want = play; this.stalled = play; this.pendingPos = startSec || 0;
@@ -220,9 +296,10 @@ export const E = {
else el.volume = Math.min(1, g * S.volume);
});
if (this.master) this.master.gain.value = S.volume;
if (Radio.el) Radio.el.volume = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); },
play() { Radio.stop(); 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;
@@ -336,22 +413,38 @@ export const Q = {
// 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.
// Cache Ahead, 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 opus = S.fmt.startsWith("opus"), want = opus ? S.precache : Math.min(1, S.precache), next = [];
for (let j = this.i + 1; j < this.items.length && next.length < want; j++) {
const t = this.items[j].t, e = Stems.find(t);
if (e && !Offline.has(t.id)) next.push(e);
}
if (!next.length) 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);
if (opus) Pre.keep(next); else Stems.prepare(next[0]);
}, 1000);
},
// Hold Next: jump to the first song from a different album.
skipAlbum() {
const album = x => `${x.t.srv || ""}|${x.t.albumKey || x.t.album || ""}`;
const cur = this.curItem();
if (!cur) return;
const n = this.items.length, a = album(cur);
for (let d = 1; d < n; d++) {
const j = this.i + d;
if (j >= n && S.repeat === "off") break;
if (album(this.items[j % n]) !== a) { this.i = j % n; this.start(0, true); return; }
}
toast("No more albums in the queue");
},
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); }
@@ -375,7 +468,7 @@ export const Q = {
this.orig = null;
}
S.shuffle = !S.shuffle;
Pre.clear(); this.prepareNext();
this.prepareNext(); // Cache Ahead drops what's no longer coming up
emit("transport"); emit("queue"); saveQueue(); save();
},
cycleRepeat() {
@@ -402,9 +495,46 @@ export const Q = {
};
E.onEnded = () => Q.next(true);
// ---- web radio --------------------------------------------------------------------------------
// A station plays on its own element. Starting it pauses the queue; playing the queue stops it.
export const Radio = {
el: null, station: null, want: false,
get playing() { return this.want; },
play(st) {
E.pause();
if (!this.el) {
this.el = new Audio();
this.el.addEventListener("error", () => { if (this.want) { toast(`Couldn't play “${this.station?.name}”`); this.stop(); } });
}
this.station = st;
this.resume();
},
// Live streams restart at the live edge rather than where they were paused.
resume() {
this.el.src = this.station.url;
this.el.volume = S.volume;
this.want = true;
this.el.play().catch(e => { if (e.name === "NotAllowedError") { this.want = false; emit("transport"); } });
emit("radio"); emit("transport");
},
toggle() {
if (!this.station) return;
if (this.want) { this.want = false; this.el.pause(); emit("radio"); emit("transport"); }
else { E.pause(); this.resume(); }
},
stop() {
if (!this.station) return;
this.want = false; this.station = null;
this.el.pause(); this.el.removeAttribute("src"); this.el.load();
emit("radio"); emit("transport");
},
};
// ---- offline ----------------------------------------------------------------------------------
export async function saveOffline(tracks, onStatus) {
// group: the album or playlist these came from, {title, href}, so Downloads can list them that way.
export async function saveOffline(tracks, onStatus, group = null) {
let n = 0;
for (const t of tracks) {
if (Offline.has(t.id)) continue;
@@ -423,7 +553,7 @@ export async function saveOffline(tracks, onStatus) {
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() });
await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, group, saved: Date.now() });
} catch (e) { toast(`Couldn't download “${t.title}”: ${e.message}`); }
}
emit("offline");