// 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, uuid } 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, setting, stemGain, presets, allPresets, presetLabel, applyPreset, currentPreset, nextPreset, savePreset, deletePreset, movePreset, restorePresets, deleteAllCustom, toggleLock, mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline, Radio } 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(); } // ---- stem filter (Stems Only / No Stems) --------------------------------------------------------------------- // 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 applyStemFilter() { document.body.classList.toggle("stems-only", S.stemFilter === "stems"); document.body.classList.toggle("no-stems", S.stemFilter === "nostems"); $$("[data-stemfilter]").forEach(b => b.classList.toggle("on", S.stemFilter === b.dataset.stemfilter)); 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 st = Radio.station; $("#player").classList.toggle("radio", !!st); if (st) { $("#player").classList.remove("empty"); $("#np-title").textContent = st.name; $("#np-sub").textContent = "Web Radio"; $("#np-art").removeAttribute("src"); document.title = `${st.name} · Stemify`; if ("mediaSession" in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: st.name, artist: "Web Radio" }); return; } 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"), radio = !!Radio.station, playing = radio ? Radio.playing : E.playing, busy = !radio && E.buffering; p.innerHTML = busy ? ICON.busy : playing ? ICON.pause : ICON.play; p.classList.toggle("busy", busy); 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 = 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 && !Radio.station && 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 lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset); 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(p => ``).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." : lock ? `${esc(presetLabel(lock.name))} is locked, so every song starts with it. Long press it to unlock.` : (S.keepMix ? "Songs without a saved mix keep this mix." : "Songs without a saved mix start at full mix.") + " Long press a preset to lock it."}

`; } 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(currentPreset(names)?.id === ds.preset ? allPresets().find(p => p.id === "b:Full Mix") : presets().find(p => p.id === ds.preset), names); else if ("newpreset" in ds) newPreset(); 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); }); // Long press (or right-click) a preset chip to lock it. { let timer = 0, fired = false; const lock = id => { fired = true; clearTimeout(timer); toggleLock(id, mixNames()); }; d$.addEventListener("pointerdown", ev => { fired = false; const b = ev.target.closest("[data-preset]"); if (b && !ev.button) timer = setTimeout(() => lock(b.dataset.preset), 550); }); for (const e of ["pointerup", "pointerleave", "pointercancel"]) d$.addEventListener(e, () => clearTimeout(timer)); d$.addEventListener("contextmenu", ev => { const b = ev.target.closest("[data-preset]"); if (!b) return; ev.preventDefault(); if (!fired) lock(b.dataset.preset); }); d$.addEventListener("click", ev => { if (fired && ev.target.closest("[data-preset]")) { ev.stopImmediatePropagation(); fired = false; } }, true); } function newPreset() { const n = prompt("Name this preset"); if (n == null) return; const err = savePreset(n, S.mix); toast(err || `Saved “${n.trim()}”`); } { 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 (Radio.station) return Radio.toggle(); if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); }; $("#b-play").onclick = togglePlay; $("#b-prev").onclick = () => Q.prev(); { // Hold Next to skip the rest of the album. const nb = $("#b-next"); let timer = 0, held = false; nb.addEventListener("pointerdown", ev => { held = false; if (!ev.button) timer = setTimeout(() => { held = true; Q.skipAlbum(); }, 600); }); for (const e of ["pointerup", "pointerleave", "pointercancel"]) nb.addEventListener(e, () => clearTimeout(timer)); nb.addEventListener("contextmenu", ev => ev.preventDefault()); nb.onclick = () => { if (held) held = false; else 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(); }; function applyBar() { $("#speed").style.display = S.bar.speed ? "" : "none"; for (const k of ["mixer", "lyrics", "queue"]) $(`#b-${k}`).style.display = S.bar[k] ? "" : "none"; } $(".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", () => Radio.station ? Radio.playing || Radio.toggle() : E.play()); ms.setActionHandler("pause", () => Radio.station ? Radio.playing && Radio.toggle() : 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 === "p") nextPreset(mixNames()); 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("radio", () => { UI.track(); UI.transport(); const el = $("#stations"); if (el) el.innerHTML = stationsHtml(); }); 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, group = null) => saveOffline(tracks, m => m && toast(m, 120000), group); 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, group = null) { 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, group)) }, ]); } // ---- views -------------------------------------------------------------------------------------- let lists = {}, listItems = {}, seq = 0, kick = null, genreCache = null; const visible = tracks => S.stemFilter ? tracks.filter(t => !!Stems.find(t.t || t) === (S.stemFilter === "stems")) : 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.

All of these have stems.

