901 lines
44 KiB
JavaScript
901 lines
44 KiB
JavaScript
// Stemify web player. Browses Plex, plays stems from stemserve, no build step.
|
||
// Each stem is its own <audio> element so speed changes keep pitch; a tick keeps them lined up.
|
||
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||
const fmtTime = s => { s = Math.max(0, Math.floor(s || 0)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
|
||
const shuffled = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||
|
||
const ICON = {
|
||
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
|
||
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
|
||
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',
|
||
next: '<svg viewBox="0 0 24 24"><rect x="17.5" y="5" width="2.5" height="14" rx="1"/><path d="M4 5.7v12.6a1 1 0 0 0 1.55.83L15 12.83a1 1 0 0 0 0-1.66l-9.45-6.3A1 1 0 0 0 4 5.7z"/></svg>',
|
||
shuffle: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4-4 4V8h-2.2c-1 0-1.9.5-2.5 1.3L7.6 16c-.9 1.3-2.4 2-4 2H2v-2h1.6c.9 0 1.8-.5 2.3-1.2l4.7-6.7C11.5 6.8 13 6 14.8 6H17V3zM2 6h1.6c1.6 0 3.1.8 4 2l.9 1.3-1.2 1.7-1.4-1.9A2.8 2.8 0 0 0 3.6 8H2V6zm12.3 9.7c.6.8 1.5 1.3 2.5 1.3H17v-3l4 4-4 4v-3h-.2c-1.7 0-3.3-.8-4.2-2.2l-.6-.8 1.2-1.7.1.4z"/></svg>',
|
||
repeat: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/></svg>',
|
||
repeat1: '<svg viewBox="0 0 24 24"><path d="M17 1l4 4-4 4V6H7a2 2 0 0 0-2 2v3H3V8a4 4 0 0 1 4-4h10V1zM7 23l-4-4 4-4v3h10a2 2 0 0 0 2-2v-3h2v3a4 4 0 0 1-4 4H7v3z"/><path d="M11.2 9.6l1.8-.9h1.2v6.6h-1.6v-4.9l-1.4.6z"/></svg>',
|
||
mixer: '<svg viewBox="0 0 24 24"><path d="M6 3h2v6.2a3 3 0 0 1 0 5.6V21H6v-6.2a3 3 0 0 1 0-5.6V3zm10 0h2v2.2a3 3 0 0 1 0 5.6V21h-2V10.8a3 3 0 0 1 0-5.6V3zM7 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></svg>',
|
||
queue: '<svg viewBox="0 0 24 24"><path d="M3 5h14v2H3zm0 6h14v2H3zm0 6h9v2H3zm13-1.5v-1.5l6 3.5-6 3.5V17z"/></svg>',
|
||
busy: '<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2.2A6.8 6.8 0 1 1 12 5.2V3z"/></svg>',
|
||
};
|
||
|
||
// ---- settings --------------------------------------------------------------------------
|
||
|
||
const KEY = "stemify-web";
|
||
const S = Object.assign({
|
||
plex: null, // {url, token, name}
|
||
section: null,
|
||
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}; kept across tracks
|
||
albumSort: "titleSort",
|
||
}, load(KEY) || {});
|
||
if (!S.clientId) S.clientId = crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2);
|
||
if (S.fmt.startsWith("opus") && !new Audio().canPlayType('audio/ogg; codecs="opus"')) S.fmt = "flac";
|
||
|
||
function load(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }
|
||
let saveTimer;
|
||
function save() {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => {
|
||
try {
|
||
localStorage.setItem(KEY, JSON.stringify(S));
|
||
localStorage.setItem(KEY + "-queue", JSON.stringify({ items: Q.items.slice(0, 2000), orig: Q.orig?.slice(0, 2000), i: Q.i, pos: E.position() }));
|
||
} catch {}
|
||
}, 300);
|
||
}
|
||
|
||
const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2];
|
||
|
||
// ---- Plex ------------------------------------------------------------------------------
|
||
|
||
const plexHeaders = token => ({
|
||
Accept: "application/json",
|
||
"X-Plex-Product": "Stemify Web",
|
||
"X-Plex-Client-Identifier": S.clientId,
|
||
...(token ? { "X-Plex-Token": token } : {}),
|
||
});
|
||
|
||
async function px(path, params = {}) {
|
||
if (!S.plex) throw new Error("Not signed in to Plex");
|
||
const u = new URL(S.plex.url + path, location.href);
|
||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
||
const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
|
||
if (!r.ok) throw new Error(`Plex ${r.status}`);
|
||
return (await r.json()).MediaContainer || {};
|
||
}
|
||
|
||
const plexImg = (thumb, size = 300) => thumb && S.plex
|
||
? `${S.plex.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${S.plex.token}`
|
||
: "";
|
||
|
||
function plexTrack(m) {
|
||
const part = m.Media?.[0]?.Part?.[0];
|
||
return {
|
||
id: "p" + m.ratingKey, title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
|
||
albumKey: m.parentRatingKey, thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
|
||
part: part?.key, file: part?.file, index: m.index,
|
||
};
|
||
}
|
||
|
||
async function plexSignIn(status) {
|
||
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: plexHeaders() })).json();
|
||
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
|
||
window.open(auth, "_blank", "width=800,height=740");
|
||
status("Waiting for Plex sign-in…");
|
||
let token = null;
|
||
for (let n = 0; n < 150 && !token; n++) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: plexHeaders() })).json()).authToken;
|
||
}
|
||
if (!token) throw new Error("Sign-in timed out");
|
||
status("Finding your server…");
|
||
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: plexHeaders(token) })).json();
|
||
const https = location.protocol === "https:";
|
||
for (const srv of res.filter(r => r.provides?.includes("server"))) {
|
||
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
|
||
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
|
||
for (const c of conns) {
|
||
try {
|
||
const r = await fetch(`${c.uri}/identity`, { headers: plexHeaders(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
|
||
if (r.ok) return { url: c.uri, token: srv.accessToken || token, name: srv.name };
|
||
} catch {}
|
||
}
|
||
}
|
||
throw new Error("Signed in, but no Plex server answered from this browser");
|
||
}
|
||
|
||
async function musicSection() {
|
||
if (S.section) return S.section;
|
||
const secs = (await px("/library/sections")).Directory || [];
|
||
S.section = secs.find(d => d.type === "artist")?.key || null;
|
||
save();
|
||
if (!S.section) throw new Error("No music library on this Plex server");
|
||
return S.section;
|
||
}
|
||
|
||
// ---- stemserve -------------------------------------------------------------------------
|
||
|
||
const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, "");
|
||
const stemQuery = extra => {
|
||
const q = new URLSearchParams(extra);
|
||
if (S.stemToken) q.set("token", S.stemToken);
|
||
const s = q.toString();
|
||
return s ? "?" + s : "";
|
||
};
|
||
const keyPath = k => k.split("/").map(encodeURIComponent).join("/");
|
||
const fmtParams = () => S.fmt.startsWith("opus") ? { fmt: "opus", br: S.fmt.slice(4) } : {};
|
||
|
||
// Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's
|
||
// suffix identifies a track. Short or duration-mismatched suffixes only count under a root
|
||
// learned from an earlier confident match.
|
||
const Stems = {
|
||
entries: [], bySource: new Map(), roots: [], etag: null, ok: false,
|
||
norm(p) {
|
||
p = p.normalize("NFC").replaceAll("\\", "/");
|
||
const slash = p.lastIndexOf("/"), dot = p.lastIndexOf(".");
|
||
if (dot > slash + 1) p = p.slice(0, dot);
|
||
return p.toLowerCase();
|
||
},
|
||
async refresh() {
|
||
try {
|
||
const r = await fetch(stemBase() + "/api/index" + stemQuery(), { headers: this.etag ? { "If-None-Match": this.etag } : {} });
|
||
this.ok = r.ok || r.status === 304;
|
||
if (r.status !== 200) return false;
|
||
this.etag = r.headers.get("ETag");
|
||
this.entries = (await r.json()).tracks || [];
|
||
this.bySource = new Map();
|
||
for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); }
|
||
return true;
|
||
} catch { this.ok = false; return false; }
|
||
},
|
||
find(t) {
|
||
if (t.stem) return t.stem;
|
||
if (!t.file || !this.entries.length) return null;
|
||
const path = this.norm(t.file);
|
||
for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; }
|
||
const parts = path.split("/");
|
||
for (let i = 1; i < parts.length; i++) {
|
||
const hit = this.bySource.get(parts.slice(i).join("/"));
|
||
if (!hit) continue;
|
||
const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000;
|
||
if (parts.length - i >= 2 && okDur) {
|
||
const root = parts.slice(0, i).join("/") + "/";
|
||
if (!this.roots.includes(root)) this.roots.push(root);
|
||
return hit;
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
url(e, s) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams())}`; },
|
||
prepare(e) {
|
||
if (e && S.fmt.startsWith("opus")) fetch(`${stemBase()}/api/prepare${stemQuery({ key: e.key, ...fmtParams() })}`, { method: "POST" }).catch(() => {});
|
||
},
|
||
};
|
||
|
||
const stemTrack = e => {
|
||
const parts = e.key.split("/");
|
||
return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e };
|
||
};
|
||
|
||
// ---- mix -------------------------------------------------------------------------------
|
||
|
||
const unity = () => ({ level: 1, muted: false, solo: false });
|
||
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 } }],
|
||
];
|
||
const setting = name => ({ ...unity(), ...S.mix[name] });
|
||
function stemGain(name, names) {
|
||
const s = setting(name), anySolo = names.some(n => setting(n).solo);
|
||
return anySolo ? (s.solo ? s.level : 0) : (s.muted ? 0 : s.level);
|
||
}
|
||
function applyPreset(build, names) {
|
||
const m = build(names);
|
||
if (!m) return;
|
||
S.mix = {};
|
||
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
|
||
mixChanged();
|
||
}
|
||
function currentPreset(names) {
|
||
const norm = m => JSON.stringify(names.map(n => { const s = { ...unity(), ...m[n] }; return [s.level, s.muted, s.solo]; }));
|
||
const now = norm(S.mix);
|
||
return PRESETS.find(([, b]) => { const m = b(names); return m && norm(Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { ...unity(), ...v }]))) === now; })?.[0];
|
||
}
|
||
|
||
// ---- audio -----------------------------------------------------------------------------
|
||
|
||
const E = {
|
||
els: [], names: [], gains: [], ctx: null, master: null, graph: false,
|
||
want: false, stalled: false, onEnded: null,
|
||
|
||
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;
|
||
},
|
||
|
||
load(sources, startSec, play) {
|
||
this.unload();
|
||
// Web Audio gains work everywhere (iOS ignores element volume) but need same-origin media.
|
||
const sameOrigin = sources.every(s => new URL(s.url, location.href).origin === location.origin);
|
||
this.graph = sameOrigin && !!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 (startSec) el.currentTime = startSec;
|
||
let gain = null;
|
||
if (this.graph) {
|
||
gain = this.ctx.createGain();
|
||
this.ctx.createMediaElementSource(el).connect(gain).connect(this.master);
|
||
}
|
||
this.els.push(el); this.gains.push(gain); this.names.push(s.name);
|
||
}
|
||
const lead = this.els[0];
|
||
lead.addEventListener("ended", () => this.onEnded?.());
|
||
lead.addEventListener("durationchange", () => UI.progress());
|
||
lead.addEventListener("error", () => { if (this.want) toast("Couldn't load this track"); });
|
||
this.applyGains();
|
||
this.want = play;
|
||
this.stalled = play;
|
||
},
|
||
|
||
unload() {
|
||
for (const el of this.els) { el.pause(); el.removeAttribute("src"); el.load(); }
|
||
this.els = []; this.names = []; this.gains = [];
|
||
},
|
||
|
||
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 * (this.graph ? 1 : S.volume));
|
||
});
|
||
if (this.master) this.master.gain.value = S.volume;
|
||
},
|
||
|
||
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); },
|
||
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); },
|
||
seek(sec) { this.els.forEach(e => { e.currentTime = sec; }); if (this.want) this.stalled = true; UI.progress(); },
|
||
setSpeed(v) { this.els.forEach(e => { e.playbackRate = e.defaultPlaybackRate = v; }); },
|
||
position() { return this.els[0]?.currentTime || 0; },
|
||
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; },
|
||
|
||
// 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];
|
||
const notReady = this.els.some(e => e.readyState < 3 && !e.ended);
|
||
if (notReady) {
|
||
if (!this.stalled) { this.els.forEach(e => e.pause()); this.stalled = true; UI.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(); UI.transport(); }
|
||
});
|
||
UI.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);
|
||
|
||
// ---- queue -----------------------------------------------------------------------------
|
||
|
||
let uidSeq = Date.now();
|
||
const wrap = t => ({ uid: uidSeq++, t });
|
||
const Q = {
|
||
items: [], orig: null, i: -1,
|
||
cur() { return this.items[this.i]?.t; },
|
||
|
||
playList(tracks, start = 0, shuffle = S.shuffle) {
|
||
if (!tracks.length) return;
|
||
const all = tracks.map(wrap);
|
||
if (shuffle) {
|
||
const first = all[start] && start >= 0 ? 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(wrap);
|
||
if (next) { this.items.splice(this.i + 1, 0, ...w); this.orig?.push(...w); }
|
||
else { this.items.push(...w); this.orig?.push(...w); }
|
||
if (this.i < 0) { this.i = 0; this.start(0, false); }
|
||
UI.drawer(); save();
|
||
toast(tracks.length === 1 ? `Added “${tracks[0].title}”` : `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--;
|
||
UI.drawer(); save();
|
||
},
|
||
jump(idx) { this.i = idx; this.start(0, true); },
|
||
|
||
start(posSec, play) {
|
||
const t = this.cur();
|
||
if (!t) { E.unload(); UI.all(); return; }
|
||
const e = Stems.find(t);
|
||
const srcs = e
|
||
? e.stems.map(s => ({ name: s.name, url: Stems.url(e, s) }))
|
||
: [{ name: null, url: `${S.plex?.url}${t.part}?X-Plex-Token=${S.plex?.token}` }];
|
||
E.load(srcs, posSec, play);
|
||
Wave.load(e);
|
||
Stems.prepare(e);
|
||
this.prepareNext();
|
||
UI.all(); save();
|
||
},
|
||
|
||
// Transcode the next track only once this one is playing and fully buffered, so they don't compete.
|
||
prepareNext() {
|
||
clearInterval(this.nextTimer);
|
||
const nt = this.items[this.i + 1]?.t;
|
||
if (!nt) return;
|
||
const ready = () => E.playing && !E.buffering && E.els.length
|
||
&& 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);
|
||
Stems.prepare(Stems.find(nt));
|
||
}, 1000);
|
||
},
|
||
|
||
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); UI.transport(); }
|
||
},
|
||
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;
|
||
UI.transport(); UI.drawer(); save();
|
||
},
|
||
cycleRepeat() {
|
||
S.repeat = { off: "all", all: "one", one: "off" }[S.repeat];
|
||
UI.transport(); save();
|
||
},
|
||
};
|
||
E.onEnded = () => Q.next(true);
|
||
|
||
function setSpeed(v) {
|
||
S.speed = v;
|
||
E.setSpeed(v);
|
||
UI.transport(); save();
|
||
}
|
||
function mixChanged() { E.applyGains(); Wave.draw(); UI.drawer(); save(); }
|
||
|
||
// ---- waveform --------------------------------------------------------------------------
|
||
|
||
const Wave = {
|
||
peaks: null, key: null, canvas: $("#wave"),
|
||
async load(e) {
|
||
this.peaks = null; this.key = e?.key || null;
|
||
this.draw();
|
||
if (!e) return;
|
||
try {
|
||
const r = await fetch(`${stemBase()}/api/peaks${stemQuery({ key: e.key, n: 300 })}`);
|
||
if (!r.ok || this.key !== e.key) return;
|
||
this.peaks = (await r.json()).stems;
|
||
this.draw();
|
||
} catch {}
|
||
},
|
||
draw() {
|
||
const c = this.canvas, dpr = devicePixelRatio || 1;
|
||
const w = c.clientWidth, h = c.clientHeight;
|
||
if (!w) return;
|
||
if (c.width !== w * dpr) { c.width = w * dpr; c.height = h * dpr; }
|
||
const x = c.getContext("2d");
|
||
x.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
x.clearRect(0, 0, w, h);
|
||
const dur = E.duration(), frac = dur ? Math.min(1, E.position() / dur) : 0;
|
||
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent");
|
||
if (!this.peaks) {
|
||
x.fillStyle = "#ffffff22"; x.fillRect(0, h / 2 - 2, w, 4);
|
||
x.fillStyle = accent; x.fillRect(0, h / 2 - 2, w * frac, 4);
|
||
return;
|
||
}
|
||
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
|
||
const full = new Float32Array(n), mixed = new Float32Array(n);
|
||
for (const nm of names) {
|
||
const g = stemGain(nm, names);
|
||
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
|
||
}
|
||
const max = Math.sqrt(Math.max(...full)) || 1;
|
||
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
|
||
for (let b = 0; b < bars; b++) {
|
||
const i = Math.floor(b / bars * n);
|
||
const v = Math.sqrt(mixed[i]) / max, ghost = Math.sqrt(full[i]) / max;
|
||
const cx = b * bw;
|
||
x.fillStyle = "#ffffff12";
|
||
const gh = Math.max(2, ghost * h);
|
||
x.fillRect(cx, (h - gh) / 2, bw - 1, gh);
|
||
x.fillStyle = b / bars < frac ? accent : "#ffffff40";
|
||
const bh = Math.max(2, v * h);
|
||
x.fillRect(cx, (h - bh) / 2, bw - 1, bh);
|
||
}
|
||
},
|
||
};
|
||
{
|
||
let drag = false;
|
||
const at = ev => { const r = Wave.canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) * E.duration(); };
|
||
Wave.canvas.addEventListener("pointerdown", ev => { drag = true; Wave.canvas.setPointerCapture(ev.pointerId); E.seek(at(ev)); });
|
||
Wave.canvas.addEventListener("pointermove", ev => { if (drag) E.seek(at(ev)); });
|
||
Wave.canvas.addEventListener("pointerup", () => { drag = false; save(); });
|
||
new ResizeObserver(() => Wave.draw()).observe(Wave.canvas);
|
||
}
|
||
|
||
// ---- player UI -------------------------------------------------------------------------
|
||
|
||
let drawerMode = null; // "mixer" | "queue"
|
||
|
||
const UI = {
|
||
all() { this.track(); this.transport(); this.progress(); this.drawer(); markPlaying(); },
|
||
track() {
|
||
const t = Q.cur();
|
||
$("#player").classList.toggle("empty", !t);
|
||
$("#np-title").textContent = t?.title || "Nothing playing";
|
||
$("#np-sub").textContent = t ? [t.artist, t.album].filter(Boolean).join(" · ") : "";
|
||
const art = t ? plexImg(t.thumb, 120) : "";
|
||
if (art) $("#np-art").src = art; else $("#np-art").removeAttribute("src");
|
||
document.title = t ? `${t.title} · Stemify` : "Stemify";
|
||
if ("mediaSession" in navigator) {
|
||
navigator.mediaSession.metadata = t ? new MediaMetadata({
|
||
title: t.title, artist: t.artist, album: t.album,
|
||
artwork: t.thumb ? [{ src: plexImg(t.thumb, 512), sizes: "512x512" }] : [],
|
||
}) : null;
|
||
}
|
||
},
|
||
transport() {
|
||
const p = $("#b-play");
|
||
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
|
||
p.classList.toggle("busy", E.buffering);
|
||
$("#b-shuffle").classList.toggle("on", S.shuffle);
|
||
$("#b-repeat").innerHTML = S.repeat === "one" ? ICON.repeat1 : ICON.repeat;
|
||
$("#b-repeat").classList.toggle("on", S.repeat !== "off");
|
||
$("#b-repeat").title = `Repeat: ${S.repeat} (R)`;
|
||
$("#speed").value = String(S.speed);
|
||
$("#speed").classList.toggle("on", S.speed !== 1);
|
||
$("#b-mixer").classList.toggle("on", drawerMode === "mixer");
|
||
$("#b-queue").classList.toggle("on", drawerMode === "queue");
|
||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused";
|
||
},
|
||
progress() {
|
||
$("#t-pos").textContent = fmtTime(E.position());
|
||
$("#t-dur").textContent = fmtTime(E.duration());
|
||
Wave.draw();
|
||
const d = E.duration();
|
||
if (d && navigator.mediaSession?.setPositionState) {
|
||
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
|
||
}
|
||
},
|
||
drawer() {
|
||
const d = $("#drawer");
|
||
d.hidden = !drawerMode;
|
||
if (drawerMode === "mixer") d.innerHTML = mixerHtml();
|
||
else if (drawerMode === "queue") d.innerHTML = queueHtml();
|
||
this.transport();
|
||
},
|
||
};
|
||
|
||
function mixerHtml() {
|
||
const names = E.names.filter(Boolean);
|
||
if (!names.length) return `<h2>Mixer</h2><p class="note">${Q.cur() ? "This track has no stems yet." : "Play something with stems to mix it."}</p>`;
|
||
const cur = currentPreset(names);
|
||
return `<h2>Mixer</h2>
|
||
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
|
||
${names.map(n => {
|
||
const s = setting(n), g = stemGain(n, names);
|
||
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
|
||
<button class="ms ${s.muted ? "on" : ""}" data-mute="${esc(n)}" title="Mute">M</button>
|
||
<button class="ms solo ${s.solo ? "on" : ""}" data-solo="${esc(n)}" title="Solo">S</button>
|
||
<input type="range" min="0" max="1" step="0.01" value="${s.level}" data-level="${esc(n)}"></div>`;
|
||
}).join("")}
|
||
<p class="note">The mix carries over to the next track.</p>`;
|
||
}
|
||
|
||
function queueHtml() {
|
||
if (!Q.items.length) return `<h2>Queue</h2><p class="note">Empty.</p>`;
|
||
return `<h2>Queue</h2>${Q.items.map((x, i) => `
|
||
<div class="q-item ${i === Q.i ? "cur" : i < Q.i ? "past" : ""}" data-q="${i}">
|
||
<img loading="lazy" ${x.t.thumb ? `src="${esc(plexImg(x.t.thumb, 80))}"` : ""} alt="">
|
||
<div class="tx"><div>${esc(x.t.title)}</div><div>${esc(x.t.artist)}</div></div>
|
||
${i === Q.i ? "" : `<button class="x" data-qx="${i}" title="Remove">×</button>`}
|
||
</div>`).join("")}`;
|
||
}
|
||
|
||
$("#drawer").addEventListener("click", ev => {
|
||
const b = ev.target.closest("button, .q-item");
|
||
if (!b) return;
|
||
const names = E.names.filter(Boolean);
|
||
if (b.dataset.preset) applyPreset(PRESETS[+b.dataset.preset][1], names);
|
||
else if (b.dataset.mute) { const n = b.dataset.mute; S.mix[n] = { ...setting(n), muted: !setting(n).muted }; mixChanged(); }
|
||
else if (b.dataset.solo) { const n = b.dataset.solo; S.mix[n] = { ...setting(n), solo: !setting(n).solo }; mixChanged(); }
|
||
else if (b.dataset.qx) Q.remove(+b.dataset.qx);
|
||
else if (b.dataset.q) Q.jump(+b.dataset.q);
|
||
});
|
||
$("#drawer").addEventListener("input", ev => {
|
||
const n = ev.target.dataset.level;
|
||
if (!n) return;
|
||
S.mix[n] = { ...setting(n), level: +ev.target.value };
|
||
E.applyGains(); Wave.draw(); save();
|
||
});
|
||
$("#drawer").addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); });
|
||
|
||
$("#b-play").innerHTML = ICON.play;
|
||
$("#b-prev").innerHTML = ICON.prev;
|
||
$("#b-next").innerHTML = ICON.next;
|
||
$("#b-shuffle").innerHTML = ICON.shuffle;
|
||
$("#b-mixer").innerHTML = ICON.mixer;
|
||
$("#b-queue").innerHTML = ICON.queue;
|
||
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
|
||
$("#volume").value = S.volume;
|
||
|
||
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); UI.transport(); save(); };
|
||
const toggleDrawer = m => { drawerMode = drawerMode === m ? null : m; UI.drawer(); };
|
||
$("#b-play").onclick = togglePlay;
|
||
$("#b-prev").onclick = () => Q.prev();
|
||
$("#b-next").onclick = () => Q.next();
|
||
$("#b-shuffle").onclick = () => Q.toggleShuffle();
|
||
$("#b-repeat").onclick = () => Q.cycleRepeat();
|
||
$("#b-mixer").onclick = () => toggleDrawer("mixer");
|
||
$("#b-queue").onclick = () => toggleDrawer("queue");
|
||
$("#speed").onchange = ev => setSpeed(+ev.target.value);
|
||
$("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); };
|
||
|
||
if ("mediaSession" in navigator) {
|
||
const ms = navigator.mediaSession;
|
||
ms.setActionHandler("play", () => { E.play(); UI.transport(); });
|
||
ms.setActionHandler("pause", () => { E.pause(); UI.transport(); });
|
||
ms.setActionHandler("previoustrack", () => Q.prev());
|
||
ms.setActionHandler("nexttrack", () => Q.next());
|
||
try { ms.setActionHandler("seekto", d => E.seek(d.seekTime)); } catch {}
|
||
}
|
||
|
||
document.addEventListener("keydown", ev => {
|
||
if (ev.target.closest("input, select, textarea") || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||
const k = ev.key.toLowerCase();
|
||
if (k === " ") { ev.preventDefault(); togglePlay(); }
|
||
else if (k === "arrowright") E.seek(Math.min(E.duration(), E.position() + (ev.shiftKey ? 30 : 5)));
|
||
else if (k === "arrowleft") E.seek(Math.max(0, E.position() - (ev.shiftKey ? 30 : 5)));
|
||
else if (k === "s") Q.toggleShuffle();
|
||
else if (k === "r") Q.cycleRepeat();
|
||
else if (k === "m") toggleDrawer("mixer");
|
||
else if (k === "q") toggleDrawer("queue");
|
||
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
|
||
});
|
||
|
||
(function frame() {
|
||
if (E.playing) {
|
||
$("#t-pos").textContent = fmtTime(E.position());
|
||
Wave.draw();
|
||
}
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
setInterval(() => { if (E.playing) { UI.progress(); save(); } }, 5000);
|
||
|
||
function toast(msg) {
|
||
let t = $("#toast");
|
||
if (!t) {
|
||
t = document.createElement("div");
|
||
t.id = "toast";
|
||
t.style.cssText = "position:fixed;left:50%;bottom:calc(var(--player-h) + 16px);transform:translateX(-50%);background:var(--raised);padding:9px 16px;border-radius:999px;box-shadow:0 8px 30px #0008;z-index:9;transition:opacity .3s";
|
||
document.body.append(t);
|
||
}
|
||
t.textContent = msg;
|
||
t.style.opacity = 1;
|
||
clearTimeout(t._h);
|
||
t._h = setTimeout(() => { t.style.opacity = 0; }, 2200);
|
||
}
|
||
|
||
// ---- views -----------------------------------------------------------------------------
|
||
|
||
const view = $("#view");
|
||
let lists = {}; // track lists rendered in the current view, by id
|
||
let routeSeq = 0;
|
||
|
||
function markPlaying() {
|
||
const id = Q.cur()?.id;
|
||
view.querySelectorAll("tr[data-id]").forEach(r => r.classList.toggle("playing", r.dataset.id === id));
|
||
}
|
||
|
||
function card(href, img, t, s, round = false) {
|
||
return `<a class="card ${round ? "round" : ""}" href="${href}">${img ? `<img loading="lazy" src="${esc(img)}" alt="">` : `<div class="ph"></div>`}
|
||
<div class="t">${esc(t)}</div><div class="s">${esc(s || "")}</div></a>`;
|
||
}
|
||
|
||
function trackTable(listId, tracks, { numbers = false, album = true } = {}) {
|
||
lists[listId] = tracks;
|
||
return `<table class="tracks"><tbody>${tracks.map((t, i) => `
|
||
<tr data-list="${listId}" data-i="${i}" data-id="${esc(t.id)}">
|
||
<td class="n">${numbers ? t.index ?? i + 1 : i + 1}</td>
|
||
<td class="ti">${esc(t.title)}${Stems.find(t) ? '<span class="stem-dot">STEMS</span>' : ""}${album ? `<small>${esc([t.artist, t.album].filter(Boolean).join(" · "))}</small>` : ""}</td>
|
||
<td class="d">${fmtTime(t.duration / 1000)}</td>
|
||
<td><button class="add" data-add="${i}" title="Add to queue (shift: play next)">+</button></td>
|
||
</tr>`).join("")}</tbody></table>`;
|
||
}
|
||
|
||
view.addEventListener("click", ev => {
|
||
const add = ev.target.closest("[data-add]");
|
||
const row = ev.target.closest("tr[data-list]");
|
||
if (add && row) { Q.add([lists[row.dataset.list][+add.dataset.add]], ev.shiftKey); return; }
|
||
if (row) { Q.playList(lists[row.dataset.list], +row.dataset.i, false); return; }
|
||
const act = ev.target.closest("[data-play]");
|
||
if (act) {
|
||
const tracks = lists[act.dataset.play];
|
||
if (act.dataset.mode === "queue") Q.add(tracks);
|
||
else if (act.dataset.mode === "stem") stemNow(tracks);
|
||
else Q.playList(tracks, act.dataset.mode === "shuffle" ? -1 : 0, act.dataset.mode === "shuffle");
|
||
}
|
||
});
|
||
|
||
async function stemNow(tracks) {
|
||
const seen = new Set();
|
||
const paths = tracks.filter(t => t.file && !Stems.find(t) && !seen.has(t.file) && seen.add(t.file)).map(t => t.file);
|
||
if (!paths.length) return toast("Already has stems");
|
||
try {
|
||
const r = await fetch(stemBase() + "/api/stem" + stemQuery(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paths }) });
|
||
const j = await r.json().catch(() => ({}));
|
||
toast(r.ok ? `Stemming ${paths.length === 1 ? "1 track" : paths.length + " tracks"} next` : `Couldn't queue: ${j.error || r.status}`);
|
||
} catch (e) { toast("Couldn't queue: " + e.message); }
|
||
}
|
||
|
||
const playButtons = id => `<div class="actions">
|
||
<button class="btn primary" data-play="${id}">${ICON.play} Play</button>
|
||
<button class="btn" data-play="${id}" data-mode="shuffle">${ICON.shuffle} Shuffle</button>
|
||
<button class="btn" data-play="${id}" data-mode="queue">${ICON.queue} Add to queue</button>
|
||
<button class="btn" data-play="${id}" data-mode="stem">Stem Now</button></div>`;
|
||
|
||
function needPlex() {
|
||
view.innerHTML = `<h1>Welcome</h1><p class="sub">Sign in to Plex in <a href="#/settings" style="color:var(--accent)">Settings</a> to browse your library, or open <a href="#/stems" style="color:var(--accent)">Stems only</a>.</p>`;
|
||
}
|
||
|
||
// Grid of Plex items, loaded 120 at a time as you scroll.
|
||
async function pagedGrid(seq, path, params, cardFn) {
|
||
const grid = $(".grid", view), more = document.createElement("div");
|
||
more.className = "more";
|
||
grid.after(more);
|
||
let start = 0, total = Infinity, busy = false;
|
||
const next = async () => {
|
||
if (busy || start >= total || seq !== routeSeq) return;
|
||
busy = true;
|
||
const mc = await px(path, { ...params, "X-Plex-Container-Start": start, "X-Plex-Container-Size": 120 });
|
||
if (seq !== routeSeq) return;
|
||
total = mc.totalSize ?? mc.size ?? 0;
|
||
const items = mc.Metadata || [];
|
||
start += items.length;
|
||
if (!items.length) total = start;
|
||
grid.insertAdjacentHTML("beforeend", items.map(cardFn).join(""));
|
||
if (!start) grid.outerHTML = `<p class="empty-msg">Nothing here.</p>`;
|
||
busy = false;
|
||
if (more.getBoundingClientRect().top < innerHeight * 1.5) next();
|
||
};
|
||
new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more);
|
||
await next();
|
||
}
|
||
|
||
const albumCard = m => card(`#/album/${m.ratingKey}`, plexImg(m.thumb), m.title, [m.parentTitle, m.year].filter(Boolean).join(" · "));
|
||
|
||
const routes = {
|
||
async albums(seq) {
|
||
if (!S.plex) return needPlex();
|
||
const sec = await musicSection();
|
||
view.innerHTML = `<div class="bar"><h1>Albums</h1>
|
||
<select id="sort">${[["titleSort", "Title"], ["addedAt:desc", "Recently added"], ["year:desc", "Year"], ["artist.titleSort", "Artist"]]
|
||
.map(([v, l]) => `<option value="${v}" ${S.albumSort === v ? "selected" : ""}>${l}</option>`).join("")}</select></div><div class="grid"></div>`;
|
||
$("#sort").onchange = ev => { S.albumSort = ev.target.value; save(); route(); };
|
||
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 9, sort: S.albumSort }, albumCard);
|
||
},
|
||
async artists(seq) {
|
||
if (!S.plex) return needPlex();
|
||
const sec = await musicSection();
|
||
view.innerHTML = `<div class="bar"><h1>Artists</h1></div><div class="grid"></div>`;
|
||
await pagedGrid(seq, `/library/sections/${sec}/all`, { type: 8, sort: "titleSort" },
|
||
m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true));
|
||
},
|
||
async playlists() {
|
||
if (!S.plex) return needPlex();
|
||
const pls = (await px("/playlists", { playlistType: "audio" })).Metadata || [];
|
||
view.innerHTML = `<div class="bar"><h1>Playlists</h1></div>` + (pls.length
|
||
? `<div class="grid">${pls.map(p => card(`#/playlist/${p.ratingKey}`, plexImg(p.composite || p.thumb), p.title, `${p.leafCount} tracks`)).join("")}</div>`
|
||
: `<p class="empty-msg">No music playlists.</p>`);
|
||
},
|
||
async album(seq, key) {
|
||
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
|
||
const a = info.Metadata?.[0] || {}, tracks = (kids.Metadata || []).map(plexTrack);
|
||
const stems = tracks.filter(t => Stems.find(t)).length;
|
||
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="">
|
||
<div class="meta"><div class="kind">Album</div><h1>${esc(a.title)}</h1>
|
||
<div class="sub"><a href="#/artist/${a.parentRatingKey}">${esc(a.parentTitle)}</a>${a.year ? " · " + a.year : ""} · ${tracks.length} tracks${stems ? ` · ${stems} with stems` : ""}</div>
|
||
${playButtons("album")}</div></div>${trackTable("album", tracks, { numbers: true, album: false })}`;
|
||
},
|
||
async artist(seq, key) {
|
||
const [info, kids] = await Promise.all([px(`/library/metadata/${key}`), px(`/library/metadata/${key}/children`)]);
|
||
const a = info.Metadata?.[0] || {};
|
||
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(a.thumb, 420))}" alt="" style="border-radius:50%">
|
||
<div class="meta"><div class="kind">Artist</div><h1>${esc(a.title)}</h1>${playButtons("artist")}</div></div>
|
||
<h2>Albums</h2><div class="grid">${(kids.Metadata || []).map(albumCard).join("")}</div>`;
|
||
lists.artist = [];
|
||
const all = await px(`/library/metadata/${key}/allLeaves`);
|
||
if (seq === routeSeq) lists.artist = (all.Metadata || []).map(plexTrack);
|
||
},
|
||
async playlist(seq, key) {
|
||
const [info, items] = await Promise.all([px(`/playlists/${key}`), px(`/playlists/${key}/items`)]);
|
||
const p = info.Metadata?.[0] || {}, tracks = (items.Metadata || []).map(plexTrack);
|
||
view.innerHTML = `<div class="hero"><img src="${esc(plexImg(p.composite || p.thumb, 420))}" alt="">
|
||
<div class="meta"><div class="kind">Playlist</div><h1>${esc(p.title)}</h1><div class="sub">${tracks.length} tracks</div>${playButtons("playlist")}</div></div>
|
||
${trackTable("playlist", tracks)}`;
|
||
},
|
||
async search(seq, q) {
|
||
if (!S.plex) return needPlex();
|
||
const hubs = (await px("/hubs/search", { query: q, limit: 12, sectionId: await musicSection() })).Hub || [];
|
||
const hub = t => hubs.find(h => h.type === t)?.Metadata || [];
|
||
const artists = hub("artist"), albums = hub("album"), tracks = hub("track").map(plexTrack);
|
||
view.innerHTML = `<div class="bar"><h1>“${esc(q)}”</h1></div>` +
|
||
(artists.length ? `<h2>Artists</h2><div class="grid">${artists.map(m => card(`#/artist/${m.ratingKey}`, plexImg(m.thumb), m.title, "", true)).join("")}</div>` : "") +
|
||
(albums.length ? `<h2>Albums</h2><div class="grid">${albums.map(albumCard).join("")}</div>` : "") +
|
||
(tracks.length ? `<h2>Songs</h2>${trackTable("search", tracks)}` : "") +
|
||
(!artists.length && !albums.length && !tracks.length ? `<p class="empty-msg">No results.</p>` : "");
|
||
},
|
||
async stems(seq) {
|
||
if (!Stems.entries.length) await Stems.refresh();
|
||
const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id));
|
||
view.innerHTML = `<div class="bar"><h1>Stems only</h1><input type="text" id="sf" placeholder="Filter ${all.length} tracks"></div><div id="sl"></div>`;
|
||
const render = () => {
|
||
const f = $("#sf").value.toLowerCase();
|
||
const hits = f ? all.filter(t => t.stem.key.toLowerCase().includes(f)) : all;
|
||
$("#sl").innerHTML = (hits.length ? trackTable("stems", hits.slice(0, 400)) : `<p class="empty-msg">${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}</p>`) +
|
||
(hits.length > 400 ? `<p class="note">Showing 400 of ${hits.length}. Filter to narrow it down.</p>` : "");
|
||
markPlaying();
|
||
};
|
||
$("#sf").oninput = render;
|
||
render();
|
||
},
|
||
async settings() {
|
||
const info = await fetch(stemBase() + "/api/info" + stemQuery()).then(r => r.ok ? r.json() : null).catch(() => null);
|
||
let secs = [];
|
||
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
|
||
view.innerHTML = `<h1>Settings</h1>
|
||
<h2>Plex</h2><div class="form">
|
||
<p class="note">${S.plex ? `Plex: <b>${esc(S.plex.env ? "via this server" : S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}</b>${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}</p>
|
||
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
|
||
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
|
||
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
|
||
<label>Server URL<input type="url" id="purl" placeholder="http://10.0.0.2:32400" value="${esc(S.plex?.url || "")}"></label>
|
||
<label>Token<input type="password" id="ptok" value="${esc(S.plex?.token || "")}"></label>
|
||
<div class="row"><button class="btn" id="psave">Save</button></div></div></details>
|
||
</div>
|
||
<h2>Stem server</h2><div class="form">
|
||
<p class="note">${info ? `stemserve v${info.version}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : "Can't reach stemserve."}</p>
|
||
<label>URL (blank = this page's server)<input type="url" id="surl" placeholder="${esc(location.origin)}" value="${esc(S.stemUrl)}"></label>
|
||
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
|
||
<label>Stream format<select id="fmt">${[["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]
|
||
.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
|
||
<div class="row"><button class="btn" id="ssave">Save</button></div>
|
||
</div>
|
||
<h2>Keys</h2><p class="note">Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · Q queue</p>`;
|
||
$("#signin").onclick = async () => {
|
||
const st = $("#sstatus");
|
||
try { S.plex = await plexSignIn(m => { st.textContent = m; }); S.section = null; save(); route(); }
|
||
catch (e) { st.textContent = e.message; }
|
||
};
|
||
if ($("#signout")) $("#signout").onclick = () => { S.plex = null; S.section = null; save(); route(); };
|
||
if ($("#sec")) $("#sec").onchange = ev => { S.section = ev.target.value; save(); };
|
||
$("#psave").onclick = () => {
|
||
const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim();
|
||
S.plex = url && token ? { url, token, name: "" } : null; S.section = null; save(); route();
|
||
};
|
||
$("#ssave").onclick = async () => {
|
||
S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); S.fmt = $("#fmt").value;
|
||
Stems.etag = null; save(); await Stems.refresh(); route();
|
||
};
|
||
},
|
||
};
|
||
|
||
async function route() {
|
||
const seq = ++routeSeq;
|
||
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
|
||
const arg = decodeURIComponent(rest.join("/"));
|
||
document.querySelectorAll("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name));
|
||
lists = {};
|
||
view.scrollTop = 0;
|
||
try { await (routes[name] || routes.albums)(seq, arg); }
|
||
catch (e) { if (seq === routeSeq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
|
||
if (seq === routeSeq) markPlaying();
|
||
}
|
||
addEventListener("hashchange", route);
|
||
$("#search").onsubmit = ev => {
|
||
ev.preventDefault();
|
||
const q = ev.target.q.value.trim();
|
||
if (q) location.hash = "#/search/" + encodeURIComponent(q);
|
||
};
|
||
|
||
// Plex from the server's PLEX_URL / PLEX_TOKEN when nothing was signed in by hand, and a
|
||
// local stemserve when this page's own server has none.
|
||
async function autoConnect() {
|
||
const ok = await Stems.refresh();
|
||
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
|
||
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
|
||
try {
|
||
const r = await fetch(u + "/api/info" + stemQuery(), { signal: AbortSignal.timeout(1500) });
|
||
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
|
||
} catch {}
|
||
}
|
||
}
|
||
if (!S.plex || S.plex.env) {
|
||
try {
|
||
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null);
|
||
if (c?.plex) S.plex = { token: c.plex.token, url: stemBase() + "/plex", name: "", env: true };
|
||
else if (S.plex?.env) S.plex = null;
|
||
save();
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// ---- start -----------------------------------------------------------------------------
|
||
|
||
(async () => {
|
||
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
|
||
const saved = load(KEY + "-queue");
|
||
if (saved?.items?.length) {
|
||
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);
|
||
uidSeq = Math.max(uidSeq, ...saved.items.map(x => x.uid + 1));
|
||
if (Q.orig) Q.orig = Q.orig.map(o => Q.items.find(x => x.uid === o.uid) || o);
|
||
Q.start(saved.pos || 0, false);
|
||
}
|
||
UI.all();
|
||
route();
|
||
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
|
||
})();
|