${inTrash ? "" : ``}
`;
el.querySelector(".cat-del")?.setAttribute("aria-label", `Move ${track.title ?? "track"} to Trash`);
el.querySelector(".cat-del")?.addEventListener("click", (e) => {
e.stopPropagation();
moveTrackToTrash(trackId);
});
wireTrackDragAndLoad(el, trackId);
return el;
}
function renderFolder(folder) {
const isTrash = folder.id === TRASH_ID;
const isUnsorted = folder.id === UNSORTED_ID;
const isSubfolder = Boolean(folder.parentId);
if (!isTrash) folder.color = normalizeFolderColor(folder.color);
const el = document.createElement("div");
el.className = `folder${folder.collapsed ? " collapsed" : ""}${isSubfolder ? " subfolder" : ""}`;
el.dataset.id = folder.id;
const head = document.createElement("div");
head.className = "folder-head";
if (!isTrash) head.style.setProperty("--folder-color", folder.color);
const folderIcon = isTrash
? ``
: ``;
head.innerHTML = `
${isTrash ? "" : ``}
${folderIcon}
${esc(folder.name)}${folder.items.length}
${isTrash ? "" : `
${isUnsorted ? "" : ``}
`}
`;
const body = document.createElement("div");
body.className = "folder-body";
const visibleItems = folder.items.filter((id) => trackMatchesSearch(tracks[id]));
const childFolders = folders.filter((f) => f.parentId === folder.id);
if (catalogSearchQuery && visibleItems.length === 0 && childFolders.length === 0) {
return null;
}
if (visibleItems.length === 0 && childFolders.length === 0) {
body.innerHTML = 'Empty folder';
} else {
for (const id of visibleItems) {
const item = renderTrackItem(id);
if (item) body.appendChild(item);
}
for (const child of childFolders) {
const childEl = renderFolder(child);
if (childEl) body.appendChild(childEl);
}
}
el.append(head, body);
let folderClickTimer = null;
// Toggle folder collapse on single click.
head.addEventListener("click", (e) => {
if (e.target.closest(".f-del, .f-subfolder, .f-grip")) return;
if (e.detail !== 1) return;
window.clearTimeout(folderClickTimer);
folderClickTimer = window.setTimeout(() => {
folder.collapsed = !folder.collapsed;
el.classList.toggle("collapsed", folder.collapsed);
saveState();
}, 180);
});
if (!isTrash) {
head.addEventListener("dblclick", (e) => {
if (e.target.closest(".f-del, .f-subfolder, .f-grip")) return;
window.clearTimeout(folderClickTimer);
e.stopPropagation();
openFolderEditor(folder.id);
});
}
head.querySelector(".f-del")?.addEventListener("click", (e) => {
e.stopPropagation();
deleteFolder(folder.id);
});
head.querySelector(".f-subfolder")?.addEventListener("click", (e) => {
e.stopPropagation();
const child = makeFolder({ parentId: folder.id });
folders.push(child);
folder.collapsed = false;
el.classList.remove("collapsed");
saveState();
render();
openFolderEditor(child.id);
});
// Folder drag handle — reorder folders.
const grip = head.querySelector(".f-grip");
if (grip) {
grip.draggable = true;
grip.addEventListener("dragstart", (e) => {
folderDragId = folder.id;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(FOLDER_DRAG_TYPE, folder.id);
e.stopPropagation();
requestAnimationFrame(() => el.classList.add("folder-dragging"));
});
grip.addEventListener("dragend", () => {
folderDragId = null;
el.classList.remove("folder-dragging");
for (const f of document.querySelectorAll(".folder.drop-before, .folder.drop-after, .folder.drop-into")) {
f.classList.remove("drop-before", "drop-after", "drop-into");
}
});
}
// Dragover: folder reorder/nest indicator OR track drop target.
el.addEventListener("dragover", (e) => {
if (folderDragId && folderDragId !== folder.id && !isTrash) {
if (isFolderDescendant(folderDragId, folder.id)) return; // prevent cycle
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = head.getBoundingClientRect();
const rel = (e.clientY - rect.top) / rect.height;
el.classList.toggle("drop-before", rel < 0.25);
el.classList.toggle("drop-into", rel >= 0.25 && rel < 0.75);
el.classList.toggle("drop-after", rel >= 0.75);
return;
}
if (!isTrackDragEvent(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
el.classList.add("drop-target");
});
el.addEventListener("dragleave", (e) => {
if (!el.contains(e.relatedTarget)) {
el.classList.remove("drop-target", "drop-before", "drop-after", "drop-into");
}
});
el.addEventListener("drop", (e) => {
e.preventDefault();
if (folderDragId && folderDragId !== folder.id && !isTrash) {
const rect = head.getBoundingClientRect();
const rel = (e.clientY - rect.top) / rect.height;
el.classList.remove("drop-before", "drop-after", "drop-into");
if (rel < 0.25) reorderFolder(folderDragId, folder.id, true);
else if (rel >= 0.75) reorderFolder(folderDragId, folder.id, false);
else reparentFolder(folderDragId, folder.id);
return;
}
el.classList.remove("drop-target");
dropOnFolder(folder.id, getDraggedTrackId(e));
});
return el;
}
function renderStrip(strip, nonTrash) {
if (!strip) return;
const folderTrackIds = new Set(folders.flatMap((folder) => folder.items));
for (const [trackId, track] of Object.entries(tracks)) {
if (folderTrackIds.has(trackId)) continue;
strip.appendChild(makeStripItem({
className: trackId === _currentTrackId ? "active" : "",
id: trackId,
title: track.title,
html: thumbHtml(track),
trackId,
}));
}
for (const folder of nonTrash) {
const folderColor = normalizeFolderColor(folder.color);
strip.appendChild(makeStripItem({
className: "folder-thumb",
id: folder.id,
title: `${folder.name} (${folder.items.length})`,
html: folderThumbHtml(false),
color: folderColor,
}));
}
}
function render() {
const list = document.getElementById("catalogList");
const strip = document.getElementById("catalogStrip");
const catalog = document.getElementById("catalogPanel");
const searchInput = document.getElementById("catalogSearch");
if (!list) return;
list.innerHTML = "";
if (strip) strip.innerHTML = "";
const trash = getTrashFolder();
const trashIds = new Set(trash?.items || []);
const isTrashView = catalogView === "trash";
const isFavoritesView = catalogView === "favorites";
const isLibraryView = !isTrashView && !isFavoritesView;
catalog?.classList.toggle("trash-view", isTrashView);
catalog?.classList.toggle("favorites-view", isFavoritesView);
document.querySelector(".rail-library")?.classList.toggle("active", isLibraryView);
document.querySelector(".rail-library")?.setAttribute("aria-pressed", String(isLibraryView));
document.querySelector(".rail-favorites")?.classList.toggle("active", isFavoritesView);
document.querySelector(".rail-favorites")?.setAttribute("aria-pressed", String(isFavoritesView));
document.querySelector(".rail-trash")?.classList.toggle("active", isTrashView);
document.querySelector(".rail-trash")?.setAttribute("aria-pressed", String(isTrashView));
if (searchInput) {
searchInput.placeholder = isTrashView ? "Search trash…" : isFavoritesView ? "Search favorites…" : "Search library…";
}
const nonTrash = folders.filter((f) => f.id !== TRASH_ID && !f.parentId);
// ── Trash view ──
if (isTrashView) {
const visibleTrashItems = (trash?.items || []).filter((id) => trackMatchesSearch(tracks[id]));
if (!trash?.items.length) {
list.innerHTML = 'Trash is empty';
} else if (visibleTrashItems.length === 0) {
list.innerHTML = 'No deleted tracks match your search';
} else {
for (const id of visibleTrashItems) {
const item = renderTrackItem(id, { inTrash: true });
if (item) list.appendChild(item);
}
}
return;
}
// ── Favorites view ──
if (isFavoritesView) {
const favIds = Object.entries(tracks)
.filter(([id, t]) => !trashIds.has(id) && t.favorite && trackMatchesSearch(t))
.sort(([, a], [, b]) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
.map(([id]) => id);
if (!favIds.length) {
list.innerHTML = `${catalogSearchQuery ? "No favorites match your search" : "No favorites yet — click ♥ on a track to save it"}`;
} else {
for (const id of favIds) {
const item = renderRecentItem(id);
if (item) list.appendChild(item);
}
}
renderStrip(strip, nonTrash);
return;
}
// ── Library view — Recent · Stem Collections · Tags ──
// Recent section
const recentIds = getRecentTracks(trashIds).filter((id) => trackMatchesSearch(tracks[id]));
if (recentIds.length) {
const section = makeSectionEl("Recent");
for (const id of recentIds) {
const item = renderRecentItem(id);
if (item) section.appendChild(item);
}
list.appendChild(section);
}
// Stem Collections section
const collectionsSection = makeSectionEl("Stem Collections");
const newFolderBtn = document.createElement("button");
newFolderBtn.id = "newFolderBtn";
newFolderBtn.className = "new-folder-btn";
newFolderBtn.type = "button";
newFolderBtn.setAttribute("aria-label", "New folder");
newFolderBtn.innerHTML = `New folder`;
newFolderBtn.addEventListener("click", createFolder);
collectionsSection.querySelector(".lib-section-head").appendChild(newFolderBtn);
let hasCollections = false;
for (const folder of nonTrash) {
const el = renderFolder(folder);
if (!el) continue;
collectionsSection.appendChild(el);
hasCollections = true;
}
if (hasCollections) list.appendChild(collectionsSection);
// Empty state when search yields nothing
if (catalogSearchQuery && !recentIds.length && !hasCollections) {
list.innerHTML = 'No tracks match your search';
return;
}
// Tags section
const tags = getAllTags(trashIds);
if (tags.length) {
const section = makeSectionEl("Tags");
const row = document.createElement("div");
row.className = "lib-tags-row";
const activeTag = catalogSearchQuery.startsWith("#") ? catalogSearchQuery.slice(1) : null;
for (const [tag, count] of tags) {
const chip = document.createElement("button");
chip.className = `lib-tag-chip${activeTag === tag ? " active" : ""}`;
chip.type = "button";
chip.dataset.tag = tag;
chip.textContent = tag;
const countSpan = document.createElement("span");
countSpan.className = "lib-tag-count";
countSpan.textContent = String(count);
chip.appendChild(countSpan);
chip.addEventListener("click", () => {
const input = document.getElementById("catalogSearch");
if (catalogSearchQuery === `#${tag}`) {
catalogSearchQuery = "";
if (input) input.value = "";
} else {
catalogSearchQuery = `#${tag}`;
if (input) input.value = `#${tag}`;
}
render();
});
row.appendChild(chip);
}
section.appendChild(row);
list.appendChild(section);
}
renderStrip(strip, nonTrash);
}
// ─── Catalog panel collapse ───
function wireCatalogToggle() {
const toggle = document.getElementById("catalogToggle");
const collapseBtn = document.getElementById("sidebarCollapseBtn");
const app = document.querySelector(".app");
if (!app) return;
const collapsed = localStorage.getItem("stemdeck.catalog.collapsed") === "1";
if (collapsed) {
app.classList.add("cat-collapsed");
collapseBtn?.setAttribute("aria-expanded", "false");
}
function setSidebarCollapsed(isCollapsed) {
app.classList.toggle("cat-collapsed", isCollapsed);
collapseBtn?.setAttribute("aria-expanded", String(!isCollapsed));
localStorage.setItem("stemdeck.catalog.collapsed", isCollapsed ? "1" : "0");
}
collapseBtn?.addEventListener("click", () => {
setSidebarCollapsed(!app.classList.contains("cat-collapsed"));
});
if (toggle) {
toggle.addEventListener("click", (e) => {
// Only expand from within the sidebar body.
if (!app.classList.contains("cat-collapsed")) return;
setSidebarCollapsed(false);
toggle.querySelector("input")?.focus();
});
toggle.addEventListener("keydown", (e) => {
if (e.code === "Enter" || e.code === "Space") { e.preventDefault(); toggle.click(); }
});
}
}
function wireCatalogRailViews() {
document.querySelector(".rail-library")?.addEventListener("click", () => setCatalogView("library"));
document.querySelector(".rail-favorites")?.addEventListener("click", () => setCatalogView("favorites"));
document.querySelector(".rail-trash")?.addEventListener("click", () => setCatalogView("trash"));
document.getElementById("clearBinBtn")?.addEventListener("click", () => {
const trash = getTrashFolder();
const toDelete = [...(trash?.items || [])];
markJobsDeleted(toDelete); // persist before purge so reload can't re-import
purgeTrash();
saveState();
render();
for (const id of toDelete) {
fetch(`/api/jobs/${id}`, { method: "DELETE" }).catch(() => {});
}
});
}
function wireCatalogSearch() {
const input = document.getElementById("catalogSearch");
if (!input || input.dataset.searchReady === "1") return;
input.dataset.searchReady = "1";
const suggest = document.getElementById("tagSuggest");
function hideSuggest() {
if (suggest) suggest.innerHTML = "";
}
function showTagSuggestions(prefix) {
if (!suggest) return;
suggest.innerHTML = "";
if (!prefix) { hideSuggest(); return; }
const trashIds = new Set(folders.find((f) => f.id === TRASH_ID)?.items || []);
const all = getAllTags(trashIds);
const matches = all.filter(([t]) => t.toLowerCase().includes(prefix));
if (!matches.length) { hideSuggest(); return; }
for (const [tag] of matches.slice(0, 8)) {
const li = document.createElement("li");
li.className = "tag-suggest-item";
li.setAttribute("role", "option");
li.textContent = `#${tag}`;
li.addEventListener("mousedown", (e) => {
e.preventDefault();
input.value = `#${tag}`;
catalogSearchQuery = `#${tag}`;
hideSuggest();
render();
});
suggest.appendChild(li);
}
}
input.addEventListener("input", () => {
catalogSearchQuery = normalizeSearch(input.value);
render();
const val = input.value;
if (val.startsWith("#")) {
showTagSuggestions(val.slice(1).toLowerCase());
} else {
hideSuggest();
}
});
input.addEventListener("blur", () => setTimeout(hideSuggest, 150));
input.addEventListener("keydown", (e) => {
if (!suggest?.children.length) return;
if (e.key === "Escape") { hideSuggest(); return; }
const items = [...suggest.querySelectorAll(".tag-suggest-item")];
const active = suggest.querySelector(".tag-suggest-item.focused");
if (e.key === "ArrowDown") {
e.preventDefault();
const next = active ? (items[items.indexOf(active) + 1] || items[0]) : items[0];
active?.classList.remove("focused");
next.classList.add("focused");
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = active ? (items[items.indexOf(active) - 1] || items[items.length - 1]) : items[items.length - 1];
active?.classList.remove("focused");
prev.classList.add("focused");
} else if (e.key === "Enter" && active) {
e.preventDefault();
active.dispatchEvent(new MouseEvent("mousedown"));
}
});
}
// ─── Collapsible widgets ───
function wireWidgets() {
for (const head of document.querySelectorAll(".widget-head")) {
const widget = head.closest(".widget");
if (!widget) continue;
const key = `stemdeck.widget.${widget.dataset.widget}`;
if (localStorage.getItem(key) === "collapsed") {
widget.classList.add("collapsed");
head.setAttribute("aria-expanded", "false");
}
head.addEventListener("click", () => {
const isCollapsed = widget.classList.toggle("collapsed");
head.setAttribute("aria-expanded", String(!isCollapsed));
localStorage.setItem(key, isCollapsed ? "collapsed" : "open");
});
head.addEventListener("keydown", (e) => {
if (e.code === "Enter" || e.code === "Space") { e.preventDefault(); head.click(); }
});
}
}
// ─── Init ───
const FALLBACK_VERSION = "0.1.0";
let currentVersion = FALLBACK_VERSION;
const REPO_URL = "https://github.com/stemdeckapp/stemdeck";
const RELEASES_URL = "https://github.com/stemdeckapp/stemdeck/releases";
const RELEASES_API = "https://api.github.com/repos/stemdeckapp/stemdeck/releases/latest";
const DISMISSED_UPDATE_KEY = "stemdeck.dismissed_update";
function normalizeVersion(value) {
return String(value || "").trim().replace(/^v/i, "") || FALLBACK_VERSION;
}
// Fold a version into one canonical form so the GitHub release tag
// ("0.7.0-alpha.9") and the backend's PEP440 package version ("0.7.0a9", from
// hatch-vcs via /api/health) compare equal. Without this the update banner
// shows on every release because the two strings never match literally.
function canonicalVersion(value) {
return normalizeVersion(value)
.toLowerCase()
.replace(/[-_]/g, "") // 0.7.0-alpha.9 -> 0.7.0alpha.9
.replace(/alpha/g, "a")
.replace(/beta/g, "b")
.replace(/preview|pre/g, "rc")
.replace(/(a|b|rc)\.?(\d)/g, "$1$2"); // alpha.9/a.9 -> a9
}
function setDisplayedVersion(version) {
const brand = document.getElementById("brandVersion");
const about = document.getElementById("aboutVersion");
currentVersion = normalizeVersion(version);
if (brand) brand.textContent = `v${currentVersion}`;
if (about) about.textContent = `v${currentVersion}`;
}
async function loadCurrentVersion() {
try {
const res = await fetch("/api/health", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setDisplayedVersion(data.version);
} catch (e) { console.warn("[catalog] version fetch failed:", e); }
}
async function checkForUpdate() {
try {
const res = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json" } });
if (!res.ok) return;
const data = await res.json();
const latest = normalizeVersion(data.tag_name);
// Compare canonically so a PEP440 current version (0.7.0a9) matches the
// release tag form (0.7.0-alpha.9) and we don't nag an already-current app.
if (!latest || canonicalVersion(latest) === canonicalVersion(currentVersion)) return;
// Dev/source builds report a git-derived version (e.g. 0.7.0a5.dev3+g…) that
// is *ahead* of the last release — don't nag them with an "update" banner.
if (/\bdev\b|\+/.test(currentVersion)) return;
let dismissed = null;
try { dismissed = localStorage.getItem(DISMISSED_UPDATE_KEY); } catch (e) { console.warn(e); }
if (dismissed === latest) return;
const card = document.getElementById("notifReleaseCard");
const desc = document.getElementById("notifReleaseDesc");
const badge = document.getElementById("notifBadge");
const empty = document.getElementById("notifEmpty");
const dismissBtn = document.getElementById("notifReleaseDismiss");
if (desc) desc.textContent = `v${latest}`;
card?.classList.remove("hidden");
badge?.classList.remove("hidden");
empty?.classList.add("hidden");
dismissBtn?.addEventListener("click", () => {
try { localStorage.setItem(DISMISSED_UPDATE_KEY, latest); } catch (e) { console.warn(e); }
card?.classList.add("hidden");
badge?.classList.add("hidden");
empty?.classList.remove("hidden");
}, { once: true });
} catch (e) { console.warn("[catalog] update check failed:", e); }
}
function wireAboutDialog() {
const btn = document.getElementById("aboutBtn");
const dialog = document.getElementById("aboutDialog");
const close = document.getElementById("aboutClose");
const version = document.getElementById("aboutVersion");
if (!btn || !dialog) return;
if (version) version.textContent = `v${currentVersion}`;
const open = () => dialog.classList.remove("hidden");
const hide = () => dialog.classList.add("hidden");
btn.addEventListener("click", open);
close?.addEventListener("click", hide);
dialog.addEventListener("mousedown", (e) => {
if (e.target === dialog) hide();
});
dialog.addEventListener("keydown", (e) => {
if (e.code === "Escape") hide();
});
}
// Supporters dialog: a TV rail button opens a centered modal (like About) with
// the partner tiles. Links open externally via the document-level
// a[target="_blank"] handler in main.js (Tauri open_url on desktop).
function wireSupportersDialog() {
const btn = document.getElementById("friendsBtn");
const dialog = document.getElementById("friendsDialog");
const close = document.getElementById("friendsClose");
const grid = document.getElementById("friendsDialogGrid");
if (!btn || !dialog) return;
if (grid && grid.dataset.ready !== "1") {
grid.dataset.ready = "1";
// Masonry: round-robin tiles into fixed columns so a tall tile in one
// column does not push the next row down. Small per-tile tilt gives the
// deliberately-uneven "frames on a wall" look.
const COLS = 3;
const tilts = ["-2deg", "1.5deg", "-1deg", "2deg", "-1.5deg", "1deg"];
const cols = [];
for (let i = 0; i < COLS; i++) {
const col = document.createElement("div");
col.className = "lib-friends-col";
cols.push(col);
grid.appendChild(col);
}
FRIENDS.forEach((f, i) => {
const a = document.createElement("a");
a.className = "lib-friend";
a.href = f.url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = f.name;
a.style.setProperty("--tilt", tilts[i % tilts.length]);
// A monogram avatar (first initial) keeps the tile on-brand when an entry
// has no image, or its image fails to load (e.g. before the asset is added).
const makeMonogram = () => {
const m = document.createElement("span");
m.className = "lib-friend-monogram";
m.textContent = (f.name || "?").trim().charAt(0).toUpperCase();
m.setAttribute("aria-hidden", "true");
return m;
};
if (f.logo) {
const img = document.createElement("img");
img.className = f.avatar ? "lib-friend-avatar" : "lib-friend-logo";
img.src = f.logo;
img.alt = f.name;
img.loading = "lazy";
img.addEventListener("error", () => img.replaceWith(makeMonogram()));
a.appendChild(img);
} else {
a.appendChild(makeMonogram());
}
const name = document.createElement("span");
name.className = "lib-friend-name";
name.textContent = f.name;
a.appendChild(name);
if (f.role) {
const role = document.createElement("span");
role.className = "lib-friend-role";
role.textContent = f.role;
a.appendChild(role);
}
if (/instagram\.com/i.test(f.url || "")) {
const SVGNS = "http://www.w3.org/2000/svg";
const ig = document.createElementNS(SVGNS, "svg");
ig.setAttribute("class", "lib-friend-ig");
ig.setAttribute("viewBox", "0 0 24 24");
ig.setAttribute("aria-hidden", "true");
const p = document.createElementNS(SVGNS, "path");
p.setAttribute("d", IG_ICON_PATH);
ig.appendChild(p);
a.appendChild(ig);
}
cols[i % COLS].appendChild(a);
});
}
const open = () => dialog.classList.remove("hidden");
const hide = () => dialog.classList.add("hidden");
btn.addEventListener("click", open);
close?.addEventListener("click", hide);
dialog.addEventListener("mousedown", (e) => { if (e.target === dialog) hide(); });
dialog.addEventListener("keydown", (e) => { if (e.code === "Escape") hide(); });
}
async function syncWithServer() {
try {
const res = await fetch("/api/jobs", { cache: "no-store" });
if (!res.ok) return;
const jobs = await res.json();
const trashIds = new Set(getTrashFolder()?.items || []);
const deletedIds = getDeletedJobIds();
for (const state of jobs) {
if (tracks[state.job_id]) continue;
if (trashIds.has(state.job_id)) continue; // soft-deleted, skip
if (deletedIds.has(state.job_id)) continue; // hard-deleted, skip
const track = stateMetadataToTrack(state, { id: state.job_id, status: state.status });
track.id = state.job_id;
addTrackToLibrary(track);
}
} catch (e) { console.warn("[catalog] failed to load jobs from backend:", e); }
}
// ─── Settings menu + Library editor ───
let libraryEditor = null;
let libraryEditorOnKey = null;
// Human-readable "Location" for a track: the imported filename for local
// uploads, otherwise the source URL.
function libraryLocation(sourceUrl) {
if (!sourceUrl) return "—";
if (sourceUrl.startsWith("local:")) return sourceUrl.slice(6) || "Imported file";
return sourceUrl;
}
// Count library tracks (excluding Trash) whose audio is gone.
function libraryUnavailableCount() {
const trashIds = new Set(getTrashFolder()?.items || []);
return Object.entries(tracks)
.filter(([id, t]) => !trashIds.has(id) && t.status === "unavailable").length;
}
// Update the editor's footer line with the out-of-sync count (red) or an
// all-clear message. Safe no-op when the editor isn't open.
function refreshLibrarySyncSummary() {
const statusEl = libraryEditor?.querySelector(".library-editor-status");
if (!statusEl) return;
const n = libraryUnavailableCount();
statusEl.classList.toggle("out-of-sync", n > 0);
statusEl.textContent = n > 0
? `${n} ${n === 1 ? "track is" : "tracks are"} out of sync`
: "All tracks in sync";
}
function closeLibraryEditor() {
if (libraryEditorOnKey) {
document.removeEventListener("keydown", libraryEditorOnKey);
libraryEditorOnKey = null;
}
libraryEditor?.remove();
libraryEditor = null;
}
// Fill the editor's table body from `tracks` (skips Trash). Built via DOM +
// textContent — titles/URLs are untrusted (YouTube/SoundCloud) so never
// interpolate them into innerHTML.
function renderLibraryRows(tbody) {
tbody.textContent = "";
const trashIds = new Set(getTrashFolder()?.items || []);
// Only out-of-sync (audio missing) tracks — this table sits next to Resync.
const entries = Object.entries(tracks)
.filter(([id, t]) => !trashIds.has(id) && t.status === "unavailable")
.sort((a, b) => (b[1].createdAt || 0) - (a[1].createdAt || 0));
if (!entries.length) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = 3;
td.className = "library-editor-empty";
td.textContent = "All tracks are in sync.";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
for (const [id, t] of entries) {
const tr = document.createElement("tr");
tr.dataset.id = id;
if (t.status === "unavailable") tr.className = "unavailable";
const name = document.createElement("td");
name.className = "le-name";
name.textContent = t.title || "—";
name.title = t.title || "";
if (t.status === "unavailable") {
const badge = document.createElement("span");
badge.className = "le-badge";
badge.textContent = "unavailable";
name.appendChild(badge);
}
const source = document.createElement("td");
source.className = "le-source";
source.textContent = deriveSource(t.sourceUrl);
const loc = document.createElement("td");
loc.className = "le-loc";
const locText = libraryLocation(t.sourceUrl);
loc.textContent = locText;
loc.title = locText;
tr.append(name, source, loc);
tbody.appendChild(tr);
}
}
// "Make StemDeck available on your network" toggle. The backend always binds
// all interfaces and gates LAN access on a runtime flag (GET/POST /api/settings)
// — so this works live, no restart, identically in the desktop app and the
// self-hosted server. Loopback is always allowed, so the owner can't lock
// themselves out of this control.
function networkSettingsHtml() {
return `
Make StemDeck available on your network
Let other devices (like your phone) open StemDeck at the address below.
`;
}
// General settings: max track length (minutes) + MP4 video quality. Read live
// and POSTed on change to /api/settings (same runtime store as the toggle).
async function wireGeneralSettings(overlay) {
const durInput = overlay.querySelector(".set-max-duration");
const heightSel = overlay.querySelector(".set-video-height");
const portInput = overlay.querySelector(".set-port");
if (!durInput && !heightSel && !portInput) return;
const apply = (d) => {
if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60));
if (heightSel && d.video_max_height) heightSel.value = String(d.video_max_height);
if (portInput && d.port) portInput.value = String(d.port);
};
// Keep the text inputs digit-only as the user types (maxlength caps the rest).
const digitsOnly = (input) => input?.addEventListener("input", () => {
const cleaned = input.value.replace(/\D/g, "");
if (cleaned !== input.value) input.value = cleaned;
});
digitsOnly(durInput);
digitsOnly(portInput);
try {
const r = await fetch("/api/settings", { cache: "no-store" });
if (r.ok) apply(await r.json());
} catch { /* leave blank */ }
const post = async (patch) => {
try {
const r = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (r.ok) apply(await r.json()); // reflect the server's clamped value
} catch { /* ignore */ }
};
durInput?.addEventListener("change", () => {
const mins = Math.max(1, Math.min(20, parseInt(durInput.value, 10) || 20));
post({ max_duration_sec: mins * 60 });
});
heightSel?.addEventListener("change", () => {
post({ video_max_height: parseInt(heightSel.value, 10) });
});
portInput?.addEventListener("change", () => {
const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8080));
post({ port });
});
}
async function wireNetworkSetting(overlay) {
const input = overlay.querySelector(".net-access-input");
const netWrap = overlay.querySelector(".settings-net");
const qrWrap = overlay.querySelector(".settings-net-qr");
if (!input) return;
let enabled = false;
let addresses = [];
try {
const r = await fetch("/api/settings", { cache: "no-store" });
if (r.ok) {
const data = await r.json();
enabled = data.allow_network === true;
addresses = Array.isArray(data.lan_addresses) ? data.lan_addresses : [];
}
} catch { /* leave defaults */ }
// QR codes: one per LAN address, each encodes the /mobile/ URL so the
// phone camera opens StemDeck directly. Cards start blurred so an open
// camera app on a nearby device doesn't scan them before you're ready.
if (qrWrap) {
qrWrap.textContent = "";
if (addresses.length) {
const hint = document.createElement("p");
hint.className = "qr-hint";
hint.textContent = "Blurred so your camera doesn't get too excited. Tap to reveal.";
qrWrap.appendChild(hint);
const row = document.createElement("div");
row.className = "qr-cards-row";
for (const a of addresses) {
const mobileUrl = `${a}/mobile/`;
const card = document.createElement("div");
card.className = "qr-card qr-blurred";
card.title = "Tap to unblur";
card.addEventListener("click", () => card.classList.toggle("qr-blurred"));
const img = document.createElement("img");
img.src = `/api/qr?url=${encodeURIComponent(mobileUrl)}`;
img.alt = `QR code for ${mobileUrl}`;
img.width = 130;
img.height = 130;
const label = document.createElement("div");
label.className = "qr-label";
label.textContent = mobileUrl;
const imgWrap = document.createElement("div");
imgWrap.className = "qr-img-wrap";
imgWrap.appendChild(img);
card.append(imgWrap, label);
row.appendChild(card);
}
qrWrap.appendChild(row);
} else {
const span = document.createElement("span");
span.className = "settings-net-empty";
span.textContent = "No local network connection detected.";
qrWrap.appendChild(span);
}
}
input.checked = enabled;
const refresh = () => netWrap?.classList.toggle("hidden", !input.checked);
refresh();
input.addEventListener("change", async () => {
const want = input.checked;
input.disabled = true;
try {
const r = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allow_network: want }),
});
input.checked = r.ok ? (await r.json()).allow_network === true : !want;
} catch {
input.checked = !want; // revert on failure
} finally {
input.disabled = false;
refresh();
}
});
}
function openLibraryEditor() {
closeFolderEditor();
closeLibraryEditor();
const overlay = document.createElement("div");
overlay.className = "library-editor-backdrop";
overlay.innerHTML = `
Settings
Max track length
Longest track accepted for processing, in minutes (max 20).
MP4 video quality
Max resolution for MP4 export and YouTube video.
${networkSettingsHtml()}
Port
Port StemDeck runs on. Restart to apply.
Out of sync tracks
Name
Source
Location
`;
renderLibraryRows(overlay.querySelector(".library-editor-body"));
overlay.querySelectorAll(".settings-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const name = tab.dataset.tab;
overlay.querySelectorAll(".settings-tab").forEach((t) => t.classList.toggle("active", t === tab));
overlay.querySelectorAll(".settings-pane").forEach((p) => p.classList.toggle("hidden", p.dataset.pane !== name));
});
});
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) closeLibraryEditor(); });
// (status summary is filled in after the overlay is in the DOM, below)
overlay.querySelector(".library-editor-close")?.addEventListener("click", closeLibraryEditor);
overlay.querySelector(".settings-done")?.addEventListener("click", closeLibraryEditor);
overlay.querySelector(".library-editor-sync")?.addEventListener("click", () => resyncLibrary());
// Escape closes from anywhere (the overlay isn't focused, so listen on document).
libraryEditorOnKey = (e) => { if (e.code === "Escape") closeLibraryEditor(); };
document.addEventListener("keydown", libraryEditorOnKey);
document.body.appendChild(overlay);
libraryEditor = overlay;
refreshLibrarySyncSummary();
const isDesktop = Boolean(window.__TAURI__?.core?.invoke);
wireGeneralSettings(overlay);
wireNetworkSetting(overlay);
if (!isDesktop) {
overlay.querySelector(".net-access-input")?.setAttribute("disabled", "");
overlay.querySelector(".set-port")?.setAttribute("readonly", "");
overlay.querySelector(".set-port")?.setAttribute("disabled", "");
const note = document.createElement("p");
note.className = "settings-server-note";
note.textContent = "These settings are read-only in server mode. To change them, update your server configuration (e.g. docker-compose.yml) and restart.";
overlay.querySelector("[data-pane='advanced']")?.prepend(note);
}
}
// Poll a job until it reaches a terminal state, so auto-restores run one at a
// time (applyState/connectEvents drive a single active studio job — overlapping
// restores would fight over the studio). Caps at 30 min as a safety net.
async function waitForJobTerminal(jobId) {
const deadline = Date.now() + 30 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
try {
const r = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" });
if (r.status === 404) return;
const s = await r.json();
if (s.status === "done" || s.status === "error" || s.status === "cancelled") return;
} catch { /* transient — keep waiting */ }
}
}
// "Sync again": reconcile the library with the backend, then auto-restore the
// tracks that fell out of sync.
// forward — add server jobs missing locally (syncWithServer)
// reverse — flag local "done" tracks the server no longer has as
// "unavailable"; restore ones that reappeared on the server.
// restore — re-import every currently-unavailable URL-sourced track
// (re-download + re-separate). Local-file tracks can't be
// auto-restored (the original file isn't kept) — they stay flagged.
// Only done↔unavailable are reconciled, so in-progress imports are never
// mis-flagged (they aren't on the server's done-list yet).
async function resyncLibrary() {
const statusEl = libraryEditor?.querySelector(".library-editor-status");
const syncBtn = libraryEditor?.querySelector(".library-editor-sync");
if (statusEl) statusEl.textContent = "Syncing…";
if (syncBtn) syncBtn.disabled = true;
try {
const res = await fetch("/api/jobs", { cache: "no-store" });
if (!res.ok) throw new Error(`status ${res.status}`);
const jobs = await res.json();
const serverIds = new Set(jobs.map((j) => j.job_id));
await syncWithServer(); // forward: pull in any new server jobs
const trashIds = new Set(getTrashFolder()?.items || []);
for (const [id, t] of Object.entries(tracks)) {
if (trashIds.has(id)) continue;
if (t.status === "done" && !serverIds.has(id)) t.status = "unavailable";
else if (t.status === "unavailable" && serverIds.has(id)) t.status = "done";
}
saveState();
render();
if (libraryEditor) renderLibraryRows(libraryEditor.querySelector(".library-editor-body"));
// Collect what's still unavailable; auto-restore the ones with a URL source.
const unavailable = Object.entries(tracks)
.filter(([id, t]) => !trashIds.has(id) && t.status === "unavailable")
.map(([, t]) => t);
const restorable = unavailable.filter((t) => t.sourceUrl && !t.sourceUrl.startsWith("local:"));
if (restorable.length) {
// Re-import each from its source. Close the editor so the studio overlay
// shows progress; restore sequentially (single active studio job).
closeLibraryEditor();
for (const t of restorable) {
const jobId = await importFromUrl(t.sourceUrl, {
title: t.title,
stems: t.selectedStems,
});
if (jobId) await waitForJobTerminal(jobId);
}
return;
}
// Nothing auto-restorable left — show the out-of-sync count (local-file
// tracks can't be re-fetched and stay flagged).
refreshLibrarySyncSummary();
} catch (e) {
console.warn("[catalog] resync failed:", e);
if (statusEl) statusEl.textContent = "Sync failed — check your connection.";
} finally {
if (syncBtn) syncBtn.disabled = false;
}
}
function wireSettingsMenu() {
const btn = document.getElementById("settingsBtn");
if (!btn || btn.dataset.menuReady === "1") return;
btn.dataset.menuReady = "1";
// The only setting today is the library, so Settings opens the Edit Library
// window directly (a centered modal, like the About dialog).
btn.addEventListener("click", openLibraryEditor);
}
export async function initCatalog() {
await loadState();
wireCatalogToggle();
wireCatalogRailViews();
wireCatalogSearch();
wireWidgets();
wireMainPanelDrop();
wireRailTrashDrop();
wireRailLibraryDrop();
wireLibraryDeleteKeys();
wireAboutDialog();
wireSupportersDialog();
wireSettingsMenu();
setDisplayedVersion(currentVersion);
render();
loadCurrentVersion().finally(checkForUpdate);
syncWithServer();
}