`; } const stemsChip = () => `
${[["stems", "Stems Only"], ["nostems", "No Stems"]].map(([v, l]) => ``).join("")}
`; 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); // Downloads by the album or playlist they came from, newest first. Single songs go under Songs. function dlGroups(recs) { const by = new Map(); for (const r of recs) { const k = r.group?.href || ""; if (!by.has(k)) by.set(k, { g: r.group, recs: [] }); by.get(k).recs.push(r); } const gs = [...by.values()].sort((a, b) => !a.g - !b.g); return gs.map(({ g, recs }, n) => { if (g) recs.sort((a, b) => a.saved - b.saved); return `

${g ? `${esc(g.title)}` : "Songs"}

${plural(recs.length, "track")}, ${fmtBytes(recs.reduce((a, r) => a + r.size, 0))}
${trackTable("dl:" + n, recs.map(r => r.track))}
`; }).join(""); } // Albums whose original release date falls today (or within three days) in an earlier year. function birthdays(albums) { const now = new Date(), y = now.getFullYear(), today = new Date(y, now.getMonth(), now.getDate()); const leap = new Date(y, 1, 29).getMonth() === 1, onDay = [], week = []; for (const m of albums) { const d = m.originallyAvailableAt || ""; if (d.length < 10) continue; const ry = +d.slice(0, 4), mo = +d.slice(5, 7) - 1; let da = +d.slice(8, 10); if (!ry || ry >= y) continue; if (mo === 1 && da === 29 && !leap) da = 28; const when = new Date(y, mo, da), diff = Math.round((when - today) / 864e5), years = y - ry; if (!diff) onDay.push({ m, years }); else if (Math.abs(diff) <= 3) week.push({ m, years, when, diff }); } onDay.sort((a, b) => b.years - a.years); week.sort((a, b) => a.diff - b.diff || b.years - a.years); return { onDay, week }; } const bdayCard = ({ m, years, when, diff }) => card({ href: `#/album/${m._srv}:${m.ratingKey}`, img: img(m._srv, m.thumb), t: m.title, kind: "album", srv: m._srv, key: m.ratingKey, s: when ? `${diff < 0 ? "Turned" : "Turns"} ${years} · ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `${m.parentTitle} · ${years === 1 ? "1 year ago" : `${years} years ago`}`, }); const shelf = cards => `
${cards.join("")}
`; const stationsHtml = () => S.stations.length ? `
${S.stations.map(st => { const on = Radio.station?.id === st.id, playing = on && Radio.playing; return `
${esc(st.name)}${esc(st.url)}
`; }).join("")}
` : `

No stations yet. Add one with its stream URL.

`; const routes = { async home(my) { const srcs = sources(); if (!srcs.length) return needPlex(); const today = new Date().toLocaleDateString(undefined, { month: "long", day: "numeric" }); view.innerHTML = `

Home

Recent Plays

Loading…

Recently Added

Loading…

On This Day

${esc(today)}

Loading…

Stations

`; const fill = (id, html) => { const el = $(id); if (el && my === seq) { el.querySelector(".note").outerHTML = html; marks(); } }; const fail = id => e => fill(id, `

${esc(e.message)}

`); await Promise.all([ new Pager(srcs, secPath, { type: 9, sort: "lastViewedAt:desc", "viewCount>>": 0 }, 24).next(24) .then(ms => fill("#h-recent", ms.length ? shelf(ms.map(albumCard)) : `

Nothing played yet.

`), fail("#h-recent")), new Pager(srcs, secPath, { type: 10, sort: "addedAt:desc" }, 10).next(10) .then(ms => my === seq && fill("#h-added", trackTable("added", ms.map(m => track(m._srv, m)))), fail("#h-added")), everything(srcs, secPath, { type: 9 }).then(all => { const { onDay, week } = birthdays(all); fill("#h-otd", (onDay.length ? shelf(onDay.map(bdayCard)) : `

${week.length ? "No album birthdays today. Coming up and just past:" : "No albums in your library came out this week in any year."}

`) + (onDay.length && week.length ? `

Coming up and just past

` : "") + (week.length ? shelf(week.map(bdayCard)) : "")); }, fail("#h-otd")), ]); }, radio() { view.innerHTML = bar("Web Radio", { filter: false }) + `
${stationsHtml()}

Add a station

Any stream the browser can play, like MP3, AAC or Ogg. Playing a station pauses the queue, and playing the queue stops the station.

