563 lines
24 KiB
JavaScript
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`);
|
|
}
|