// Stemify web player: views, the player bar, drawers (mixer, lyrics, queue), the context // menu and settings. Playback lives in player.js, Plex in plex.js, stemserve in stems.js. import { $, $$, esc, fmtTime, fmtBytes, plural, on, emit, toast, ICON, store } from "./util.js"; import { S, save, songMixes, saveSongMix, forgetSongMix, playlists, savePlaylists, newPlaylist, addToPlaylist, Offline } from "./store.js"; import { px, img, track, sources, liveServers, server, refreshServer, addServer, manualServer, signIn, sortState, pickSort, comparator, sortTracks, Pager, everything } from "./plex.js"; import { Stems, Live, stemNow, stemTrack, stemBase, api } from "./stems.js"; import { E, Q, PRESETS, setting, stemGain, applyPreset, currentPreset, mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline } from "./player.js"; import { lyricsFor, lineAt } from "./lyrics.js"; const SPEEDS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2]; const view = $("#view"); // ---- appearance ------------------------------------------------------------------------------- const THEMES = { amber: { bg: "#0d0e12", panel: "#15171d", raised: "#1d2028", line: "#262a33", text: "#eceef3", muted: "#8a8f9c", accent: "#f2b544" }, ocean: { bg: "#0a1018", panel: "#101a26", raised: "#172434", line: "#1f2f42", text: "#e6eef7", muted: "#8698ad", accent: "#4cc2ff" }, rose: { bg: "#120c10", panel: "#1b1218", raised: "#251922", line: "#33222e", text: "#f5e9f0", muted: "#a48b99", accent: "#ff6f9f" }, forest: { bg: "#0b110d", panel: "#111a14", raised: "#18241c", line: "#223127", text: "#e8f2ea", muted: "#88a08e", accent: "#62d38a" }, mono: { bg: "#0e0e0e", panel: "#161616", raised: "#202020", line: "#2c2c2c", text: "#f0f0f0", muted: "#8c8c8c", accent: "#f0f0f0" }, oled: { bg: "#000000", panel: "#000000", raised: "#141414", line: "#1e1e1e", text: "#f2f2f2", muted: "#8a8a8a", accent: "#f2b544" }, light: { bg: "#f6f6f3", panel: "#ffffff", raised: "#ebeae5", line: "#dddbd4", text: "#18181b", muted: "#6b6b73", accent: "#c27a06", light: true }, }; const inkFor = hex => { const n = parseInt(hex.slice(1), 16), l = (0.299 * (n >> 16) + 0.587 * (n >> 8 & 255) + 0.114 * (n & 255)) / 255; return l > 0.6 ? "#141414" : "#ffffff"; }; function applyTheme() { const t = THEMES[S.theme] || THEMES.amber, a = S.accent || t.accent; $("#theme").textContent = `:root{--bg:${t.bg};--panel:${t.panel};--raised:${t.raised};--line:${t.line};--text:${t.text};--muted:${t.muted};` + `--accent:${a};--accent-ink:${inkFor(a)};--glass:${t.panel}ee;--hover:${t.text}0d;--shadow:${t.light ? "#0003" : "#0009"};` + `--wave:${t.text}44;--wave-ghost:${t.text}14;color-scheme:${t.light ? "light" : "dark"}}`; $("#custom-css").textContent = S.css || ""; $('meta[name="theme-color"]').content = t.bg; Wave.colors(); } // ---- stems-only filter ------------------------------------------------------------------------ // Tracks know whether they have stems. Album and artist cards are matched by folder name // (stems are stored as Artist/Album/Track), which is close enough for a filter. const nameKey = s => (s || "").toLowerCase().replace(/\s*[([][^)\]]*[)\]]/g, "").replace(/[^\p{L}\p{N}]+/gu, ""); let stemSetCache = null; function stemSets() { if (!stemSetCache) { stemSetCache = { album: new Set(), artist: new Set() }; for (const e of Stems.entries) { const p = e.key.split("/"); stemSetCache.album.add(nameKey(p.at(-2))); stemSetCache.artist.add(nameKey(p.at(-3))); } } return stemSetCache; } const cardHasStems = (kind, title) => stemSets()[kind]?.has(nameKey(title)) ?? true; function applyStemsOnly() { document.body.classList.toggle("stems-only", S.stemsOnly); $$("[data-stemsonly]").forEach(b => b.classList.toggle("on", S.stemsOnly)); kick?.(); } // ---- waveform --------------------------------------------------------------------------------- const Wave = { peaks: null, key: null, canvas: $("#wave"), c: {}, colors() { const cs = getComputedStyle(document.documentElement), v = n => cs.getPropertyValue(n).trim(); this.c = { accent: v("--accent"), bar: v("--wave"), ghost: v("--wave-ghost") }; this.draw(); }, async load(t) { const e = t && Stems.find(t); this.peaks = null; this.key = t?.id || null; this.draw(); if (!t) return; const key = this.key; let peaks = null; if (Offline.has(t.id)) peaks = (await Offline.get(t.id).catch(() => null))?.peaks; else if (e) peaks = (await fetch(api("/api/peaks", { key: e.key, n: 300 })).then(r => r.ok ? r.json() : null).catch(() => null))?.stems; if (this.key === key && peaks) { this.peaks = peaks; this.draw(); } }, draw() { const c = this.canvas, dpr = devicePixelRatio || 1, 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; if (E.loading) { x.fillStyle = this.c.ghost; x.fillRect(0, h / 2 - 2, w, 4); x.fillStyle = this.c.bar; x.fillRect(0, h / 2 - 2, w * E.loadFrac, 4); return; } if (!this.peaks) { x.fillStyle = this.c.ghost; x.fillRect(0, h / 2 - 2, w, 4); x.fillStyle = this.c.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 gh = Math.max(2, ghost * h), bh = Math.max(2, v * h); x.fillStyle = this.c.ghost; x.fillRect(b * bw, (h - gh) / 2, bw - 1, gh); x.fillStyle = b / bars < frac ? this.c.accent : this.c.bar; x.fillRect(b * bw, (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; saveQueue(); }); new ResizeObserver(() => Wave.draw()).observe(Wave.canvas); } // ---- player bar and drawers ------------------------------------------------------------------- let drawer = null; // "mixer" | "lyrics" | "queue" const Lyr = { id: null, data: null, idx: -1 }; const artOf = (t, size) => t?.srv ? img(t.srv, t.thumb, size) : ""; const UI = { all() { this.track(); this.transport(); this.progress(); this.drawer(); marks(); }, 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 = artOf(t, 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: art ? [{ src: artOf(t, 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); p.title = E.loading ? `Loading stems ${Math.round(E.loadFrac * 100)}%` : "Play (Space)"; $("#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); for (const d of ["mixer", "lyrics", "queue"]) $(`#b-${d}`).classList.toggle("on", drawer === d); $("#b-mixer").classList.toggle("dot", S.karaoke); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = E.playing ? "playing" : "paused"; }, progress() { $("#t-pos").textContent = E.loading ? `${Math.round(E.loadFrac * 100)}%` : 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 = !drawer; if (drawer) { const tabs = ``; d.innerHTML = tabs + `
${drawer === "mixer" ? mixerHtml() : drawer === "queue" ? queueHtml() : lyricsShell()}
`; if (drawer === "lyrics") loadLyrics(); if (drawer === "queue") $(".q-item.cur", d)?.scrollIntoView({ block: "center" }); } this.transport(); }, }; const toggleDrawer = m => { drawer = drawer === m || !m ? null : m; UI.drawer(); }; const mixNames = () => E.entry ? E.entry.stems.map(s => s.name).filter(n => E.names.includes(n)) : []; const karaokeHtml = () => `
${[["off", "No vocals"], ["soft", "Soft vocals"], ["on", "Vocals on"]].map(([m, l]) => ``).join("")}
${S.karaokeMode === "soft" ? `` : ""}
`; function mixerHtml() { const names = mixNames(); if (!names.length) return `

