b9437115ca
Two halves of the same bug. The upload steps passed no `prerelease` to softprops/action-gh-release, which defaults it to false and writes it back. Attaching assets therefore promoted the release it was attaching them to. v0.13.0 and v0.14.0 were both published as pre-releases and both ended up marked as the latest release within the hour, which also fired `released` and pushed :latest to GHCR. Each workflow now carries the release's own flag. The updater took the newest non-draft release, pre-releases included. That was deliberate when it was written, because /releases/latest hides pre-releases and every release was one, so tracking stable meant nobody would ever be notified. With the flag preserved, a pre-release is now genuinely a pre-release, and offering it would push unverified builds to everyone. It now takes the newest release that is neither draft nor pre-release, so a release reaches users only once it has been promoted. The e2e stub grew an unpromoted pre-release ahead of the stable one, and the spec pins which of the two the card names. Co-authored-by: Thales <>
3820 lines
156 KiB
JavaScript
3820 lines
156 KiB
JavaScript
// 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, foregroundJobId, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js";
|
||
import { showError, importFromUrl, detachForegroundJob, runVocalSplitIfWanted } from "./job.js";
|
||
import {
|
||
cancelQueuedJob, getQueueSnapshot, isPaused, onJobSettled, onQueueChange,
|
||
queueCount, queueRowStates, reorderQueuedJob, runningLabel, startQueue,
|
||
startQueueStream,
|
||
} from "./queue.js";
|
||
import { fmtTime, storeGet, storeSet } from "./utils.js";
|
||
import { notifyFailure, setReleasePending, dismissFailuresByJobId, dismissFailuresByKind } from "./notifications.js";
|
||
// Aliased (not the bare "t") -- this file already uses "t"/"tr" as local
|
||
// variable names for track objects and table rows in several scopes.
|
||
import { t as i18nT, plural as i18nPlural, LANGUAGES, getLanguage, setLanguage, onLanguageChange, applyTranslations } from "./i18n.js";
|
||
|
||
// Escape user-supplied strings before inserting into innerHTML.
|
||
function esc(s) {
|
||
return String(s ?? "")
|
||
.replace(/&/g, "&")
|
||
.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: "Analog4Lyfe",
|
||
role: "All-analog music gear, no digital shortcuts",
|
||
url: "https://www.instagram.com/analog4lyfe",
|
||
logo: "/img/friends/analog4lyfe.jpg",
|
||
avatar: true,
|
||
},
|
||
{
|
||
name: "Beltr",
|
||
role: "Turns the songs you already own into karaoke gold, right on your own machine, no subscription, no cloud, just you and the mic",
|
||
url: "https://beltr.app/",
|
||
},
|
||
{
|
||
name: "Dlima Guitars",
|
||
role: "Custom guitars and basses, built one at a time",
|
||
url: "https://www.instagram.com/dlimaguitars",
|
||
logo: "/img/friends/dlima-guitars-ig.jpg",
|
||
avatar: true,
|
||
},
|
||
{
|
||
name: "Empress Effects",
|
||
role: "Boutique effects pedals for tone chasers who don't settle",
|
||
url: "https://empresseffects.com",
|
||
logo: "/img/friends/empress-effects.png",
|
||
},
|
||
{
|
||
name: "Joao Gaspar",
|
||
role: "Producer and film scorer, also plays as a touring/session musician",
|
||
url: "https://www.instagram.com/jay_glaspar",
|
||
logo: "/img/friends/joao-gaspar.jpg",
|
||
avatar: true,
|
||
},
|
||
{
|
||
name: "Kris Luthier",
|
||
role: "Hand-repairs and restores instruments in Lisbon, one careful fix at a time",
|
||
url: "https://www.instagram.com/krisluthier",
|
||
logo: "/img/friends/kris-luthier.jpg",
|
||
avatar: true,
|
||
},
|
||
{
|
||
name: "Lisbon Guitar Works",
|
||
role: "Guitars built by hand in Lisbon",
|
||
url: "https://dlimaguitars.com",
|
||
logo: "/img/friends/lisbon-guitar-works.webp",
|
||
},
|
||
{
|
||
name: "More Notes Less Talk",
|
||
role: "Instruments and gear with personality, recorded raw to tape. No hype, no gatekeeping.",
|
||
url: "https://www.youtube.com/@morenoteslesstalk",
|
||
},
|
||
{
|
||
name: "Seratone",
|
||
role: "Turns any TV into a studio-grade karaoke stage",
|
||
url: "https://seratone.audio/",
|
||
},
|
||
{
|
||
name: "slashCAM",
|
||
role: "German-language camera and video tech: hands-on tests, industry news, and the post-production details most reviews skip",
|
||
url: "https://www.instagram.com/slashcam.de",
|
||
logo: "/img/friends/slashcam.webp",
|
||
},
|
||
{
|
||
name: "Thomann",
|
||
role: "One of Europe's largest music gear retailers, practically everything a musician could need",
|
||
url: "https://www.instagram.com/thomann.music",
|
||
logo: "/img/friends/thomann.jpg",
|
||
avatar: true,
|
||
},
|
||
];
|
||
|
||
// 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 = i18nT("library.newFolder"), 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 = [];
|
||
// Catches a job that errored after its track was trashed but before this
|
||
// permanent delete — moveTrackToTrash's own dismiss already fired earlier
|
||
// and can't have caught a failure that didn't exist yet (#401).
|
||
for (const id of trashIds) dismissFailuresByJobId(id);
|
||
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);
|
||
}
|
||
// The old track is gone either way (deleted or replaced) — any failure
|
||
// notification tied to it, whichever kind, is moot now (#401).
|
||
dismissFailuresByJobId(existingId);
|
||
}
|
||
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: i18nT("folder.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 "—";
|
||
// Short form ("Aug 14, 11:43 AM") -- this now lives in the footer's single
|
||
// compact meta line (#269 follow-up rebuild), which has no room for the
|
||
// long month name and year the summary panel's date affords.
|
||
return new Date(ts * 1000).toLocaleString("en-US", {
|
||
month: "short", day: "numeric",
|
||
hour: "numeric", minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function deriveSource(sourceUrl) {
|
||
if (!sourceUrl) return "—";
|
||
if (sourceUrl.startsWith("local:")) return i18nT("track.localFile");
|
||
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube";
|
||
if (sourceUrl.includes("soundcloud.com")) return "SoundCloud";
|
||
return i18nT("track.web");
|
||
}
|
||
|
||
function deriveQuality(sourceUrl) {
|
||
if (!sourceUrl) return "—";
|
||
if (sourceUrl.startsWith("local:")) {
|
||
const ext = sourceUrl.split(".").pop()?.toLowerCase();
|
||
if (ext === "wav") return i18nT("track.losslessWav");
|
||
if (ext === "mp3") return i18nT("track.compressedMp3");
|
||
return i18nT("track.localFile");
|
||
}
|
||
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return i18nT("track.qualityHigh");
|
||
if (sourceUrl.includes("soundcloud.com")) return i18nT("track.compressedMp3");
|
||
return "—";
|
||
}
|
||
|
||
// Same buckets as job.js's dr.*/stability.* labels (duplicated here rather
|
||
// than imported: this file renders a saved track's stats after reload, job.js
|
||
// renders a job actively in progress -- same thresholds, different data path).
|
||
function drLabel(dr) {
|
||
if (dr < 7) return i18nT("job.dr.compressed");
|
||
if (dr < 10) return i18nT("job.dr.moderate");
|
||
if (dr < 14) return i18nT("job.dr.high");
|
||
return i18nT("job.dr.wide");
|
||
}
|
||
|
||
function stabilityLabel(pct) {
|
||
if (pct >= 90) return i18nT("job.stability.veryStable");
|
||
if (pct >= 70) return i18nT("job.stability.stable");
|
||
if (pct >= 50) return i18nT("job.stability.moderate");
|
||
return i18nT("job.stability.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 || i18nT("track.untitled");
|
||
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 ? i18nT("job.peakDb", { value: Number(track.peakDb).toFixed(1) }) : "";
|
||
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");
|
||
// Static duration for the footer's compact meta line -- deliberately not
|
||
// #t-time, which live-updates during playback and would duplicate the
|
||
// Position group in the transport row below it.
|
||
const metaDuration = document.getElementById("t-meta-duration");
|
||
if (metaDuration) metaDuration.textContent = track.duration ? fmtTime(track.duration) : "—";
|
||
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;
|
||
// The user is done with this track — clear any failure tied to it (#401).
|
||
// purgeTrash() does the same on permanent delete, for a job that errors
|
||
// after being trashed but before it's purged.
|
||
dismissFailuresByJobId(trackId);
|
||
saveState();
|
||
render();
|
||
}
|
||
|
||
function setCatalogView(view) {
|
||
catalogView = ["trash", "favorites", "queue"].includes(view) ? view : "library";
|
||
const app = document.querySelector(".app");
|
||
if (catalogView !== "library") {
|
||
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)));
|
||
}
|
||
}
|
||
|
||
// A track's files went missing (folder deleted or moved outside the app).
|
||
// URL-sourced tracks can be rebuilt from the source, so trigger that
|
||
// directly rather than making the user hunt for a "resync" button in
|
||
// Settings. Local uploads have no source bytes left to rebuild from (#354's
|
||
// cleanup deletes the upload once the pipeline is done with it), so the
|
||
// only path back is a fresh re-upload.
|
||
function reimportUnavailableTrack(trackId, track) {
|
||
updateTrackStatus(trackId, "unavailable");
|
||
if (track.sourceUrl && !track.sourceUrl.startsWith("local:")) {
|
||
importFromUrl(track.sourceUrl, { title: track.title, stems: track.selectedStems });
|
||
return;
|
||
}
|
||
showError(i18nT("track.audioUnavailableError"));
|
||
}
|
||
|
||
async function loadTrackIntoStudio(trackId) {
|
||
let track = tracks[trackId];
|
||
if (!track) return;
|
||
if (track.status === "unavailable") {
|
||
reimportUnavailableTrack(trackId, track);
|
||
return;
|
||
}
|
||
// The user has chosen to look at something else, so the running import gives
|
||
// up the studio. It keeps running and keeps updating its own row; it just
|
||
// stops repainting this view (and, at completion, replacing the audio that
|
||
// is about to load here).
|
||
if (trackId !== foregroundJobId) detachForegroundJob();
|
||
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();
|
||
if (state.status === "unavailable") {
|
||
reimportUnavailableTrack(trackId, track);
|
||
return;
|
||
}
|
||
} else if (res.status === 404) {
|
||
track = { ...track, status: "unavailable" };
|
||
tracks[trackId] = track;
|
||
saveState();
|
||
updateTrackStatus(trackId, "unavailable");
|
||
showError(i18nT("track.audioUnavailableError"));
|
||
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);
|
||
}
|
||
|
||
/** Put a whole playlist import in a folder of its own.
|
||
*
|
||
* Placement happens before addTrackToLibrary, which only assigns a folder to a
|
||
* track that is not in one yet -- so claiming the ids first is what keeps these
|
||
* tracks out of Unsorted. Reuses an existing folder of the same name so
|
||
* re-importing a playlist tops it up instead of creating a duplicate.
|
||
*/
|
||
export function addPlaylistToLibrary(playlistTitle, jobs) {
|
||
const name = String(playlistTitle || "Playlist").trim().slice(0, 80) || "Playlist";
|
||
let folder = folders.find((f) => f.id !== TRASH_ID && !f.parentId && f.name === name);
|
||
if (!folder) {
|
||
folder = makeFolder({ name });
|
||
folders.unshift(folder);
|
||
}
|
||
|
||
for (const job of jobs) {
|
||
if (!folder.items.includes(job.job_id)) folder.items.push(job.job_id);
|
||
addTrackToLibrary({
|
||
id: job.job_id,
|
||
title: job.title || job.source_url || i18nT("job.queuedTrack"),
|
||
channel: i18nT("job.processing"),
|
||
thumb: "",
|
||
stems: [...selectedStems],
|
||
selectedStems: [...selectedStems],
|
||
audioStems: [],
|
||
status: "queued",
|
||
bpm: null,
|
||
key: null,
|
||
scale: null,
|
||
keyConfidence: null,
|
||
lufs: null,
|
||
peakDb: null,
|
||
sourceUrl: job.source_url,
|
||
});
|
||
}
|
||
folder.collapsed = false;
|
||
saveState();
|
||
render();
|
||
return 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) => `
|
||
<button
|
||
class="folder-color-dot${color === activeColor ? " active" : ""}"
|
||
type="button"
|
||
data-color="${color}"
|
||
style="--folder-color: ${color};"
|
||
aria-label="Set folder color ${index + 1}"
|
||
aria-pressed="${color === activeColor}"
|
||
></button>
|
||
`).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 = `
|
||
<form class="folder-editor" role="dialog" aria-modal="true" aria-label="Edit folder" data-i18n-aria-label="folderEditor.title">
|
||
<div class="folder-editor-head">
|
||
<span data-i18n="folderEditor.title">Edit folder</span>
|
||
<button class="folder-editor-close" type="button" aria-label="Close" data-i18n-aria-label="folderEditor.closeAria">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<path d="M18 6 6 18M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<label class="folder-editor-field">
|
||
<span data-i18n="folderEditor.nameLabel">Name</span>
|
||
<input class="folder-editor-name" type="text" maxlength="100" autocomplete="off" spellcheck="false" />
|
||
</label>
|
||
<div class="folder-editor-field">
|
||
<span data-i18n="folderEditor.colorLabel">Color</span>
|
||
<div class="folder-editor-colors" role="group" aria-label="Folder color" data-i18n-aria-label="folderEditor.colorGroupAria">
|
||
${folderColorButtonsHtml(selectedColor)}
|
||
</div>
|
||
</div>
|
||
<div class="folder-editor-msg" role="alert" aria-live="polite"></div>
|
||
<div class="folder-editor-actions">
|
||
<button class="folder-editor-cancel" type="button" data-i18n="folderEditor.cancel">Cancel</button>
|
||
<button class="folder-editor-save" type="submit" data-i18n="folderEditor.save">Save</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
applyTranslations(overlay);
|
||
|
||
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 = i18nT("folderEditor.emptyError");
|
||
input.focus();
|
||
return;
|
||
}
|
||
if (name.length > MAX_FOLDER_NAME_LEN) {
|
||
msgEl.textContent = i18nT("folderEditor.tooLong", { max: MAX_FOLDER_NAME_LEN });
|
||
input.focus();
|
||
return;
|
||
}
|
||
if (!isValidFolderName(name)) {
|
||
msgEl.textContent = i18nT("folderEditor.charsError");
|
||
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;
|
||
}
|
||
|
||
// A URL-sourced track can be rebuilt by re-running the import; a local
|
||
// upload has no source bytes left to rebuild from, only a re-upload gets it
|
||
// back (see reimportUnavailableTrack).
|
||
function canReimportTrack(track) {
|
||
return Boolean(track.sourceUrl) && !track.sourceUrl.startsWith("local:");
|
||
}
|
||
|
||
function unavailableWarningHtml(track) {
|
||
const label = canReimportTrack(track)
|
||
? i18nT("track.unavailableReimport")
|
||
: i18nT("track.unavailableReupload");
|
||
return `<span class="cat-unavailable-warning">${esc(label)}</span>`;
|
||
}
|
||
|
||
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, i18nPlural("footer.stemsCount", stemCount)].filter(Boolean).join(" · ");
|
||
el.innerHTML = `
|
||
<div class="cat-thumb">${thumbHtml(track)}</div>
|
||
<div class="cat-meta">
|
||
<div class="cat-title">${esc(displayTitle(track.title))}</div>
|
||
<div class="cat-sub">${isUnavailable ? unavailableWarningHtml(track) : `<span>${esc(sub)}</span>`}</div>
|
||
</div>
|
||
<div class="cat-status${PROCESSING_STATUSES.has(track.status) ? " processing" : isUnavailable ? " unavailable" : ""}"></div>
|
||
`;
|
||
wireTrackDragAndLoad(el, trackId);
|
||
return el;
|
||
}
|
||
|
||
// ─── Rendering ───
|
||
|
||
// A queued URL import has no title yet -- nothing has been downloaded, so the
|
||
// only thing to show is the URL the user pasted, which renders as a truncated
|
||
// unreadable string. Name the source instead until the real title arrives.
|
||
const _SOURCE_LABELS = [
|
||
[/(^|\.)youtube\.com$|(^|\.)youtu\.be$|(^|\.)youtube-nocookie\.com$/, "YouTube"],
|
||
[/(^|\.)soundcloud\.com$/, "SoundCloud"],
|
||
];
|
||
|
||
export function displayTitle(title) {
|
||
const text = String(title ?? "").trim();
|
||
if (!/^https?:\/\//i.test(text)) return text || i18nT("track.unknown");
|
||
try {
|
||
const host = new URL(text).hostname.replace(/^www\./, "");
|
||
const match = _SOURCE_LABELS.find(([re]) => re.test(host));
|
||
return `${match ? match[1] : host} link`;
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
function thumbHtml(track) {
|
||
if (track.thumb) return `<img src="${esc(track.thumb)}" alt="" loading="lazy" />`;
|
||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`;
|
||
}
|
||
|
||
function folderThumbHtml(isTrash = false) {
|
||
if (isTrash) {
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>';
|
||
}
|
||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
|
||
}
|
||
|
||
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;
|
||
const subHtml = isUnavailable
|
||
? unavailableWarningHtml(track)
|
||
: `<span>${esc(track.channel ?? "")}</span>
|
||
<span class="dot">·</span>
|
||
<span>${inTrash ? esc(i18nT("track.removed")) : esc(i18nPlural("footer.stemsCount", stemCount))}</span>`;
|
||
el.innerHTML = `
|
||
<div class="cat-thumb">${thumbHtml(track)}</div>
|
||
<div class="cat-meta">
|
||
<div class="cat-title">${esc(displayTitle(track.title))}</div>
|
||
<div class="cat-sub">${subHtml}</div>
|
||
</div>
|
||
<div class="cat-status${PROCESSING_STATUSES.has(track.status) ? " processing" : isUnavailable ? " unavailable" : ""}"></div>
|
||
${inTrash ? "" : `<button class="cat-del" type="button" title="${esc(i18nT("track.moveToTrash"))}">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<polyline points="3 6 5 6 21 6"></polyline>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path>
|
||
</svg>
|
||
</button>`}
|
||
`;
|
||
el.querySelector(".cat-del")?.setAttribute("aria-label", i18nT("track.moveTitleToTrash", { title: track.title ?? i18nT("track.unknown") }));
|
||
|
||
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
|
||
? `<svg class="f-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>`
|
||
: `<svg class="f-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>`;
|
||
|
||
head.innerHTML = `
|
||
${isTrash ? "" : `<span class="f-grip" title="${esc(i18nT("folder.dragToReorder"))}">
|
||
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor" aria-hidden="true">
|
||
<circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/>
|
||
</svg>
|
||
</span>`}
|
||
<svg class="f-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||
${folderIcon}
|
||
<span class="f-name">${esc(folder.name)}</span>
|
||
<span class="f-count">${folder.items.length}</span>
|
||
${isTrash ? "" : `
|
||
<button class="f-subfolder" type="button" aria-label="${esc(i18nT("folder.newSubfolder"))}" title="${esc(i18nT("folder.newSubfolder"))}">
|
||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||
<path d="M12 11v6M9 14h6"/>
|
||
</svg>
|
||
</button>
|
||
${isUnsorted ? "" : `<button class="f-del" type="button" aria-label="${esc(i18nT("folder.deleteFolder"))}" title="${esc(i18nT("folder.deleteFolder"))}">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>
|
||
</button>`}
|
||
`}
|
||
`;
|
||
|
||
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 = '<span class="folder-empty">Empty folder</span>';
|
||
} 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 isQueueView = catalogView === "queue";
|
||
const isLibraryView = !isTrashView && !isFavoritesView && !isQueueView;
|
||
|
||
catalog?.classList.toggle("trash-view", isTrashView);
|
||
catalog?.classList.toggle("favorites-view", isFavoritesView);
|
||
catalog?.classList.toggle("queue-view", isQueueView);
|
||
|
||
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));
|
||
document.querySelector(".rail-queue")?.classList.toggle("active", isQueueView);
|
||
document.querySelector(".rail-queue")?.setAttribute("aria-pressed", String(isQueueView));
|
||
|
||
if (searchInput) {
|
||
searchInput.placeholder = isTrashView
|
||
? i18nT("search.placeholderTrash")
|
||
: isFavoritesView
|
||
? i18nT("search.placeholderFavorites")
|
||
: i18nT("search.placeholderLibrary");
|
||
}
|
||
|
||
// ── Queue view ──
|
||
// Rendered from the queue snapshot, not the library: it shows what the
|
||
// backend is actually working on, in the order it will work on it.
|
||
if (isQueueView) {
|
||
renderQueueList(list);
|
||
renderStrip(strip, folders.filter((f) => f.id !== TRASH_ID && !f.parentId));
|
||
updateQueueBadge();
|
||
return;
|
||
}
|
||
|
||
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 = `<span class="folder-empty trash-empty">${esc(i18nT("trash.isEmptyState"))}</span>`;
|
||
} else if (visibleTrashItems.length === 0) {
|
||
list.innerHTML = `<span class="folder-empty trash-empty">${esc(i18nT("trash.noSearchMatch"))}</span>`;
|
||
} 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 = `<span class="folder-empty trash-empty">${esc(catalogSearchQuery ? i18nT("favorites.noSearchMatch") : i18nT("favorites.empty"))}</span>`;
|
||
} 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(i18nT("library.recent"));
|
||
for (const id of recentIds) {
|
||
const item = renderRecentItem(id);
|
||
if (item) section.appendChild(item);
|
||
}
|
||
list.appendChild(section);
|
||
}
|
||
|
||
// Stem Collections section
|
||
const collectionsSection = makeSectionEl(i18nT("library.stemCollections"));
|
||
const newFolderBtn = document.createElement("button");
|
||
newFolderBtn.id = "newFolderBtn";
|
||
newFolderBtn.className = "new-folder-btn";
|
||
newFolderBtn.type = "button";
|
||
newFolderBtn.setAttribute("aria-label", i18nT("library.newFolder"));
|
||
newFolderBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 11v6 M9 14h6"/></svg>${esc(i18nT("library.newFolder"))}`;
|
||
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 = '<span class="folder-empty trash-empty">No tracks match your search</span>';
|
||
return;
|
||
}
|
||
|
||
// Tags section
|
||
const tags = getAllTags(trashIds);
|
||
if (tags.length) {
|
||
const section = makeSectionEl(i18nT("library.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);
|
||
updateQueueBadge();
|
||
applyQueueDecorations();
|
||
}
|
||
|
||
// ─── Import queue decoration ───
|
||
//
|
||
// Rows are patched in place rather than re-rendered. The queue stream delivers
|
||
// a frame several times a second, and a full render() rebuilds the whole
|
||
// sidebar and re-runs every drag/click wiring -- at that rate it would fight
|
||
// the user for the DOM. render() calls this once at the end so a genuine
|
||
// rebuild picks the decoration back up.
|
||
|
||
function progressBarHtml() {
|
||
return '<div class="cat-progress"><div class="cat-progress-fill"></div></div>';
|
||
}
|
||
|
||
function decorateRow(el, rowState) {
|
||
const sub = el.querySelector(".cat-sub");
|
||
if (!sub) return;
|
||
|
||
if (!rowState) {
|
||
// Left the queue (finished, failed or cancelled). render() will have
|
||
// rebuilt the row from the library entry, so just drop the decoration.
|
||
el.classList.remove("in-queue", "queue-waiting", "queue-running");
|
||
el.querySelector(".cat-progress")?.remove();
|
||
if (el.dataset.subRestore) {
|
||
sub.innerHTML = el.dataset.subRestore;
|
||
delete el.dataset.subRestore;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const waiting = rowState.state === "waiting";
|
||
el.classList.add("in-queue");
|
||
el.classList.toggle("queue-waiting", waiting);
|
||
el.classList.toggle("queue-running", !waiting);
|
||
|
||
// Keep the original sub line so it can come back if this row is still on
|
||
// screen when the job leaves the queue.
|
||
if (!el.dataset.subRestore) el.dataset.subRestore = sub.innerHTML;
|
||
const label = `<span class="cat-queue-label">${esc(rowState.label)}</span>`;
|
||
if (sub.innerHTML !== label) sub.innerHTML = label;
|
||
|
||
let bar = el.querySelector(".cat-progress");
|
||
if (waiting) {
|
||
bar?.remove();
|
||
return;
|
||
}
|
||
if (!bar) {
|
||
sub.insertAdjacentHTML("afterend", progressBarHtml());
|
||
bar = el.querySelector(".cat-progress");
|
||
}
|
||
const fill = bar?.querySelector(".cat-progress-fill");
|
||
if (fill) fill.style.width = `${Math.round(rowState.progress * 100)}%`;
|
||
}
|
||
|
||
/** A background import has finished (or failed, or was cancelled). It has no
|
||
* per-job stream, so fetch its final state once and complete its library entry
|
||
* -- stems, duration and analysis all land here, which is what makes the track
|
||
* playable from the sidebar without a page reload. */
|
||
async function completeSettledJob(jobId) {
|
||
const existing = tracks[jobId];
|
||
if (!existing) return; // not ours (or already deleted)
|
||
try {
|
||
const res = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" });
|
||
if (!res.ok) {
|
||
// 404 means the job is gone from the backend entirely.
|
||
if (res.status === 404) updateTrackStatus(jobId, "unavailable");
|
||
return;
|
||
}
|
||
const state = await res.json();
|
||
if (state.status === "cancelled") {
|
||
// Nothing was produced; drop the placeholder row rather than leaving a
|
||
// track that can never be loaded.
|
||
delete tracks[jobId];
|
||
removeTrackFromFolders(jobId);
|
||
saveState();
|
||
render();
|
||
return;
|
||
}
|
||
// A background job that failed used to say nothing at all: no banner (that
|
||
// belongs to the foreground import), no queue UI, just a console warning
|
||
// and a library row indistinguishable from a healthy one. Queue three
|
||
// tracks, lose one, never find out. It gets a notification like any other
|
||
// failure now.
|
||
if (state.status === "error") {
|
||
notifyFailure({
|
||
kind: "import",
|
||
message: state.error || i18nT("job.audioProcessingFailed"),
|
||
detail: state.error_detail || null,
|
||
context: {
|
||
jobId,
|
||
stage: state.stage,
|
||
device: state.compute_device,
|
||
gpuFallback: state.gpu_fallback,
|
||
timings: state.stage_timings ? JSON.stringify(state.stage_timings) : null,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Background jobs have no per-job stream (see the comment above), so this
|
||
// is the only place a background import's on-demand vocal split (#275)
|
||
// can be triggered -- runVocalSplitIfWanted no-ops if it wasn't requested.
|
||
const finalState = state.status === "done" ? await runVocalSplitIfWanted(state) : state;
|
||
const track = stateMetadataToTrack(finalState, { ...existing, id: jobId });
|
||
track.id = jobId;
|
||
track.channel = finalState.status === "done" ? i18nT("footer.extractedLabel") : existing.channel;
|
||
addTrackToLibrary(track);
|
||
} catch (e) {
|
||
console.warn("[catalog] could not finish background job", jobId, e);
|
||
}
|
||
}
|
||
|
||
// ─── Queue view ───
|
||
|
||
function queueEntries(snap) {
|
||
const entries = [];
|
||
if (snap.running) entries.push({ job: snap.running, running: true });
|
||
for (const job of snap.queued ?? []) entries.push({ job, running: false });
|
||
return entries;
|
||
}
|
||
|
||
function queueRowHtml({ job, running }, place, { paused = false } = {}) {
|
||
const track = tracks[job.job_id];
|
||
const label = running ? runningLabel(job) : paused ? i18nT("queue.pausedStatus") : i18nT("queue.positionInLine", { position: place });
|
||
const thumb = track ? thumbHtml(track) : thumbHtml({ thumb: job.thumbnail });
|
||
// The running job cannot be reordered -- it is already running. Only waiting
|
||
// rows drag, and only they offer "play next".
|
||
const handle = running
|
||
? ""
|
||
: `<span class="queue-grip" title="Drag to reorder" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor">
|
||
<circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/>
|
||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||
<circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/>
|
||
</svg>
|
||
</span>`;
|
||
return `
|
||
<div class="cat-item queue-row ${running ? "queue-running" : "queue-waiting"}" data-id="${esc(job.job_id)}"${running ? "" : ' draggable="true"'}>
|
||
${handle}
|
||
<div class="cat-thumb">${thumb}</div>
|
||
<div class="cat-meta">
|
||
<div class="cat-title">${esc(displayTitle(job.title || track?.title || job.source_url))}</div>
|
||
<div class="cat-sub"><span class="cat-queue-label">${esc(label)}</span></div>
|
||
${running ? '<div class="cat-progress"><div class="cat-progress-fill"></div></div>' : ""}
|
||
</div>
|
||
${running || place <= 2 ? "" : `<button class="queue-top" type="button" title="${esc(i18nT("queue.extractNext"))}"
|
||
aria-label="${esc(i18nT("queue.moveToFront", { title: displayTitle(job.title || job.source_url) }))}">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||
<path d="M12 19V5 M5 12l7-7 7 7"></path>
|
||
</svg>
|
||
</button>`}
|
||
<button class="queue-cancel" type="button" title="${esc(i18nT("queue.cancelImport"))}"
|
||
aria-label="${esc(i18nT("queue.cancelImportOf", { title: displayTitle(job.title || job.source_url) }))}">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||
<path d="M18 6 6 18 M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>`;
|
||
}
|
||
|
||
// True while the user is dragging a queue row. Incoming queue frames arrive
|
||
// several times a second; re-rendering the list mid-drag would pull the row out
|
||
// from under the cursor and cancel the drag.
|
||
let _queueDragId = null;
|
||
|
||
export function isQueueDragging() {
|
||
return _queueDragId !== null;
|
||
}
|
||
|
||
/** The id the dragged row should sit after, given where it was dropped.
|
||
* Null means the front of the queue. */
|
||
function dropAnchorId(listEl, draggedId, clientY) {
|
||
const rows = [...listEl.querySelectorAll(".queue-row")].filter(
|
||
(r) => r.dataset.id !== draggedId,
|
||
);
|
||
let anchor = null;
|
||
for (const row of rows) {
|
||
const box = row.getBoundingClientRect();
|
||
if (clientY > box.top + box.height / 2) anchor = row.dataset.id;
|
||
}
|
||
return anchor;
|
||
}
|
||
|
||
function wireQueueRow(el, listEl) {
|
||
el.querySelector(".queue-cancel")?.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const btn = e.currentTarget;
|
||
btn.disabled = true;
|
||
await cancelQueuedJob(el.dataset.id);
|
||
});
|
||
|
||
el.querySelector(".queue-top")?.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
e.currentTarget.disabled = true;
|
||
await reorderQueuedJob(el.dataset.id, null);
|
||
});
|
||
|
||
if (el.getAttribute("draggable") !== "true") return;
|
||
|
||
el.addEventListener("dragstart", (e) => {
|
||
_queueDragId = el.dataset.id;
|
||
el.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
// Firefox refuses to start a drag without payload.
|
||
e.dataTransfer.setData("text/plain", el.dataset.id);
|
||
});
|
||
|
||
el.addEventListener("dragend", () => {
|
||
_queueDragId = null;
|
||
el.classList.remove("dragging");
|
||
for (const r of listEl.querySelectorAll(".queue-row")) r.classList.remove("drop-below");
|
||
});
|
||
}
|
||
|
||
function wireQueueListDrop(listEl) {
|
||
listEl.addEventListener("dragover", (e) => {
|
||
if (!_queueDragId) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
const anchor = dropAnchorId(listEl, _queueDragId, e.clientY);
|
||
for (const r of listEl.querySelectorAll(".queue-row")) {
|
||
r.classList.toggle("drop-below", !!anchor && r.dataset.id === anchor);
|
||
}
|
||
});
|
||
|
||
listEl.addEventListener("drop", async (e) => {
|
||
if (!_queueDragId) return;
|
||
e.preventDefault();
|
||
const dragged = _queueDragId;
|
||
const anchor = dropAnchorId(listEl, dragged, e.clientY);
|
||
_queueDragId = null;
|
||
for (const r of listEl.querySelectorAll(".queue-row")) r.classList.remove("drop-below");
|
||
if (anchor !== dragged) await reorderQueuedJob(dragged, anchor);
|
||
});
|
||
}
|
||
|
||
function queuePausedBannerHtml(count) {
|
||
return `
|
||
<div class="queue-paused-banner">
|
||
<div class="queue-paused-text">
|
||
${i18nPlural("queue.paused", count)}
|
||
</div>
|
||
<button class="queue-start-btn" type="button">${i18nT("queue.start")}</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderQueueList(listEl, snap = getQueueSnapshot()) {
|
||
const entries = queueEntries(snap);
|
||
listEl.innerHTML = "";
|
||
|
||
const section = document.createElement("div");
|
||
section.className = "lib-section queue-section";
|
||
section.innerHTML = `<div class="lib-section-head"><span>${esc(i18nT("queue.importQueue"))}</span></div>`;
|
||
|
||
if (isPaused(snap)) {
|
||
section.insertAdjacentHTML("beforeend", queuePausedBannerHtml(entries.length));
|
||
section.querySelector(".queue-start-btn")?.addEventListener("click", async (e) => {
|
||
e.currentTarget.disabled = true;
|
||
e.currentTarget.textContent = i18nT("queue.starting");
|
||
await startQueue();
|
||
});
|
||
}
|
||
|
||
if (!entries.length) {
|
||
section.insertAdjacentHTML(
|
||
"beforeend",
|
||
'<span class="folder-empty trash-empty">Nothing importing. Queued tracks appear here.</span>',
|
||
);
|
||
listEl.appendChild(section);
|
||
return;
|
||
}
|
||
|
||
const paused = isPaused(snap);
|
||
section.insertAdjacentHTML(
|
||
"beforeend",
|
||
entries.map((entry, i) => queueRowHtml(entry, i + 1, { paused })).join(""),
|
||
);
|
||
listEl.appendChild(section);
|
||
for (const el of section.querySelectorAll(".queue-row")) wireQueueRow(el, listEl);
|
||
wireQueueListDrop(listEl);
|
||
updateQueueRows(snap);
|
||
}
|
||
|
||
/** Patch the open queue view in place. Only a change to which jobs are present
|
||
* (or their order) costs a rebuild; progress and stage text are written
|
||
* straight to the existing nodes, because this runs several times a second. */
|
||
function updateQueueRows(snap = getQueueSnapshot()) {
|
||
const listEl = document.getElementById("catalogList");
|
||
if (!listEl || catalogView !== "queue") return;
|
||
// A frame landing mid-drag would rebuild the list and yank the row out
|
||
// from under the cursor.
|
||
if (isQueueDragging()) return;
|
||
|
||
const entries = queueEntries(snap);
|
||
const shown = [...listEl.querySelectorAll(".queue-row")].map((el) => el.dataset.id);
|
||
const wanted = entries.map((e) => e.job.job_id);
|
||
const bannerShown = !!listEl.querySelector(".queue-paused-banner");
|
||
if (
|
||
shown.length !== wanted.length ||
|
||
shown.some((id, i) => id !== wanted[i]) ||
|
||
bannerShown !== isPaused(snap)
|
||
) {
|
||
renderQueueList(listEl, snap);
|
||
return;
|
||
}
|
||
|
||
entries.forEach((entry, i) => {
|
||
const el = listEl.querySelector(`.queue-row[data-id="${entry.job.job_id}"]`);
|
||
if (!el) return;
|
||
const label = entry.running
|
||
? runningLabel(entry.job)
|
||
: isPaused(snap)
|
||
? i18nT("queue.pausedStatus")
|
||
: i18nT("queue.positionInLine", { position: i + 1 });
|
||
const labelEl = el.querySelector(".cat-queue-label");
|
||
if (labelEl && labelEl.textContent !== label) labelEl.textContent = label;
|
||
const fill = el.querySelector(".cat-progress-fill");
|
||
if (fill) fill.style.width = `${Math.round((entry.job.progress || 0) * 100)}%`;
|
||
});
|
||
}
|
||
|
||
/** The rail button only exists while there is something to look at, and carries
|
||
* the count so the queue is legible without opening it. */
|
||
function updateQueueBadge(snap = getQueueSnapshot()) {
|
||
const btn = document.querySelector(".rail-queue");
|
||
const badge = document.getElementById("queueBadge");
|
||
if (!btn) return;
|
||
const count = queueCount(snap);
|
||
btn.classList.toggle("hidden", count === 0 && catalogView !== "queue");
|
||
if (badge) {
|
||
badge.textContent = count > 99 ? "99+" : String(count);
|
||
badge.classList.toggle("hidden", count === 0);
|
||
}
|
||
}
|
||
|
||
function onQueueFrame(snap) {
|
||
updateQueueBadge(snap);
|
||
if (catalogView !== "queue") {
|
||
applyQueueDecorations(snap);
|
||
return;
|
||
}
|
||
if (queueCount(snap) === 0) {
|
||
// Nothing left to manage. Fall back to the library rather than leaving the
|
||
// user in a view that can only ever be empty from here. Deliberately not
|
||
// done inside updateQueueBadge, which render() calls -- that would recurse.
|
||
setCatalogView("library");
|
||
return;
|
||
}
|
||
updateQueueRows(snap);
|
||
}
|
||
|
||
function applyQueueDecorations(snap = getQueueSnapshot()) {
|
||
const states = queueRowStates(snap);
|
||
for (const el of document.querySelectorAll(".cat-item[data-id]")) {
|
||
const state = states.get(el.dataset.id);
|
||
// Only touch rows that are, or just were, in the queue.
|
||
if (!state && !el.classList.contains("in-queue")) continue;
|
||
decorateRow(el, state);
|
||
}
|
||
}
|
||
|
||
// ─── 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.querySelector(".rail-queue")?.addEventListener("click", () => setCatalogView("queue"));
|
||
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";
|
||
// The releases LIST, not /releases/latest. GitHub defines "latest" as the most
|
||
// recent NON-PRERELEASE release, so the moment a version ships with the
|
||
// pre-release box ticked it becomes invisible here and nobody is ever told an
|
||
// update exists. StemDeck has historically published even its alphas as normal
|
||
// releases, which is why that has not bitten yet -- this makes the check
|
||
// correct either way rather than dependent on remembering not to tick a box.
|
||
const RELEASES_API =
|
||
"https://api.github.com/repos/stemdeckapp/stemdeck/releases?per_page=10";
|
||
const DISMISSED_UPDATE_KEY = "stemdeck.dismissed_update";
|
||
|
||
// The full GitHub release object from the last successful update check, used to
|
||
// populate the release dialog (notes + per-arch download link) on card click.
|
||
let latestRelease = null;
|
||
let cachedBuildTarget = null;
|
||
|
||
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}`;
|
||
}
|
||
|
||
// Kept from the health check so a bug report can state the running version,
|
||
// model and ffmpeg status without a second round trip.
|
||
let healthInfo = {};
|
||
|
||
async function loadCurrentVersion() {
|
||
try {
|
||
const res = await fetch("/api/health", { cache: "no-store" });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
healthInfo = data;
|
||
setDisplayedVersion(data.version);
|
||
} catch (e) { console.warn("[catalog] version fetch failed:", e); }
|
||
}
|
||
|
||
/** Everything a bug report needs about this install. */
|
||
export async function collectDiagnostics() {
|
||
return {
|
||
version: currentVersion,
|
||
model: healthInfo.demucs_model,
|
||
ffmpegConfigured: healthInfo.ffmpeg_configured,
|
||
buildTarget: await getBuildTarget(),
|
||
isDesktop: Boolean(window.__TAURI__?.core?.invoke),
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? "" : value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// Inline markdown, applied AFTER escapeHtml so the markers (`* [ ] ( )`) are
|
||
// still literal characters and never HTML. Links are restricted to http(s) to
|
||
// block javascript:/data: URIs; label and URL are already entity-escaped.
|
||
function renderInlineMd(text) {
|
||
return text
|
||
.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`)
|
||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(
|
||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||
(_, label, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`,
|
||
);
|
||
}
|
||
|
||
// Minimal, XSS-safe markdown subset for GitHub release bodies: headings, bullet
|
||
// lists, fenced code blocks, paragraphs, and the inline set above. Everything is
|
||
// HTML-escaped first; only a fixed whitelist of tags is ever emitted.
|
||
function renderReleaseNotes(markdown) {
|
||
const lines = escapeHtml(markdown).split(/\r?\n/);
|
||
const out = [];
|
||
let inList = false;
|
||
let inCode = false;
|
||
let inQuote = false;
|
||
const code = [];
|
||
const closeList = () => {
|
||
if (inList) {
|
||
out.push("</ul>");
|
||
inList = false;
|
||
}
|
||
};
|
||
const closeQuote = () => {
|
||
if (inQuote) {
|
||
out.push("</blockquote>");
|
||
inQuote = false;
|
||
}
|
||
};
|
||
for (const raw of lines) {
|
||
let line = raw.replace(/\s+$/, "");
|
||
// Blockquote prefix — GitHub admonitions (macOS block) use `> [!IMPORTANT]`
|
||
// and `> ...` lines, and can wrap a fenced code block. escapeHtml already
|
||
// ran, so the marker is `>`. Strip it unconditionally (so a blockquoted
|
||
// closing fence is still recognised), but only open/close the <blockquote>
|
||
// wrapper when not mid-fence, to avoid toggling it inside a code block.
|
||
const quoted = /^>\s?/.test(line);
|
||
if (quoted) line = line.replace(/^>\s?/, "");
|
||
if (!inCode) {
|
||
if (quoted && !inQuote) {
|
||
closeList();
|
||
out.push("<blockquote>");
|
||
inQuote = true;
|
||
} else if (!quoted && inQuote) {
|
||
closeList();
|
||
closeQuote();
|
||
}
|
||
}
|
||
|
||
if (/^```/.test(line)) {
|
||
if (inCode) {
|
||
out.push(`<pre><code>${code.join("\n")}</code></pre>`);
|
||
code.length = 0;
|
||
inCode = false;
|
||
} else {
|
||
closeList();
|
||
inCode = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (inCode) {
|
||
code.push(line);
|
||
continue;
|
||
}
|
||
let m;
|
||
if ((m = /^\[!(\w+)\]/.exec(line))) {
|
||
// GitHub admonition label ([!IMPORTANT] -> "Important").
|
||
const label = m[1].charAt(0) + m[1].slice(1).toLowerCase();
|
||
out.push(`<p class="release-callout">${label}</p>`);
|
||
} else if ((m = /^#{1,6}\s+(.*)$/.exec(line))) {
|
||
closeList();
|
||
out.push(`<h4>${renderInlineMd(m[1])}</h4>`);
|
||
} else if ((m = /^\s*[-*]\s+(.*)$/.exec(line))) {
|
||
if (!inList) {
|
||
out.push("<ul>");
|
||
inList = true;
|
||
}
|
||
out.push(`<li>${renderInlineMd(m[1])}</li>`);
|
||
} else if (line.trim() === "") {
|
||
closeList();
|
||
} else {
|
||
closeList();
|
||
out.push(`<p>${renderInlineMd(line)}</p>`);
|
||
}
|
||
}
|
||
if (inCode) out.push(`<pre><code>${code.join("\n")}</code></pre>`);
|
||
closeList();
|
||
closeQuote();
|
||
return out.join("\n");
|
||
}
|
||
|
||
// Resolve the running build's OS/arch/GPU variant. On desktop this is exact
|
||
// (Rust build_target); on web/server there is no reliable signal, so guess the
|
||
// OS from the user agent and leave the variant as CPU.
|
||
export async function getBuildTarget() {
|
||
if (cachedBuildTarget) return cachedBuildTarget;
|
||
const invoke = window.__TAURI__?.core?.invoke;
|
||
if (invoke) {
|
||
try {
|
||
cachedBuildTarget = await invoke("build_target");
|
||
return cachedBuildTarget;
|
||
} catch (e) {
|
||
console.warn("[catalog] build_target failed:", e);
|
||
}
|
||
}
|
||
const ua = navigator.userAgent || "";
|
||
let os = "linux";
|
||
if (/Mac/i.test(ua)) os = "macos";
|
||
else if (/Win/i.test(ua)) os = "windows";
|
||
const arch = /arm64|aarch64/i.test(ua) ? "arm64" : "x64";
|
||
cachedBuildTarget = { os, arch, gpu: "cpu" };
|
||
return cachedBuildTarget;
|
||
}
|
||
|
||
// Expected release-asset filename for a build target. Names are deterministic
|
||
// from the release workflows: macOS keys on arch; Windows/Linux add a .NVIDIA
|
||
// infix for the CUDA variant.
|
||
function assetNameFor(target) {
|
||
if (target.os === "macos") {
|
||
return `StemDeck-macOS-${target.arch === "arm64" ? "arm64" : "x64"}.dmg`;
|
||
}
|
||
const variant = target.gpu === "nvidia" ? ".NVIDIA" : "";
|
||
if (target.os === "windows") return `StemDeck-Windows-x64${variant}.zip`;
|
||
return `StemDeck-Linux-x64${variant}.tar.gz`;
|
||
}
|
||
|
||
function pickReleaseAsset(release, target) {
|
||
const name = assetNameFor(target);
|
||
const asset = (release.assets || []).find((a) => a.name === name);
|
||
return asset ? { url: asset.browser_download_url, name } : null;
|
||
}
|
||
|
||
// ─── In-app updater ───
|
||
// Downloads and applies an update without leaving the app, instead of sending
|
||
// the user to a browser download -- see download_app_update/apply_app_update in
|
||
// desktop/src-tauri/src/main.rs for the file swap.
|
||
//
|
||
// Windows and Linux only. Both ship a flat directory with the executable,
|
||
// backend/ and python/ side by side, which is the shape the swap needs. macOS
|
||
// resolves its backend inside the downloaded runtime pack rather than the .app,
|
||
// so its app layer is a different thing entirely and is handled separately.
|
||
//
|
||
// The updater replaces the executable and backend/ ONLY. It never touches
|
||
// python/, because an NVIDIA install rewrites that directory with CUDA torch on
|
||
// first run and replacing it would silently drop the machine back to CPU. So an
|
||
// in-app update is only safe when the release needs the same Python
|
||
// dependencies the install already has -- that is what the runtime id gates.
|
||
// When it doesn't match, we fall back to the normal full-package download.
|
||
|
||
function findReleaseAsset(release, name) {
|
||
return (release.assets || []).find((a) => a.name === name) || null;
|
||
}
|
||
|
||
// Asset names the packaging scripts publish for each in-place-updatable
|
||
// platform. The archive format differs because each script already produces
|
||
// one: Compress-Archive on Windows, tar on Linux (which also preserves the
|
||
// executable bit the relaunch depends on).
|
||
function updaterAssetNames(target) {
|
||
if (target.os === "windows") {
|
||
return { app: "StemDeck-Windows-x64-app.zip", runtimeId: "StemDeck-Windows-x64-runtime-version.json" };
|
||
}
|
||
if (target.os === "linux") {
|
||
return { app: "StemDeck-Linux-x64-app.tar.gz", runtimeId: "StemDeck-Linux-x64-runtime-version.json" };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Resolves the app-layer asset for the in-app updater, or null when this
|
||
// release cannot be applied in place -- it predates the updater assets, is
|
||
// missing one, or changed the Python dependency set. Null means "use the full
|
||
// download link", which is always correct, just less convenient.
|
||
//
|
||
// The checksum and runtime-id files are read by Rust (`check_app_update`), not
|
||
// fetched here. This page is served by the Python backend, so its CSP applies,
|
||
// and connect-src allows api.github.com but NOT the github.com /
|
||
// objects.githubusercontent.com hosts that serve release *assets*. Fetching
|
||
// them from JS is blocked outright; Rust's HTTP client is not bound by the page
|
||
// CSP, so the policy stays as tight as it is today.
|
||
async function resolveInAppUpdatePlan(release, target) {
|
||
const names = updaterAssetNames(target);
|
||
if (!names) return null;
|
||
const appAsset = findReleaseAsset(release, names.app);
|
||
const appShaAsset = findReleaseAsset(release, `${names.app}.sha256`);
|
||
const runtimeIdAsset = findReleaseAsset(release, names.runtimeId);
|
||
if (!appAsset || !appShaAsset || !runtimeIdAsset) return null;
|
||
|
||
const check = await window.__TAURI__.core.invoke("check_app_update", {
|
||
query: {
|
||
appShaUrl: appShaAsset.browser_download_url,
|
||
runtimeIdUrl: runtimeIdAsset.browser_download_url,
|
||
},
|
||
});
|
||
if (!check?.supported) {
|
||
console.info("[catalog] in-app update unavailable:", check?.reason || "unknown");
|
||
return null;
|
||
}
|
||
|
||
return { appUrl: appAsset.browser_download_url, appSha256: check.appSha256 };
|
||
}
|
||
|
||
function showInappError(message) {
|
||
const errorEl = document.getElementById("releaseInappError");
|
||
if (!errorEl) return;
|
||
errorEl.textContent = `${i18nT("release.updateFailed")}: ${message}`;
|
||
errorEl.classList.remove("hidden");
|
||
}
|
||
|
||
// Wires the download/apply buttons for a resolved plan. Returns false (and
|
||
// touches nothing) when this release has no in-app-updatable assets, so the
|
||
// caller can fall back to the plain download link.
|
||
async function wireInAppUpdate(target) {
|
||
const downloadBtn = document.getElementById("releaseDownloadApp");
|
||
const applyBtn = document.getElementById("releaseApplyUpdate");
|
||
const inapp = document.getElementById("releaseInapp");
|
||
const progress = document.getElementById("releaseInappProgress");
|
||
const progressText = document.getElementById("releaseInappProgressText");
|
||
const errorEl = document.getElementById("releaseInappError");
|
||
if (!downloadBtn || !applyBtn || !latestRelease) return false;
|
||
|
||
const plan = await resolveInAppUpdatePlan(latestRelease, target);
|
||
if (!plan) return false;
|
||
|
||
// The manual download stays visible alongside the auto-update pill: some
|
||
// people would rather grab the zip, and it is the escape hatch if an in-app
|
||
// update fails.
|
||
inapp?.classList.remove("hidden");
|
||
progress?.classList.add("hidden");
|
||
errorEl?.classList.add("hidden");
|
||
downloadBtn.disabled = false;
|
||
applyBtn.disabled = false;
|
||
downloadBtn.classList.remove("hidden");
|
||
applyBtn.classList.add("hidden");
|
||
|
||
// Indeterminate, not a byte-accurate bar. Real progress would mean listening
|
||
// to the Rust download event, and this page is served over http by the Python
|
||
// backend -- a remote origin, which the Tauri capability in
|
||
// desktop/src-tauri/capabilities/default.json does not cover, so
|
||
// `plugin:event|listen` is refused by the ACL. Granting a remote origin event
|
||
// permissions would widen exactly the IPC surface #171 locked down, and the
|
||
// app layer is ~5 MB. Not worth it. (App-defined commands like the invokes
|
||
// below are not ACL-gated, which is why those work.)
|
||
downloadBtn.onclick = async () => {
|
||
errorEl?.classList.add("hidden");
|
||
downloadBtn.disabled = true;
|
||
if (progressText) progressText.textContent = i18nT("release.downloading");
|
||
progress?.classList.remove("hidden");
|
||
progress?.classList.add("indeterminate");
|
||
try {
|
||
await window.__TAURI__.core.invoke("download_app_update", { plan });
|
||
progress?.classList.add("hidden");
|
||
downloadBtn.classList.add("hidden");
|
||
applyBtn.classList.remove("hidden");
|
||
} catch (e) {
|
||
console.warn("[catalog] download_app_update failed:", e);
|
||
showInappError(String(e?.message || e));
|
||
downloadBtn.disabled = false;
|
||
progress?.classList.add("hidden");
|
||
}
|
||
};
|
||
|
||
applyBtn.onclick = async () => {
|
||
errorEl?.classList.add("hidden");
|
||
applyBtn.disabled = true;
|
||
if (progressText) progressText.textContent = i18nT("release.applying");
|
||
progress?.classList.remove("hidden");
|
||
try {
|
||
// On success the app exits and relaunches -- this promise never resolves.
|
||
await window.__TAURI__.core.invoke("apply_app_update");
|
||
} catch (e) {
|
||
console.warn("[catalog] apply_app_update failed:", e);
|
||
showInappError(String(e?.message || e));
|
||
applyBtn.disabled = false;
|
||
progress?.classList.add("hidden");
|
||
}
|
||
};
|
||
|
||
return true;
|
||
}
|
||
|
||
async function openReleaseDialog() {
|
||
const dialog = document.getElementById("releaseDialog");
|
||
if (!dialog || !latestRelease) return;
|
||
const version = document.getElementById("releaseVersion");
|
||
const notes = document.getElementById("releaseNotes");
|
||
const download = document.getElementById("releaseDownload");
|
||
const docker = document.getElementById("releaseDocker");
|
||
const dockerCmd = document.getElementById("releaseDockerCmd");
|
||
|
||
if (version) version.textContent = `v${normalizeVersion(latestRelease.tag_name)}`;
|
||
if (notes) {
|
||
const body = (latestRelease.body || "").trim();
|
||
notes.innerHTML = body
|
||
? renderReleaseNotes(body)
|
||
: `<p>No release notes provided. See the full release on GitHub.</p>`;
|
||
}
|
||
|
||
// Server/Docker mode has no Tauri: updating is an image pull, not a file
|
||
// download, and the client browser's OS/arch is irrelevant to the container.
|
||
// Show the docker pull command instead of a (meaningless) desktop download.
|
||
const serverMode = !window.__TAURI__?.core?.invoke;
|
||
if (serverMode) {
|
||
const tag = normalizeVersion(latestRelease.tag_name);
|
||
if (dockerCmd) dockerCmd.textContent = `docker pull ghcr.io/stemdeckapp/stemdeck:${tag}`;
|
||
docker?.classList.remove("hidden");
|
||
download?.classList.add("hidden");
|
||
} else if (download) {
|
||
docker?.classList.add("hidden");
|
||
const target = await getBuildTarget();
|
||
|
||
// The manual download is always offered. On Windows, when the release can
|
||
// be applied in place, an "Update now" pill appears beside it -- additive,
|
||
// never a replacement, so the zip stays one click away either way.
|
||
const picked = pickReleaseAsset(latestRelease, target);
|
||
if (picked) {
|
||
download.href = picked.url;
|
||
download.textContent = i18nT("release.download");
|
||
} else {
|
||
// No matching asset (e.g. an arch we don't build): fall back to the
|
||
// release page so the user can pick manually.
|
||
download.href = latestRelease.html_url || RELEASES_URL;
|
||
download.textContent = i18nT("release.viewDownload");
|
||
}
|
||
download.classList.remove("hidden");
|
||
|
||
let usedInapp = false;
|
||
if (updaterAssetNames(target)) {
|
||
try {
|
||
usedInapp = await wireInAppUpdate(target);
|
||
} catch (e) {
|
||
console.warn("[catalog] in-app update setup failed, falling back to link:", e);
|
||
}
|
||
}
|
||
if (!usedInapp) {
|
||
document.getElementById("releaseInapp")?.classList.add("hidden");
|
||
document.getElementById("releaseDownloadApp")?.classList.add("hidden");
|
||
document.getElementById("releaseApplyUpdate")?.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
dialog.classList.remove("hidden");
|
||
}
|
||
|
||
function wireReleaseDialog() {
|
||
const dialog = document.getElementById("releaseDialog");
|
||
const close = document.getElementById("releaseClose");
|
||
if (!dialog) return;
|
||
const hide = () => dialog.classList.add("hidden");
|
||
close?.addEventListener("click", hide);
|
||
dialog.addEventListener("mousedown", (e) => {
|
||
if (e.target === dialog) hide();
|
||
});
|
||
dialog.addEventListener("keydown", (e) => {
|
||
if (e.code === "Escape") hide();
|
||
});
|
||
}
|
||
|
||
async function checkForUpdate() {
|
||
try {
|
||
const res = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json" } });
|
||
if (!res.ok) return;
|
||
// The check itself succeeded, regardless of what it finds below — clear
|
||
// any stale "update check failed" card (#401).
|
||
dismissFailuresByKind("update");
|
||
// Newest first, as GitHub returns them. Drafts are invisible to an
|
||
// unauthenticated request anyway, but filter them so a maintainer running a
|
||
// dev build is not offered a release that has no assets yet.
|
||
//
|
||
// Pre-releases are skipped as well: a release is published as a pre-release
|
||
// first, verified, and only then promoted ("Set as the latest release"), so
|
||
// nobody is offered a build that has not been through that. The list
|
||
// endpoint is used rather than /releases/latest because it keeps the choice
|
||
// here, in code, rather than in GitHub's endpoint semantics. It assumes a
|
||
// stable release inside the last 10 -- true unless ten consecutive
|
||
// pre-releases go out without one being promoted.
|
||
const releases = await res.json();
|
||
const data = Array.isArray(releases)
|
||
? releases.find((r) => !r.draft && !r.prerelease)
|
||
: null;
|
||
if (!data) return;
|
||
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;
|
||
|
||
// Keep the full release so the dialog can render notes + pick the download.
|
||
latestRelease = data;
|
||
|
||
const card = document.getElementById("notifReleaseCard");
|
||
const desc = document.getElementById("notifReleaseDesc");
|
||
const dismissBtn = document.getElementById("notifReleaseDismiss");
|
||
|
||
if (desc) desc.textContent = `v${latest}`;
|
||
card?.classList.remove("hidden");
|
||
// The badge and empty state are shared with failure cards now, so they are
|
||
// decided in one place from the full set rather than toggled from here.
|
||
setReleasePending(true);
|
||
|
||
// Clicking the card (anywhere but the dismiss button) opens the release dialog.
|
||
card?.addEventListener("click", (e) => {
|
||
if (e.target.closest("#notifReleaseDismiss")) return;
|
||
openReleaseDialog();
|
||
});
|
||
|
||
dismissBtn?.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
try { localStorage.setItem(DISMISSED_UPDATE_KEY, latest); } catch (e) { console.warn(e); }
|
||
card?.classList.add("hidden");
|
||
setReleasePending(false);
|
||
}, { once: true });
|
||
} catch (e) {
|
||
console.warn("[catalog] update check failed:", e);
|
||
// Only report a genuine failure, not "we are offline": an update check that
|
||
// cannot reach GitHub is not a StemDeck bug and must not file one.
|
||
if (!(e instanceof TypeError)) {
|
||
notifyFailure({
|
||
kind: "update",
|
||
message: i18nT("update.checkFailed"),
|
||
detail: String(e?.message || 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(); });
|
||
}
|
||
|
||
// Ground truth for "done" <-> "unavailable" is the server (it checks the
|
||
// stems folder on disk), catching a job whose registry entry survived but
|
||
// whose files did not - something mere presence in `jobs` cannot tell apart
|
||
// from a healthy one. Shared by the passive startup sync and the
|
||
// user-triggered "Resync" button so both apply the same rule.
|
||
function reconcileAvailability(jobs) {
|
||
const serverIds = new Set(jobs.map((j) => j.job_id));
|
||
const serverStatus = new Map(jobs.map((j) => [j.job_id, j.status]));
|
||
const trashIds = new Set(getTrashFolder()?.items || []);
|
||
for (const [id, t] of Object.entries(tracks)) {
|
||
if (trashIds.has(id)) continue;
|
||
if (t.status !== "done" && t.status !== "unavailable") continue;
|
||
if (serverIds.has(id)) t.status = serverStatus.get(id) === "unavailable" ? "unavailable" : "done";
|
||
else if (t.status === "done") t.status = "unavailable"; // gone from the registry entirely
|
||
}
|
||
saveState();
|
||
render();
|
||
}
|
||
|
||
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);
|
||
}
|
||
reconcileAvailability(jobs);
|
||
} catch (e) { console.warn("[catalog] failed to load jobs from backend:", e); }
|
||
}
|
||
|
||
// ─── Settings menu + Library editor ───
|
||
|
||
let libraryEditor = null;
|
||
let libraryEditorOnKey = null;
|
||
|
||
// The settings modal has a lot of server-fetched, dynamically-computed text
|
||
// (resolved device suffix, out-of-sync status, stems-location message, ...)
|
||
// that a generic data-i18n re-apply pass can't safely re-derive after a
|
||
// language switch. Simplest robust fix: if the modal is open when the
|
||
// language changes, just rebuild it from scratch -- openLibraryEditor()
|
||
// already re-fetches everything fresh and is safe to call while already open
|
||
// (it closes any existing instance first).
|
||
onLanguageChange(() => {
|
||
if (libraryEditor) openLibraryEditor();
|
||
});
|
||
|
||
// Library list rows bake their subtitle/placeholder/empty-state text into
|
||
// plain innerHTML at render time (no data-i18n hooks to re-resolve), so a
|
||
// generic applyTranslations() pass can't fix an already-rendered list --
|
||
// rebuild it explicitly on language switch (same reasoning as the editor
|
||
// listener above).
|
||
onLanguageChange(() => render());
|
||
|
||
// 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) || i18nT("library.importedFile");
|
||
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
|
||
? i18nPlural("settings.outOfSync.summary", n)
|
||
: i18nT("settings.outOfSync.allSynced");
|
||
}
|
||
|
||
function closeLibraryEditor() {
|
||
if (libraryEditorOnKey) {
|
||
document.removeEventListener("keydown", libraryEditorOnKey);
|
||
libraryEditorOnKey = null;
|
||
}
|
||
closeResetConfirm();
|
||
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 = i18nT("settings.stemsLocation.inSync");
|
||
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 = i18nT("status.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 `
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.network.allowTitle">Make StemDeck available on your network</div>
|
||
<div class="settings-row-desc" data-i18n="settings.network.allowDesc">Let other devices (like your phone) open StemDeck at the address below.</div>
|
||
<div class="settings-row-desc settings-lock-note" data-i18n="settings.network.lockNote">Read-only when StemDeck is started in server mode — network access is then set by your server configuration.</div>
|
||
</div>
|
||
<label class="settings-switch">
|
||
<input type="checkbox" class="net-access-input" />
|
||
<span class="settings-switch-track"><span class="settings-switch-thumb"></span></span>
|
||
</label>
|
||
</div>
|
||
<div class="settings-net hidden">
|
||
<div class="settings-net-qr"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** Long paths are truncated from the LEFT: the folder name is what the user
|
||
* needs to see, and the leading /Users/... is the part they already know.
|
||
* Done here rather than with CSS -- the direction:rtl trick that gives a
|
||
* leading ellipsis also moves the path's leading slash to the far end, so
|
||
* /private/tmp/x renders as tmp/x/ and reads like a different path. */
|
||
export function shortenPath(path, max = 52) {
|
||
const text = String(path ?? "");
|
||
if (text.length <= max) return text;
|
||
return "…" + text.slice(text.length - (max - 1));
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (!bytes) return "";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let value = bytes;
|
||
let i = 0;
|
||
while (value >= 1024 && i < units.length - 1) {
|
||
value /= 1024;
|
||
i += 1;
|
||
}
|
||
return `${value >= 10 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
|
||
}
|
||
|
||
// Where extracted stems live (#354). Documents is a fine default until you
|
||
// notice it is syncing tens of gigabytes to iCloud.
|
||
async function wireStemsLocation(overlay) {
|
||
const pathEl = overlay.querySelector(".stems-location-path");
|
||
const sizeEl = overlay.querySelector(".stems-location-size");
|
||
const btn = overlay.querySelector(".set-stems-location");
|
||
const msg = overlay.querySelector(".stems-location-msg");
|
||
if (!pathEl || !btn) return;
|
||
|
||
const hideRow = () =>
|
||
overlay.querySelector(".stems-location")?.closest(".settings-row")?.remove();
|
||
|
||
let current = null;
|
||
|
||
const setMessage = (text, kind = "") => {
|
||
if (!msg) return;
|
||
msg.textContent = text || "";
|
||
msg.className = `stems-location-msg${kind ? " " + kind : ""}`;
|
||
};
|
||
|
||
const apply = (d) => {
|
||
current = d.path;
|
||
pathEl.textContent = shortenPath(d.path);
|
||
pathEl.title = d.path;
|
||
if (sizeEl) sizeEl.textContent = formatSize(d.bytes);
|
||
// Reopening Settings after a move, before the restart, should still say so.
|
||
if (d.restart_required) setMessage(i18nT("settings.stemsLocation.restartNote"), "ok");
|
||
};
|
||
|
||
// The backend decides whether this setting exists at all -- it is false on a
|
||
// server, Docker or Unraid deployment, where storage comes from a mounted
|
||
// volume the operator chose and moving it from inside the app would fight the
|
||
// mount. Asking it, rather than sniffing for Tauri, keeps that judgement in
|
||
// one place and means the row is testable in a browser against a desktop
|
||
// backend.
|
||
try {
|
||
const r = await fetch("/api/settings/stems-location", { cache: "no-store" });
|
||
if (!r.ok) {
|
||
hideRow();
|
||
return;
|
||
}
|
||
const data = await r.json();
|
||
if (!data.editable) {
|
||
hideRow();
|
||
return;
|
||
}
|
||
apply(data);
|
||
} catch (e) {
|
||
console.warn("[settings] could not read the stems location:", e);
|
||
hideRow();
|
||
return;
|
||
}
|
||
|
||
btn.addEventListener("click", async () => {
|
||
let picked = null;
|
||
const invoke = window.__TAURI__?.core?.invoke;
|
||
if (invoke) {
|
||
try {
|
||
picked = await invoke("pick_stems_folder");
|
||
} catch (e) {
|
||
console.warn("[settings] folder picker failed:", e);
|
||
setMessage(i18nT("settings.stemsLocation.pickerFailed"), "error");
|
||
return;
|
||
}
|
||
} else {
|
||
// No native picker outside the desktop shell. Only reachable when a
|
||
// desktop-mode backend is being driven from a browser, which is a
|
||
// development setup -- in the shipped app invoke is always there.
|
||
picked = window.prompt("Full path to the folder for extracted stems:", current || "");
|
||
}
|
||
if (!picked) return; // cancelled
|
||
|
||
btn.disabled = true;
|
||
setMessage(i18nT("settings.stemsLocation.moving"));
|
||
try {
|
||
const r = await fetch("/api/settings/stems-location", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ path: picked }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
setMessage(data.detail || i18nT("settings.stemsLocation.moveFailed"), "error");
|
||
return;
|
||
}
|
||
// Re-read rather than trust the POST: the GET reports where the stems
|
||
// actually are now, including the size at the new location.
|
||
try {
|
||
const again = await fetch("/api/settings/stems-location", { cache: "no-store" });
|
||
if (again.ok) apply(await again.json());
|
||
else apply({ path: data.path, bytes: 0 });
|
||
} catch (e) {
|
||
console.warn("[settings] refresh failed:", e);
|
||
apply({ path: data.path, bytes: 0 });
|
||
}
|
||
if (data.persisted === false) {
|
||
// The physical move genuinely succeeded (moved_entries is real) --
|
||
// but settings.json didn't take the new path, so a restart right now
|
||
// would read the OLD default back while the library sits at the new
|
||
// folder (#403). Say so plainly rather than the usual "ok" message.
|
||
setMessage(i18nPlural("settings.stemsLocation.movedPersistFailed", data.moved_entries), "error");
|
||
} else {
|
||
setMessage(i18nPlural("settings.stemsLocation.movedOk", data.moved_entries), "ok");
|
||
}
|
||
} catch (e) {
|
||
console.warn("[settings] move failed:", e);
|
||
setMessage(i18nT("settings.stemsLocation.serverUnreachable"), "error");
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Language picker: purely a client-side/cosmetic preference (no server
|
||
// behavior depends on it), so it's read/written via setLanguage() (i18n.js,
|
||
// storeGet/storeSet under "stemdeck.language") rather than the /api/settings
|
||
// round trip the rest of this modal uses -- see i18n.js's module comment for
|
||
// why that split matches the app's existing convention.
|
||
function wireLanguageSetting(overlay) {
|
||
const sel = overlay.querySelector(".set-language");
|
||
if (!sel) return;
|
||
sel.innerHTML = LANGUAGES.map(
|
||
(l) => `<option value="${l.code}">${esc(l.flag)} ${esc(l.name)}</option>`,
|
||
).join("");
|
||
sel.value = getLanguage();
|
||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||
}
|
||
|
||
// General settings: max track length (minutes), playlist import limit, and
|
||
// 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 playlistInput = overlay.querySelector(".set-playlist-max");
|
||
const heightSel = overlay.querySelector(".set-video-height");
|
||
const sampleRateSel = overlay.querySelector(".set-export-samplerate");
|
||
const portInput = overlay.querySelector(".set-port");
|
||
const deviceSel = overlay.querySelector(".set-demucs-device");
|
||
const deviceDesc = overlay.querySelector(".set-demucs-desc");
|
||
const qualitySel = overlay.querySelector(".set-separation-quality");
|
||
if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel) return;
|
||
|
||
// Last server-confirmed device choice, to revert the select when the server
|
||
// rejects a forced device (e.g. CUDA not available on this machine).
|
||
let lastDevice = "auto";
|
||
|
||
const apply = (d) => {
|
||
if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60));
|
||
if (playlistInput && d.playlist_max_items) playlistInput.value = String(d.playlist_max_items);
|
||
if (heightSel && d.video_max_height) heightSel.value = String(d.video_max_height);
|
||
if (sampleRateSel && d.export_sample_rate) sampleRateSel.value = String(d.export_sample_rate);
|
||
if (portInput && d.port) portInput.value = String(d.port);
|
||
if (qualitySel && d.separation_quality) qualitySel.value = d.separation_quality;
|
||
if (deviceSel) {
|
||
// Gray out devices this machine can't use (Auto and CPU are always
|
||
// available). Label disabled options so it's clear WHY they're greyed.
|
||
// The base label is captured into a data attribute the first time this
|
||
// runs (before any "not available" suffix is ever appended), rather
|
||
// than stripped back out of a previous textContent each call -- a
|
||
// regex keyed to the English suffix would silently stop matching once
|
||
// that suffix is translated, leaving the untranslated suffix appended
|
||
// forever on every subsequent apply().
|
||
const avail = new Set(d.demucs_devices_available || []);
|
||
for (const opt of deviceSel.options) {
|
||
if (opt.dataset.baseLabel === undefined) opt.dataset.baseLabel = opt.textContent;
|
||
const base = opt.dataset.baseLabel;
|
||
const ok = opt.value === "auto" || avail.has(opt.value);
|
||
opt.disabled = !ok;
|
||
opt.textContent = ok ? base : `${base}${i18nT("settings.device.notAvailable")}`;
|
||
}
|
||
if (d.demucs_device) {
|
||
deviceSel.value = d.demucs_device;
|
||
lastDevice = d.demucs_device;
|
||
}
|
||
}
|
||
if (deviceDesc) {
|
||
const resolved = d.demucs_device_resolved ? i18nT("settings.device.currently", { device: d.demucs_device_resolved }) : "";
|
||
deviceDesc.textContent = i18nT("settings.device.desc", { resolved });
|
||
}
|
||
};
|
||
|
||
// 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(playlistInput);
|
||
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 });
|
||
});
|
||
playlistInput?.addEventListener("change", () => {
|
||
const items = Math.max(1, Math.min(200, parseInt(playlistInput.value, 10) || 50));
|
||
post({ playlist_max_items: items });
|
||
});
|
||
heightSel?.addEventListener("change", () => {
|
||
post({ video_max_height: parseInt(heightSel.value, 10) });
|
||
});
|
||
sampleRateSel?.addEventListener("change", () => {
|
||
post({ export_sample_rate: parseInt(sampleRateSel.value, 10) });
|
||
});
|
||
portInput?.addEventListener("change", () => {
|
||
const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8000));
|
||
post({ port });
|
||
});
|
||
qualitySel?.addEventListener("change", () => {
|
||
post({ separation_quality: qualitySel.value });
|
||
});
|
||
// Compute device needs its own POST path: unlike the clamped numeric
|
||
// settings, the server can REJECT a forced device (422 with a reason, e.g.
|
||
// "cuda is not available on this machine") -- surface that and revert.
|
||
deviceSel?.addEventListener("change", async () => {
|
||
try {
|
||
const r = await fetch("/api/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ demucs_device: deviceSel.value }),
|
||
});
|
||
if (r.ok) {
|
||
apply(await r.json());
|
||
return;
|
||
}
|
||
let detail = i18nT("settings.device.changeFailed");
|
||
try {
|
||
detail = (await r.json()).detail || detail;
|
||
} catch (err) {
|
||
console.warn("settings error body parse failed:", err);
|
||
}
|
||
showError(detail);
|
||
deviceSel.value = lastDevice;
|
||
} catch (err) {
|
||
console.warn("compute device update failed:", err);
|
||
deviceSel.value = lastDevice;
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
|
||
// Server mode (no Tauri shell): network availability is governed by the server
|
||
// deployment, not this toggle. A headless server exists to be reached over the
|
||
// network, so present the switch as on and read-only (the "read-only in server
|
||
// mode" note explains how to change it via server config).
|
||
const serverMode = !window.__TAURI__?.core?.invoke;
|
||
|
||
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 */ }
|
||
if (serverMode) enabled = true;
|
||
|
||
// 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 = i18nT("settings.network.qrHint");
|
||
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 = i18nT("settings.network.tapToUnblur");
|
||
card.addEventListener("click", () => card.classList.toggle("qr-blurred"));
|
||
const img = document.createElement("img");
|
||
img.src = `/api/qr?url=${encodeURIComponent(mobileUrl)}`;
|
||
img.alt = i18nT("settings.network.qrCodeFor", { url: 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 = i18nT("settings.network.noConnection");
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadRegistryView(overlay) {
|
||
// Scoped to the registry pane: the log viewers reuse .settings-registry-view
|
||
// for its read-only-textarea styling and sit earlier in the markup, so a bare
|
||
// class lookup returned the *application log* box. The registry JSON was
|
||
// being written into a hidden textarea while the registry pane sat on its
|
||
// literal "Loading…" placeholder for ever.
|
||
const view = overlay.querySelector('[data-pane="registry"] .settings-registry-view');
|
||
if (!view) return;
|
||
view.value = "Loading…";
|
||
try {
|
||
const r = await fetch("/api/registry", { cache: "no-store" });
|
||
view.value = r.ok ? await r.text() : `Failed to load registry (status ${r.status}).`;
|
||
} catch {
|
||
view.value = "Failed to load registry — check your connection.";
|
||
}
|
||
}
|
||
|
||
function _fmtBytes(n) {
|
||
if (!n) return "0 B";
|
||
const u = ["B", "KB", "MB"];
|
||
let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
|
||
}
|
||
|
||
/** Read-only listing of the log files: paths, sizes and what writes each one.
|
||
* Contents are deliberately not served -- a traceback can capture anything,
|
||
* and the files are on the machine the user is already sitting at. */
|
||
async function loadLogsView(overlay) {
|
||
const pathEl = overlay.querySelector(".settings-logs-path");
|
||
const listEl = overlay.querySelector(".settings-logs-list");
|
||
if (!pathEl || !listEl) return;
|
||
listEl.textContent = i18nT("settings.logs.loading");
|
||
try {
|
||
const r = await fetch("/api/logs", { cache: "no-store" });
|
||
if (!r.ok) throw new Error(`status ${r.status}`);
|
||
const info = await r.json();
|
||
pathEl.textContent = info.dir;
|
||
const present = (info.files || []).filter((f) => f.exists);
|
||
if (!present.length) {
|
||
listEl.innerHTML = `<div class="settings-logs-empty">${esc(i18nT("settings.logs.noFilesYet", {
|
||
folderNote: info.dir_exists ? "" : i18nT("settings.logs.folderNotCreated"),
|
||
}))}</div>`;
|
||
return;
|
||
}
|
||
listEl.innerHTML = present
|
||
.map((f) => {
|
||
const when = f.modified
|
||
? new Date(f.modified * 1000).toLocaleString()
|
||
: "";
|
||
return `<div class="settings-log-row">
|
||
<div class="settings-log-main">
|
||
<code class="settings-log-name">${f.name}</code>
|
||
<span class="settings-log-meta">${_fmtBytes(f.size)}${when ? ` · ${when}` : ""}</span>
|
||
</div>
|
||
<div class="settings-log-desc">${f.description}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
} catch (e) {
|
||
console.warn("[settings] failed to load log info:", e);
|
||
pathEl.textContent = i18nT("status.unavailable");
|
||
listEl.textContent = i18nT("settings.logs.failedToLoad");
|
||
}
|
||
}
|
||
|
||
/** Recent lines from one log, read-only. The window is applied server-side so
|
||
* a large file is never shipped in full. */
|
||
async function loadLogTail(overlay, view) {
|
||
const el = overlay.querySelector(`.settings-logtail-view[data-view="${view}"]`);
|
||
if (!el) return;
|
||
el.value = "Loading…";
|
||
try {
|
||
const r = await fetch(`/api/logs/${view}?minutes=60`, { cache: "no-store" });
|
||
el.value = r.ok ? await r.text() : `Failed to load the log (status ${r.status}).`;
|
||
} catch (e) {
|
||
console.warn("[settings] failed to load log tail:", e);
|
||
el.value = "Failed to load the log — check your connection.";
|
||
}
|
||
// Newest entries are the interesting ones.
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
/** Download every log file as one zip, for attaching to a bug report. */
|
||
async function exportLogs(btn) {
|
||
if (btn) { btn.disabled = true; btn.textContent = i18nT("settings.exportLogs.preparing"); }
|
||
try {
|
||
const r = await fetch("/api/logs.zip", { cache: "no-store" });
|
||
if (!r.ok) throw new Error(`status ${r.status}`);
|
||
const blob = await r.blob();
|
||
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `stemdeck-logs-${stamp}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
// Log export isn't tied to a track/job, so a plain by-kind dismiss is the
|
||
// right granularity — it never touches a per-track export failure (#401).
|
||
dismissFailuresByKind("export");
|
||
} catch (e) {
|
||
console.warn("[settings] log export failed:", e);
|
||
showError(i18nT("settings.exportLogs.error"), null, { retry: false });
|
||
notifyFailure({
|
||
kind: "export",
|
||
message: i18nT("settings.exportLogs.error"),
|
||
detail: String(e?.message || e),
|
||
context: { stage: "Exporting logs" },
|
||
});
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.textContent = i18nT("settings.exportLogs.button"); }
|
||
}
|
||
}
|
||
|
||
let resetConfirmOverlay = null;
|
||
|
||
function closeResetConfirm() {
|
||
resetConfirmOverlay?.remove();
|
||
resetConfirmOverlay = null;
|
||
}
|
||
|
||
function openResetConfirm() {
|
||
closeResetConfirm();
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "reset-confirm-backdrop";
|
||
overlay.innerHTML = `
|
||
<div class="reset-confirm-card" role="dialog" aria-modal="true" aria-label="Reset app data" data-i18n-aria-label="resetConfirm.ariaLabel">
|
||
<div class="reset-confirm-title" data-i18n="resetConfirm.title">Reset app data?</div>
|
||
<p class="reset-confirm-body" data-i18n="resetConfirm.body">This permanently deletes every track, job, and library entry. On a shared server this affects everyone who uses it. This cannot be undone.</p>
|
||
<p class="reset-confirm-hint" data-i18n="resetConfirm.hint">Type <strong>RESET</strong> to confirm.</p>
|
||
<input class="reset-confirm-input" type="text" autocomplete="off" spellcheck="false" aria-label="Type RESET to confirm" data-i18n-aria-label="resetConfirm.typeToConfirmAria" />
|
||
<div class="reset-confirm-msg" role="alert" aria-live="polite"></div>
|
||
<div class="reset-confirm-actions">
|
||
<button class="reset-confirm-cancel" type="button" data-i18n="resetConfirm.cancel">Cancel</button>
|
||
<button class="reset-confirm-go" type="button" disabled data-i18n="resetConfirm.go">Reset app data</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
applyTranslations(overlay);
|
||
|
||
const input = overlay.querySelector(".reset-confirm-input");
|
||
const goBtn = overlay.querySelector(".reset-confirm-go");
|
||
const msg = overlay.querySelector(".reset-confirm-msg");
|
||
|
||
input.addEventListener("input", () => {
|
||
goBtn.disabled = input.value.trim() !== "RESET";
|
||
});
|
||
overlay.querySelector(".reset-confirm-cancel").addEventListener("click", closeResetConfirm);
|
||
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) closeResetConfirm(); });
|
||
const onKey = (e) => { if (e.code === "Escape") closeResetConfirm(); };
|
||
document.addEventListener("keydown", onKey, { once: true });
|
||
|
||
goBtn.addEventListener("click", async () => {
|
||
goBtn.disabled = true;
|
||
input.disabled = true;
|
||
msg.textContent = i18nT("resetConfirm.resetting");
|
||
try {
|
||
const r = await fetch("/api/reset", { method: "POST" });
|
||
if (!r.ok) {
|
||
let detail = i18nT("resetConfirm.failed");
|
||
try { detail = (await r.json()).detail || detail; } catch (err) { console.warn("reset error body parse failed:", err); }
|
||
msg.textContent = detail;
|
||
goBtn.disabled = false;
|
||
input.disabled = false;
|
||
return;
|
||
}
|
||
// Backend job/registry data is gone. The browser-side library index
|
||
// (folders, tracks, per-job mixer state, trash) is a separate store
|
||
// the API call above doesn't touch -- clear it too, then reload so
|
||
// every in-memory JS structure re-initializes from the now-empty state
|
||
// instead of trying to reconcile itself piecemeal.
|
||
if (window.__TAURI__?.core?.invoke) {
|
||
try {
|
||
await window.__TAURI__.core.invoke("reset_user_data");
|
||
} catch (err) {
|
||
console.warn("reset_user_data failed:", err);
|
||
}
|
||
}
|
||
try { localStorage.clear(); } catch (err) { console.warn("localStorage.clear failed:", err); }
|
||
window.location.reload();
|
||
} catch (err) {
|
||
console.warn("reset failed:", err);
|
||
msg.textContent = i18nT("resetConfirm.failedConnection");
|
||
goBtn.disabled = false;
|
||
input.disabled = false;
|
||
}
|
||
});
|
||
|
||
document.body.appendChild(overlay);
|
||
resetConfirmOverlay = overlay;
|
||
input.focus();
|
||
}
|
||
|
||
function openLibraryEditor() {
|
||
closeFolderEditor();
|
||
closeLibraryEditor();
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "library-editor-backdrop";
|
||
overlay.innerHTML = `
|
||
<div class="library-editor" role="dialog" aria-modal="true" aria-label="Settings" data-i18n-aria-label="settings.title">
|
||
<div class="library-editor-head">
|
||
<span data-i18n="settings.title">Settings</span>
|
||
<button class="library-editor-close" type="button" aria-label="Close" data-i18n-aria-label="settings.closeAria">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||
</button>
|
||
</div>
|
||
<div class="settings-tabs" role="tablist">
|
||
<button class="settings-tab active" type="button" data-tab="general" role="tab" data-i18n="settings.tab.general">General</button>
|
||
<button class="settings-tab" type="button" data-tab="network" role="tab" data-i18n="settings.tab.network">Network</button>
|
||
<button class="settings-tab" type="button" data-tab="export" role="tab" data-i18n="settings.tab.export">Export</button>
|
||
<button class="settings-tab" type="button" data-tab="logs" role="tab" data-i18n="settings.tab.logs">Logs</button>
|
||
<button class="settings-tab" type="button" data-tab="registry" role="tab" data-i18n="settings.tab.registry">Registry</button>
|
||
</div>
|
||
<div class="settings-pane" data-pane="general">
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.language.title">Language</div>
|
||
<div class="settings-row-desc" data-i18n="settings.language.desc">Display language for this app.</div>
|
||
</div>
|
||
<select class="settings-select settings-select-wide set-language" aria-label="Language"></select>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.maxDuration.title">Max track length</div>
|
||
<div class="settings-row-desc" data-i18n="settings.maxDuration.desc">Longest track accepted for processing, in minutes (max 20).</div>
|
||
</div>
|
||
<input type="text" class="settings-num-input set-max-duration" inputmode="numeric" maxlength="2" aria-label="Max track length in minutes" data-i18n-aria-label="settings.maxDuration.title" />
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.playlistLimit.title">Playlist import limit</div>
|
||
<div class="settings-row-desc" data-i18n="settings.playlistLimit.desc">Most tracks one playlist import will queue (max 200).</div>
|
||
</div>
|
||
<input type="text" class="settings-num-input set-playlist-max" inputmode="numeric" maxlength="3" aria-label="Playlist import limit" data-i18n-aria-label="settings.playlistLimit.title" />
|
||
</div>
|
||
<div class="settings-row settings-row-stack">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.stemsLocation.title">StemData location</div>
|
||
</div>
|
||
<div class="stems-location">
|
||
<code class="stems-location-path" title=""></code>
|
||
<span class="stems-location-size"></span>
|
||
<button class="settings-btn set-stems-location" type="button" data-i18n="settings.stemsLocation.change">Change…</button>
|
||
</div>
|
||
<div class="stems-location-msg" role="status" aria-live="polite"></div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.device.title">Compute device</div>
|
||
<div class="settings-row-desc set-demucs-desc">Device used for stem separation. Applies to the next track.</div>
|
||
</div>
|
||
<select class="settings-select settings-select-wide set-demucs-device" aria-label="Compute device" data-i18n-aria-label="settings.device.title">
|
||
<option value="auto" data-i18n="settings.device.auto">Auto</option>
|
||
<option value="cuda">CUDA (NVIDIA)</option>
|
||
<option value="mps">MPS (Apple Silicon)</option>
|
||
<option value="cpu">CPU</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.quality.title">Separation quality</div>
|
||
<div class="settings-row-desc" data-i18n="settings.quality.desc">Best runs the separator twice with randomized shifts and averages the result — cleaner stems, twice the time.</div>
|
||
</div>
|
||
<select class="settings-select settings-select-wide set-separation-quality" aria-label="Separation quality" data-i18n-aria-label="settings.quality.title">
|
||
<option value="standard" data-i18n="settings.quality.standard">Standard</option>
|
||
<option value="best" data-i18n="settings.quality.best">Best (2× slower)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="settings-subhead" data-i18n="settings.outOfSync.subhead">Out of sync tracks</div>
|
||
<div class="library-editor-table-wrap">
|
||
<table class="library-editor-table">
|
||
<thead><tr><th data-i18n="settings.outOfSync.colName">Name</th><th data-i18n="settings.outOfSync.colSource">Source</th><th data-i18n="settings.outOfSync.colLocation">Location</th></tr></thead>
|
||
<tbody class="library-editor-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="library-editor-foot">
|
||
<span class="library-editor-status" aria-live="polite"></span>
|
||
<button class="library-editor-sync" type="button" data-i18n="settings.outOfSync.resync">Resync out of sync tracks</button>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.exportLogs.title">Export logs</div>
|
||
<div class="settings-row-desc" data-i18n="settings.exportLogs.desc">Download every log file as a single zip — the thing to attach to a bug report. See the Logs tab for where they live.</div>
|
||
</div>
|
||
<button class="settings-export-logs" type="button" data-i18n="settings.exportLogs.button">Export logs</button>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section settings-danger-zone">
|
||
<div class="settings-subhead settings-danger-subhead" data-i18n="settings.dangerZone">Danger zone</div>
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.resetData.title">Reset app data</div>
|
||
<div class="settings-row-desc" data-i18n="settings.resetData.desc">Permanently deletes every track, job, and library entry. On a shared server this affects everyone who uses it. Cannot be undone.</div>
|
||
</div>
|
||
<button class="settings-reset-btn" type="button" data-i18n="settings.resetData.button">Reset app data…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-pane hidden" data-pane="network">
|
||
${networkSettingsHtml()}
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.network.port.title">Port</div>
|
||
<div class="settings-row-desc" data-i18n="settings.network.port.desc">Port StemDeck runs on. Restart to apply.</div>
|
||
</div>
|
||
<input type="text" class="settings-num-input set-port" inputmode="numeric" maxlength="5" aria-label="Port" data-i18n-aria-label="settings.network.port.title" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-pane hidden" data-pane="export">
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.export.sampleRate.title">Sample rate</div>
|
||
<div class="settings-row-desc" data-i18n="settings.export.sampleRate.desc">Sample rate for exported mixes and regions (WAV, FLAC, MP3). 44.1 kHz suits most DAWs and samplers; pick another if your hardware needs it.</div>
|
||
</div>
|
||
<select class="settings-select set-export-samplerate" aria-label="Export sample rate" data-i18n-aria-label="settings.export.sampleRate.title">
|
||
<option value="22050">22.05 kHz</option>
|
||
<option value="32000">32 kHz</option>
|
||
<option value="44100">44.1 kHz</option>
|
||
<option value="48000">48 kHz</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="settings-section">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.export.videoQuality.title">MP4 video quality</div>
|
||
<div class="settings-row-desc" data-i18n="settings.export.videoQuality.desc">Max resolution for MP4 export and YouTube video.</div>
|
||
</div>
|
||
<select class="settings-select set-video-height">
|
||
<option value="360">360p</option>
|
||
<option value="480">480p</option>
|
||
<option value="720">720p</option>
|
||
<option value="1080">1080p</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-pane hidden" data-pane="logs">
|
||
<div class="settings-subtabs" role="tablist">
|
||
<button class="settings-subtab active" type="button" data-sub="location" role="tab" data-i18n="settings.logs.locationTab">Location</button>
|
||
<button class="settings-subtab" type="button" data-sub="application" role="tab" data-i18n="settings.logs.applicationTab">Application log</button>
|
||
<button class="settings-subtab" type="button" data-sub="backend" role="tab" data-i18n="settings.logs.backendTab">Backend log</button>
|
||
<button class="settings-subtab" type="button" data-sub="setup" role="tab" data-i18n="settings.logs.setupTab">Setup log</button>
|
||
</div>
|
||
<div class="settings-subpane" data-subpane="location">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.logs.location.title">Log location</div>
|
||
<div class="settings-row-desc" data-i18n="settings.logs.location.desc">Where StemDeck writes its logs on this machine. Read-only — open them in a file manager or use Export logs.</div>
|
||
</div>
|
||
<button class="settings-registry-refresh settings-logs-refresh" type="button" data-i18n="settings.logs.refresh">Refresh</button>
|
||
</div>
|
||
<div class="settings-logs-dir"><code class="settings-logs-path" data-i18n="settings.logs.loading">Loading…</code></div>
|
||
<div class="settings-logs-list" data-i18n="settings.logs.loading">Loading…</div>
|
||
</div>
|
||
<div class="settings-subpane hidden" data-subpane="application">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.logs.application.title">Application log</div>
|
||
<div class="settings-row-desc" data-i18n="settings.logs.application.desc">The last hour from <code>stemdeck.log</code> — pipeline, API and job activity. Read-only.</div>
|
||
</div>
|
||
<button class="settings-registry-refresh settings-logtail-refresh" type="button" data-view="application" data-i18n="settings.logs.refresh">Refresh</button>
|
||
</div>
|
||
<textarea class="settings-registry-view settings-logtail-view" data-view="application" readonly spellcheck="false" aria-label="Application log (read only)" data-i18n-aria-label="settings.logs.applicationAria" data-i18n="settings.logs.loading">Loading…</textarea>
|
||
</div>
|
||
<div class="settings-subpane hidden" data-subpane="backend">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.logs.backend.title">Backend log</div>
|
||
<div class="settings-row-desc" data-i18n="settings.logs.backend.desc">The last hour from <code>backend.log</code> — raw output of the bundled Python process, including anything that crashed it before the application log could record it. Desktop app only. Read-only.</div>
|
||
</div>
|
||
<button class="settings-registry-refresh settings-logtail-refresh" type="button" data-view="backend" data-i18n="settings.logs.refresh">Refresh</button>
|
||
</div>
|
||
<textarea class="settings-registry-view settings-logtail-view" data-view="backend" readonly spellcheck="false" aria-label="Backend log (read only)" data-i18n-aria-label="settings.logs.backendAria" data-i18n="settings.logs.loading">Loading…</textarea>
|
||
</div>
|
||
<div class="settings-subpane hidden" data-subpane="setup">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.logs.setup.title">Setup log</div>
|
||
<div class="settings-row-desc" data-i18n="settings.logs.setup.desc">The last hour from <code>setup.log</code> — first-run setup and GPU runtime installation. Desktop app only. Read-only.</div>
|
||
</div>
|
||
<button class="settings-registry-refresh settings-logtail-refresh" type="button" data-view="setup" data-i18n="settings.logs.refresh">Refresh</button>
|
||
</div>
|
||
<textarea class="settings-registry-view settings-logtail-view" data-view="setup" readonly spellcheck="false" aria-label="Setup log (read only)" data-i18n-aria-label="settings.logs.setupAria" data-i18n="settings.logs.loading">Loading…</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="settings-pane hidden" data-pane="registry">
|
||
<div class="settings-row">
|
||
<div class="settings-row-text">
|
||
<div class="settings-row-title" data-i18n="settings.registry.title">Job registry</div>
|
||
<div class="settings-row-desc" data-i18n="settings.registry.desc">Read-only view of <code>registry.json</code> — the persisted list of completed jobs on disk.</div>
|
||
</div>
|
||
<button class="settings-registry-refresh" type="button" data-i18n="settings.logs.refresh">Refresh</button>
|
||
</div>
|
||
<textarea class="settings-registry-view" readonly spellcheck="false" aria-label="Job registry (read only)" data-i18n-aria-label="settings.registry.aria" data-i18n="settings.logs.loading">Loading…</textarea>
|
||
</div>
|
||
<div class="settings-foot">
|
||
<button class="settings-done" type="button" data-i18n="settings.done">Done</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
applyTranslations(overlay);
|
||
|
||
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));
|
||
if (name === "registry") loadRegistryView(overlay);
|
||
if (name === "logs") loadLogsView(overlay);
|
||
});
|
||
});
|
||
overlay.querySelector(".settings-registry-refresh:not(.settings-logs-refresh)")
|
||
?.addEventListener("click", () => loadRegistryView(overlay));
|
||
overlay.querySelector(".settings-logs-refresh")?.addEventListener("click", () => loadLogsView(overlay));
|
||
overlay.querySelectorAll(".settings-subtab").forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
const name = tab.dataset.sub;
|
||
overlay.querySelectorAll(".settings-subtab").forEach((t) => t.classList.toggle("active", t === tab));
|
||
overlay.querySelectorAll(".settings-subpane").forEach((p) => p.classList.toggle("hidden", p.dataset.subpane !== name));
|
||
if (name === "location") loadLogsView(overlay);
|
||
else loadLogTail(overlay, name);
|
||
});
|
||
});
|
||
overlay.querySelectorAll(".settings-logtail-refresh").forEach((b) =>
|
||
b.addEventListener("click", () => loadLogTail(overlay, b.dataset.view)));
|
||
overlay.querySelector(".settings-export-logs")?.addEventListener("click", (e) => exportLogs(e.currentTarget));
|
||
|
||
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);
|
||
wireLanguageSetting(overlay);
|
||
wireGeneralSettings(overlay);
|
||
wireStemsLocation(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 = i18nT("settings.readOnlyServer");
|
||
overlay.querySelector("[data-pane='network']")?.prepend(note);
|
||
}
|
||
// Reset app data (#312): originally a "session keeps coming back across
|
||
// desktop reinstalls" fix (the real persisted state lives in
|
||
// ~/Documents/StemDeck, not the extracted package's own bundled data/
|
||
// folder), available in server mode too -- same network_gate trust
|
||
// boundary as every other settings-mutating endpoint, enforced
|
||
// server-side (not just hidden here). On a shared server this deletes
|
||
// every user's library, which the confirm dialog says explicitly.
|
||
overlay.querySelector(".settings-reset-btn")?.addEventListener("click", () => {
|
||
openResetConfirm();
|
||
});
|
||
}
|
||
|
||
// 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 = i18nT("library.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}`);
|
||
|
||
await syncWithServer(); // pulls in new server jobs, reconciles done <-> unavailable
|
||
if (libraryEditor) renderLibraryRows(libraryEditor.querySelector(".library-editor-body"));
|
||
|
||
// Collect what's still unavailable; auto-restore the ones with a URL source.
|
||
const trashIds = new Set(getTrashFolder()?.items || []);
|
||
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 = i18nT("library.syncFailed");
|
||
} 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();
|
||
wireReleaseDialog();
|
||
wireSettingsMenu();
|
||
setDisplayedVersion(currentVersion);
|
||
render();
|
||
|
||
// Patch rows in place on every queue frame. A full render() here would
|
||
// rebuild the sidebar several times a second.
|
||
onQueueChange(onQueueFrame);
|
||
onJobSettled(completeSettledJob);
|
||
startQueueStream();
|
||
|
||
loadCurrentVersion().finally(checkForUpdate);
|
||
syncWithServer();
|
||
}
|