Track-detail modal: click any download row for a rich, status-aware view

Clicking a track row in the download modal now opens a polished detail modal
(its own template, webui/track-detail-modal.html, included into index.html;
behavior in static/track-detail.js): cover, title/artist/album, status badge,
in-app play, source, quality, AcoustID verdict, file location, and the
expected-vs-downloaded provenance — backed by /api/downloads/task/<id>/detail.

It adapts by status:
- completed  -> play (library stream) + full provenance
- quarantined-> reason + Listen (quarantine stream) + Accept & Import + Search
- failed/not_found -> reason + Search

This absorbs the standalone quarantine chooser, which is removed (its
Listen/Accept/Search live here now, with the same Windows file-handle release
before Accept and the thin-sidecar -> Recover-to-Staging fallback). Plain
failed/not-found rows still go straight to the search modal; sync-import modal
unaffected. Status cells clear their clickable/detail state each render so a row
that flips to completed isn't left with a stale handler.
This commit is contained in:
BoulderBadgeDad 2026-05-31 20:24:37 -07:00
parent e4bbcfda1b
commit 134d306511
5 changed files with 401 additions and 149 deletions

View file

@ -8012,6 +8012,7 @@
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
@ -8045,6 +8046,8 @@
</div>
<div class="gsearch-results" id="gsearch-results"></div>
{% include 'track-detail-modal.html' %}
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
<div class="media-player idle" id="media-player">
<!-- Thin interactive progress bar spanning full width at top -->

View file