${E.loading ? "Loading stems…" : Q.cur() ? "This track has no stems yet. Right-click it and pick Stem Now." : "Play something with stems to mix it."}

`; const cur = currentPreset(names), key = E.entry.key, saved = songMixes[key]; const savedNote = saved ? (sameMix(saved, S.mix, names) ? "Using this song's saved mix." : "Changed from this song's saved mix.") : ""; return karaokeHtml() + `
${PRESETS.map(([label, b], i) => ``).join("")}
${names.map(n => { const s = setting(n), g = stemGain(n, names); return `
${esc(n)}
`; }).join("")}
${saved ? `` : ""}

${savedNote} ${S.karaoke ? "Karaoke is on, so saved mixes are ignored until you turn it off." : S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix."}

`; } function queueHtml() { if (!Q.items.length) return `

Empty.

`; const left = Q.items.length - Q.i - 1; return `
${plural(left, "track")} up next
${Q.items.map((x, i) => `
${artOf(x.t, 80) ? `` : `
`}
${esc(x.t.title)}${x.mix ? ' MIX' : ""}
${esc(x.t.artist)}
${i === Q.i ? "" : ``}
`).join("")}`; } const lyricsShell = () => `
${mixNames().includes("vocals") ? karaokeHtml() : ""}
Looking for lyrics…
`; async function loadLyrics() { const t = Q.cur(), box = $("#lyr"); Lyr.idx = -1; if (!t) { box.textContent = "Nothing playing."; return; } if (Lyr.id !== t.id) { Lyr.id = t.id; Lyr.data = null; Lyr.data = await lyricsFor(t).catch(() => null); } if (Lyr.id !== t.id || drawer !== "lyrics" || !$("#lyr")) return; const d = Lyr.data, el = $("#lyr"); if (!d) { el.textContent = "No lyrics found on LRCLIB."; return; } if (d.instrumental) { el.textContent = "Instrumental."; return; } el.className = d.lines ? "synced" : "plain"; el.innerHTML = d.lines ? d.lines.map((l, i) => `

${esc(l.text) || "♪"}

`).join("") : esc(d.plain || "").replace(/\n/g, "
"); } function tickLyrics() { const lines = Lyr.data?.lines; if (drawer !== "lyrics" || !lines || Lyr.id !== Q.cur()?.id) return; const i = lineAt(lines, E.position()); if (i === Lyr.idx) return; Lyr.idx = i; $$("#lyr p.on").forEach(p => p.classList.remove("on")); const p = $(`#lyr p[data-li="${i}"]`); if (p) { p.classList.add("on"); p.scrollIntoView({ block: "center", behavior: "smooth" }); } } const d$ = $("#drawer"); d$.addEventListener("click", ev => { const b = ev.target.closest("button, .q-item, #lyr p"); if (!b) return; const names = mixNames(), ds = b.dataset; if (ds.drawer != null) toggleDrawer(ds.drawer || null); else if (ds.preset) applyPreset(PRESETS[+ds.preset][1], names); else if (ds.mute) { S.mix[ds.mute] = { ...setting(ds.mute), muted: !setting(ds.mute).muted }; mixChanged(); } else if (ds.solo) { S.mix[ds.solo] = { ...setting(ds.solo), solo: !setting(ds.solo).solo }; mixChanged(); } else if ("kara" in ds) setKaraoke(!S.karaoke); else if (ds.kmode) setKaraokeMode(ds.kmode); else if ("savemix" in ds) { saveSongMix(E.entry.key, S.mix); toast("Saved the mix for this song"); UI.drawer(); } else if ("forgetmix" in ds) { forgetSongMix(E.entry.key); UI.drawer(); } else if ("mixtopl" in ds) { const r = b.getBoundingClientRect(); pickPlaylist(r.left, r.bottom, [Q.cur()], S.mix); } else if ("qclear" in ds) Q.clear(); else if (ds.qx) Q.remove(+ds.qx); else if (ds.q) Q.jump(+ds.q); else if (ds.li) E.seek(Lyr.data.lines[+ds.li].t); }); d$.addEventListener("input", ev => { const ds = ev.target.dataset; if (ds.level) { S.mix[ds.level] = { ...setting(ds.level), level: +ev.target.value }; E.applyGains(); Wave.draw(); save(); } if (ds.softdb) $("#softdb").textContent = ev.target.value + " dB"; }); d$.addEventListener("change", ev => { if (ev.target.dataset.level) UI.drawer(); if (ev.target.dataset.softdb) setKaraokeMode("soft", +ev.target.value); }); { let from = -1; d$.addEventListener("dragstart", ev => { from = +ev.target.closest("[data-q]")?.dataset.q; }); d$.addEventListener("dragover", ev => { if (ev.target.closest("[data-q]")) ev.preventDefault(); }); d$.addEventListener("drop", ev => { const to = ev.target.closest("[data-q]"); if (to && from >= 0) { ev.preventDefault(); Q.move(from, +to.dataset.q); } from = -1; }); } $("#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-lyrics").innerHTML = ICON.lyrics; $("#b-queue").innerHTML = ICON.queue; $("#speed").innerHTML = SPEEDS.map(v => ``).join(""); $("#volume").value = S.volume; const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); }; $("#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-lyrics").onclick = () => toggleDrawer("lyrics"); $("#b-queue").onclick = () => toggleDrawer("queue"); $("#speed").onchange = ev => setSpeed(+ev.target.value); $("#volume").oninput = ev => { S.volume = +ev.target.value; E.applyGains(); save(); }; $(".np").onclick = () => { const t = Q.cur(); if (t?.albumKey) location.hash = `#/album/${t.srv}:${t.albumKey}`; }; if ("mediaSession" in navigator) { const ms = navigator.mediaSession; ms.setActionHandler("play", () => E.play()); ms.setActionHandler("pause", () => E.pause()); 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.key === "Escape") return Menu.close(); 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 === "l") toggleDrawer("lyrics"); else if (k === "q") toggleDrawer("queue"); else if (k === "k") setKaraoke(!S.karaoke); else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); } }); (function frame() { if (E.playing && !E.loading) { $("#t-pos").textContent = fmtTime(E.position()); Wave.draw(); tickLyrics(); } requestAnimationFrame(frame); })(); setInterval(() => { if (E.playing) { UI.progress(); saveQueue(); } }, 5000); on("track", () => { UI.track(); UI.transport(); UI.progress(); Wave.load(Q.cur()); marks(); if (drawer) UI.drawer(); }); on("transport", () => UI.transport()); on("progress", () => UI.progress()); on("loading", () => { UI.transport(); UI.progress(); }); on("attached", () => { UI.progress(); if (drawer === "mixer") UI.drawer(); }); on("queue", () => { if (drawer === "queue") UI.drawer(); }); on("mix", () => { Wave.draw(); UI.transport(); if (drawer === "mixer") UI.drawer(); else if (drawer === "lyrics" && $("#lyrhead")) $("#lyrhead").innerHTML = mixNames().includes("vocals") ? karaokeHtml() : ""; }); // ---- live stem status --------------------------------------------------------------------------- const base = p => (p || "").split("/").pop().replace(/\.[^.]+$/, ""); const badge = t => { if (Stems.find(t)) return 'STEMS'; const s = Live.state(t); return s ? `${{ queued: "QUEUED", stemming: "STEMMING", failed: "FAILED" }[s]}` : ""; }; // Refreshes playing, stem and download markers in whatever is on screen. function marks() { const id = Q.cur()?.id; for (const r of $$("tr[data-list]", view)) { const t = lists[r.dataset.list]?.[+r.dataset.i]; if (!t) continue; r.classList.toggle("playing", t.id === id); r.classList.toggle("nostem", !Stems.find(t)); const b = $(".badge", r), html = badge(t); if (b.innerHTML !== html) b.innerHTML = html; $(".off", r).innerHTML = Offline.has(t.id) ? ICON.download : ""; } for (const c of $$(".card[data-nk]", view)) c.classList.toggle("nostem", !stemSets()[c.dataset.kind]?.has(c.dataset.nk)); } function liveStatus() { const el = $("#live"), n = Live.pending.size; el.hidden = !Live.busy; el.innerHTML = Live.current ? `Stemming “${esc(base(Live.current))}”${n ? ` · ${n} queued` : ""}` : n ? `${n} queued for stems` : ""; } on("live", () => { marks(); liveStatus(); }); on("stems", () => { stemSetCache = null; marks(); }); on("offline", () => marks()); on("newstems", fresh => { toast(fresh.length === 1 ? `Stems ready: ${base(fresh[0].key)}` : `Stems ready for ${fresh.length} tracks`); const t = Q.cur(); if (t && !E.entry && !E.loading && Stems.find(t)) Q.reload(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) Live.poll(); }); // ---- context menu ------------------------------------------------------------------------------- const Menu = { el: null, open(x, y, items) { this.close(); items = items.filter(Boolean); const m = document.createElement("div"); m.id = "menu"; m.innerHTML = items.map((it, i) => it === "-" ? "
" : ``).join(""); document.body.append(m); const r = m.getBoundingClientRect(); m.style.left = Math.max(8, Math.min(x, innerWidth - r.width - 8)) + "px"; m.style.top = Math.max(8, Math.min(y, innerHeight - r.height - 8)) + "px"; m.onclick = ev => { const b = ev.target.closest("[data-mi]"); if (b) { const it = items[+b.dataset.mi]; this.close(); it.run(); } }; this.el = m; }, close() { this.el?.remove(); this.el = null; }, }; addEventListener("pointerdown", ev => { if (Menu.el && !Menu.el.contains(ev.target)) Menu.close(); }, true); addEventListener("blur", () => Menu.close()); view.addEventListener("scroll", () => Menu.close(), { passive: true }); const download = tracks => saveOffline(tracks, m => m && toast(m, 120000)); function stemItem(tracks) { const need = tracks.filter(t => !Stems.find(t)); if (!need.length) return { label: tracks.length === 1 ? "Has stems" : "All have stems" }; if (tracks.length === 1) { const s = Live.state(tracks[0]); if (s === "stemming") return { label: "Stemming now…" }; if (s === "queued") return { label: "Queued for stems" }; if (!tracks[0].file) return { label: "Stem Now (no file path)" }; } return { label: tracks.length === 1 ? "Stem Now" : `Stem Now (${need.length})`, run: () => stemNow(tracks) }; } function pickPlaylist(x, y, tracks, mix = null) { const add = p => { addToPlaylist(p, tracks, mix); toast(`Added to “${p.title}”${mix ? " with this mix" : ""}`); }; Menu.open(x, y, [ { label: "New stem playlist…", run: () => { const n = prompt("Stem playlist name"); if (n?.trim()) add(newPlaylist(n.trim())); } }, playlists.length && "-", ...playlists.map(p => ({ label: p.title, run: () => add(p) })), ]); } function trackMenu(x, y, listId, i) { const t = lists[listId][i], pl = listId.startsWith("spl:") && playlists.find(p => p.id === listId.slice(4)); Menu.open(x, y, [ { label: "Play", run: () => playFrom(listId, i) }, { label: "Play next", run: () => Q.add([listItems[listId]?.[i] || t], true) }, { label: "Add to queue", run: () => Q.add([listItems[listId]?.[i] || t]) }, "-", stemItem([t]), { label: "Add to stem playlist…", run: () => pickPlaylist(x, y, [t]) }, Offline.has(t.id) ? { label: "Remove download", run: () => Offline.remove(t.id).then(() => emit("offline")) } : { label: "Download for offline", run: () => download([t]) }, (t.albumKey || t.artistKey) && "-", t.albumKey && { label: "Go to album", run: () => { location.hash = `#/album/${t.srv}:${t.albumKey}`; } }, t.artistKey && { label: "Go to artist", run: () => { location.hash = `#/artist/${t.srv}:${t.artistKey}`; } }, pl && "-", pl && Q.cur()?.id === t.id && E.entry && { label: "Use the current mix here", run: () => { pl.items[i].mix = JSON.parse(JSON.stringify(S.mix)); savePlaylists(); route(); } }, pl && pl.items[i].mix && { label: "Clear this item's mix", run: () => { pl.items[i].mix = null; savePlaylists(); route(); } }, pl && { label: "Remove from playlist", run: () => { pl.items.splice(i, 1); pl.updated = Date.now(); savePlaylists(); route(); } }, ]); } async function leaves(srv, key) { return ((await px(srv, `/library/metadata/${key}/allLeaves`)).Metadata || []).map(m => track(srv, m)); } function listMenu(x, y, get) { const run = fn => async () => { try { fn(visible(await get())); } catch (e) { toast(e.message); } }; Menu.open(x, y, [ { label: "Play", run: run(ts => Q.playList(ts, 0, false)) }, { label: "Shuffle", run: run(ts => Q.playList(ts, -1, true)) }, { label: "Play next", run: run(ts => Q.add(ts, true)) }, { label: "Add to queue", run: run(ts => Q.add(ts)) }, "-", { label: "Stem Now", run: run(ts => stemNow(ts)) }, { label: "Add to stem playlist…", run: run(ts => pickPlaylist(x, y, ts)) }, { label: "Download for offline", run: run(ts => download(ts)) }, ]); } // ---- views -------------------------------------------------------------------------------------- let lists = {}, listItems = {}, seq = 0, kick = null, genreCache = null; const visible = tracks => S.stemsOnly ? tracks.filter(t => (t.t || t) && Stems.find(t.t || t)) : tracks; function playFrom(listId, i) { const all = listItems[listId] || lists[listId], pick = all[i]; const vis = visible(all); Q.playList(vis.includes(pick) ? vis : all, Math.max(0, (vis.includes(pick) ? vis : all).indexOf(pick)), false); } function card({ href, img: src, t, s = "", round, kind, srv, key }) { const nk = kind === "album" || kind === "artist" ? nameKey(t) : ""; const hide = nk && !stemSets()[kind].has(nk); return ` ${src ? `` : `
`}
${esc(t)}
${esc(s)}
`; } const albumCard = m => card({ href: `#/album/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, s: [m.parentTitle, m.year].filter(Boolean).join(" · "), kind: "album", srv: m._srv, key: m.ratingKey }); const artistCard = m => card({ href: `#/artist/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, round: true, kind: "artist", srv: m._srv, key: m.ratingKey }); const rowHtml = (listId, t, i, { numbers, album = true, mix } = {}) => ` ${numbers ? t.index ?? i + 1 : i + 1} ${esc(t.title)}${badge(t)}${mix ? 'MIX' : ""}${album ? `${esc([t.artist, t.album].filter(Boolean).join(" · "))}` : ""} ${Offline.has(t.id) ? ICON.download : ""} ${fmtTime(t.duration / 1000)} `; function trackTable(listId, tracks, opts = {}) { lists[listId] = tracks; if (!tracks.length) return `

Nothing here.

`; return `${tracks.map((t, i) => rowHtml(listId, t, i, { ...opts, mix: listItems[listId]?.[i]?.mix })).join("")}

None of these have stems yet.

`; } const stemsChip = () => ``; function sortControl(screen) { const st = sortState(screen); return `
${st.random ? `` : ``}
`; } const bar = (title, { sort, filter = true, extra = "" } = {}) => `

${esc(title)}

${extra}${filter ? stemsChip() : ""}${sort ? sortControl(sort) : ""}
`; const actions = id => `
`; const hero = (src, kind, title, sub, id, round) => `
${src ? `` : `
`}
${kind}

${esc(title)}

${sub}
${id ? actions(id) : ""}
`; function needPlex() { view.innerHTML = `

Welcome

Add a Plex server in Settings to browse your library, or browse the stems on their own.

`; } const splitKey = arg => { const i = arg.lastIndexOf(":"); return [arg.slice(0, i), arg.slice(i + 1)]; }; const count = (n, w) => { const c = $("#count"); if (c) c.textContent = n ? plural(n, w) : ""; }; // Appends pages from a Pager as you scroll. `html(items, offset)` renders a page. async function paged(mySeq, target, pager, size, html, word) { const more = document.createElement("div"); more.className = "more"; (target.closest("table") || target).after(more); let busy = false, shown = 0; const next = async () => { if (busy || pager.done || mySeq !== seq) return; busy = true; let items; try { items = await pager.next(size); } catch (e) { more.textContent = e.message; busy = false; return; } if (mySeq !== seq) return; target.insertAdjacentHTML("beforeend", html(items, shown)); shown += items.length; count(pager.total, word); busy = false; if (pager.done && !shown) more.outerHTML = `

Nothing here.

`; else if (!pager.done && more.getBoundingClientRect().top < innerHeight * 1.5) next(); }; kick = next; new IntersectionObserver(es => { if (es[0].isIntersecting) next(); }, { root: view, rootMargin: "600px" }).observe(more); await next(); } const secPath = s => `/library/sections/${s.sec}/all`; async function genres() { if (genreCache && Date.now() - genreCache.at < 10 * 60e3) return genreCache.list; const by = new Map(); for (const g of await everything(sources(), s => `/library/sections/${s.sec}/genre`, { type: 9 })) { const k = g.title.toLowerCase(); if (!by.has(k)) by.set(k, { title: g.title, srcs: [] }); const src = sources().find(s => s.srv.id === g._srv && s.sec === g._sec); by.get(k).srcs.push({ ...src, params: { genre: g.key.match(/(\d+)\D*$/)?.[1] ?? g.key } }); } genreCache = { at: Date.now(), list: [...by.values()].sort((a, b) => a.title.localeCompare(b.title)) }; return genreCache.list; } const hue = s => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7); const routes = { async albums(my) { const srcs = sources(); if (!srcs.length) return needPlex(); view.innerHTML = bar("Albums", { sort: "albums" }) + `
`; await paged(my, $(".grid", view), new Pager(srcs, secPath, { type: 9, sort: sortState("albums").param }), 120, ms => ms.map(albumCard).join(""), "album"); }, async artists(my) { const srcs = sources(); if (!srcs.length) return needPlex(); view.innerHTML = bar("Artists", { sort: "artists" }) + `
`; await paged(my, $(".grid", view), new Pager(srcs, secPath, { type: 8, sort: sortState("artists").param }), 120, ms => ms.map(artistCard).join(""), "artist"); }, async songs(my) { const srcs = sources(); if (!srcs.length) return needPlex(); view.innerHTML = bar("Songs", { sort: "songs", extra: `` }) + `

No songs with stems loaded yet. Scroll for more.

`; lists.songs = []; const size = S.stemsOnly ? 1000 : 150; await paged(my, $("tbody", view), new Pager(srcs, secPath, { type: 10, sort: sortState("songs").param }, size), size, (ms, off) => { const ts = ms.map(m => track(m._srv, m)); lists.songs.push(...ts); return ts.map((t, i) => rowHtml("songs", t, off + i)).join(""); }, "song"); }, async genres(my) { if (!sources().length) return needPlex(); view.innerHTML = bar("Genres", { filter: false }) + `

Loading…

`; const list = await genres(); if (my !== seq) return; count(list.length, "genre"); view.innerHTML = bar("Genres", { filter: false }) + (list.length ? `
${list.map(g => `${esc(g.title)}`).join("")}
` : `

No genres.

`); count(list.length, "genre"); }, async genre(my, title) { const g = (await genres()).find(x => x.title === title); if (my !== seq) return; if (!g) { view.innerHTML = `

${esc(title)}

No albums in this genre.

`; return; } view.innerHTML = bar(g.title, { sort: "albums", extra: `` }) + `
`; view.dataset.genre = g.title; await paged(my, $(".grid", view), new Pager(g.srcs, secPath, { type: 9, sort: sortState("albums").param }), 120, ms => ms.map(albumCard).join(""), "album"); }, async playlists(my) { const st = sortState("playlists"); view.innerHTML = bar("Playlists", { sort: "playlists", filter: false }) + `

Stem playlists

` + (playlists.length ? `
${playlists.map(p => card({ href: `#/stemplaylist/${p.id}`, img: artOf(p.items[0]?.track, 300), t: p.title, s: plural(p.items.length, "track") })).join("")}
` : `

Stem playlists remember a mix for each song. Right-click any song to add it.

`) + `

Plex playlists

${liveServers().length ? "Loading…" : "No Plex server set up."}

`; if (!liveServers().length) return; let pls = await everything(liveServers().map(srv => ({ srv })), "/playlists", { playlistType: "audio" }); if (my !== seq) return; pls = st.random ? pls.sort(() => Math.random() - 0.5) : pls.sort(comparator(st.param)); count(pls.length + playlists.length, "playlist"); $("#plexpl").innerHTML = pls.length ? `
${pls.map(p => card({ href: `#/playlist/${p._srv}:${p.ratingKey}`, img: img(p._srv, p.composite || p.thumb), t: p.title, s: plural(p.leafCount || 0, "track") })).join("")}
` : `

No music playlists.

`; }, async album(my, arg) { const [srv, key] = splitKey(arg); const [info, kids] = await Promise.all([px(srv, `/library/metadata/${key}`), px(srv, `/library/metadata/${key}/children`)]); if (my !== seq) return; const a = info.Metadata?.[0] || {}, tracks = sortTracks((kids.Metadata || []).map(m => track(srv, m)), sortState("album")); const stems = tracks.filter(t => Stems.find(t)).length; view.innerHTML = hero(img(srv, a.thumb, 420), "Album", a.title, `${esc(a.parentTitle)}${a.year ? " · " + a.year : ""} · ${plural(tracks.length, "track")}${stems ? ` · ${stems} with stems` : ""}`, "album") + `
${stemsChip()}${sortControl("album")}
` + trackTable("album", tracks, { numbers: !sortState("album").o[1], album: false }); }, async artist(my, arg) { const [srv, key] = splitKey(arg); const [info, kids] = await Promise.all([px(srv, `/library/metadata/${key}`), px(srv, `/library/metadata/${key}/children`)]); if (my !== seq) return; const a = info.Metadata?.[0] || {}, albums = (kids.Metadata || []).map(m => (m._srv = srv, m)); view.innerHTML = hero(img(srv, a.thumb, 420), "Artist", a.title, plural(albums.length, "album"), "artist", true) + `

Albums

${stemsChip()}
${albums.map(albumCard).join("")}

Songs

${sortControl("artist")}

Loading…

`; const tracks = sortTracks(await leaves(srv, key), sortState("artist")); if (my !== seq) return; $("#artist-songs").innerHTML = trackTable("artist", tracks); }, async playlist(my, arg) { const [srv, key] = splitKey(arg); const [info, items] = await Promise.all([px(srv, `/playlists/${key}`), px(srv, `/playlists/${key}/items`)]); if (my !== seq) return; const p = info.Metadata?.[0] || {}, tracks = sortTracks((items.Metadata || []).map(m => track(srv, m)), sortState("playlist")); view.innerHTML = hero(img(srv, p.composite || p.thumb, 420), "Playlist", p.title, plural(tracks.length, "track"), "playlist") + `
${stemsChip()}${sortControl("playlist")}
` + trackTable("playlist", tracks); }, async stemplaylist(my, id) { const p = playlists.find(x => x.id === id); if (!p) { view.innerHTML = `

Not found

This stem playlist was deleted.

`; return; } const lid = "spl:" + id; listItems[lid] = p.items.map(x => ({ t: x.track, mix: x.mix })); view.innerHTML = hero(artOf(p.items[0]?.track, 420), "Stem playlist", p.title, `${plural(p.items.length, "track")} · ${p.items.filter(x => x.mix).length} with their own mix · · `, lid) + `
${stemsChip()}
` + trackTable(lid, p.items.map(x => x.track)); }, async search(my, q) { const srcs = sources(); if (!srcs.length) return needPlex(); const hubs = (await Promise.all(srcs.map(s => px(s.srv, "/hubs/search", { query: q, limit: 12, sectionId: s.sec }) .then(mc => (mc.Hub || []).map(h => ({ ...h, Metadata: (h.Metadata || []).map(m => (m._srv = s.srv.id, m)) }))).catch(() => [])))).flat(); if (my !== seq) return; const hub = t => hubs.filter(h => h.type === t).flatMap(h => h.Metadata); const artists = hub("artist"), albums = hub("album"), tracks = sortTracks(hub("track").map(m => track(m._srv, m)), sortState("search")); view.innerHTML = bar(`“${q}”`) + (artists.length ? `

Artists

${artists.map(artistCard).join("")}
` : "") + (albums.length ? `

Albums

${albums.map(albumCard).join("")}
` : "") + (tracks.length ? `

Songs

${sortControl("search")}
${trackTable("search", tracks)}` : "") + (!artists.length && !albums.length && !tracks.length ? `

No results.

` : ""); }, async downloads(my) { const recs = (await Offline.all().catch(() => [])).sort((a, b) => b.saved - a.saved); if (my !== seq) return; const total = recs.reduce((a, r) => a + r.size, 0); const est = await navigator.storage?.estimate?.().catch(() => null); view.innerHTML = bar("Downloads", { filter: false, extra: recs.length ? `` : "" }) + `

${plural(recs.length, "track")}, ${fmtBytes(total)} stored in this browser${est?.quota ? ` (${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} used)` : ""}. Downloads play without the network. ${isSecureContext ? "" : "This page isn't on HTTPS, so the browser may clear them when space runs low."}

` + (recs.length ? trackTable("downloads", recs.map(r => r.track)) : `

Nothing downloaded. Right-click a song or album and pick Download for offline.

`); count(recs.length, "track"); }, async stems(my) { if (!Stems.entries.length) await Stems.refresh(); if (my !== seq) return; const all = Stems.entries.map(stemTrack).sort((a, b) => a.id.localeCompare(b.id)); view.innerHTML = bar("All stems", { filter: false, extra: `` }) + `
`; 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)) : `

${Stems.ok ? "No matches." : "Can't reach stemserve. Check Settings."}

`) + (hits.length > 400 ? `

Showing 400 of ${hits.length}. Filter to narrow it down.

` : ""); }; $("#sf").oninput = render; render(); }, settings: renderSettings, }; async function route() { const my = ++seq; const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/"); const arg = decodeURIComponent(rest.join("/")); $$("#nav a").forEach(a => a.classList.toggle("on", a.dataset.nav === name || (a.dataset.nav === "genres" && name === "genre") || (a.dataset.nav === "playlists" && /playlist$/.test(name)))); lists = {}; listItems = {}; kick = null; delete view.dataset.genre; view.scrollTop = 0; Menu.close(); try { await (routes[name] || routes.albums)(my, arg); } catch (e) { if (my === seq) view.innerHTML = `

Something went wrong

${esc(e.message)}

`; } if (my === seq) marks(); } addEventListener("hashchange", route); $("#search").onsubmit = ev => { ev.preventDefault(); const q = ev.target.q.value.trim(); if (q) location.hash = "#/search/" + encodeURIComponent(q); }; view.addEventListener("click", async ev => { const el = ev.target.closest("button, tr[data-list]"); if (!el) return; const ds = el.dataset, row = ev.target.closest("tr[data-list]"); if ("stemsonly" in ds) { S.stemsOnly = !S.stemsOnly; save(); applyStemsOnly(); return; } if ("sortdir" in ds) { pickSort(ds.sortdir, sortState(ds.sortdir).i); return route(); } if ("reshuffle" in ds) return route(); if ("add" in ds && row) return Q.add([listItems[row.dataset.list]?.[+row.dataset.i] || lists[row.dataset.list][+row.dataset.i]], ev.shiftKey); if ("menu" in ds && row) { const r = el.getBoundingClientRect(); return trackMenu(r.left, r.bottom, row.dataset.list, +row.dataset.i); } if (el === row) return playFrom(row.dataset.list, +row.dataset.i); if (ds.play) { const all = listItems[ds.play] || lists[ds.play] || [], tracks = visible(all); if (ds.mode === "stem") return stemNow(all.map(x => x.t || x)); if (!tracks.length) return toast(all.length ? "None of these have stems yet" : "Still loading…"); return Q.playList(tracks, ds.mode === "shuffle" ? -1 : 0, ds.mode === "shuffle"); } if (ds.listmenu) { const r = el.getBoundingClientRect(); return listMenu(r.left, r.bottom, async () => listItems[ds.listmenu] || lists[ds.listmenu] || []); } if ("shuffleall" in ds) { const ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m)); return Q.playList(visible(ts), -1, true); } if (ds.genreplay) { const g = (await genres()).find(x => x.title === view.dataset.genre); const albums = await new Pager(g.srcs, secPath, { type: 9, sort: "random" }, 12).next(12); const ts = (await Promise.all(albums.map(m => leaves(m._srv, m.ratingKey)))).flat(); return Q.playList(visible(ts), -1, true); } if ("newpl" in ds) { const n = prompt("Stem playlist name"); if (n?.trim()) { newPlaylist(n.trim()); route(); } return; } if (ds.plrename) { const p = playlists.find(x => x.id === ds.plrename), n = prompt("Rename", p.title); if (n?.trim()) { p.title = n.trim(); savePlaylists(); route(); } return; } if (ds.pldelete) { if (confirm("Delete this stem playlist?")) { playlists.splice(playlists.findIndex(x => x.id === ds.pldelete), 1); savePlaylists(); location.hash = "#/playlists"; } return; } if ("dlclear" in ds) { if (confirm("Remove every download?")) { for (const id of [...Offline.ids]) await Offline.remove(id); emit("offline"); route(); } } }); view.addEventListener("change", ev => { const s = ev.target.dataset.sort; if (s) { pickSort(s, +ev.target.value); route(); } }); view.addEventListener("contextmenu", ev => { const row = ev.target.closest("tr[data-list]"), c = ev.target.closest(".card[data-kind]"); if (row) { ev.preventDefault(); trackMenu(ev.clientX, ev.clientY, row.dataset.list, +row.dataset.i); } else if (c) { ev.preventDefault(); listMenu(ev.clientX, ev.clientY, () => leaves(c.dataset.srv, c.dataset.key)); } }); // ---- settings ----------------------------------------------------------------------------------- const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["playback", "Playback"], ["karaoke", "Karaoke"], ["look", "Appearance"], ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"]]; const FORMATS = [["opus96", "Opus 96 kbps"], ["opus160", "Opus 160 kbps"], ["opus256", "Opus 256 kbps"], ["flac", "FLAC (original)"]]; function serverHtml(srv) { const dup = srv.mid && S.servers.find(o => o !== srv && o.mid === srv.mid && S.servers.indexOf(o) < S.servers.indexOf(srv)); return `
${esc(srv.name || srv.url)} ${srv.env ? "through this stemserve (PLEX_URL)" : esc(srv.url)} ${srv.env ? "" : ``}
${dup ? `

Same Plex server as ${esc(dup.name)}, which is used instead.

` : (srv.sections || []).length ? `
${srv.sections.map(sec => ``).join("")}
` : `

No music libraries found. Try Refresh.

`}
`; } function renderSettings() { const theme = THEMES[S.theme] || THEMES.amber; view.innerHTML = `

Plex

Turn on the music libraries you want. Everything that's on is browsed as one library.

${S.servers.map(serverHtml).join("") || `

No servers yet.

`}
Add a server by URL and token

Stem server

Checking stemserve…

Playback

Stems are downloaded whole before a song starts. Opus 160 is about 1 MB per stem-minute; FLAC is around eight times that.

${plural(Object.keys(songMixes).length, "saved song mix")}${Object.keys(songMixes).length ? `` : ""}

Karaoke

Synced lyrics come from LRCLIB, shown in the Lyrics drawer (L).

Appearance

${Object.entries(THEMES).map(([k, t]) => ``).join("")}
${S.accent ? `` : ""}

Applied live, after the theme. Variables: --bg --panel --raised --line --text --muted --accent --accent-ink --radius --wave --wave-ghost.

Offline

${plural(Offline.ids.size, "track")} downloaded. Manage downloads

${isSecureContext && navigator.storage?.persist ? `
` : `

Over plain HTTP the browser may clear downloads when it's short on space.

`}

Backup

Settings, saved mixes and stem playlists. The file includes your Plex tokens.

Keys

Space play/pause · ←/→ seek 5s (shift 30s) · S shuffle · R repeat · [ ] speed · M mixer · L lyrics · Q queue · K karaoke · right-click a song or album for more

`; // Slow bits fill in after the page is up. fetch(api("/api/info"), { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null).then(info => { const el = $("#stem-info"); if (el) el.textContent = info ? `stemserve v${info.version} at ${stemBase()}, ${info.tracks} tracks with stems${info.ffmpeg ? ", Opus available" : ""}.` : `Can't reach stemserve at ${stemBase()}.`; }); navigator.storage?.estimate?.().then(e => { const el = $("#storage-info"); if (el && e?.quota) el.insertAdjacentText("afterbegin", `${fmtBytes(e.usage)} of ${fmtBytes(e.quota)} used. `); }).catch(() => {}); const again = () => { if (location.hash.startsWith("#/settings")) renderSettings(); }; const plexChanged = () => { genreCache = null; save(); }; $("#signin").onclick = async () => { const st = $("#sstatus"); try { const ok = await signIn(m => { st.textContent = m; }); plexChanged(); toast(`Added ${plural(ok.length, "server")}`); again(); } catch (e) { st.textContent = e.message; } }; $("#padd").onclick = async () => { const url = $("#purl").value.trim().replace(/\/+$/, ""), token = $("#ptok").value.trim(); if (!url || !token) return toast("Need both a URL and a token"); const srv = addServer(manualServer(url, token)); try { await refreshServer(srv); } catch (e) { toast(e.message); } plexChanged(); again(); }; $("#ssave").onclick = async () => { S.stemUrl = $("#surl").value.trim(); S.stemToken = $("#stok").value.trim(); Stems.etag = null; save(); await Stems.refresh(); Live.poll(); again(); }; $("#fmt").onchange = ev => { S.fmt = ev.target.value; save(); }; $("#keepmix").onchange = ev => { S.keepMix = ev.target.checked; save(); }; if ($("#forgetall")) $("#forgetall").onclick = () => { for (const k of Object.keys(songMixes)) forgetSongMix(k); again(); }; $("#kon").onchange = ev => setKaraoke(ev.target.checked); $("#kmode").onchange = ev => setKaraokeMode(ev.target.value); $("#kdb").oninput = ev => { $("#kdbv").textContent = ev.target.value + " dB"; }; $("#kdb").onchange = ev => setKaraokeMode(S.karaokeMode, +ev.target.value); $("#accent").oninput = ev => { S.accent = ev.target.value; applyTheme(); save(); }; if ($("#accentreset")) $("#accentreset").onclick = () => { S.accent = ""; applyTheme(); save(); again(); }; let cssTimer; $("#css").oninput = ev => { clearTimeout(cssTimer); cssTimer = setTimeout(() => { S.css = ev.target.value; applyTheme(); save(); }, 250); }; if ($("#persist")) $("#persist").onclick = async () => toast(await navigator.storage.persist() ? "Downloads will be kept" : "The browser said no"); $("#export").onclick = () => { const blob = new Blob([JSON.stringify({ stemify: 1, settings: S, mixes: songMixes, playlists }, null, 2)], { type: "application/json" }); const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "stemify-web.json" }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }; $("#import").onchange = async ev => { try { const j = JSON.parse(await ev.target.files[0].text()); if (!j.stemify) throw new Error("Not a Stemify backup"); store("stemify-web", { ...j.settings, clientId: S.clientId }); if (j.mixes) store("stemify-web-mixes", j.mixes); if (j.playlists) store("stemify-web-playlists", j.playlists); location.reload(); } catch (e) { toast(e.message); } }; const sv = $(".settings"); sv.addEventListener("click", async ev => { const b = ev.target.closest("[data-jump], [data-theme], [data-refsrv], [data-rmsrv]"); if (!b) return; const ds = b.dataset; if (ds.jump) { ev.preventDefault(); $("#set-" + ds.jump).scrollIntoView({ behavior: "smooth" }); } else if (ds.theme) { S.theme = ds.theme; S.accent = ""; applyTheme(); save(); again(); } else if (ds.refsrv) { try { await refreshServer(server(ds.refsrv)); toast("Refreshed"); } catch (e) { toast(e.message); } plexChanged(); again(); } else if (ds.rmsrv && confirm("Remove this server?")) { S.servers = S.servers.filter(s => s.id !== ds.rmsrv); plexChanged(); again(); } }); sv.addEventListener("change", ev => { const k = ev.target.dataset.sec; if (!k) return; const [id, key] = k.split("|"), sec = server(id)?.sections.find(s => s.key === key); if (sec) { sec.on = ev.target.checked; plexChanged(); } }); } on("mix", () => { if ($("#kon")) $("#kon").checked = S.karaoke; }); // ---- start -------------------------------------------------------------------------------------- // The server's own PLEX_URL / PLEX_TOKEN show up as a server proxied through stemserve, and a // local stemserve is tried when this page's server has none. async function connect() { 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", { signal: AbortSignal.timeout(1500) }); if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; } } catch {} } } try { const c = await fetch(api("/api/config"), { signal: AbortSignal.timeout(4000) }).then(r => r.ok ? r.json() : null); if (c?.plex) addServer({ id: "env", env: true, token: c.plex.token, url: stemBase() + "/plex" }); else if (c) S.servers = S.servers.filter(s => !s.env); } catch {} await Promise.all(S.servers.filter(s => !s.sections?.length || !s.mid).map(s => refreshServer(s).catch(() => {}))); save(); } (async () => { applyTheme(); applyStemsOnly(); await Offline.init(); await Promise.race([connect(), new Promise(r => setTimeout(r, 5000))]); Q.restore(); UI.all(); route(); Live.poll(); setInterval(() => Stems.refresh(), 5 * 60 * 1000); })();