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:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
+157
-27
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user