// 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 + `
${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() + `${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 `${esc(l.text) || "♪"}
`).join("") : esc(d.plain || "").replace(/\n/g, "Nothing here.
`; return `None of these have stems yet.
`; } const stemsChip = () => ``; function sortControl(screen) { const st = sortState(screen); return `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 ? `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 = `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 }) + `` + (playlists.length ? `Stem playlists remember a mix for each song. Right-click any song to add it.
`) + `${liveServers().length ? "Loading…" : "No Plex server set up."}
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") + `` + 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) + `Loading…
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) + `` + 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 ? `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 = `${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 `Same Plex server as ${esc(dup.name)}, which is used instead.
` : (srv.sections || []).length ? `No music libraries found. Try Refresh.
`}Turn on the music libraries you want. Everything that's on is browsed as one library.
No servers yet.
`}Checking stemserve…
Stems are downloaded whole before a song starts. Opus 160 is about 1 MB per stem-minute; FLAC is around eight times that.
Synced lyrics come from LRCLIB, shown in the Lyrics drawer (L).
Applied live, after the theme. Variables: --bg --panel --raised --line --text --muted --accent --accent-ink --radius --wave --wave-ghost.
${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.
`}Settings, saved mixes and stem playlists. The file includes your Plex tokens.
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