@ -2982,21 +2982,17 @@ function _ensureCandidatesClickListener(statusEl) {
statusEl.addEventListener('click', function (e) {
e.stopPropagation();
_hideErrorTooltip();
// Single click handler routes by dataset set at render time. A
// quarantined task opens the chooser; everything else opens the search
// modal. Deciding at click-time (not bind-time) means a task that fails
// first and only quarantines on a later retry can't end up double-bound.
if (this.dataset.quarantineEntryId) {
showQuarantineChooser({
entryId: this.dataset.quarantineEntryId,
taskId: this.dataset.taskId,
reason: this.dataset.quarantineReason || '',
trackName: this.dataset.quarantineTrack || 'this track',
});
return;
}
const taskId = this.dataset.taskId;
if (taskId) showCandidatesModal(taskId);
if (!taskId) return;
// Decide at click-time from dataset set each render: completed and
// quarantined rows open the rich track-detail modal (it carries the
// play/listen + Accept/Search actions); plain failed/not-found go
// straight to the search modal.
if (this.dataset.detailOpen && typeof openTrackDetail === 'function') {
openTrackDetail(taskId);
} else {
showCandidatesModal(taskId);
}
});
}
@ -3395,125 +3391,9 @@ async function approveQuarantineFromDownloadRow(button) {
}
}
// ── Quarantine chooser (download-modal status click) ──────────────────────
// Clicking a 🛡️ Quarantined status opens this instead of the search modal:
// listen to the file, accept & import it (bypassing the failed check), or fall
// back to searching for a different result. (Routing is handled inside
// _ensureCandidatesClickListener so a single listener covers both cases.)
// Stop and unload the chooser's <audio> so the server closes its read handle
// on the quarantined file. On Windows an open handle blocks the subsequent
// Approve/Recover move (WinError 32 "file in use") — listening then accepting
// would otherwise fail. Returns a short promise so callers can let the
// streaming connection actually tear down before moving the file.
function _qcReleaseAudio() {
const aud = document.getElementById('qc-audio');
if (!aud) return Promise.resolve();
try {
aud.pause();
aud.removeAttribute('src');
aud.load(); // forces the browser to drop the current stream connection
} catch (e) { /* ignore */ }
return new Promise((r) => setTimeout(r, 400));
}
function closeQuarantineChooser() {
const o = document.getElementById('quarantine-chooser-overlay');
if (o) o.remove();
}
function showQuarantineChooser({ entryId, taskId, reason, trackName }) {
closeQuarantineChooser();
const streamUrl = entryId ? `/api/quarantine/${encodeURIComponent(entryId)}/stream` : '';
const overlay = document.createElement('div');
overlay.id = 'quarantine-chooser-overlay';
overlay.className = 'candidates-modal-overlay';
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeQuarantineChooser(); });
overlay.innerHTML = `
<div class="candidates-modal quarantine-chooser-modal">
<div class="candidates-modal-header">
<div>
<h2>🛡 Quarantined File</h2>
<div class="quarantine-chooser-track">${escapeHtml(trackName)}</div>
</div>
<button class="candidates-modal-close" onclick="closeQuarantineChooser()">×</button>
</div>
<div class="quarantine-chooser-body">
<div class="quarantine-chooser-reason"><strong>Why it was quarantined:</strong><br>${escapeHtml(reason) || 'Unknown reason'}</div>
<div class="quarantine-chooser-listen">
<label>Listen before deciding</label>
${streamUrl ? `<audio id="qc-audio" controls preload="none" src="${streamUrl}"></audio>` : '<span class="quarantine-chooser-muted">Playback unavailable for this entry.</span>'}
</div>
<div class="quarantine-chooser-actions">
<button class="quarantine-chooser-btn quarantine-chooser-accept" id="qc-accept-btn"> Accept &amp; Import</button>
<button class="quarantine-chooser-btn quarantine-chooser-search" id="qc-search-btn">🔍 Search for a different result</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
const acceptBtn = overlay.querySelector('#qc-accept-btn');
if (acceptBtn) acceptBtn.addEventListener('click', () => acceptQuarantineFromChooser(acceptBtn, entryId, taskId));
const searchBtn = overlay.querySelector('#qc-search-btn');
if (searchBtn) searchBtn.addEventListener('click', () => {
closeQuarantineChooser();
if (taskId) showCandidatesModal(taskId);
});
}
async function acceptQuarantineFromChooser(button, entryId, taskId) {
if (!entryId) { showToast('Cannot accept — missing quarantine id.', 'error'); return; }
const confirmed = await showConfirmDialog({
title: 'Accept Quarantined File',
message: 'Import this file and skip the quarantine checks for this approved pass?',
confirmText: 'Accept & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const original = button.textContent;
button.disabled = true;
button.textContent = 'Importing…';
const restore = () => { button.disabled = false; button.textContent = original; };
// Release the preview stream first so the file isn't locked during the move.
await _qcReleaseAudio();
try {
const resp = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId || '' }),
});
const data = await resp.json();
if (data.success) {
showToast('Accepted. Re-running post-processing.', 'success');
closeQuarantineChooser();
return;
}
// One-click approve needs the saved import context. Older / orphaned
// quarantine entries (thin or missing sidecar) can't be re-imported in
// place — fall back to moving the file into Staging so the user can
// finish via the Import flow, same as the Quarantine manager does.
const needsRecover = /thin sidecar|recover to staging|embedded context|missing file or sidecar/i.test(data.error || '');
if (needsRecover) {
button.textContent = 'Recovering…';
const rec = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
const recData = await rec.json();
if (recData.success) {
showToast('Older entry — moved to Staging. Finish it from the Import page.', 'success');
closeQuarantineChooser();
return;
}
showToast(`Recover failed: ${recData.error || 'Unknown error'}`, 'error');
restore();
return;
}
showToast(`Accept failed: ${data.error || 'Unknown error'}`, 'error');
restore();
} catch (err) {
showToast(`Accept failed: ${err.message}`, 'error');
restore();
}
}
// Quarantine actions (Listen / Accept & Import / Search) now live in the
// track-detail modal (static/track-detail.js), which a quarantined row opens
// via _ensureCandidatesClickListener + dataset.detailOpen.
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
@ -3787,6 +3667,7 @@ function processModalStatusUpdate(playlistId, data) {
delete statusEl.dataset.quarantineEntryId;
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
delete statusEl.dataset.detailOpen;
statusEl.textContent = statusText;
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
@ -3794,25 +3675,22 @@ function processModalStatusUpdate(playlistId, data) {
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
// Make not_found / failed / cancelled cells clickable to open
// the candidates modal. Always bind — even when no auto-search
// candidates were cached — because the modal carries the manual
// search bar, which is the user's recourse for empty results.
if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
// Completed rows are clickable into the rich track-detail modal
// (play, location, AcoustID verdict, provenance).
if (task.status === 'completed') {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
statusEl.dataset.detailOpen = '1';
_ensureCandidatesClickListener(statusEl);
} else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
// Clickable to recover: quarantined -> track-detail modal
// (Listen / Accept / Search); plain failed/not-found ->
// straight to the search modal. detailOpen is set/cleared
// each render so a row that changes kind stays correct.
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
// Quarantined files route to the chooser (Listen / Accept /
// Search); everything else to the search modal. Set or CLEAR
// the quarantine flags every render so a row that flips
// between failure kinds always reflects its current state.
if (isQuarantinedTask && task.quarantine_entry_id) {
statusEl.dataset.quarantineEntryId = task.quarantine_entry_id;
statusEl.dataset.quarantineReason = task.error_message || '';
statusEl.dataset.quarantineTrack = (task.track_info && task.track_info.name) || '';
} else {
delete statusEl.dataset.quarantineEntryId;
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
statusEl.dataset.detailOpen = '1';
}
_ensureCandidatesClickListener(statusEl);
}

View file

@ -42468,6 +42468,100 @@ div.artist-hero-badge {
background: rgba(var(--accent-rgb), 0.28);
}
/* ── Track Detail modal ─────────────────────────────────────────────────── */
.td-overlay {
position: fixed; inset: 0;
background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(12px);
z-index: 100002;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.25s ease;
}
.td-overlay.visible { opacity: 1; pointer-events: auto; }
.td-modal {
background: linear-gradient(135deg, #1c1c1c 0%, #131313 100%);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 16px;
width: 90%; max-width: 560px; max-height: 86vh;
display: flex; flex-direction: column;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.7);
transform: scale(0.96);
transition: transform 0.25s ease;
overflow: hidden;
}
.td-overlay.visible .td-modal { transform: scale(1); }
.td-header {
display: flex; align-items: center; gap: 14px;
padding: 18px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.td-thumb-wrap { flex: 0 0 auto; width: 56px; height: 56px; }
.td-thumb, .td-thumb-ph {
width: 56px; height: 56px; border-radius: 10px; object-fit: cover;
}
.td-thumb-ph {
display: flex; align-items: center; justify-content: center;
font-size: 24px; background: rgba(255, 255, 255, 0.06);
}
.td-headtext { flex: 1 1 auto; min-width: 0; }
.td-title { margin: 0; font-size: 17px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.td-artist { color: rgba(255, 255, 255, 0.75); font-size: 13px; margin-top: 2px; }
.td-album { color: rgba(255, 255, 255, 0.45); font-size: 12px; margin-top: 1px; }
.td-status-badge {
flex: 0 0 auto; padding: 4px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
border: 1px solid currentColor;
}
.td-badge-ok { color: #4caf50; }
.td-badge-warn { color: #ff9800; }
.td-badge-bad { color: #ef5350; }
.td-badge-muted { color: #888; }
.td-badge-info { color: rgb(var(--accent-rgb)); }
.td-close {
flex: 0 0 auto; width: 30px; height: 30px; border: none; border-radius: 8px;
background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7);
font-size: 20px; line-height: 1; cursor: pointer;
}
.td-close:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.td-body { padding: 18px 20px 22px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.td-audio { width: 100%; }
.td-reason {
background: rgba(250, 204, 21, 0.08);
border: 1px solid rgba(250, 204, 21, 0.25);
border-radius: 10px; padding: 12px 14px;
color: rgba(255, 255, 255, 0.85); font-size: 13px; line-height: 1.5;
}
.td-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.td-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.td-field-wide { grid-column: 1 / -1; }
.td-label { color: rgba(255, 255, 255, 0.5); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.td-value { color: rgba(255, 255, 255, 0.9); font-size: 14px; word-break: break-word; }
.td-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: rgba(255, 255, 255, 0.7); }
.td-aid-ok { color: #4caf50; }
.td-aid-bad { color: #ef5350; }
.td-aid-muted { color: rgba(255, 255, 255, 0.55); }
.td-provenance {
border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px;
padding: 10px 14px; display: flex; flex-direction: column; gap: 6px;
}
.td-prov-row { display: flex; gap: 10px; font-size: 13px; }
.td-prov-k { flex: 0 0 92px; color: rgba(255, 255, 255, 0.5); }
.td-prov-v { color: rgba(255, 255, 255, 0.85); word-break: break-word; }
.td-actions { display: flex; flex-direction: column; gap: 10px; }
.td-action-btn {
padding: 12px 16px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05); color: #fff;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.td-action-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
.td-action-btn:disabled { opacity: 0.6; cursor: default; }
.td-action-primary {
border-color: rgba(var(--accent-rgb), 0.5); background: rgba(var(--accent-rgb), 0.15);
}
.td-action-primary:hover:not(:disabled) { background: rgba(var(--accent-rgb), 0.28); }
.candidates-modal-body {
padding: 20px 28px;
overflow-y: auto;

View file

@ -0,0 +1,233 @@
/* Track Detail modal opens from any track row in the download modal and
* shows a rich, status-aware view: cover, title/artist/album, play/listen,
* source, quality, AcoustID verdict, file location, expected-vs-downloaded,
* and the right actions for the row's state (Accept/Search for quarantined,
* Search for failed). Backed by /api/downloads/task/<id>/detail.
*
* Globals it leans on (defined in downloads.js, loaded first): showToast,
* showConfirmDialog, showCandidatesModal, escapeHtml.
*/
const _TD_STATUS = {
completed: { label: 'Completed', cls: 'td-badge-ok' },
quarantined: { label: 'Quarantined', cls: 'td-badge-warn' },
failed: { label: 'Failed', cls: 'td-badge-bad' },
not_found: { label: 'Not Found', cls: 'td-badge-muted' },
in_progress: { label: 'In Progress', cls: 'td-badge-info' },
};
const _TD_ACOUSTID = {
pass: { label: 'Verified', cls: 'td-aid-ok' },
fail: { label: 'Failed', cls: 'td-aid-bad' },
error: { label: 'Error', cls: 'td-aid-bad' },
skip: { label: 'No match', cls: 'td-aid-muted' },
disabled: { label: 'Off', cls: 'td-aid-muted' },
};
function _tdEsc(s) {
if (typeof escapeHtml === 'function') return escapeHtml(s == null ? '' : s);
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _tdSetText(id, value, fallback = '—') {
const el = document.getElementById(id);
if (el) el.textContent = (value && String(value).trim()) ? value : fallback;
}
// Release the preview <audio> so the OS file handle is freed before any move
// (Windows locks an open file — same reason the quarantine chooser does this).
function _tdReleaseAudio() {
const a = document.getElementById('td-audio');
if (!a) return Promise.resolve();
try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ }
return new Promise((r) => setTimeout(r, 400));
}
function closeTrackDetail() {
const o = document.getElementById('track-detail-overlay');
if (!o) return;
const a = document.getElementById('td-audio');
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ } }
o.classList.remove('visible');
o.setAttribute('aria-hidden', 'true');
}
async function openTrackDetail(taskId) {
if (!taskId) return;
const overlay = document.getElementById('track-detail-overlay');
if (!overlay) { console.warn('track-detail modal not present'); return; }
let detail;
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/detail`);
const data = await resp.json();
if (!data.success) { showToast(data.error || 'Could not load track detail', 'error'); return; }
detail = data.detail;
} catch (err) {
showToast(`Could not load track detail: ${err.message}`, 'error');
return;
}
_tdRender(detail, taskId);
overlay.classList.add('visible');
overlay.setAttribute('aria-hidden', 'false');
}
function _tdRender(d, taskId) {
const kind = d.status_kind || 'in_progress';
// Header
_tdSetText('td-title', d.title, 'Unknown Track');
_tdSetText('td-artist', d.artist, '');
_tdSetText('td-album', d.album, '');
const thumb = document.getElementById('td-thumb');
const thumbPh = document.getElementById('td-thumb-ph');
if (thumb && thumbPh) {
if (d.thumb_url && /^https?:\/\//.test(d.thumb_url)) {
thumb.src = d.thumb_url; thumb.hidden = false; thumbPh.hidden = true;
thumb.onerror = () => { thumb.hidden = true; thumbPh.hidden = false; };
} else {
thumb.hidden = true; thumbPh.hidden = false;
}
}
const badge = document.getElementById('td-status-badge');
if (badge) {
const s = _TD_STATUS[kind] || _TD_STATUS.in_progress;
badge.textContent = s.label;
badge.className = `td-status-badge ${s.cls}`;
}
// Info grid
_tdSetText('td-f-source', d.source);
_tdSetText('td-f-quality', d.quality);
_tdSetText('td-f-location', d.file_path);
const aidEl = document.getElementById('td-f-acoustid');
if (aidEl) {
const a = _TD_ACOUSTID[d.acoustid_result];
aidEl.textContent = a ? a.label : '—';
aidEl.className = `td-value ${a ? a.cls : ''}`;
}
// Expected vs downloaded (only when we have provenance)
const prov = document.getElementById('td-provenance');
const exp = (d.expected && (d.expected.title || d.expected.artist));
const dl = (d.downloaded && (d.downloaded.title || d.downloaded.artist));
if (prov) {
if (exp || dl) {
prov.hidden = false;
_tdSetText('td-exp', exp ? `${d.expected.title}${d.expected.artist ? ' — ' + d.expected.artist : ''}` : '', '—');
_tdSetText('td-dl', dl ? `${d.downloaded.title}${d.downloaded.artist ? ' — ' + d.downloaded.artist : ''}` : '', '—');
} else {
prov.hidden = true;
}
}
// Reason banner (quarantined / failed)
const reason = document.getElementById('td-reason');
if (reason) {
if ((kind === 'quarantined' || kind === 'failed') && d.reason) {
reason.hidden = false;
reason.innerHTML = `<strong>${kind === 'quarantined' ? 'Why it was quarantined' : 'Why it failed'}:</strong><br>${_tdEsc(d.reason)}`;
} else {
reason.hidden = true;
}
}
// Audio: completed -> library stream; quarantined -> quarantine stream.
const audio = document.getElementById('td-audio');
if (audio) {
let src = '';
if (kind === 'completed' && d.file_path) {
src = `/stream/library-audio?path=${encodeURIComponent(d.file_path)}`;
} else if (kind === 'quarantined' && d.quarantine_entry_id) {
src = `/api/quarantine/${encodeURIComponent(d.quarantine_entry_id)}/stream`;
}
if (src) { audio.src = src; audio.hidden = false; } else { audio.removeAttribute('src'); audio.hidden = true; }
}
_tdRenderActions(d, taskId, kind);
}
function _tdRenderActions(d, taskId, kind) {
const el = document.getElementById('td-actions');
if (!el) return;
el.innerHTML = '';
const add = (label, cls, onClick) => {
const b = document.createElement('button');
b.className = `td-action-btn ${cls}`;
b.textContent = label;
b.addEventListener('click', onClick);
el.appendChild(b);
return b;
};
if (kind === 'quarantined') {
add('✓ Accept & Import', 'td-action-primary', (e) => _tdAccept(e.currentTarget, d.quarantine_entry_id, taskId));
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
} else if (kind === 'failed' || kind === 'not_found') {
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
}
// completed / in_progress: no destructive actions — the player + info is it.
}
async function _tdAccept(button, entryId, taskId) {
if (!entryId) { showToast('Cannot accept — missing quarantine id.', 'error'); return; }
const confirmed = await showConfirmDialog({
title: 'Accept Quarantined File',
message: 'Import this file and skip the quarantine checks for this approved pass?',
confirmText: 'Accept & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const original = button.textContent;
button.disabled = true;
button.textContent = 'Importing…';
await _tdReleaseAudio(); // free the file handle before the move (Windows lock)
try {
const resp = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId || '' }),
});
const data = await resp.json();
if (data.success) {
showToast('Accepted. Re-running post-processing.', 'success');
closeTrackDetail();
return;
}
const needsRecover = /thin sidecar|recover to staging|embedded context|missing file or sidecar/i.test(data.error || '');
if (needsRecover) {
button.textContent = 'Recovering…';
const rec = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
const recData = await rec.json();
if (recData.success) {
showToast('Older entry — moved to Staging. Finish it from the Import page.', 'success');
closeTrackDetail();
return;
}
showToast(`Recover failed: ${recData.error || 'Unknown error'}`, 'error');
} else {
showToast(`Accept failed: ${data.error || 'Unknown error'}`, 'error');
}
} catch (err) {
showToast(`Accept failed: ${err.message}`, 'error');
}
button.disabled = false;
button.textContent = original;
}
// Close on backdrop click + Escape.
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('track-detail-overlay');
if (overlay) {
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeTrackDetail(); });
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const o = document.getElementById('track-detail-overlay');
if (o && o.classList.contains('visible')) closeTrackDetail();
}
});

