// catalog.js — library panel: folders, tracks, collapse, drag-and-drop import { STEM_NAMES } from "./constants.js"; import { wireUpAudio, updateFooterTrack } from "./player.js"; import { initSections } from "./sections.js"; import { bpmChip, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js"; import { showError, importFromUrl } from "./job.js"; import { fmtTime, storeGet, storeSet } from "./utils.js"; // Escape user-supplied strings before inserting into innerHTML. function esc(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } const STORAGE_KEY = "stemdeck.folders"; const STORAGE_VERSION = 2; // bump to wipe stale seeded data const DELETED_JOBS_KEY = "stemdeck.deleted_jobs"; // Curated "Our Friends" partners shown at the bottom of the library. Add an // entry here to feature another store/band/etc. Logos are bundled under // static/img/friends/ so they render offline. Links open externally via the // document-level a[target="_blank"] handler in main.js (Tauri open_url). const FRIENDS = [ { name: "Dlima Guitars", url: "https://www.instagram.com/dlimaguitars", logo: "/img/friends/dlima-guitars-ig.jpg", avatar: true }, { name: "Lisbon Guitar Works", url: "https://dlimaguitars.com", logo: "/img/friends/lisbon-guitar-works.webp" }, { name: "Joao Gaspar", role: "Producer/Film Scorer, Touring/Session Musician", url: "https://www.instagram.com/jay_glaspar", logo: "/img/friends/joao-gaspar.jpg", avatar: true, }, { name: "Kris Luthier", role: "Luthier and Musical Instrument Repair, Lisboa", url: "https://www.instagram.com/krisluthier", logo: "/img/friends/kris-luthier.jpg", avatar: true, }, { name: "Thomann", role: "Online Music Store", url: "https://www.instagram.com/thomann.music", logo: "/img/friends/thomann.jpg", avatar: true, }, { name: "Analog4Lyfe", role: "Analog music gear", url: "https://www.instagram.com/analog4lyfe", logo: "/img/friends/analog4lyfe.jpg", avatar: true, }, { name: "Empress Effects", role: "Effects pedals", url: "https://empresseffects.com", logo: "/img/friends/empress-effects.png", }, ]; // Instagram glyph (Simple Icons), shown under tiles that link to Instagram. const IG_ICON_PATH = "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"; let folders = []; let tracks = {}; let _deletedJobIds = new Set(); let _currentTrackId = null; let _loadTrackToken = 0; let catalogView = "library"; let catalogSearchQuery = ""; // ─── Persistence ─── const TRASH_ID = "trash"; // The default landing folder for unorganized tracks — protected from deletion. const UNSORTED_ID = "f-unsorted"; const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]); const FOLDER_COLORS = ["#d8a84a", "#e85f6f", "#64c86f", "#4f9de8", "#a985f4"]; const DEFAULT_FOLDER_COLOR = FOLDER_COLORS[0]; const TRACK_DRAG_TYPE = "application/x-stemdeck-track"; const FOLDER_DRAG_TYPE = "application/x-stemdeck-folder"; function getDeletedJobIds() { return _deletedJobIds; } function markJobsDeleted(ids) { for (const id of ids) _deletedJobIds.add(id); storeSet(DELETED_JOBS_KEY, [..._deletedJobIds]).catch((e) => console.warn("[catalog] failed to persist deleted jobs", e) ); } function normalizeFolderColor(color) { return FOLDER_COLORS.includes(color) ? color : DEFAULT_FOLDER_COLOR; } function makeFolder({ id = `f-${Date.now()}`, name = "New folder", collapsed = false, items = [], parentId = null } = {}) { return { id, name, collapsed, items, color: DEFAULT_FOLDER_COLOR, parentId: parentId ?? null }; } function ensureTrash() { if (!folders.find((f) => f.id === TRASH_ID)) { folders.push({ id: TRASH_ID, name: "Trash", collapsed: true, items: [] }); } } function getTrashFolder() { ensureTrash(); return folders.find((f) => f.id === TRASH_ID); } function removeTrackFromFolders(trackId) { for (const folder of folders) { folder.items = folder.items.filter((id) => id !== trackId); } } function normalizeSource(value) { const s = String(value || "").trim(); if (!s) return s; // Normalize YouTube URLs to the bare video ID so that youtu.be/xxx, // youtube.com/watch?v=xxx, and variants with &t= / ?si= all match. const yt = s.match(/(?:youtu\.be\/|[?&]v=)([a-zA-Z0-9_-]{11})/); if (yt) return `yt:${yt[1]}`; return s; } function normalizeSearch(value) { return String(value || "").trim().toLowerCase(); } function trackMatchesSearch(track) { const q = normalizeSearch(catalogSearchQuery); if (!q) return true; if (q.startsWith("#")) { const tag = q.slice(1); if (!tag) return true; return (track?.tags ?? []).some((t) => String(t).toLowerCase().includes(tag)); } return [ track?.title, track?.channel, track?.sourceUrl, ...(track?.stems || []), ...(track?.tags || []), ].some((value) => String(value || "").toLowerCase().includes(q)); } function findTrackBySource(sourceUrl, exceptId) { const source = normalizeSource(sourceUrl); if (!source) return null; for (const [id, track] of Object.entries(tracks)) { if (id === exceptId) continue; if (normalizeSource(track.sourceUrl) === source) return id; } return null; } function replaceTrackId(oldId, newId) { if (!oldId || !newId || oldId === newId || !tracks[oldId]) return; tracks[newId] = { ...tracks[oldId], ...(tracks[newId] || {}), id: newId }; delete tracks[oldId]; for (const folder of folders) { folder.items = folder.items.map((id) => (id === oldId ? newId : id)); folder.items = [...new Set(folder.items)]; } if (_currentTrackId === oldId) _currentTrackId = newId; } function purgeTrash() { const trash = folders.find((f) => f.id === TRASH_ID); if (!trash?.items.length) return false; const trashIds = new Set(trash.items); for (const id of trashIds) delete tracks[id]; for (const folder of folders) { folder.items = folder.items.filter((id) => !trashIds.has(id)); } trash.items = []; return true; } async function loadState() { let changed = false; try { const data = await storeGet(STORAGE_KEY, null); if (data) { if ((data.v ?? 1) >= STORAGE_VERSION) { folders = data.folders ?? []; tracks = data.tracks ?? {}; // Migrate old timestamp-based "Unsorted" folder to reserved ID. const oldUnsorted = folders.find((f) => f.id !== TRASH_ID && f.name === "Unsorted" && f.id !== "f-unsorted"); if (oldUnsorted) { oldUnsorted.id = "f-unsorted"; changed = true; } // Ensure all folders have parentId field. for (const f of folders) { if (!Object.prototype.hasOwnProperty.call(f, "parentId")) { f.parentId = null; changed = true; } } // Drop title-less entries left over from before metadata persistence. const noTitle = Object.keys(tracks).filter((id) => !tracks[id].title); if (noTitle.length) { const toRemove = new Set(noTitle); noTitle.forEach((id) => delete tracks[id]); folders.forEach((f) => { f.items = f.items.filter((id) => !toRemove.has(id)); }); changed = true; } } // else: stale version → start fresh } } catch (e) { console.warn("[catalog] failed to load state:", e); } try { const arr = await storeGet(DELETED_JOBS_KEY, []); if (Array.isArray(arr)) _deletedJobIds = new Set(arr); } catch (e) { console.warn("[catalog] failed to load deleted jobs", e); } ensureTrash(); for (const folder of folders) { if (folder.id !== TRASH_ID) { const nextColor = normalizeFolderColor(folder.color); if (folder.color !== nextColor) { folder.color = nextColor; changed = true; } } } // Remove trash refs whose track data is missing (orphaned), but don't auto-empty. const trashFolder = folders.find((f) => f.id === TRASH_ID); if (trashFolder) { const before = trashFolder.items.length; trashFolder.items = trashFolder.items.filter((id) => tracks[id]); if (trashFolder.items.length !== before) changed = true; } if (changed) saveState(); } function saveState() { ensureTrash(); storeSet(STORAGE_KEY, { v: STORAGE_VERSION, folders, tracks }).catch((e) => console.warn("[catalog] failed to save state:", e) ); } // ─── Track management ─── export function addTrackToLibrary(track) { // track: { id, title, channel, thumb, stems, status, sourceUrl } const existingId = findTrackBySource(track.sourceUrl, track.id); if (existingId) { const trash = getTrashFolder(); const inTrash = trash?.items.includes(existingId); if (inTrash) { // Old track was trashed — delete it silently so the new import lands // in the library instead of inheriting the trash placement. delete tracks[existingId]; for (const f of folders) f.items = f.items.filter((id) => id !== existingId); } else { replaceTrackId(existingId, track.id); } } const existing = tracks[track.id] || {}; tracks[track.id] = { ...existing, ...track, createdAt: existing.createdAt ?? track.createdAt ?? (Date.now() / 1000), favorite: existing.favorite ?? false, }; const alreadyPlaced = folders.some((folder) => folder.items.includes(track.id)); if (!alreadyPlaced) { // Put into first non-trash folder or create an "Unsorted" folder. let target = folders.find((folder) => folder.id !== TRASH_ID); if (!target) { target = makeFolder({ id: "f-unsorted", name: "Unsorted" }); folders.unshift(target); } target.items.unshift(track.id); } saveState(); render(); } export function updateTrackStatus(trackId, status) { if (tracks[trackId]) { tracks[trackId].status = status; saveState(); const statusDot = document.querySelector(`.cat-item[data-id="${trackId}"] .cat-status`); if (statusDot) { const modifier = PROCESSING_STATUSES.has(status) ? " processing" : status === "unavailable" ? " unavailable" : ""; statusDot.className = `cat-status${modifier}`; } for (const el of document.querySelectorAll(`.cat-item[data-id="${trackId}"]`)) { el.classList.toggle("unavailable", status === "unavailable"); } } } function hasTrackAnalysis(track) { return Boolean( track?.bpm || track?.key || track?.scale || track?.keyConfidence != null || track?.lufs != null || track?.peakDb != null, ); } function stateMetadataToTrack(state, fallbackTrack) { return { ...fallbackTrack, title: state.title || fallbackTrack.title, thumb: state.thumbnail || fallbackTrack.thumb, stems: state.selected_stems || fallbackTrack.stems, selectedStems: state.selected_stems || fallbackTrack.selectedStems, audioStems: state.stems || fallbackTrack.audioStems || [], duration: state.duration || fallbackTrack.duration, status: state.status || fallbackTrack.status, bpm: state.bpm ?? fallbackTrack.bpm, key: state.key ?? fallbackTrack.key, scale: state.scale ?? fallbackTrack.scale, keyConfidence: state.key_confidence ?? fallbackTrack.keyConfidence, lufs: state.lufs ?? fallbackTrack.lufs, peakDb: state.peak_db ?? fallbackTrack.peakDb, stemPresence: state.stem_presence ?? fallbackTrack.stemPresence, dynamicRange: state.dynamic_range ?? fallbackTrack.dynamicRange, tempoStability: state.tempo_stability ?? fallbackTrack.tempoStability, tags: state.tags ?? fallbackTrack.tags ?? [], sections: state.sections ?? fallbackTrack.sections ?? null, sourceUrl: state.source_url || fallbackTrack.sourceUrl, mixUrl: state.mix_url ?? fallbackTrack.mixUrl ?? null, hasVideo: state.has_video ?? fallbackTrack.hasVideo ?? false, createdAt: fallbackTrack.createdAt ?? state.created_at, favorite: fallbackTrack.favorite ?? false, }; } function fmtExtracted(ts) { if (!ts) return "—"; return new Date(ts * 1000).toLocaleString("en-US", { month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); } function deriveSource(sourceUrl) { if (!sourceUrl) return "—"; if (sourceUrl.startsWith("local:")) return "Local file"; if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube"; if (sourceUrl.includes("soundcloud.com")) return "SoundCloud"; return "Web"; } function deriveQuality(sourceUrl) { if (!sourceUrl) return "—"; if (sourceUrl.startsWith("local:")) { const ext = sourceUrl.split(".").pop()?.toLowerCase(); if (ext === "wav") return "Lossless (WAV)"; if (ext === "mp3") return "Compressed (MP3)"; return "Local file"; } if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "High"; if (sourceUrl.includes("soundcloud.com")) return "Compressed (MP3)"; return "—"; } function drLabel(dr) { if (dr < 7) return "Compressed"; if (dr < 10) return "Moderate"; if (dr < 14) return "High"; return "Wide"; } function stabilityLabel(pct) { if (pct >= 90) return "Very Stable"; if (pct >= 70) return "Stable"; if (pct >= 50) return "Moderate"; return "Variable"; } export function applyStemPresenceCards(stemPresence) { const cards = document.querySelectorAll(".stem-presence-panel .stem-card"); cards.forEach((card) => { const stem = card.dataset.stem; const pct = stemPresence?.[stem]; const label = card.querySelector(".stem-card-pct"); if (pct != null && pct > 0) { card.classList.remove("inactive"); if (label) label.textContent = `${pct}%`; } else { card.classList.add("inactive"); if (label) label.textContent = pct === 0 ? "0%" : "—"; } }); } function applyTrackInfoToPanel(track) { titleEl.textContent = track.title || "Untitled track"; bpmChip.textContent = track.bpm ? `${track.bpm} BPM` : "— BPM"; keyChip.textContent = track.key || "— —"; updateFooterTrack({ title: track.title, thumbnail: track.thumb, key: track.key, bpm: track.bpm, stemCount: (track.audioStems || track.stems || []).filter((s) => (s.name ?? s) !== "original").length || null, }); applyStemPresenceCards(track.stemPresence); const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); const summaryScale = document.getElementById("summary-scale"); const summaryScaleName = document.getElementById("summary-scale-name"); const summaryConfidence = document.getElementById("summary-confidence"); const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const summaryLufs = document.getElementById("summary-lufs"); const summaryPeak = document.getElementById("summary-peak"); const summaryDuration = document.getElementById("summary-duration"); if (summaryKey) summaryKey.textContent = track.key || "—"; if (summaryBpm) summaryBpm.textContent = track.bpm ? String(track.bpm) : "—"; if (summaryScale) summaryScale.textContent = track.scale || ""; if (summaryScaleName) summaryScaleName.textContent = track.scale || "—"; if (summaryLufs) summaryLufs.textContent = track.lufs != null ? Number(track.lufs).toFixed(1) : "—"; if (summaryPeak) summaryPeak.textContent = track.peakDb != null ? `Peak ${Number(track.peakDb).toFixed(1)} dB` : ""; if (summaryDuration) summaryDuration.textContent = track.duration ? fmtTime(track.duration) : "—"; const trackExtracted = document.getElementById("track-extracted"); const trackSource = document.getElementById("track-source"); const trackQuality = document.getElementById("track-quality"); const favBtn = document.getElementById("fav-btn"); if (trackExtracted) trackExtracted.textContent = fmtExtracted(track.createdAt); if (trackSource) trackSource.textContent = deriveSource(track.sourceUrl); if (trackQuality) trackQuality.textContent = deriveQuality(track.sourceUrl); if (favBtn) { favBtn.classList.toggle("active", Boolean(track.favorite)); favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite))); favBtn.onclick = () => { if (!_currentTrackId) return; const t = tracks[_currentTrackId]; if (!t) return; t.favorite = !t.favorite; favBtn.classList.toggle("active", t.favorite); favBtn.setAttribute("aria-pressed", String(t.favorite)); saveState(); }; } const summaryDr = document.getElementById("summary-dr"); const summaryDrLabel = document.getElementById("summary-dr-label"); const summaryStability = document.getElementById("summary-stability"); const summaryStabilityLabel = document.getElementById("summary-stability-label"); if (summaryDr) summaryDr.textContent = track.dynamicRange != null ? String(track.dynamicRange) : "—"; if (summaryDrLabel) summaryDrLabel.textContent = track.dynamicRange != null ? drLabel(track.dynamicRange) : ""; if (summaryStability) { summaryStability.textContent = track.tempoStability != null ? `${track.tempoStability}%` : "—"; summaryStability.className = "meta-card-value" + (track.tempoStability != null && track.tempoStability >= 80 ? " stability-high" : ""); } if (summaryStabilityLabel) summaryStabilityLabel.textContent = track.tempoStability != null ? stabilityLabel(track.tempoStability) : ""; if (summaryConfidence) { summaryConfidence.textContent = ""; summaryConfidence.style.removeProperty("--confidence-pct"); summaryConfidence.classList.add("hidden"); summaryConfidenceLabel?.classList.add("hidden"); if (track.keyConfidence != null) { const confidence = Math.max(0, Math.min(100, Number(track.keyConfidence))); const confSpan = document.createElement("span"); confSpan.textContent = `${confidence}%`; summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); summaryConfidenceLabel?.classList.remove("hidden"); } } } function moveTrackToTrash(trackId) { if (!tracks[trackId]) return; removeTrackFromFolders(trackId); const trash = getTrashFolder(); if (trash && !trash.items.includes(trackId)) trash.items.unshift(trackId); if (_currentTrackId === trackId) _currentTrackId = null; saveState(); render(); } function setCatalogView(view) { catalogView = ["trash", "favorites"].includes(view) ? view : "library"; const app = document.querySelector(".app"); if (catalogView === "trash" || catalogView === "favorites") { app?.classList.remove("cat-collapsed"); localStorage.setItem("stemdeck.catalog.collapsed", "0"); } render(); } function applyStoredStemSelection(track) { const stored = track.selectedStems || track.stems || []; const next = stored.filter((name) => STEM_NAMES.includes(name)); if (!next.length) return; selectedStems.clear(); for (const name of next) selectedStems.add(name); saveSelectedStems(); for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) { btn.setAttribute("aria-pressed", String(selectedStems.has(btn.dataset.stem))); } } async function loadTrackIntoStudio(trackId) { let track = tracks[trackId]; if (!track) return; if (track.status === "unavailable") { showError("This track's audio is no longer available. Re-upload to restore it."); return; } const hadStoredAudio = Boolean(track.audioStems?.length); const token = ++_loadTrackToken; // Start peaks fetch immediately — runs in parallel with job-data fetch so it // resolves before wireUpAudio calls Multitrack.create. This prevents peaks.json // from competing with stem WAV fetches for Safari's 6-connection-per-origin limit. const peaksPromise = fetch(`/api/jobs/${trackId}/stems/peaks.json`) .then((r) => (r.ok ? r.json() : {})) .catch(() => ({})); // Always fetch fresh state so server-side changes (sections, analysis, stems) // are reflected — cached localStorage data can be stale. try { const res = await fetch(`/api/jobs/${trackId}`); if (token !== _loadTrackToken) return; if (res.ok) { const state = await res.json(); track = stateMetadataToTrack(state, track); tracks[trackId] = track; saveState(); } else if (res.status === 404) { track = { ...track, status: "unavailable" }; tracks[trackId] = track; saveState(); updateTrackStatus(trackId, "unavailable"); showError("This track's audio is no longer available. Re-upload to restore it."); return; } } catch (e) { console.warn("[catalog] server sync failed, using stored track:", e); } if (token !== _loadTrackToken) return; // A reprocessing track may still carry the previous extraction's stems // (hadStoredAudio), but it isn't ready — loading it would replace the live // job-progress overlay with stale audio. Leave the progress UI in place. if (PROCESSING_STATUSES.has(track.status)) return; if (!track.audioStems?.length) return; if (track.status !== "done" && !hadStoredAudio) return; applyStoredStemSelection(track); setCurrentTrack(trackId); const urlInput = document.getElementById("url"); if (urlInput && track.sourceUrl) { urlInput.value = track.sourceUrl.startsWith("local:") ? track.sourceUrl.slice(6) : track.sourceUrl; } applyTrackInfoToPanel(track); wireUpAudio(trackId, track.audioStems, track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise, track.hasVideo ?? false); initSections(trackId, track.sections, track.duration || 0); } export function setCurrentTrack(trackId) { _currentTrackId = trackId; for (const el of document.querySelectorAll(".cat-item.active")) el.classList.remove("active"); for (const el of document.querySelectorAll(`.cat-item[data-id="${trackId}"]`)) el.classList.add("active"); for (const el of document.querySelectorAll(".strip-thumb.active")) el.classList.remove("active"); for (const el of document.querySelectorAll(`.strip-thumb[data-id="${trackId}"]`)) el.classList.add("active"); } // ─── Folder operations ─── function createFolder() { const folder = makeFolder(); folders.push(folder); saveState(); render(); openFolderEditor(folder.id); } function deleteFolder(folderId) { if (folderId === TRASH_ID || folderId === UNSORTED_ID) return; // Cascade: delete children first. for (const child of folders.filter((f) => f.parentId === folderId)) deleteFolder(child.id); const idx = folders.findIndex((f) => f.id === folderId); if (idx === -1) return; const [folder] = folders.splice(idx, 1); const trash = getTrashFolder(); for (const trackId of folder.items) { if (tracks[trackId] && trash && !trash.items.includes(trackId)) { trash.items.unshift(trackId); } } saveState(); render(); } function reorderFolder(draggedId, targetId, before) { if (draggedId === targetId) return; const dragged = folders.find((f) => f.id === draggedId); const target = folders.find((f) => f.id === targetId); if (!dragged || !target) return; folders.splice(folders.indexOf(dragged), 1); const toIdx = folders.indexOf(target); folders.splice(before ? toIdx : toIdx + 1, 0, dragged); saveState(); render(); } function isFolderDescendant(ancestorId, candidateId) { let cur = folders.find((f) => f.id === candidateId); while (cur?.parentId) { if (cur.parentId === ancestorId) return true; cur = folders.find((f) => f.id === cur.parentId); } return false; } function reparentFolder(childId, newParentId) { if (childId === newParentId) return; if (isFolderDescendant(childId, newParentId)) return; // would create cycle const child = folders.find((f) => f.id === childId); if (!child) return; child.parentId = newParentId; saveState(); render(); } let folderEditor = null; function folderColorButtonsHtml(activeColor) { return FOLDER_COLORS.map((color, index) => ` `).join(""); } function closeFolderEditor() { folderEditor?.remove(); folderEditor = null; } // Folder names accept letters (any language), digits, spaces, and a small safe // punctuation set — markup/symbols are rejected so names like the XSS probe or // "±!@£$%^&*" can't be created (#170 follow-up). const FOLDER_NAME_RE = /^[\p{L}\p{M}\p{N} '’&().,_-]+$/u; const MAX_FOLDER_NAME_LEN = 100; const isValidFolderName = (s) => FOLDER_NAME_RE.test(s); function openFolderEditor(folderId) { const folder = folders.find((f) => f.id === folderId); if (!folder || folder.id === TRASH_ID) return; closeFolderEditor(); let selectedColor = normalizeFolderColor(folder.color); const overlay = document.createElement("div"); overlay.className = "folder-editor-backdrop"; overlay.innerHTML = ` `; const form = overlay.querySelector(".folder-editor"); const input = overlay.querySelector(".folder-editor-name"); input.value = folder.name; const refreshDots = () => { for (const dot of overlay.querySelectorAll(".folder-color-dot")) { const active = dot.dataset.color === selectedColor; dot.classList.toggle("active", active); dot.setAttribute("aria-pressed", String(active)); } }; overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) closeFolderEditor(); }); overlay.querySelector(".folder-editor-close")?.addEventListener("click", closeFolderEditor); overlay.querySelector(".folder-editor-cancel")?.addEventListener("click", closeFolderEditor); for (const dot of overlay.querySelectorAll(".folder-color-dot")) { dot.addEventListener("click", () => { selectedColor = normalizeFolderColor(dot.dataset.color); refreshDots(); }); } const msgEl = overlay.querySelector(".folder-editor-msg"); input.addEventListener("input", () => { msgEl.textContent = ""; }); form.addEventListener("submit", (e) => { e.preventDefault(); const name = input.value.trim(); if (!name) { msgEl.textContent = "Enter a folder name."; input.focus(); return; } if (name.length > MAX_FOLDER_NAME_LEN) { msgEl.textContent = `Folder name is too long (max ${MAX_FOLDER_NAME_LEN}).`; input.focus(); return; } if (!isValidFolderName(name)) { msgEl.textContent = "Use letters, numbers, spaces, or - _ ' & ( ) . ,"; input.focus(); return; // don't save or close until the name is valid } folder.name = name; folder.color = selectedColor; saveState(); closeFolderEditor(); render(); }); overlay.addEventListener("keydown", (e) => { if (e.code === "Escape") closeFolderEditor(); }); document.body.appendChild(overlay); folderEditor = overlay; input.focus(); input.select(); } // ─── Drag-and-drop ─── let dragId = null; let folderDragId = null; function isTrackDragEvent(event) { return dragId != null || Boolean(event?.dataTransfer?.types?.includes(TRACK_DRAG_TYPE)); } function getDraggedTrackId(event) { return event?.dataTransfer?.getData(TRACK_DRAG_TYPE) || event?.dataTransfer?.getData("text/plain") || dragId; } function startDrag(trackId, itemEl, event) { dragId = trackId; if (event?.dataTransfer) { event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData(TRACK_DRAG_TYPE, trackId); event.dataTransfer.setData("text/plain", trackId); } itemEl.classList.add("dragging"); } function endDrag(itemEl) { dragId = null; itemEl.classList.remove("dragging"); for (const el of document.querySelectorAll(".folder.drop-target")) el.classList.remove("drop-target"); document.querySelector(".rail-trash")?.classList.remove("drop-target"); document.getElementById("lanes")?.classList.remove("library-drop-target"); } function dropOnFolder(folderId, trackId) { const id = trackId ?? dragId; if (!id) return; // Remove from current folder for (const f of folders) { const idx = f.items.indexOf(id); if (idx !== -1) { f.items.splice(idx, 1); break; } } // Add to target folder const target = folders.find((f) => f.id === folderId); if (target && !target.items.includes(id)) target.items.push(id); saveState(); render(); } function wireTrackDragAndLoad(el, trackId) { el.draggable = true; el.addEventListener("dragstart", (e) => { startDrag(trackId, el, e); }); el.addEventListener("dragend", () => endDrag(el)); el.addEventListener("click", (e) => { if (e.target.closest(".cat-del")) return; loadTrackIntoStudio(trackId); }); } function wireMainPanelDrop() { const lanes = document.getElementById("lanes"); if (!lanes || lanes.dataset.libraryDropReady === "1") return; lanes.dataset.libraryDropReady = "1"; lanes.addEventListener("dragover", (e) => { if (!isTrackDragEvent(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; lanes.classList.add("library-drop-target"); }); lanes.addEventListener("dragleave", (e) => { if (!lanes.contains(e.relatedTarget)) lanes.classList.remove("library-drop-target"); }); lanes.addEventListener("drop", (e) => { const trackId = getDraggedTrackId(e); if (!trackId || !tracks[trackId]) return; e.preventDefault(); lanes.classList.remove("library-drop-target"); loadTrackIntoStudio(trackId); }); } function wireRailTrashDrop() { const trash = document.querySelector(".rail-trash"); if (!trash || trash.dataset.dropReady === "1") return; trash.dataset.dropReady = "1"; trash.addEventListener("dragover", (e) => { if (!isTrackDragEvent(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; trash.classList.add("drop-target"); }); trash.addEventListener("dragleave", (e) => { if (!trash.contains(e.relatedTarget)) trash.classList.remove("drop-target"); }); trash.addEventListener("drop", (e) => { const trackId = getDraggedTrackId(e); if (!trackId || !tracks[trackId]) return; e.preventDefault(); trash.classList.remove("drop-target"); moveTrackToTrash(trackId); }); } function restoreTrackFromTrash(trackId) { if (!tracks[trackId]) return; const trash = getTrashFolder(); if (!trash?.items.includes(trackId)) return; trash.items = trash.items.filter((id) => id !== trackId); let target = folders.find((f) => f.id !== TRASH_ID); if (!target) { target = makeFolder({ id: "f-unsorted", name: "Unsorted" }); folders.unshift(target); } if (!target.items.includes(trackId)) target.items.push(trackId); saveState(); render(); } function wireRailLibraryDrop() { const btn = document.querySelector(".rail-library"); if (!btn || btn.dataset.dropReady === "1") return; btn.dataset.dropReady = "1"; btn.addEventListener("dragover", (e) => { if (!isTrackDragEvent(e) || catalogView !== "trash") return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; btn.classList.add("drop-target"); }); btn.addEventListener("dragleave", (e) => { if (!btn.contains(e.relatedTarget)) btn.classList.remove("drop-target"); }); btn.addEventListener("drop", (e) => { btn.classList.remove("drop-target"); if (catalogView !== "trash") return; const trackId = getDraggedTrackId(e); if (!trackId || !tracks[trackId]) return; e.preventDefault(); restoreTrackFromTrash(trackId); setCatalogView("library"); }); } function isTextEditingTarget(target) { return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true'], .folder-editor")); } function wireLibraryDeleteKeys() { if (document.body.dataset.libraryDeleteReady === "1") return; document.body.dataset.libraryDeleteReady = "1"; document.addEventListener("keydown", (e) => { if (e.key !== "Delete" && e.key !== "Backspace") return; if (isTextEditingTarget(e.target)) return; if (!_currentTrackId || !tracks[_currentTrackId]) return; e.preventDefault(); moveTrackToTrash(_currentTrackId); }); } // ─── Rendering helpers ─── function getRecentTracks(trashIds, n = 3) { return Object.entries(tracks) .filter(([id, t]) => !trashIds.has(id) && t.title) .sort(([, a], [, b]) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) .slice(0, n) .map(([id]) => id); } function getAllTags(trashIds) { const counts = {}; for (const [id, track] of Object.entries(tracks)) { if (trashIds.has(id)) continue; for (const tag of track.tags ?? []) { counts[tag] = (counts[tag] || 0) + 1; } } return Object.entries(counts).sort(([, a], [, b]) => b - a); } function makeSectionEl(labelText) { const section = document.createElement("div"); section.className = "lib-section"; const head = document.createElement("div"); head.className = "lib-section-head"; head.textContent = labelText; section.appendChild(head); return section; } function renderRecentItem(trackId) { const track = tracks[trackId]; if (!track) return null; const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; const duration = track.duration ? fmtTime(track.duration) : ""; const stemCount = track.stems?.length ?? 0; const sub = [duration, `${stemCount} stem${stemCount !== 1 ? "s" : ""}`].filter(Boolean).join(" · "); el.innerHTML = `
${thumbHtml(track)}
${esc(track.title ?? "Unknown track")}
${esc(sub)}
`; wireTrackDragAndLoad(el, trackId); return el; } // ─── Rendering ─── function thumbHtml(track) { if (track.thumb) return ``; return ``; } function folderThumbHtml(isTrash = false) { if (isTrash) { return ''; } return ''; } function makeStripItem({ className = "", id, title, html, color, trackId }) { const item = document.createElement("div"); item.className = className ? `strip-thumb ${className}` : "strip-thumb"; item.dataset.id = id; item.title = title; item.innerHTML = html; if (color) item.style.setProperty("--folder-color", color); if (trackId) wireTrackDragAndLoad(item, trackId); return item; } function renderTrackItem(trackId, { inTrash = false } = {}) { const track = tracks[trackId]; if (!track) return null; const el = document.createElement("div"); const isUnavailable = track.status === "unavailable"; el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; const stemCount = track.stems?.length ?? 0; el.innerHTML = `
${thumbHtml(track)}
${esc(track.title ?? "Unknown track")}
${esc(track.channel ?? "")} · ${inTrash ? "Removed" : `${stemCount} stem${stemCount !== 1 ? "s" : ""}`}
${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 = ` `; 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(); }