Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+262 -40
View File
@@ -1,11 +1,12 @@
// 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 { $, $$, 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, PRESETS, setting, stemGain, applyPreset, currentPreset, mixChanged, setKaraoke, setKaraokeMode, sameMix, setSpeed, saveQueue, saveOffline } from "./player.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];
@@ -36,7 +37,7 @@ function applyTheme() {
Wave.colors();
}
// ---- stems-only filter ------------------------------------------------------------------------
// ---- 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.
@@ -55,9 +56,10 @@ function stemSets() {
}
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));
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?.();
}
@@ -134,6 +136,17 @@ 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";
@@ -148,9 +161,9 @@ const UI = {
}
},
transport() {
const p = $("#b-play");
p.innerHTML = E.buffering ? ICON.busy : E.playing ? ICON.pause : ICON.play;
p.classList.toggle("busy", E.buffering);
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;
@@ -160,14 +173,14 @@ const UI = {
$("#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";
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 && navigator.mediaSession?.setPositionState) {
if (d && !Radio.station && navigator.mediaSession?.setPositionState) {
try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(d, E.position()), playbackRate: S.speed }); } catch {}
}
},
@@ -196,9 +209,11 @@ function mixerHtml() {
const names = mixNames();
if (!names.length) return `<p class="note">${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."}</p>`;
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() + `
<div class="presets">${PRESETS.map(([label, b], i) => `<button class="chip ${cur === label ? "on" : ""}" data-preset="${i}" ${b(names) ? "" : "disabled"}>${label}</button>`).join("")}</div>
<div class="presets">${presets().map(p => `<button class="chip ${cur?.id === p.id ? "on" : ""} ${lock?.id === p.id ? "locked" : ""}" data-preset="${esc(p.id)}" ${p.build(names) ? "" : "disabled"}
title="Tap again for Full Mix. Long press to ${lock?.id === p.id ? "unlock" : "lock"}.">${lock?.id === p.id ? ICON.lock : ""}${esc(presetLabel(p.name))}</button>`).join("")}<button class="chip" data-newpreset title="Save this mix as a preset">+</button></div>
${names.map(n => {
const s = setting(n), g = stemGain(n, names);
return `<div class="stem ${g ? "" : "silent"}"><span class="name">${esc(n)}</span>
@@ -211,7 +226,8 @@ function mixerHtml() {
${saved ? `<button class="btn small" data-forgetmix>Forget</button>` : ""}
<button class="btn small" data-mixtopl>Add to stem playlist…</button>
</div>
<p class="note">${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."}</p>`;
<p class="note">${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."}</p>`;
}
function queueHtml() {
@@ -256,7 +272,8 @@ d$.addEventListener("click", ev => {
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.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);
@@ -278,6 +295,30 @@ 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; });
@@ -295,10 +336,18 @@ $("#b-queue").innerHTML = ICON.queue;
$("#speed").innerHTML = SPEEDS.map(v => `<option value="${v}">${v}×</option>`).join("");
$("#volume").value = S.volume;
const togglePlay = () => { if (!Q.cur()) return; E.playing ? E.pause() : E.play(); saveQueue(); };
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();
$("#b-next").onclick = () => Q.next();
{
// 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");
@@ -306,12 +355,16 @@ $("#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", () => E.play());
ms.setActionHandler("pause", () => E.pause());
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 {}
@@ -330,6 +383,7 @@ document.addEventListener("keydown", ev => {
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]); }
});
@@ -345,6 +399,7 @@ 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();
@@ -411,7 +466,7 @@ addEventListener("pointerdown", ev => { if (Menu.el && !Menu.el.contains(ev.targ
addEventListener("blur", () => Menu.close());
view.addEventListener("scroll", () => Menu.close(), { passive: true });
const download = tracks => saveOffline(tracks, m => m && toast(m, 120000));
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" };
@@ -452,7 +507,7 @@ function trackMenu(x, y, listId, i) {
]);
}
async function leaves(srv, key) { return ((await px(srv, `/library/metadata/${key}/allLeaves`)).Metadata || []).map(m => track(srv, m)); }
function listMenu(x, y, get) {
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)) },
@@ -462,14 +517,14 @@ function listMenu(x, y, get) {
"-",
{ 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)) },
{ label: "Download for offline", run: run(ts => download(ts, group)) },
]);
}
// ---- 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;
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);
@@ -497,10 +552,11 @@ function trackTable(listId, tracks, opts = {}) {
lists[listId] = tracks;
if (!tracks.length) return `<p class="empty-msg">Nothing here.</p>`;
return `<table class="tracks"><tbody>${tracks.map((t, i) => rowHtml(listId, t, i, { ...opts, mix: listItems[listId]?.[i]?.mix })).join("")}</tbody></table>
<p class="empty-msg only-stems">None of these have stems yet.</p>`;
<p class="empty-msg only-stems">None of these have stems yet.</p><p class="empty-msg only-nostems">All of these have stems.</p>`;
}
const stemsChip = () => `<button class="chip ${S.stemsOnly ? "on" : ""}" data-stemsonly title="Only show songs with stems">Stems only</button>`;
const stemsChip = () => `<div class="seg stemfilter">${[["stems", "Stems Only"], ["nostems", "No Stems"]].map(([v, l]) =>
`<button class="${S.stemFilter === v ? "on" : ""}" data-stemfilter="${v}">${l}</button>`).join("")}</div>`;
function sortControl(screen) {
const st = sortState(screen);
return `<div class="sort"><select data-sort="${screen}" title="Sort">${st.opts.map((o, i) => `<option value="${i}" ${i === st.i ? "selected" : ""}>${o[0]}</option>`).join("")}</select>
@@ -563,7 +619,89 @@ async function genres() {
}
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 `<section class="dlgroup"><div class="bar sm"><h2>${g ? `<a href="${esc(g.href)}">${esc(g.title)}</a>` : "Songs"}</h2>
<span class="sub">${plural(recs.length, "track")}, ${fmtBytes(recs.reduce((a, r) => a + r.size, 0))}</span><span class="grow"></span>
<button class="chip" data-dlgroup="${n}">Remove</button></div>${trackTable("dl:" + n, recs.map(r => r.track))}</section>`;
}).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 => `<div class="shelf ${cards.length > 4 ? "two" : ""}">${cards.join("")}</div>`;
const stationsHtml = () => S.stations.length ? `<div class="stationlist">${S.stations.map(st => {
const on = Radio.station?.id === st.id, playing = on && Radio.playing;
return `<div class="stn ${on ? "on" : ""}"><button class="btn icon ${playing ? "primary" : ""}" data-radio="${esc(st.id)}" title="${playing ? "Stop" : "Play"}">${playing ? ICON.pause : ICON.play}</button>
<div class="tx"><b>${esc(st.name)}</b><span class="note">${esc(st.url)}</span></div>
<button class="btn small" data-rdrename="${esc(st.id)}">Rename</button><button class="btn small" data-rddelete="${esc(st.id)}">Delete</button></div>`;
}).join("")}</div>` : `<p class="empty-msg">No stations yet. Add one with its stream URL.</p>`;
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 = `<h1>Home</h1>
<section id="h-recent"><h2>Recent Plays</h2><p class="note">Loading…</p></section>
<section id="h-added"><h2>Recently Added</h2><p class="note">Loading…</p></section>
<section id="h-otd"><h2>On This Day</h2><p class="sub">${esc(today)}</p><p class="note">Loading…</p></section>
<section><h2>Stations</h2><div class="stations">
<button class="station" data-station="library"><b>Library Radio</b><span>Random songs from your whole library</span></button>
<button class="station" data-station="albums"><b>Random Album Radio</b><span>Ten random albums, each played in order</span></button></div></section>`;
const fill = (id, html) => { const el = $(id); if (el && my === seq) { el.querySelector(".note").outerHTML = html; marks(); } };
const fail = id => e => fill(id, `<p class="note">${esc(e.message)}</p>`);
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)) : `<p class="note">Nothing played yet.</p>`), 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)) : `<p class="note">${week.length ? "No album birthdays today. Coming up and just past:" : "No albums in your library came out this week in any year."}</p>`)
+ (onDay.length && week.length ? `<p class="note">Coming up and just past</p>` : "") + (week.length ? shelf(week.map(bdayCard)) : ""));
}, fail("#h-otd")),
]);
},
radio() {
view.innerHTML = bar("Web Radio", { filter: false }) + `<div id="stations">${stationsHtml()}</div>
<h2>Add a station</h2><div class="form">
<label>Name<input type="text" id="rname" placeholder="KEXP"></label>
<label>Stream URL<input type="url" id="rurl" placeholder="https://example.com/stream.mp3"></label>
<div class="row"><button class="btn primary" data-rdadd>Add station</button></div>
<p class="note">Any stream the browser can play, like MP3, AAC or Ogg. Playing a station pauses the queue, and playing the queue stops the station.</p></div>`;
},
async albums(my) {
const srcs = sources();
if (!srcs.length) return needPlex();
@@ -580,9 +718,9 @@ const routes = {
const srcs = sources();
if (!srcs.length) return needPlex();
view.innerHTML = bar("Songs", { sort: "songs", extra: `<button class="chip" data-shuffleall>${ICON.shuffle} Shuffle all</button>` })
+ `<table class="tracks"><tbody></tbody></table><p class="empty-msg only-stems">No songs with stems loaded yet. Scroll for more.</p>`;
+ `<table class="tracks"><tbody></tbody></table><p class="empty-msg only-stems">No songs with stems loaded yet. Scroll for more.</p><p class="empty-msg only-nostems">Every song loaded so far has stems. Scroll for more.</p>`;
lists.songs = [];
const size = S.stemsOnly ? 1000 : 150;
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);
@@ -684,7 +822,7 @@ const routes = {
view.innerHTML = bar("Downloads", { filter: false, extra: recs.length ? `<button class="chip" data-dlclear>Remove all</button>` : "" })
+ `<p class="note">${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."}</p>`
+ (recs.length ? trackTable("downloads", recs.map(r => r.track)) : `<p class="empty-msg">Nothing downloaded. Right-click a song or album and pick Download for offline.</p>`);
+ (recs.length ? dlGroups(recs) : `<p class="empty-msg">Nothing downloaded. Right-click a song or album and pick Download for offline.</p>`);
count(recs.length, "track");
},
async stems(my) {
@@ -706,14 +844,14 @@ const routes = {
async function route() {
const my = ++seq;
const [, name = "albums", ...rest] = (location.hash || "#/albums").split("/");
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.albums)(my, arg); }
try { await (routes[name] || routes.home)(my, arg); }
catch (e) { if (my === seq) view.innerHTML = `<h1>Something went wrong</h1><p class="sub">${esc(e.message)}</p>`; }
if (my === seq) marks();
}
@@ -728,7 +866,7 @@ 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 (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);
@@ -737,10 +875,41 @@ view.addEventListener("click", async ev => {
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…");
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] || []); }
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);
@@ -763,12 +932,31 @@ view.addEventListener("change", ev => {
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)); }
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"], ["playback", "Playback"], ["karaoke", "Karaoke"], ["look", "Appearance"], ["offline", "Offline"], ["backup", "Backup"], ["keys", "Keys"]];
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 `<h3>Locked</h3>
${lock ? `<div class="prow"><span class="pn">${ICON.lock}${esc(presetLabel(lock.name))}</span><span class="grow"></span><button class="btn small" data-plock="${esc(lock.id)}">Unlock</button></div>` : `<p class="note">Nothing locked.</p>`}
<p class="note">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.</p>
<h3>Mixer Bar</h3><div class="plist">${ps.map((p, i) => `<div class="prow"><span class="pn">${esc(presetLabel(p.name))}${p.custom ? ' <span class="tag">CUSTOM</span>' : ""}</span><span class="grow"></span>
<button class="btn small ${lock?.id === p.id ? "on" : ""}" data-plock="${esc(p.id)}" title="${lock?.id === p.id ? "Unlock" : "Lock"}">${ICON.lock}</button>
<button class="btn small" data-pmove="${esc(p.id)}" data-pdir="-1" ${i ? "" : "disabled"} title="Move up">↑</button>
<button class="btn small" data-pmove="${esc(p.id)}" data-pdir="1" ${i < ps.length - 1 ? "" : "disabled"} title="Move down">↓</button>
<button class="btn small" data-pdel="${esc(p.id)}" ${p.id === "b:Full Mix" ? "disabled" : ""} title="Delete">×</button></div>`).join("")}</div>
<h3>Stems</h3><div class="row">
<button class="btn small" data-padd ${mixNames().length ? "" : "disabled"} title="Play a song with stems to save its mix">Save the current mix as a preset</button>
<button class="btn small" data-prestore ${S.hiddenPresets.length ? "" : "disabled"}>Restore Deleted Presets</button>
<button class="btn small" data-pclear ${S.customPresets.length ? "" : "disabled"}>Delete All Custom Presets</button></div>`;
}
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) {
@@ -800,12 +988,16 @@ function renderSettings() {
<label>Token (if stemserve has TOKEN set)<input type="password" id="stok" value="${esc(S.stemToken)}"></label>
<div class="row"><button class="btn" id="ssave">Save and reconnect</button></div></div>
</section>
<section id="set-playback"><h2>Playback</h2><div class="form">
<section id="set-stemopts"><h2>Stems</h2><div class="form">
<label>Stem format<select id="fmt">${FORMATS.map(([v, l]) => `<option value="${v}" ${S.fmt === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
<p class="note">Stems are downloaded whole before a song starts. Opus 160 is about 1 MB per stem-minute; FLAC is around eight times that.</p>
<label class="check"><input type="checkbox" id="keepmix" ${S.keepMix ? "checked" : ""}>Keep the mix when the next song has no saved mix</label>
<label>Cache Ahead<div class="row"><input type="range" id="precache" min="0" max="50" step="1" value="${S.precache}"><input type="number" id="precachen" min="0" max="50" value="${S.precache}"> songs</div></label>
<p class="note">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.</p>
<label class="check"><input type="checkbox" id="keepmix" ${S.keepMix ? "checked" : ""}>Keep Stem Mix Between Songs</label>
<p class="note">Songs without a saved mix keep the current mix. When it's off they start at full mix, and karaoke turns off.</p>
<div class="row"><span class="note">${plural(Object.keys(songMixes).length, "saved song mix")}</span>${Object.keys(songMixes).length ? `<button class="btn small" id="forgetall">Forget all</button>` : ""}</div></div>
</section>
<section id="set-presets"><h2>Stem Editor</h2><div id="pedit">${presetEditor()}</div></section>
<section id="set-karaoke"><h2>Karaoke</h2><div class="form">
<label class="check"><input type="checkbox" id="kon" ${S.karaoke ? "checked" : ""}>Karaoke on (K)</label>
<label>Vocals in karaoke<select id="kmode">${[["off", "Off"], ["soft", "Soft"], ["on", "Left on (lyrics only)"]].map(([v, l]) => `<option value="${v}" ${S.karaokeMode === v ? "selected" : ""}>${l}</option>`).join("")}</select></label>
@@ -815,6 +1007,8 @@ function renderSettings() {
<section id="set-look"><h2>Appearance</h2><div class="form">
<div class="themes">${Object.entries(THEMES).map(([k, t]) => `<button class="swatch ${S.theme === k ? "on" : ""}" data-theme="${k}" style="background:${t.bg};color:${t.text};border-color:${t.line}"><i style="background:${t.accent}"></i>${k[0].toUpperCase() + k.slice(1)}</button>`).join("")}</div>
<div class="row"><label class="check">Accent <input type="color" id="accent" value="${S.accent || theme.accent}"></label>${S.accent ? `<button class="btn small" id="accentreset">Use the theme's</button>` : ""}</div>
<label class="check"><input type="checkbox" id="acapella" ${S.acapella ? "checked" : ""}>Spell the vocals-only preset “Acapella”</label>
<div class="row"><span class="note">Player bar</span>${BAR.map(([k, l]) => `<label class="check"><input type="checkbox" data-bar="${k}" ${S.bar[k] ? "checked" : ""}>${l}</label>`).join("")}</div>
<label>Custom CSS<textarea id="css" rows="8" spellcheck="false" placeholder=":root { --accent: #7cf; --radius: 4px; }\n.card img { border-radius: 0; }">${esc(S.css)}</textarea></label>
<p class="note">Applied live, after the theme. Variables: --bg --panel --raised --line --text --muted --accent --accent-ink --radius --wave --wave-ghost.</p></div>
</section>
@@ -826,12 +1020,27 @@ function renderSettings() {
<p class="note">Settings, saved mixes and stem playlists. The file includes your Plex tokens.</p>
<div class="row"><button class="btn" id="export">Export</button><label class="btn">Import<input type="file" id="import" accept="application/json" hidden></label></div>
</section>
<section id="set-keys"><h2>Keys</h2><p class="note">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</p></section>
<section id="set-keys"><h2>Keys</h2><p class="note">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</p></section>
<section id="set-about"><h2>About</h2>
<p><b>Stemify</b> <span class="note" id="about-ver"></span></p>
<p class="note">Your Plex and Jellyfin music, split into stems you can mix.</p>
<div class="people">
<div class="person"><b>Chris Nutter</b><span class="note">Stemify WebUI and the iOS app</span><div class="row">
<a class="btn small" href="https://iamchrisama.com" target="_blank" rel="noopener">iamchrisama.com</a>
<a class="btn small" href="https://buymeacoffee.com/cdnutter" target="_blank" rel="noopener">Buy Me a Coffee</a></div></div>
<div class="person"><b>Kevin Viers</b><span class="note">stemd and the Android app</span><div class="row">
<a class="btn small" href="https://github.com/kevinViers/" target="_blank" rel="noopener">GitHub</a>
<button class="btn small" disabled>Buy Me a Coffee: Coming soon</button></div></div>
</div>
<p class="note">Stemify is free. A coffee goes straight to the person you pick.</p>
</section>
</div></div>`;
// 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(() => {});
@@ -855,7 +1064,11 @@ function renderSettings() {
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(); };
$("#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);
@@ -883,15 +1096,23 @@ function renderSettings() {
};
const sv = $(".settings");
sv.addEventListener("click", async ev => {
const b = ev.target.closest("[data-jump], [data-theme], [data-refsrv], [data-rmsrv]");
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);
@@ -925,7 +1146,8 @@ async function connect() {
(async () => {
applyTheme();
applyStemsOnly();
applyStemFilter();
applyBar();
await Offline.init();
await Promise.race([connect(), new Promise(r => setTimeout(r, 5000))]);
Q.restore();
+3 -1
View File
@@ -14,12 +14,14 @@
<aside id="nav">
<div class="brand">stemify</div>
<form id="search"><input type="search" name="q" placeholder="Search" autocomplete="off"></form>
<a href="#/home" data-nav="home">Home</a>
<a href="#/albums" data-nav="albums">Albums</a>
<a href="#/artists" data-nav="artists">Artists</a>
<a href="#/songs" data-nav="songs">Songs</a>
<a href="#/genres" data-nav="genres">Genres</a>
<a href="#/playlists" data-nav="playlists">Playlists</a>
<a href="#/downloads" data-nav="downloads">Downloads</a>
<a href="#/radio" data-nav="radio">Web Radio</a>
<div id="live" hidden></div>
<a href="#/settings" data-nav="settings" class="settings-link">Settings</a>
</aside>
@@ -36,7 +38,7 @@
<button id="b-shuffle" class="side" title="Shuffle (S)"></button>
<button id="b-prev" title="Previous"></button>
<button id="b-play" class="play" title="Play (Space)"></button>
<button id="b-next" title="Next"></button>
<button id="b-next" title="Next (hold to skip the album)"></button>
<button id="b-repeat" class="side" title="Repeat (R)"></button>
</div>
<div class="seek">
+157 -27
View File
@@ -28,14 +28,77 @@ export function stemGain(name, names, mix = S.mix) {
}
const normMix = (m, names) => JSON.stringify(names.map(n => { const s = { ...unity(), ...m?.[n] }; return [+s.level.toFixed(3), s.muted, s.solo]; }));
export const sameMix = (a, b, names) => normMix(a, names) === normMix(b, names);
export function applyPreset(build, names) {
const m = build(names);
// Presets in the mixer: the built-ins ("b:Name") and saved custom mixes ("c:Name"), in the
// user's order. Deleting a built-in hides it; Full Mix can't be deleted.
const customPreset = c => ({ id: `c:${c.name}`, name: c.name, custom: true, build: n => Object.keys(c.mix).some(k => n.includes(k)) && c.mix });
export function allPresets() {
const all = [...PRESETS.map(([name, build]) => ({ id: `b:${name}`, name, build })), ...S.customPresets.map(customPreset)];
const rank = (p, i) => { const r = S.presetOrder.indexOf(p.id); return r < 0 ? 1e6 + i : r; };
return all.map((p, i) => [rank(p, i), p]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
}
export const presets = () => allPresets().filter(p => !S.hiddenPresets.includes(p.id));
export const presetLabel = name => S.acapella && name === "A Cappella" ? "Acapella" : name;
const presetsChanged = () => { emit("presets"); emit("mix"); save(); };
export function applyPreset(p, names) {
const m = p?.build(names);
if (!m) return;
S.mix = {};
for (const [k, v] of Object.entries(m)) S.mix[k] = { ...unity(), ...v };
mixChanged();
}
export const currentPreset = names => PRESETS.find(([, b]) => { const m = b(names); return m && sameMix(m, S.mix, names); })?.[0];
export const currentPreset = names => presets().find(p => { const m = p.build(names); return m && sameMix(m, S.mix, names); });
// Steps to the next preset that fits this song (the P key).
export function nextPreset(names) {
const ok = presets().filter(p => p.build(names));
const cur = currentPreset(names);
applyPreset(ok[(ok.indexOf(cur) + 1) % ok.length], names);
}
// Saves `mix` as a custom preset, keeping only stems that differ from the default. Returns an
// error message, or "" when saved.
export function savePreset(name, mix) {
name = name.trim();
if (!name) return "Give the preset a name";
if (PRESETS.some(([n]) => n.toLowerCase() === name.toLowerCase())) return `“${name}” is a built-in preset`;
const kept = {};
for (const [k, v] of Object.entries(mix || {})) {
const st = { ...unity(), ...v };
if (Math.abs(st.level - 1) > 0.001 || st.muted || st.solo) kept[k] = st;
}
if (!Object.keys(kept).length) return "This is the full mix. Change a stem first.";
const i = S.customPresets.findIndex(c => c.name === name);
if (i >= 0) S.customPresets[i].mix = kept; else S.customPresets.push({ name, mix: kept });
presetsChanged();
return "";
}
export function deletePreset(id) {
if (id === "b:Full Mix") return;
if (id.startsWith("b:")) { if (!S.hiddenPresets.includes(id)) S.hiddenPresets.push(id); }
else S.customPresets = S.customPresets.filter(c => `c:${c.name}` !== id);
if (S.lockedPreset === id) S.lockedPreset = "";
S.presetOrder = S.presetOrder.filter(x => x !== id);
presetsChanged();
}
export function movePreset(id, d) {
const order = presets().map(p => p.id), i = order.indexOf(id), j = i + d;
if (i < 0 || j < 0 || j >= order.length) return;
[order[i], order[j]] = [order[j], order[i]];
S.presetOrder = order;
presetsChanged();
}
export function restorePresets() { S.hiddenPresets = []; presetsChanged(); }
export function deleteAllCustom() {
S.customPresets = [];
if (S.lockedPreset.startsWith("c:")) S.lockedPreset = "";
S.presetOrder = S.presetOrder.filter(x => !x.startsWith("c:"));
presetsChanged();
}
// Locking a preset applies it now and to every song after.
export function toggleLock(id, names) {
S.lockedPreset = S.lockedPreset === id ? "" : id;
if (S.lockedPreset) applyPreset(allPresets().find(p => p.id === id), names);
presetsChanged();
}
export function mixChanged() { E.applyGains(); emit("mix"); save(); }
// Karaoke: vocals on, soft (default -12 dB) or off. The iOS app does the same.
@@ -64,11 +127,14 @@ export function setKaraokeMode(mode, softDb) {
if (wasKaraoke) S.mix = karaokeMix();
mixChanged();
}
// The mix a track starts with: karaoke, the queue item's own mix (stem playlists), the song's
// saved mix, or whatever was playing before.
// The mix a track starts with: karaoke (only kept between songs when the mix is), the locked
// preset, the queue item's own mix (stem playlists), the song's saved mix, or whatever was
// playing before.
function startMix(item, entry) {
if (S.karaoke && !S.keepMix) { S.karaoke = false; delete S.preKaraokeMix; }
if (S.karaoke) return karaokeMix();
return clone(item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {}));
const lock = S.lockedPreset && allPresets().find(p => p.id === S.lockedPreset)?.build(entry.stems.map(s => s.name));
return clone(lock || item.mix || songMixes[entry.key] || (S.keepMix ? S.mix : {}));
}
// ---- fetching stems ---------------------------------------------------------------------------
@@ -100,19 +166,28 @@ export async function fetchStems(entry, fmt, { conc = 4, signal, onProgress } =
return out;
}
// One prefetched track, so the next song starts instantly. Kept to Opus (FLAC is too big).
// Cache Ahead: the next S.precache songs, fetched one at a time and held in memory, so they
// start instantly. Kept to Opus (FLAC is too big).
const Pre = {
key: null, promise: null, ctl: null,
get(entry) { return this.key === `${entry.key}|${S.fmt}` ? this.promise : null; },
start(entry) {
const k = `${entry.key}|${S.fmt}`;
if (this.key === k) return;
this.ctl?.abort();
this.key = k; this.ctl = new AbortController();
this.promise = fetchStems(entry, S.fmt, { conc: 2, signal: this.ctl.signal });
this.promise.catch(() => { if (this.key === k) this.key = null; });
map: new Map(), run: 0, // "key|fmt" -> {promise, ctl}
k: entry => `${entry.key}|${S.fmt}`,
get(entry) { return this.map.get(this.k(entry))?.promise || null; },
// Drops songs no longer coming up and fetches the missing ones in queue order.
async keep(entries) {
const run = ++this.run, want = new Set(entries.map(this.k));
for (const [k, v] of this.map) if (!want.has(k)) { v.ctl.abort(); this.map.delete(k); }
for (const e of entries) {
if (run !== this.run) return;
const k = this.k(e);
let p = this.map.get(k)?.promise;
if (!p) {
const ctl = new AbortController();
p = fetchStems(e, S.fmt, { conc: 2, signal: ctl.signal });
this.map.set(k, { promise: p, ctl });
}
await p.catch(() => { if (this.map.get(k)?.promise === p) this.map.delete(k); });
}
},
clear() { this.ctl?.abort(); this.key = this.promise = null; },
};
// ---- engine -----------------------------------------------------------------------------------
@@ -137,6 +212,7 @@ export const E = {
// Loads a track: its stems (offline copy, prefetched, or downloaded now) or the Plex file.
async load(t, entry, startSec, play) {
if (play) Radio.stop();
this.unload();
const gen = ++this.gen;
this.want = play; this.stalled = play; this.pendingPos = startSec || 0;
@@ -220,9 +296,10 @@ export const E = {
else el.volume = Math.min(1, g * S.volume);
});
if (this.master) this.master.gain.value = S.volume;
if (Radio.el) Radio.el.volume = S.volume;
},
play() { this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); },
play() { Radio.stop(); this.audioCtx(); this.want = true; this.stalled = true; this.tick(); emit("transport"); },
pause() { this.want = false; this.stalled = false; this.els.forEach(e => e.pause()); emit("transport"); },
seek(sec) {
this.pendingPos = sec;
@@ -336,22 +413,38 @@ export const Q = {
// Reloads the current track in place, e.g. when it just got stems.
reload() { if (this.cur()) this.start(E.position(), E.playing); },
// Fetch the next track only once this one is playing and fully loaded, so they don't compete.
// Cache Ahead, only once this one is playing and fully loaded, so they don't compete.
prepareNext() {
clearInterval(this.nextTimer);
const nt = this.items[this.i + 1]?.t;
if (!nt) return;
const opus = S.fmt.startsWith("opus"), want = opus ? S.precache : Math.min(1, S.precache), next = [];
for (let j = this.i + 1; j < this.items.length && next.length < want; j++) {
const t = this.items[j].t, e = Stems.find(t);
if (e && !Offline.has(t.id)) next.push(e);
}
if (!next.length) return;
const ready = () => E.playing && !E.buffering && E.ready
&& E.els.every(el => el.duration > 0 && el.buffered.length && el.buffered.end(el.buffered.length - 1) >= el.duration - 0.5);
this.nextTimer = setInterval(() => {
if (!ready()) return;
clearInterval(this.nextTimer);
const e = Stems.find(nt);
if (!e || Offline.has(nt.id)) return;
if (S.fmt.startsWith("opus")) Pre.start(e); else Stems.prepare(e);
if (opus) Pre.keep(next); else Stems.prepare(next[0]);
}, 1000);
},
// Hold Next: jump to the first song from a different album.
skipAlbum() {
const album = x => `${x.t.srv || ""}|${x.t.albumKey || x.t.album || ""}`;
const cur = this.curItem();
if (!cur) return;
const n = this.items.length, a = album(cur);
for (let d = 1; d < n; d++) {
const j = this.i + d;
if (j >= n && S.repeat === "off") break;
if (album(this.items[j % n]) !== a) { this.i = j % n; this.start(0, true); return; }
}
toast("No more albums in the queue");
},
next(auto = false) {
if (auto && S.repeat === "one") { E.seek(0); E.play(); return; }
if (this.i + 1 < this.items.length) { this.i++; this.start(0, true); }
@@ -375,7 +468,7 @@ export const Q = {
this.orig = null;
}
S.shuffle = !S.shuffle;
Pre.clear(); this.prepareNext();
this.prepareNext(); // Cache Ahead drops what's no longer coming up
emit("transport"); emit("queue"); saveQueue(); save();
},
cycleRepeat() {
@@ -402,9 +495,46 @@ export const Q = {
};
E.onEnded = () => Q.next(true);
// ---- web radio --------------------------------------------------------------------------------
// A station plays on its own element. Starting it pauses the queue; playing the queue stops it.
export const Radio = {
el: null, station: null, want: false,
get playing() { return this.want; },
play(st) {
E.pause();
if (!this.el) {
this.el = new Audio();
this.el.addEventListener("error", () => { if (this.want) { toast(`Couldn't play “${this.station?.name}”`); this.stop(); } });
}
this.station = st;
this.resume();
},
// Live streams restart at the live edge rather than where they were paused.
resume() {
this.el.src = this.station.url;
this.el.volume = S.volume;
this.want = true;
this.el.play().catch(e => { if (e.name === "NotAllowedError") { this.want = false; emit("transport"); } });
emit("radio"); emit("transport");
},
toggle() {
if (!this.station) return;
if (this.want) { this.want = false; this.el.pause(); emit("radio"); emit("transport"); }
else { E.pause(); this.resume(); }
},
stop() {
if (!this.station) return;
this.want = false; this.station = null;
this.el.pause(); this.el.removeAttribute("src"); this.el.load();
emit("radio"); emit("transport");
},
};
// ---- offline ----------------------------------------------------------------------------------
export async function saveOffline(tracks, onStatus) {
// group: the album or playlist these came from, {title, href}, so Downloads can list them that way.
export async function saveOffline(tracks, onStatus, group = null) {
let n = 0;
for (const t of tracks) {
if (Offline.has(t.id)) continue;
@@ -423,7 +553,7 @@ export async function saveOffline(tracks, onStatus) {
blobs = { file: await r.blob() };
}
const size = Object.values(blobs).reduce((a, b) => a + b.size, 0);
await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, saved: Date.now() });
await Offline.put({ id: t.id, track: t, entry, fmt: entry ? S.fmt : "original", size, peaks: peaks?.stems || null, blobs, group, saved: Date.now() });
} catch (e) { toast(`Couldn't download “${t.title}”: ${e.message}`); }
}
emit("offline");
+4
View File
@@ -25,6 +25,10 @@ export async function px(srv, path, params = {}) {
export function img(srvId, thumb, size = 300) {
const s = server(srvId);
if (!thumb || !s) return "";
// Playlist composites are already images Plex renders at any size; the photo transcoder
// can't fetch them, so ask for them directly.
if (thumb.includes("/composite/")) return `${s.url}${thumb}${thumb.includes("?") ? "&" : "?"}width=${size}&height=${size}&X-Plex-Token=${s.token}`;
return thumb && s
? `${s.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${s.token}`
: "";
+12 -2
View File
@@ -15,7 +15,15 @@ export const S = Object.assign({
keepMix: true, // carry the mix to the next track when it has no saved mix
karaoke: false, karaokeMode: "off", softDb: -12,
sorts: {}, // screen -> sort value
stemsOnly: false,
stemFilter: "", // "" | "stems" (only songs with stems) | "nostems"
precache: 5, // Cache Ahead: songs fetched ahead of the one playing (0-50)
presetOrder: [], // preset ids in mixer order: "b:Name" built-in, "c:Name" custom
hiddenPresets: [], // deleted built-ins (deleted custom presets are just gone)
customPresets: [], // [{name, mix}]
lockedPreset: "", // every song starts with this preset
acapella: false, // spell "A Cappella" as "Acapella"
bar: { speed: true, mixer: true, lyrics: true, queue: true }, // player bar buttons
stations: [], // web radio: [{id, name, url}]
theme: "amber", accent: "", css: "",
}, load(KEY) || {});
@@ -28,6 +36,7 @@ if (S.plex) {
if (!p.env) S.servers.push({ id: "m" + Date.now().toString(36), url: p.url, token: p.token, name: p.name || p.url, sections: S.section ? [{ key: String(S.section), title: "Music", on: true }] : [] });
delete S.plex; delete S.section;
}
if ("stemsOnly" in S) { if (S.stemsOnly) S.stemFilter = "stems"; delete S.stemsOnly; }
if (S.albumSort) { S.sorts.albums = S.albumSort; delete S.albumSort; }
let timer;
@@ -61,7 +70,8 @@ export function addToPlaylist(p, tracks, mix) {
}
// ---- offline downloads -----------------------------------------------------------------
// One record per track: {id, track, entry, fmt, size, peaks, lyrics, blobs: {name: Blob}}.
// One record per track: {id, track, entry, fmt, size, peaks, lyrics, blobs: {name: Blob}, group}.
// group is the album or playlist it was downloaded with: {title, href}.
let dbp;
function db() {
+40
View File
@@ -103,6 +103,11 @@ select, textarea, input[type=text], input[type=url], input[type=password] { back
body.stems-only .tracks tr.nostem, body.stems-only .card.nostem { display: none; }
.only-stems { display: none; }
body.stems-only .tracks:not(:has(tr:not(.nostem))) + .only-stems { display: block; }
.only-nostems { display: none; }
body.no-stems .tracks tr:not(.nostem) { display: none; }
body.no-stems .tracks:not(:has(tr.nostem)) ~ .only-nostems { display: block; }
.seg.stemfilter { display: inline-flex; }
.seg.stemfilter button { padding: 5px 10px; white-space: nowrap; }
.genres { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; }
.genre { display: flex; align-items: flex-end; aspect-ratio: 16 / 9; padding: 14px; border-radius: 10px; font-weight: 700; font-size: 16px; color: #fff;
@@ -243,3 +248,38 @@ input[type=range] { accent-color: var(--accent); }
.hero img { width: 160px; height: 160px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 16px 12px; }
}
/* presets */
.presets .chip { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.chip.locked { box-shadow: inset 0 0 0 1.5px var(--accent); }
.chip.on.locked { box-shadow: inset 0 0 0 1.5px var(--accent-ink); }
.chip svg, .prow svg { width: 13px; height: 13px; fill: currentColor; }
.plist { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.prow { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--raised); border-radius: 8px; }
.prow .pn { display: inline-flex; align-items: center; gap: 6px; }
.prow .btn.on { background: var(--accent); color: var(--accent-ink); }
/* home */
.shelf { display: grid; grid-auto-flow: column; grid-auto-columns: 150px; gap: 16px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 20px; }
.shelf.two { grid-template-rows: auto auto; }
.stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-bottom: 20px; }
.station { display: flex; flex-direction: column; gap: 4px; text-align: left; padding: 16px; border-radius: 12px; background: var(--raised); }
.station:hover { box-shadow: inset 0 0 0 1.5px var(--accent); }
.station span { color: var(--muted); font-size: 13px; }
/* downloads */
.dlgroup { margin-bottom: 24px; }
/* web radio */
#player.radio .seek, #player.radio #b-prev, #player.radio #b-next, #player.radio #b-shuffle { visibility: hidden; }
.stationlist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.stn { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--raised); }
.stn.on { box-shadow: inset 0 0 0 1.5px var(--accent); }
.stn .tx { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.stn .tx .note { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
input[type=number]#precachen { width: 64px; background: var(--raised); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; }
#precache { flex: 1; }
/* about */
.people { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 12px 0; }
.person { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 12px; background: var(--raised); }
+1
View File
@@ -33,6 +33,7 @@ export function toast(msg, ms = 2400) {
}
export const ICON = {
lock: '<svg viewBox="0 0 24 24"><path d="M7 10V7a5 5 0 0 1 10 0v3h.5A1.5 1.5 0 0 1 19 11.5v8a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19.5v-8A1.5 1.5 0 0 1 6.5 10H7zm2 0h6V7a3 3 0 0 0-6 0v3z"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><rect x="5.5" y="4" width="4.5" height="16" rx="1.2"/><rect x="14" y="4" width="4.5" height="16" rx="1.2"/></svg>',
prev: '<svg viewBox="0 0 24 24"><rect x="4" y="5" width="2.5" height="14" rx="1"/><path d="M20 5.7v12.6a1 1 0 0 1-1.55.83L9 12.83a1 1 0 0 1 0-1.66l9.45-6.3A1 1 0 0 1 20 5.7z"/></svg>',