`; }, 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.

Every song loaded so far has stems. Scroll for more.

`; lists.songs = []; const size = S.stemFilter ? 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 ? dlGroups(recs) : `

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 = "home", ...rest] = (location.hash || "#/home").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.home)(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 (ds.stemfilter) { S.stemFilter = S.stemFilter === ds.stemfilter ? "" : ds.stemfilter; save(); applyStemFilter(); 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 ? "Still loading…" : S.stemFilter === "nostems" ? "All of these have stems" : "None of these have stems yet"); 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] || [], { title: $(".hero h1", view)?.textContent || "", href: location.hash }); } if (ds.station) { try { let ts; if (ds.station === "library") ts = (await new Pager(sources(), secPath, { type: 10, sort: "random" }, 200).next(200)).map(m => track(m._srv, m)); else ts = (await Promise.all((await new Pager(sources(), secPath, { type: 9, sort: "random" }, 10).next(10)).map(m => leaves(m._srv, m.ratingKey)))).flat(); return ts.length ? Q.playList(ts, 0, false) : toast("Nothing to play"); } catch { return toast("Couldn't start station"); } } if ("rdadd" in ds) { const name = $("#rname").value.trim(), url = $("#rurl").value.trim(); if (!name || !/^https?:\/\//i.test(url)) return toast("Need a name and an http(s) stream URL"); S.stations.push({ id: uuid(), name, url }); save(); return route(); } if (ds.radio) { const st = S.stations.find(x => x.id === ds.radio); return Radio.station?.id === st.id && Radio.playing ? Radio.stop() : Radio.play(st); } if (ds.rdrename) { const st = S.stations.find(x => x.id === ds.rdrename), n = prompt("Rename", st.name); if (n?.trim()) { st.name = n.trim(); save(); if (Radio.station?.id === st.id) Radio.station.name = st.name; UI.track(); route(); } return; } if (ds.rddelete) { if (!confirm("Delete this station?")) return; if (Radio.station?.id === ds.rddelete) Radio.stop(); S.stations = S.stations.filter(x => x.id !== ds.rddelete); save(); return route(); } if (ds.dlgroup) { const ts = lists["dl:" + ds.dlgroup] || []; if (confirm(`Remove ${plural(ts.length, "download")}?`)) { for (const t of ts) await Offline.remove(t.id); emit("offline"); route(); } return; } 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), { title: $(".t", c).textContent, href: c.getAttribute("href") }); } }); // ---- settings ----------------------------------------------------------------------------------- const SECTIONS = [["plex", "Plex"], ["stems", "Stem server"], ["stemopts", "Stems"], ["presets", "Stem Editor"], ["karaoke", "Karaoke"], ["look", "Appearance"], ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"], ["about", "About"]]; const BAR = [["speed", "Speed"], ["mixer", "Mixer"], ["lyrics", "Lyrics"], ["queue", "Queue"]]; function presetEditor() { const ps = presets(), lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset); return `

Locked

${lock ? `
${ICON.lock}${esc(presetLabel(lock.name))}
` : `

Nothing locked.

`}

Every song starts with this mix, whether or not Keep Stem Mix Between Songs is on. Long press a preset in the mixer to lock or unlock it.

Mixer Bar

${ps.map((p, i) => `
${esc(presetLabel(p.name))}${p.custom ? ' CUSTOM' : ""}
`).join("")}

Stems

`; } on("presets", () => { const el = $("#pedit"); if (el) el.innerHTML = presetEditor(); }); 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…

Stems

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

How many upcoming songs load their stems in the background. Each one is held in memory, so keep it low on phones. FLAC loads one at most.

Songs without a saved mix keep the current mix. When it's off they start at full mix, and karaoke turns off.

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

Stem Editor

${presetEditor()}

Karaoke

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

Appearance

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

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 · P next preset · hold Next to skip the album · right-click a song or album for more

About

Stemify

Your Plex and Jellyfin music, split into stems you can mix.

Chris NutterStemify WebUI and the iOS app
Kevin Viersstemd and the Android app
GitHub

Stemify is free. A coffee goes straight to the person you pick.

`; // 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"); const v = $("#about-ver"); if (v && info) v.textContent = `stemserve v${info.version}`; 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; if (!S.keepMix && S.karaoke) setKaraoke(false); save(); }; const ahead = v => { S.precache = Math.max(0, Math.min(50, Math.round(+v || 0))); $("#precache").value = $("#precachen").value = S.precache; save(); }; $("#precache").oninput = ev => ahead(ev.target.value); $("#precachen").onchange = ev => ahead(ev.target.value); $("#acapella").onchange = ev => { S.acapella = ev.target.checked; save(); emit("presets"); emit("mix"); }; 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], [data-plock], [data-pmove], [data-pdel], [data-padd], [data-prestore], [data-pclear]"); 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(); } else if (ds.plock) toggleLock(ds.plock, mixNames()); else if (ds.pmove) movePreset(ds.pmove, +ds.pdir); else if (ds.pdel) { if (ds.pdel.startsWith("b:") || confirm("Delete this preset?")) deletePreset(ds.pdel); } else if ("padd" in ds) newPreset(); else if ("prestore" in ds) restorePresets(); else if ("pclear" in ds && confirm("Delete every custom preset?")) deleteAllCustom(); }); sv.addEventListener("change", ev => { const bk = ev.target.dataset.bar; if (bk) { S.bar = { ...S.bar, [bk]: ev.target.checked }; save(); applyBar(); return; } 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(); applyStemFilter(); applyBar(); 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); })();