Files

563 lines
24 KiB
JavaScript

// 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);
// 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(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.
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 (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();
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 ---------------------------------------------------------------------------
// 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;
}
// 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 = {
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); });
}
},
};
// ---- 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) {
if (play) Radio.stop();
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;
if (Radio.el) Radio.el.volume = S.volume;
},
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;
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); },
// Cache Ahead, only once this one is playing and fully loaded, so they don't compete.
prepareNext() {
clearInterval(this.nextTimer);
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);
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); }
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;
this.prepareNext(); // Cache Ahead drops what's no longer coming up
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);
// ---- 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 ----------------------------------------------------------------------------------
// 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;
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, group, 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`);
}