video downloads: wire Grab + build the live Downloads page
Phase 3 — the UX: - Grab button now actually grabs (Soulseek results only — they carry the slskd username/filename): POSTs /downloads/grab with the result + search context, shows ✓ on success, toasts 'Sent to Downloads', fires a refresh event. Endpoint returns size_bytes for the payload. - New Downloads PAGE (video-downloads-page.js, .vdpg-*): every grab lands here. Polls /downloads/active while open and shows live status — type icon (🎬/📺/▶️), title + release, a pulsing 'Downloading' pill with a shimmering progress bar, then 'Completed' with the file's library destination (→ /media/movies/…), or 'Failed' with the reason. Clear-finished button, empty state, staggered entrance, vibes. Reduced-motion honored. JS/CSS/HTML balance clean (verified w/ a real tokenizer).
This commit is contained in:
parent
ceccb4ee65
commit
fff6448a14
5 changed files with 230 additions and 5 deletions
|
|
@ -160,7 +160,7 @@ def register_routes(bp):
|
|||
want_episode=want_episode, size_gb=size_gb)
|
||||
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
|
||||
results.append({
|
||||
"title": hit.get("title"), "size_gb": size_gb,
|
||||
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
|
||||
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
|
||||
"username": hit.get("username"), "slots": hit.get("slots"),
|
||||
"filename": hit.get("filename"), "_avail": avail,
|
||||
|
|
|
|||
|
|
@ -1273,6 +1273,23 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Downloads page — every grab from the video side lives here (live status).
|
||||
Isolated: built by video/video-downloads-page.js, styled by .vdpg-*. -->
|
||||
<section class="video-subpage" data-video-subpage="video-downloads" hidden>
|
||||
<div class="vdpg-page">
|
||||
<div class="vdpg-head">
|
||||
<div class="vdpg-head-glow" aria-hidden="true"></div>
|
||||
<div class="vdpg-head-row">
|
||||
<div class="vdpg-head-titles">
|
||||
<h1 class="vdpg-title">Downloads</h1>
|
||||
<p class="vdpg-sub" data-vdpg-sub>Everything you've grabbed from the video side</p>
|
||||
</div>
|
||||
<button class="vdpg-clear" type="button" data-vdpg-clear hidden>Clear finished</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vdpg-list" data-vdpg-list></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- TV-show detail (drill-in from a show card; not a nav page).
|
||||
Isolated: built by video/video-detail.js, styled by .vd-* in
|
||||
video-side.css. Inspired by the music artist-detail vibe. -->
|
||||
|
|
@ -10222,6 +10239,8 @@
|
|||
<!-- Download VIEW renderer (movie / show / youtube): per-source search + quality verdict;
|
||||
rendered inline inside the get-modal (not its own modal) -->
|
||||
<script src="{{ url_for('static', filename='video/video-download-view.js', v=static_v) }}"></script>
|
||||
<!-- Downloads page (isolated): live status of every grab from the video side -->
|
||||
<script src="{{ url_for('static', filename='video/video-downloads-page.js', v=static_v) }}"></script>
|
||||
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
|
||||
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
|
||||
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@
|
|||
|
||||
function onClick(e) {
|
||||
var container = e.currentTarget;
|
||||
if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet — coming soon', 'info'); return; }
|
||||
var grab = e.target.closest('[data-vdl-grab]');
|
||||
if (grab) { doGrab(grab); return; }
|
||||
var sb = e.target.closest('[data-vdl-search]');
|
||||
if (sb) { _movieSearch(container, sb.closest('[data-vdl-src-block]')); return; }
|
||||
if (e.target.closest('[data-vdl-search-all]')) {
|
||||
|
|
@ -221,6 +222,7 @@
|
|||
if (d && d.error) { resultsEl.innerHTML = '<div class="vdl-res-empty vdl-res-err">⚠ ' + esc(d.error) + '</div>'; return; }
|
||||
var rows = (d && d.results) || [];
|
||||
if (!rows.length) { resultsEl.innerHTML = '<div class="vdl-res-empty">No matching releases found.</div>'; return; }
|
||||
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
|
||||
var okN = rows.filter(function (r) { return r.accepted; }).length;
|
||||
var live = d && d.live ? '<span class="vdl-res-live">● live</span>' : '<span class="vdl-res-demo">demo data</span>';
|
||||
resultsEl.innerHTML =
|
||||
|
|
@ -230,6 +232,30 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Grab → start a real download (Soulseek only for now), then it lives on the
|
||||
// Downloads page. Reads the card's row + the panel's search context.
|
||||
function doGrab(btn) {
|
||||
var panel = btn.closest('.vdl-results'); if (!panel || !panel._rows) return;
|
||||
var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return;
|
||||
var p = panel._search || {};
|
||||
btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…';
|
||||
postJSON('/api/video/downloads/grab', {
|
||||
kind: p.scope || 'movie', title: p.title || '', release_title: r.title,
|
||||
source: 'soulseek', username: r.username, filename: r.filename,
|
||||
size_bytes: r.size_bytes, quality_label: r.quality_label
|
||||
}).then(function (res) {
|
||||
btn.classList.remove('vdl-res-grab--busy');
|
||||
if (res && res.ok) {
|
||||
btn.textContent = '✓'; btn.classList.add('vdl-res-grab--done');
|
||||
toast('Sent to Downloads', 'success');
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-download-started'));
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = '⤓';
|
||||
toast((res && res.error) || 'Couldn’t start the download', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scopeWord(s) {
|
||||
return s === 'season' ? 'for the season pack' : s === 'series' ? 'for the full series'
|
||||
: s === 'episode' ? 'this episode' : 'for the movie';
|
||||
|
|
@ -250,7 +276,7 @@
|
|||
|
||||
// Readable card: a big resolution tile anchors it, a plain-English quality summary
|
||||
// leads, the raw release name is demoted to a muted one-liner, then size/seeders.
|
||||
function resultCardHTML(r) {
|
||||
function resultCardHTML(r, i) {
|
||||
var summary = [SRC_LABEL[r.source] || r.source,
|
||||
r.codec ? String(r.codec).toUpperCase() : '',
|
||||
r.audio ? String(r.audio).toUpperCase().replace('-', ' ') : ''].filter(Boolean).join(' · ');
|
||||
|
|
@ -274,7 +300,7 @@
|
|||
(r.group ? '<span class="vdl-res-stat vdl-res-grp">' + esc(r.group) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(r.accepted ? '<button class="vdl-res-grab" type="button" data-vdl-grab title="Grab this release">⤓</button>' : '') +
|
||||
(r.accepted && r.username ? '<button class="vdl-res-grab" type="button" data-vdl-grab="' + i + '" title="Grab this release">⤓</button>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
|
|
@ -412,7 +438,8 @@
|
|||
|
||||
function onShowClick(e) {
|
||||
var container = e.currentTarget; var st = container._dl; if (!st) return;
|
||||
if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet — coming soon', 'info'); return; }
|
||||
var grab = e.target.closest('[data-vdl-grab]');
|
||||
if (grab) { doGrab(grab); return; }
|
||||
// Episode-scope search (a source row inside an expanded episode → its own panel).
|
||||
var srch = e.target.closest('[data-vdl-search]');
|
||||
if (srch) {
|
||||
|
|
|
|||
121
webui/static/video/video-downloads-page.js
Normal file
121
webui/static/video/video-downloads-page.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* SoulSync — Video Downloads page.
|
||||
*
|
||||
* Every grab from the video side lands here. Polls /downloads/active while the page
|
||||
* is open and renders live status: a progress bar while downloading, then the file's
|
||||
* final library destination when it completes. Self-contained; isolated .vdpg-* styling.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var URL_ACTIVE = '/api/video/downloads/active';
|
||||
var URL_CLEAR = '/api/video/downloads/clear';
|
||||
var _timer = null, _wired = false;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function toast(m, t) { if (typeof showToast === 'function') showToast(m, t); }
|
||||
function getJSON(u) {
|
||||
return fetch(u, { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
|
||||
var KIND_ICON = { movie: '🎬', show: '📺', episode: '📺', season: '📺', series: '📺', youtube: '▶️' };
|
||||
var STATUS = {
|
||||
downloading: { label: 'Downloading', cls: 'dl' },
|
||||
queued: { label: 'Queued', cls: 'q' },
|
||||
completed: { label: 'Completed', cls: 'ok' },
|
||||
failed: { label: 'Failed', cls: 'fail' }
|
||||
};
|
||||
|
||||
function fmtGB(bytes) {
|
||||
var gb = (bytes || 0) / (1024 * 1024 * 1024);
|
||||
return gb >= 0.1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round((bytes || 0) / (1024 * 1024)) + ' MB';
|
||||
}
|
||||
|
||||
function itemHTML(d) {
|
||||
var st = STATUS[d.status] || STATUS.downloading;
|
||||
var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬';
|
||||
var pct = Math.max(0, Math.min(100, d.progress || 0));
|
||||
var name = d.title || d.release_title || 'Download';
|
||||
var bar = (d.status === 'downloading' || d.status === 'queued')
|
||||
? '<div class="vdpg-bar"><div class="vdpg-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<div class="vdpg-pct">' + (Math.round(pct)) + '%</div>'
|
||||
: '';
|
||||
var meta = [];
|
||||
meta.push('<span>' + fmtGB(d.size_bytes) + '</span>');
|
||||
if (d.username) meta.push('<span>👤 ' + esc(d.username) + '</span>');
|
||||
if (d.status === 'completed' && d.dest_path) meta.push('<span class="vdpg-dest">→ ' + esc(d.dest_path) + '</span>');
|
||||
if (d.status === 'failed' && d.error) meta.push('<span class="vdpg-err">' + esc(d.error) + '</span>');
|
||||
return '<div class="vdpg-item vdpg-item--' + st.cls + '">' +
|
||||
'<div class="vdpg-ic">' + icon + '</div>' +
|
||||
'<div class="vdpg-body">' +
|
||||
'<div class="vdpg-row1"><span class="vdpg-name">' + esc(name) + '</span>' +
|
||||
'<span class="vdpg-pill vdpg-pill--' + st.cls + '">' + st.label + '</span></div>' +
|
||||
(d.release_title && d.release_title !== name ? '<div class="vdpg-rel" title="' + esc(d.release_title) + '">' + esc(d.release_title) + '</div>' : '') +
|
||||
bar +
|
||||
'<div class="vdpg-meta">' + meta.join('') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function render(list) {
|
||||
var host = document.querySelector('[data-vdpg-list]'); if (!host) return;
|
||||
list = list || [];
|
||||
var clearBtn = document.querySelector('[data-vdpg-clear]');
|
||||
var finished = list.filter(function (d) { return d.status === 'completed' || d.status === 'failed'; }).length;
|
||||
if (clearBtn) clearBtn.hidden = finished === 0;
|
||||
var active = list.filter(function (d) { return d.status === 'downloading' || d.status === 'queued'; }).length;
|
||||
var sub = document.querySelector('[data-vdpg-sub]');
|
||||
if (sub) sub.textContent = list.length
|
||||
? (active + ' active · ' + finished + ' finished')
|
||||
: "Everything you've grabbed from the video side";
|
||||
if (!list.length) {
|
||||
host.innerHTML = '<div class="vdpg-empty"><div class="vdpg-empty-ic">⤓</div>' +
|
||||
'<div class="vdpg-empty-t">No downloads yet</div>' +
|
||||
'<div class="vdpg-empty-s">Hit Grab on a search result and it\'ll show up here.</div></div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = list.map(itemHTML).join('');
|
||||
}
|
||||
|
||||
function load() {
|
||||
getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); });
|
||||
}
|
||||
|
||||
function anyActive() {
|
||||
return !!document.querySelector('.vdpg-item--dl, .vdpg-item--q');
|
||||
}
|
||||
|
||||
function start() {
|
||||
load();
|
||||
if (_timer) clearInterval(_timer);
|
||||
// Poll while open; the monitor moves files server-side regardless.
|
||||
_timer = setInterval(function () { load(); }, 2500);
|
||||
wire();
|
||||
}
|
||||
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
|
||||
|
||||
function wire() {
|
||||
if (_wired) return; _wired = true;
|
||||
var clearBtn = document.querySelector('[data-vdpg-clear]');
|
||||
if (clearBtn) clearBtn.addEventListener('click', function () {
|
||||
fetch(URL_CLEAR, { method: 'POST', headers: { Accept: 'application/json' } })
|
||||
.then(function () { toast('Cleared finished downloads', 'success'); load(); })
|
||||
.catch(function () { /* ignore */ });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('soulsync:video-page-shown', function (e) {
|
||||
if (e.detail === 'video-downloads') start(); else stop();
|
||||
});
|
||||
// A fresh grab elsewhere → if the page is open, reflect it quickly.
|
||||
document.addEventListener('soulsync:video-download-started', function () {
|
||||
if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(load, 400);
|
||||
});
|
||||
// Keep polling lively only while something is in flight (handled by the 2.5s timer);
|
||||
// anyActive() is exposed for future adaptive intervals.
|
||||
window._vdpgAnyActive = anyActive;
|
||||
})();
|
||||
|
|
@ -3310,3 +3310,61 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdl-src-block { display: flex; flex-direction: column; }
|
||||
.vdl-src-block .vdl-results { margin: 8px 0 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .vdl-res, .vdl-res-spin { animation: none !important; } }
|
||||
|
||||
/* ── Downloads page (.vdpg-*) ─────────────────────────────────────────────── */
|
||||
.vdpg-page { max-width: 1000px; margin: 0 auto; padding: 8px 4px 40px; }
|
||||
.vdpg-head { position: relative; margin-bottom: 22px; padding: 6px 2px; overflow: hidden; }
|
||||
.vdpg-head-glow { position: absolute; inset: -40% -10% auto -10%; height: 200px; pointer-events: none;
|
||||
background: radial-gradient(60% 100% at 30% 0%, rgba(var(--accent-rgb), 0.18), transparent 70%); }
|
||||
.vdpg-head-row { position: relative; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.vdpg-title { font-size: 34px; font-weight: 900; letter-spacing: -0.03em; margin: 0; color: #fff; }
|
||||
.vdpg-sub { margin: 6px 0 0; font-size: 13.5px; font-weight: 600; color: rgba(255, 255, 255, 0.5); }
|
||||
.vdpg-clear { flex-shrink: 0; padding: 9px 16px; border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.85);
|
||||
transition: all 0.15s ease; }
|
||||
.vdpg-clear:hover { background: rgba(239, 68, 68, 0.16); border-color: rgba(239, 68, 68, 0.45); color: #fecaca; }
|
||||
.vdpg-clear[hidden] { display: none; }
|
||||
.vdpg-list { display: flex; flex-direction: column; gap: 11px; }
|
||||
|
||||
.vdpg-item { display: flex; gap: 15px; padding: 15px 17px; border-radius: 16px; align-items: center;
|
||||
background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
animation: vdlRise 0.45s both; transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; }
|
||||
.vdpg-item:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.055); }
|
||||
.vdpg-item--dl { border-left: 3px solid rgb(var(--accent-rgb)); }
|
||||
.vdpg-item--ok { border-left: 3px solid #6cd391; }
|
||||
.vdpg-item--fail { border-left: 3px solid #ef4444; }
|
||||
.vdpg-item--q { border-left: 3px solid rgba(255, 255, 255, 0.3); }
|
||||
.vdpg-ic { flex-shrink: 0; width: 50px; height: 50px; border-radius: 13px; display: grid; place-items: center;
|
||||
font-size: 24px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.vdpg-body { flex: 1; min-width: 0; }
|
||||
.vdpg-row1 { display: flex; align-items: center; gap: 10px; }
|
||||
.vdpg-name { font-size: 15.5px; font-weight: 800; color: #fff; letter-spacing: -0.01em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vdpg-pill { flex-shrink: 0; font-size: 10px; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 999px; }
|
||||
.vdpg-pill--dl { background: rgba(var(--accent-rgb), 0.18); color: rgb(var(--accent-rgb)); animation: vdpgPulse 1.6s ease-in-out infinite; }
|
||||
.vdpg-pill--q { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6); }
|
||||
.vdpg-pill--ok { background: rgba(108, 211, 145, 0.18); color: #8fe7af; }
|
||||
.vdpg-pill--fail { background: rgba(239, 68, 68, 0.18); color: #fca5a5; }
|
||||
.vdpg-rel { font: 500 11px/1.3 'JetBrains Mono', ui-monospace, monospace; color: rgba(255, 255, 255, 0.4);
|
||||
margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vdpg-bar { margin-top: 10px; height: 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
|
||||
.vdpg-bar-fill { height: 100%; border-radius: 999px; transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), rgb(var(--accent-rgb))); position: relative; }
|
||||
.vdpg-bar-fill::after { content: ''; position: absolute; inset: 0; border-radius: 999px;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.35) 50%, transparent 70%);
|
||||
animation: vdpgShimmer 1.4s linear infinite; }
|
||||
.vdpg-pct { margin-top: 4px; font-size: 10.5px; font-weight: 800; color: rgba(255, 255, 255, 0.5); }
|
||||
.vdpg-meta { display: flex; flex-wrap: wrap; gap: 13px; margin-top: 8px; font-size: 11.5px; font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.55); }
|
||||
.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; }
|
||||
.vdpg-err { color: #fca5a5; }
|
||||
.vdpg-empty { text-align: center; padding: 70px 20px; color: rgba(255, 255, 255, 0.45); }
|
||||
.vdpg-empty-ic { font-size: 46px; opacity: 0.5; }
|
||||
.vdpg-empty-t { font-size: 17px; font-weight: 800; color: rgba(255, 255, 255, 0.7); margin-top: 12px; }
|
||||
.vdpg-empty-s { font-size: 13px; margin-top: 6px; }
|
||||
@keyframes vdpgPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
||||
@keyframes vdpgShimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vdpg-item, .vdpg-pill--dl, .vdpg-bar-fill::after { animation: none !important; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue