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:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
+262
-40
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user