View file

@ -0,0 +1,44 @@
{# Track Detail modal — click any track row in the download modal to inspect it.
Populated by static/track-detail.js (openTrackDetail). One shell that adapts
by status_kind: completed shows play + provenance; quarantined adds the
reason + Listen / Accept / Search; failed/not-found offer Search. #}
<div id="track-detail-overlay" class="td-overlay" aria-hidden="true">
<div class="td-modal" role="dialog" aria-modal="true" aria-labelledby="td-title">
<div class="td-header">
<div class="td-thumb-wrap">
<img id="td-thumb" class="td-thumb" alt="" hidden>
<div id="td-thumb-ph" class="td-thumb-ph">🎵</div>
</div>
<div class="td-headtext">
<h2 id="td-title" class="td-title">Track</h2>
<div id="td-artist" class="td-artist"></div>
<div id="td-album" class="td-album"></div>
</div>
<span id="td-status-badge" class="td-status-badge"></span>
<button type="button" class="td-close" aria-label="Close" onclick="closeTrackDetail()">&times;</button>
</div>
<div class="td-body">
<audio id="td-audio" class="td-audio" controls preload="none" hidden></audio>
<div id="td-reason" class="td-reason" hidden></div>
<div class="td-grid">
<div class="td-field"><span class="td-label">Source</span><span id="td-f-source" class="td-value"></span></div>
<div class="td-field"><span class="td-label">Quality</span><span id="td-f-quality" class="td-value"></span></div>
<div class="td-field"><span class="td-label">AcoustID</span><span id="td-f-acoustid" class="td-value"></span></div>
<div class="td-field td-field-wide">
<span class="td-label">Location</span>
<span id="td-f-location" class="td-value td-mono"></span>
</div>
</div>
<div id="td-provenance" class="td-provenance" hidden>
<div class="td-prov-row"><span class="td-prov-k">Expected</span><span id="td-exp" class="td-prov-v"></span></div>
<div class="td-prov-row"><span class="td-prov-k">Downloaded</span><span id="td-dl" class="td-prov-v"></span></div>
</div>
<div id="td-actions" class="td-actions"></div>
</div>
</div>
</div>