soulsync/webui/static/video/video-download-view.js
BoulderBadgeDad ecd9c25e3c video download view: drop the footnote + make Sources section pop
- Removed the 'Automatic searching arrives…' footnote.
- Sources rows are no longer tame: each carries its own brand colour (Soulseek
  blue / Torrent amber / Usenet purple / YouTube red) driving a tinted gradient
  row, a left accent rail, a glowing brand icon TILE, and an in-brand hover lift
  with a coloured shadow. A live pulsing status DOT sits by the label, the Search
  button is brand-tinted, and the scan bar/'Searching…' state now glow in the
  source's own colour. Reduced-motion still honoured.

CSS + JS balance clean.
2026-06-19 11:42:43 -07:00

201 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SoulSync — Download VIEW renderer (movie / TV show / YouTube).
*
* NOT its own modal: it renders the direct-download content INTO a container the
* caller owns — the get-modal swaps its detail body for this view (with a Back
* button) when you click "Download", and a future YouTube trigger can reuse it.
*
* v1 is VISUAL scaffolding: it shows the quality TARGET (read from the Settings →
* Downloads profile), judges any copy you ALREADY own against that target (real —
* via /downloads/evaluate), and lists each attached source with a per-source
* "Search" affordance. The searches are stubs — no backend yet (engine phase).
*
* VideoDownload.render(containerEl, { kind, id, source, isYt, file }). Self-contained.
*/
(function () {
'use strict';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); }
function resLabel(res) {
if (!res) return '';
res = String(res).toLowerCase();
if (res.indexOf('2160') > -1 || res === '4k') return '4K';
if (res.indexOf('1080') > -1) return '1080p';
if (res.indexOf('720') > -1) return '720p';
if (res.indexOf('480') > -1 || res.indexOf('576') > -1) return 'SD';
return res.toUpperCase();
}
var CUT_LABEL = { '2160p': '4K', '1080p': '1080p', '720p': '720p', '480p': 'SD' };
var SRC_META = {
soulseek: { name: 'Soulseek', emoji: '🎵' },
torrent: { name: 'Torrent', emoji: '🧲' },
usenet: { name: 'Usenet', emoji: '📰' },
youtube: { name: 'YouTube', emoji: '▶' }
};
function getJSON(url) {
return fetch(url, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function postJSON(url, body) {
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body) }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function contentHTML() {
return '<div class="vdl-section">' +
'<div class="vdl-sec-label">Quality target</div>' +
'<div class="vdl-chips" data-vdl-target><span class="vdl-chip vdl-chip--ghost">Loading…</span></div>' +
'</div>' +
'<div class="vdl-owned" data-vdl-owned hidden></div>' +
'<div class="vdl-section">' +
'<div class="vdl-sec-head">' +
'<div class="vdl-sec-label">Sources</div>' +
'<button class="vdl-search-all" type="button" data-vdl-search-all>⌕ Search all</button>' +
'</div>' +
'<div class="vdl-sources" data-vdl-sources><div class="vdl-src-empty">Loading sources…</div></div>' +
'</div>';
}
function onClick(e) {
var container = e.currentTarget;
var sb = e.target.closest('[data-vdl-search]');
if (sb) { stubSearch(container, sb.getAttribute('data-vdl-search')); return; }
if (e.target.closest('[data-vdl-search-all]')) { stubSearch(container, '*'); }
}
// Render the download view into `container`. Re-callable (resets each time).
function render(container, opts) {
if (!container) return;
opts = opts || {};
container.innerHTML = contentHTML();
if (!container._vdlWired) { container._vdlWired = true; container.addEventListener('click', onClick); }
var isYt = !!opts.isYt;
getJSON(isYt ? '/api/video/downloads/youtube-quality' : '/api/video/downloads/quality')
.then(function (p) { if (container.isConnected && p) renderTarget(container, p, isYt); });
if (isYt) {
renderSources(container, ['youtube']);
} else {
getJSON('/api/video/downloads/config').then(function (c) {
if (container.isConnected) renderSources(container, sourcesFromConfig(c));
});
if (opts.file) renderOwned(container, opts.file);
}
}
function sourcesFromConfig(c) {
c = c || {};
if (c.download_mode === 'hybrid' && Array.isArray(c.hybrid_order) && c.hybrid_order.length) return c.hybrid_order;
if (c.download_mode) return [c.download_mode];
return ['soulseek'];
}
function chip(text, mod) { return '<span class="vdl-chip' + (mod ? ' vdl-chip--' + mod : '') + '">' + esc(text) + '</span>'; }
function renderTarget(container, p, isYt) {
var box = container.querySelector('[data-vdl-target]'); if (!box) return;
var chips = [];
if (isYt) {
chips.push(chip('Up to ' + (p.max_resolution === 'best' ? 'best' : (p.max_resolution || '1080p'))));
if (p.video_codec && p.video_codec !== 'any') chips.push(chip('Prefer ' + p.video_codec.toUpperCase()));
if (p.container) chips.push(chip(p.container.toUpperCase()));
if (p.prefer_60fps) chips.push(chip('60fps'));
chips.push(chip(p.allow_hdr ? 'HDR ok' : 'SDR'));
} else {
chips.push(chip(p.cutoff_resolution ? 'Stop at ' + (CUT_LABEL[p.cutoff_resolution] || p.cutoff_resolution) : 'Always upgrade'));
if (p.prefer_codec && p.prefer_codec !== 'any') chips.push(chip('Prefer ' + (p.prefer_codec === 'hevc' ? 'HEVC' : p.prefer_codec.toUpperCase())));
if (p.prefer_hdr === 'prefer') chips.push(chip('Prefer HDR'));
else if (p.prefer_hdr === 'require') chips.push(chip('HDR required', 'req'));
if (Array.isArray(p.rejects) && p.rejects.length) chips.push(chip('Reject ' + p.rejects.join(', '), 'rej'));
if (p.max_movie_gb) chips.push(chip('Movie ≤ ' + p.max_movie_gb + ' GB'));
if (p.max_episode_gb) chips.push(chip('Episode ≤ ' + p.max_episode_gb + ' GB'));
}
box.innerHTML = chips.join('');
}
// Owned copy → "In your library · 720p · BluRay · X265" + a verdict against the
// quality target (real: /downloads/evaluate). meets → reassuring; else upgrade.
function renderOwned(container, file) {
var box = container.querySelector('[data-vdl-owned]'); if (!box) return;
var bits = [resLabel(file.resolution), file.release_source, (file.video_codec || '').toUpperCase()].filter(Boolean);
box.innerHTML =
'<div class="vdl-owned-row">' +
'<span class="vdl-owned-ic">✓</span>' +
'<span class="vdl-owned-txt"><strong>In your library</strong>' + (bits.length ? ' · ' + esc(bits.join(' · ')) : '') + '</span>' +
'<span class="vdl-verdict vdl-verdict--pending" data-vdl-verdict>checking…</span>' +
'</div>' +
'<div class="vdl-reasons" data-vdl-reasons></div>';
box.hidden = false;
postJSON('/api/video/downloads/evaluate', { file: file }).then(function (v) {
if (!container.isConnected || !v) return;
var badge = box.querySelector('[data-vdl-verdict]');
if (badge) {
badge.classList.remove('vdl-verdict--pending');
badge.classList.add(v.meets ? 'vdl-verdict--ok' : 'vdl-verdict--up');
badge.textContent = v.meets ? 'Meets your target' : 'Eligible for upgrade';
}
var rs = box.querySelector('[data-vdl-reasons]');
if (rs && v.reasons && v.reasons.length) {
rs.innerHTML = v.reasons.map(function (r) {
return '<div class="vdl-reason vdl-reason--' + (r.ok ? 'ok' : 'no') + '">' +
(r.ok ? '✓' : '↑') + ' ' + esc(r.text) + '</div>';
}).join('');
}
});
}
function renderSources(container, list) {
var box = container.querySelector('[data-vdl-sources]'); if (!box) return;
list = (list || []).filter(function (s) { return SRC_META[s]; });
if (!list.length) {
box.innerHTML = '<div class="vdl-src-empty">No download source configured — pick one on Settings → Downloads.</div>';
return;
}
box.innerHTML = list.map(function (s) {
var m = SRC_META[s];
return '<div class="vdl-src" data-vdl-src="' + s + '">' +
'<span class="vdl-src-icon"><span class="vdl-src-emoji">' + m.emoji + '</span></span>' +
'<span class="vdl-src-main">' +
'<span class="vdl-src-name">' + esc(m.name) + '</span>' +
'<span class="vdl-src-meta"><span class="vdl-src-dot"></span>' +
'<span class="vdl-src-status" data-vdl-status>Ready</span></span>' +
'</span>' +
'<button class="vdl-src-search" type="button" data-vdl-search="' + s + '">⌕ Search</button>' +
'</div>';
}).join('');
}
// Scaffold: a satisfying faux-scan (animated) that resolves to "coming soon".
// No backend yet — this is the motion the real engine will drive.
function scanRow(row, i) {
if (row._scanning) return;
row._scanning = true;
var st = row.querySelector('[data-vdl-status]');
var btn = row.querySelector('[data-vdl-search]');
row.classList.add('vdl-src--scanning');
if (btn) btn.disabled = true;
if (st) { st.textContent = 'Searching'; st.className = 'vdl-src-status vdl-src-status--scanning'; }
setTimeout(function () {
if (!row.isConnected) { row._scanning = false; return; }
row.classList.remove('vdl-src--scanning');
row._scanning = false;
if (btn) btn.disabled = false;
var s = row.querySelector('[data-vdl-status]');
if (s) { s.textContent = 'Search engine coming soon'; s.className = 'vdl-src-status vdl-src-status--soon'; }
}, 1300 + i * 280); // staggered finish so a "search all" ripples
}
function stubSearch(container, which) {
var sel = which === '*' ? '[data-vdl-src]' : '[data-vdl-src="' + which + '"]';
var rows = container.querySelectorAll(sel);
for (var i = 0; i < rows.length; i++) scanRow(rows[i], i);
toast('Automatic search isnt wired up yet — coming soon', 'info');
}
window.VideoDownload = { render: render };
})();