diff --git a/static/js/catalog.js b/static/js/catalog.js index fae15a0f..be494ad9 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -289,9 +289,10 @@ function trackMatchesSearch(track) { if (!tag) return true; return (track?.tags ?? []).some((t) => String(t).toLowerCase().includes(tag)); } + // Not `channel`: it was a stored status label, not something a person would + // search by, and old tracks still carry it (#656). return [ track?.title, - track?.channel, track?.sourceUrl, ...(track?.stems || []), ...(track?.tags || []), @@ -398,7 +399,7 @@ function saveState() { // ─── Track management ─── export function addTrackToLibrary(track) { - // track: { id, title, channel, thumb, stems, status, sourceUrl } + // track: { id, title, thumb, stems, status, sourceUrl } const existingId = findTrackBySource(track.sourceUrl, track.id); // A match already in the Trash is left exactly where it is. It is a distinct // job with its own files on disk, and the user is the only one who decides @@ -571,7 +572,7 @@ function applyTrackInfoToPanel(track) { thumbnail: track.thumb, key: track.key, bpm: track.bpm, - stemCount: (track.audioStems || track.stems || []).filter((s) => (s.name ?? s) !== "original").length || null, + stemCount: stemCountOf(track) || null, }); applyStemPresenceCards(track.stemPresence); @@ -883,7 +884,6 @@ export function addPlaylistToLibrary(playlistTitle, jobs) { addTrackToLibrary({ id: job.job_id, title: job.title || job.source_url || i18nT("job.queuedTrack"), - channel: i18nT("job.processing"), thumb: "", stems: [...selectedStems], selectedStems: [...selectedStems], @@ -1241,14 +1241,6 @@ function wireLibraryDeleteKeys() { // ─── 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)) { @@ -1284,6 +1276,53 @@ function unavailableWarningHtml(track) { return `${esc(label)}`; } +/** + * How many stems a track has: the stem files that exist once it is done, and + * the ones asked for before then. The mix itself ("original") is not a stem. + */ +function stemCountOf(track) { + const stems = track?.audioStems?.length ? track.audioStems : track?.stems || []; + return stems.filter((s) => (s?.name ?? s) !== "original").length; +} + +/** + * The line under a track's title, built from what is true of the track now. + * + * It used to read a `channel` field that was never a channel: six code paths + * stored a status label in it, as text in whatever language was active at the + * time, and the path that adopts a server job the library did not know about + * stored nothing. So two copies of the same song could read "Extracted · + * 6 stems" and " · 6 stems" side by side (#656), and a label written in one + * language stayed in it after the user switched. Nothing is stored now, and + * every path that creates a track gets the same line. + * + * Returns HTML: an unavailable track's line is a warning, not plain text. + */ +function trackSublineHtml(track, { inTrash = false } = {}) { + if (track.status === "unavailable") return unavailableWarningHtml(track); + let parts; + if (PROCESSING_STATUSES.has(track.status)) { + parts = [i18nT("job.processing")]; + } else if (track.status === "error") { + parts = [i18nT("notifKind.importFailed")]; + } else { + const stems = stemCountOf(track); + // A done track that knows neither its length nor its stems says nothing + // rather than "0 stems", which would be a claim and a wrong one. + parts = [ + track.duration ? fmtTime(track.duration) : "", + stems ? i18nPlural("footer.stemsCount", stems) : "", + ]; + } + if (inTrash) parts.push(i18nT("track.removed")); + // Spaced as text as well as by the row's flex gap, so what a screen reader + // or a copy gets reads "05:57 · 6 stems" rather than "05:57·6 stems". + return parts + .filter(Boolean) + .map((part) => `${esc(part)}`) + .join(' · '); +} + function renderRecentItem(trackId) { const track = tracks[trackId]; if (!track) return null; @@ -1291,14 +1330,11 @@ function renderRecentItem(trackId) { 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 = `
${thumbHtml(track)}
${esc(displayTitle(track.title))}
-
${isUnavailable ? unavailableWarningHtml(track) : `${esc(sub)}`}
+
${trackSublineHtml(track)}
`; @@ -1386,17 +1422,11 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; - const stemCount = track.stems?.length ?? 0; - const subHtml = isUnavailable - ? unavailableWarningHtml(track) - : `${esc(track.channel ?? "")} - · - ${inTrash ? esc(i18nT("track.removed")) : esc(i18nPlural("footer.stemsCount", stemCount))}`; el.innerHTML = `
${thumbHtml(track)}
${esc(displayTitle(track.title))}
-
${subHtml}
+
${trackSublineHtml(track, { inTrash })}
${inTrash ? "" : `