video downloads: live tracking on the grabbed result + movie detail, redesigned cards

After a grab (manual or Auto) the user now SEES what happened and can follow it:
- The chosen result card is spotlighted (Auto scrolls it into view) and grows a live
  tracker: a progress bar that follows the real download + a 'Track on Downloads ↗'
  button that closes the modal and jumps to the Downloads page. Polls the new
  GET /api/video/downloads/status?id= until the download reaches a terminal state.
- A movie's detail page shows a live download chip (progress bar + %) for any in-flight
  download of that title; clicking it jumps to Downloads. Looks up by media identity
  via /downloads/status?media_id=&media_source=, polls while active, clears on
  navigate-away. (video-detail.js)
- Result CARDS redesigned (the part you didn't like): a column card with a colour
  resolution badge, a green 'accepted' edge, cleaner hierarchy, and the grab button is
  now an accent 'Get' pill matching the source Auto button language.

Plumbing: new lightweight /downloads/status endpoint (by id, or by media for detail
pages); soulsync:video-navigate event in video-side.js to reach a top-level page from
anywhere; VideoGet.close exported so the tracker can dismiss the modal. All video-only.

Tests: status endpoint (by id + by media + null cases) in test_video_api.py;
tracking/detail/nav wiring in test_video_download_tracking.py. node --check clean;
isolation guards green.
This commit is contained in:
BoulderBadgeDad 2026-06-20 09:41:05 -07:00
parent afe3d0d04d
commit 7e504e03c6
8 changed files with 386 additions and 44 deletions

View file

@ -292,6 +292,33 @@ def register_routes(bp):
ensure_started(get_video_db) # also (re)start the monitor when the page is open
return jsonify({"downloads": db.list_video_downloads()})
@bp.route("/downloads/status", methods=["GET"])
def video_downloads_status():
"""Lightweight live-tracking lookup — used by the Download modal's result
card (by ``id``) and a movie/show detail page (by ``media_id`` +
``media_source``, returning that title's most relevant download so the page
can show live progress). Returns ``{"download": {...}|null}``."""
from . import get_video_db
from core.video.download_monitor import ensure_started
db = get_video_db()
ensure_started(get_video_db)
dl_id = request.args.get("id")
if dl_id:
try:
return jsonify({"download": db.get_video_download(int(dl_id))})
except (TypeError, ValueError):
return jsonify({"download": None})
media_id = request.args.get("media_id")
if media_id:
media_source = request.args.get("media_source")
match = [r for r in db.list_video_downloads()
if str(r.get("media_id")) == str(media_id)
and (not media_source or r.get("media_source") == media_source)]
# list_video_downloads orders active-first then newest — so an active
# download (or else the most recent) for this title is simply match[0].
return jsonify({"download": match[0] if match else None})
return jsonify({"download": None})
@bp.route("/downloads/cancel", methods=["POST"])
def video_downloads_cancel():
from . import get_video_db

View file

@ -541,6 +541,45 @@ def test_downloads_grab_and_active(tmp_path, monkeypatch):
videoapi._video_db = None
def test_downloads_status_lookup_by_id_and_media(tmp_path, monkeypatch):
"""The live-tracking endpoint: the modal result card looks up by download id,
a movie detail page looks up by media_id+media_source. Powers both progress UIs."""
import api.video as videoapi
import core.video.download_monitor as mon
import core.video.slskd_download as slskd
from database.video_database import VideoDatabase
monkeypatch.setattr(slskd, "start_download", lambda *a, **k: {"ok": True})
monkeypatch.setattr(mon, "ensure_started", lambda *a, **k: None)
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
db.set_setting("movies_path", "/media/movies")
videoapi._video_db = db
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
client = app.test_client()
try:
gid = client.post("/api/video/downloads/grab", json={
"kind": "movie", "title": "The Matrix", "source": "soulseek",
"username": "neo", "filename": "m.mkv", "size_bytes": 8, "quality_label": "1080p",
"media_id": 42, "media_source": "library"}).get_json()["id"]
# by id
byid = client.get("/api/video/downloads/status?id=" + str(gid)).get_json()["download"]
assert byid and byid["id"] == gid and byid["status"] == "downloading"
# by media identity (what the movie detail page uses)
bym = client.get("/api/video/downloads/status?media_id=42&media_source=library").get_json()["download"]
assert bym and bym["id"] == gid
# a different movie / unknown id → null, never an error
assert client.get("/api/video/downloads/status?media_id=999&media_source=library").get_json()["download"] is None
assert client.get("/api/video/downloads/status?id=123456").get_json()["download"] is None
assert client.get("/api/video/downloads/status").get_json()["download"] is None
finally:
videoapi._video_db = None
def test_slskd_config_shared_via_config_manager(tmp_path, monkeypatch):
import api.video as videoapi
import config.settings as cfg

View file

@ -0,0 +1,73 @@
"""Live download tracking wiring: after a grab the user must (a) see WHICH release
was selected, (b) get a live progress bar on it, and (c) get a button to the
Downloads page and a movie's detail page shows live progress for an in-flight
download that jumps back to Downloads.
Backed by GET /api/video/downloads/status (tested in test_video_api.py
::test_downloads_status_lookup_by_id_and_media). These pin the frontend wiring so
a refactor can't quietly unhook the tracker, the detail chip, or the navigation.
"""
from __future__ import annotations
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_VIEW = (_ROOT / "webui" / "static" / "video" / "video-download-view.js").read_text(encoding="utf-8")
_DETAIL = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")
_SIDE = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
_GETMODAL = (_ROOT / "webui" / "static" / "video" / "video-get-modal.js").read_text(encoding="utf-8")
_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8")
# --- result card: redesign + selected/tracking state ----------------------
def test_result_card_redesigned_as_column_with_get_button():
assert 'vdl-res-main' in _VIEW # row wrapper so a tracker can dock below
assert 'data-vdl-card="' in _VIEW # addressable card (auto-pick targets it)
assert 'vdl-res-grab-ic' in _VIEW # the grab button is now a labelled "Get" pill
assert '.vdl-res-main' in _CSS
def test_grab_begins_live_tracking_on_the_card():
assert 'function beginTracking(' in _VIEW
assert 'vdl-res--grabbed' in _VIEW
assert 'data-vdl-track-fill' in _VIEW # the progress bar fill
assert "/api/video/downloads/status?id=" in _VIEW
# both the manual grab and the auto-pick start tracking the chosen card
assert _VIEW.count('beginTracking(') >= 3 # 1 def + doGrab + _autoPick
def test_track_button_goes_to_downloads_and_closes_modal():
assert 'Track on Downloads' in _VIEW
assert 'function gotoDownloads(' in _VIEW
assert 'VideoGet.close' in _VIEW
assert "'soulsync:video-navigate'" in _VIEW
assert '.vdl-res-track' in _CSS
def test_modal_exposes_close():
assert 'close: closeModal' in _GETMODAL
# --- movie detail page: live download chip --------------------------------
def test_detail_page_watches_movie_download():
assert 'function watchMovieDownload(' in _DETAIL
assert '/api/video/downloads/status?media_id=' in _DETAIL
assert 'data-vd-dlchip' in _DETAIL
# the chip jumps to the Downloads page
assert "'soulsync:video-navigate'" in _DETAIL
assert '.vd-dlchip' in _CSS
def test_detail_watch_started_for_library_movies():
assert 'watchMovieDownload(id)' in _DETAIL
assert 'stopMovieDownloadWatch()' in _DETAIL # cleared on (re)load / navigate away
# --- navigation plumbing --------------------------------------------------
def test_video_side_handles_navigate_event():
assert "'soulsync:video-navigate'" in _SIDE
assert 'navigate(pageId)' in _SIDE

View file

@ -1246,11 +1246,65 @@
}
// ── movie detail (flat) ───────────────────────────────────────────────────
// ── live download status (a movie being grabbed shows progress here, and the
// chip jumps to the Downloads page) ─────────────────────────────────────
var _dlWatch = { id: null, t: null };
function stopMovieDownloadWatch() {
if (_dlWatch.t) { clearTimeout(_dlWatch.t); _dlWatch.t = null; }
_dlWatch.id = null;
var c = q('[data-vd-dlchip]'); if (c) c.remove();
}
function renderMovieDownloadChip(dl) {
var a = q('[data-vd-actions]'); if (!a) return;
var chip = q('[data-vd-dlchip]');
var show = dl && ['downloading', 'queued', 'searching', 'completed', 'failed'].indexOf(dl.status) > -1;
if (!show) { if (chip) chip.remove(); return; }
if (!chip) {
chip = document.createElement('button');
chip.type = 'button';
chip.setAttribute('data-vd-dlchip', '');
chip.title = 'Open the Downloads page';
chip.addEventListener('click', function () {
document.dispatchEvent(new CustomEvent('soulsync:video-navigate', { detail: 'video-downloads' }));
});
a.parentNode.insertBefore(chip, a); // sits above the action buttons (renderActions won't wipe it)
}
var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0));
if (st === 'completed') pct = 100;
chip.className = 'vd-dlchip ' + (st === 'completed' ? 'is-done' : (st === 'failed' ? 'is-fail' : 'is-active'));
var label = st === 'completed' ? 'Downloaded' : st === 'failed' ? 'Download failed'
: st === 'searching' ? 'Finding a release…' : st === 'queued' ? 'Queued' : 'Downloading';
var pctTxt = (st === 'downloading' || st === 'queued') ? ' · ' + pct + '%' : '';
var ic = st === 'completed' ? '✓ ' : st === 'failed' ? '✕ ' : '⤓ ';
chip.innerHTML =
'<span class="vd-dlchip-bar" style="width:' + pct + '%"></span>' +
'<span class="vd-dlchip-txt">' + ic + esc(label) + pctTxt + '<span class="vd-dlchip-go"> · Track ↗</span></span>';
}
function watchMovieDownload(id) {
stopMovieDownloadWatch();
_dlWatch.id = id;
(function tick() {
if (currentId !== id || currentKind !== 'movie') return; // navigated away → stop
fetch('/api/video/downloads/status?media_id=' + encodeURIComponent(id) +
'&media_source=' + encodeURIComponent(currentSource || 'library'),
{ headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (currentId !== id || currentKind !== 'movie') return;
var dl = res && res.download;
renderMovieDownloadChip(dl);
var active = dl && ['downloading', 'queued', 'searching'].indexOf(dl.status) > -1;
if (active) _dlWatch.t = setTimeout(tick, 1800);
}).catch(function () { /* keep last state */ });
})();
}
function loadMovie(id, source) {
currentKind = 'movie'; currentSource = source || 'library';
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
stopMovieDownloadWatch(); // clear any prior movie's chip
showLoading(true);
resetExtras();
var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = '';
@ -1272,6 +1326,7 @@
} else {
maybeRefreshMovie(id);
loadExtras('movie', id);
watchMovieDownload(id); // live download progress chip (if any)
}
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });

View file

@ -336,16 +336,16 @@
// 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 card = btn.closest('.vdl-res');
var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return;
btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…';
btn.disabled = true; btn.classList.add('vdl-res-grab--busy');
sendGrab(buildGrabPayload(panel, r)).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');
beginTracking(card, res.id); // selected card → live tracker + Track button
document.dispatchEvent(new CustomEvent('soulsync:video-download-started'));
} else {
btn.disabled = false; btn.textContent = '⤓';
btn.disabled = false; btn.classList.remove('vdl-res-grab--busy');
toast((res && res.error) || 'Couldnt start the download', 'error');
}
});
@ -367,23 +367,25 @@
toast('Auto: no release met your quality profile', 'error');
return;
}
// Highlight the card we're auto-grabbing so the choice is transparent.
var cards = panel.querySelectorAll('.vdl-res');
var card = cards[bestIdx];
if (card) card.classList.add('vdl-res--auto');
// Spotlight the card we're auto-grabbing so the choice is obvious, and
// scroll it into view (it may be below the fold among many results).
var card = panel.querySelector('[data-vdl-card="' + bestIdx + '"]');
if (card) {
card.classList.add('vdl-res--auto');
if (card.scrollIntoView) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
var gbtn = card && card.querySelector('[data-vdl-grab]');
if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); gbtn.textContent = '…'; }
if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); }
if (statusEl) { statusEl.textContent = 'Auto-grabbing'; statusEl.className = 'vdl-src-status vdl-src-status--scanning'; }
toast('Auto-grabbing best: ' + (best.quality_label || best.title || 'release'), 'info');
toast('Auto-picked best: ' + (best.quality_label || best.title || 'release'), 'info');
sendGrab(buildGrabPayload(panel, best)).then(function (res) {
if (gbtn) gbtn.classList.remove('vdl-res-grab--busy');
if (res && res.ok) {
if (gbtn) { gbtn.textContent = '✓'; gbtn.classList.add('vdl-res-grab--done'); }
if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; }
toast('Sent to Downloads', 'success');
beginTracking(card, res.id); // chosen card → live tracker + Track button
document.dispatchEvent(new CustomEvent('soulsync:video-download-started'));
} else {
if (gbtn) { gbtn.disabled = false; gbtn.textContent = '⤓'; }
if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); }
if (statusEl) { statusEl.textContent = 'Ready'; statusEl.className = 'vdl-src-status'; }
toast((res && res.error) || 'Auto: couldnt start the download', 'error');
}
@ -408,8 +410,10 @@
return '<span class="vdl-res-stat vdl-res-seed">▲ ' + (r.seeders || 0) + ' seeders</span>';
}
// 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.
// Result card: a colour-coded resolution badge anchors the left; the headline is
// a plain-English quality summary with the verdict pill; the raw release name is
// demoted to a mono one-liner; a stat strip (size / uploader / group) sits below.
// The card is a column so a live download tracker can drop in under it on grab.
function resultCardHTML(r, i) {
var summary = [SRC_LABEL[r.source] || r.source,
r.codec ? String(r.codec).toUpperCase() : '',
@ -420,24 +424,99 @@
var verdict = r.accepted
? '<span class="vdl-res-verdict vdl-res-verdict--ok">✓ Meets profile</span>'
: '<span class="vdl-res-verdict vdl-res-verdict--no" title="' + esc(r.rejected || '') + '">✕ ' + esc(r.rejected || 'Filtered') + '</span>';
return '<div class="vdl-res' + (r.accepted ? '' : ' vdl-res--rejected') + '">' +
'<div class="vdl-res-res vdl-res-res--' + resKind(r.resolution) + '">' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '</div>' +
'<div class="vdl-res-body">' +
'<div class="vdl-res-line1">' +
'<span class="vdl-res-summary">' + esc(summary) + '</span>' + tags +
verdict +
'</div>' +
'<div class="vdl-res-name" title="' + esc(r.title) + '">' + esc(r.title) + '</div>' +
'<div class="vdl-res-meta">' +
'<span class="vdl-res-stat"><span class="vdl-res-ico">💾</span>' + r.size_gb + ' GB</span>' +
resAvailHTML(r) +
(r.group ? '<span class="vdl-res-stat vdl-res-grp">' + esc(r.group) + '</span>' : '') +
var grab = (r.accepted && r.username)
? '<button class="vdl-res-grab" type="button" data-vdl-grab="' + i + '" title="Download this release">' +
'<span class="vdl-res-grab-ic" aria-hidden="true">⤓</span><span class="vdl-res-grab-tx">Get</span></button>'
: '';
return '<div class="vdl-res' + (r.accepted ? ' vdl-res--ok' : ' vdl-res--rejected') + '" data-vdl-card="' + i + '">' +
'<div class="vdl-res-main">' +
'<div class="vdl-res-res vdl-res-res--' + resKind(r.resolution) + '">' +
'<span class="vdl-res-res-txt">' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '</span></div>' +
'<div class="vdl-res-body">' +
'<div class="vdl-res-line1">' +
'<span class="vdl-res-summary">' + esc(summary) + '</span>' + tags +
verdict +
'</div>' +
'<div class="vdl-res-name" title="' + esc(r.title) + '">' + esc(r.title) + '</div>' +
'<div class="vdl-res-meta">' +
'<span class="vdl-res-stat"><span class="vdl-res-ico">💾</span>' + r.size_gb + ' GB</span>' +
resAvailHTML(r) +
(r.group ? '<span class="vdl-res-stat vdl-res-grp">' + esc(r.group) + '</span>' : '') +
'</div>' +
'</div>' +
grab +
'</div>' +
(r.accepted && r.username ? '<button class="vdl-res-grab" type="button" data-vdl-grab="' + i + '" title="Grab this release">⤓</button>' : '') +
'</div>';
}
// States the result-card tracker shows while a grabbed release downloads.
var TRACK_LABEL = { downloading: 'Downloading', queued: 'Queued',
searching: 'Finding another release…', completed: 'Downloaded', failed: 'Failed', cancelled: 'Cancelled' };
var TRACK_DONE = { completed: 1, failed: 1, cancelled: 1 };
// Close the modal (if any) and jump to the Downloads page.
function gotoDownloads() {
if (window.VideoGet && VideoGet.close) VideoGet.close();
document.dispatchEvent(new CustomEvent('soulsync:video-navigate', { detail: 'video-downloads' }));
}
// After a grab, turn the chosen card into a live tracker: a progress bar that
// follows the real download + a button that jumps to the Downloads page. Polls
// /downloads/status?id= until the download reaches a terminal state.
function beginTracking(card, dlId) {
if (!card) return;
card.classList.add('vdl-res--grabbed');
var gb = card.querySelector('[data-vdl-grab]'); if (gb) gb.remove();
var main = card.querySelector('.vdl-res-main') || card;
var foot = card.querySelector('[data-vdl-track]');
if (!foot) {
foot = document.createElement('div');
foot.className = 'vdl-res-track vdl-res-track--active';
foot.setAttribute('data-vdl-track', '');
foot.innerHTML =
'<div class="vdl-res-track-head">' +
'<span class="vdl-res-track-state" data-vdl-track-state><span class="vdl-res-track-spin"></span>Starting…</span>' +
'<span class="vdl-res-track-pct" data-vdl-track-pct></span>' +
'<button class="vdl-res-track-go" type="button" data-vdl-track-go>Track on Downloads ↗</button>' +
'</div>' +
'<div class="vdl-res-track-bar"><span class="vdl-res-track-fill" data-vdl-track-fill></span></div>';
if (main.nextSibling) card.insertBefore(foot, main.nextSibling); else card.appendChild(foot);
var go = foot.querySelector('[data-vdl-track-go]');
if (go) go.addEventListener('click', gotoDownloads);
}
_trackPoll(card, foot, dlId);
}
function _trackPoll(card, foot, dlId) {
if (foot._t) { clearTimeout(foot._t); foot._t = null; }
function tick() {
if (!card.isConnected) return; // modal closed → stop
getJSON('/api/video/downloads/status?id=' + encodeURIComponent(dlId)).then(function (d) {
if (!card.isConnected) return;
var dl = d && d.download;
if (!dl) { foot._t = setTimeout(tick, 2000); return; }
var st = dl.status;
var pct = Math.max(0, Math.min(100, dl.progress || 0));
if (st === 'completed') pct = 100;
var active = !(st in TRACK_DONE);
foot.className = 'vdl-res-track vdl-res-track--' +
(st === 'completed' ? 'done' : (st === 'failed' || st === 'cancelled' ? 'fail' : 'active'));
var fill = foot.querySelector('[data-vdl-track-fill]'); if (fill) fill.style.width = pct + '%';
var pctEl = foot.querySelector('[data-vdl-track-pct]');
if (pctEl) pctEl.textContent = (st === 'downloading' || st === 'queued') ? pct + '%' : '';
var stEl = foot.querySelector('[data-vdl-track-state]');
if (stEl) {
var spin = (st === 'downloading' || st === 'queued' || st === 'searching')
? '<span class="vdl-res-track-spin"></span>' : '';
var ic = st === 'completed' ? '✓ ' : (st === 'failed' || st === 'cancelled' ? '✕ ' : '');
stEl.innerHTML = spin + ic + esc(TRACK_LABEL[st] || 'Downloading');
}
if (active) foot._t = setTimeout(tick, 1700);
});
}
tick();
}
// ── TV show download view ─────────────────────────────────────────────────
// A season→episode picker (everything you're missing pre-ticked), each season
// and episode searchable inline across your sources, plus a bulk "Search N

View file

@ -650,5 +650,5 @@
return parts.length ? '<span class="vcard-ctrls">' + parts.join('') + '</span>' : '';
}
window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal, cardButton: cardButton };
window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal, close: closeModal, cardButton: cardButton };
})();

View file

@ -448,6 +448,28 @@ body[data-side="video"] .dashboard-header-sweep {
(.library-artist-watchlist-btn / .discog-download-btn / .artist-hero-badge);
only the billboard-specific bits live here. */
.vd-actions { margin-bottom: 16px; }
/* live download status chip sits above the action buttons when a movie on this
page is downloading; the bar fills with progress and it jumps to Downloads. */
.vd-dlchip { position: relative; overflow: hidden; display: inline-flex; align-items: center; gap: 0;
margin-bottom: 12px; padding: 9px 15px; border-radius: 11px; cursor: pointer; max-width: 100%;
border: 1px solid rgba(var(--accent-rgb), 0.45); background: rgba(var(--accent-rgb), 0.1);
color: #fff; font-size: 13px; font-weight: 800; letter-spacing: 0.01em;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s; animation: vdlRise 0.4s both; }
.vd-dlchip:hover { transform: translateY(-1px); border-color: rgba(var(--accent-rgb), 0.7);
box-shadow: 0 8px 20px -10px rgba(var(--accent-rgb), 0.9); }
.vd-dlchip-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 11px;
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.42), rgba(var(--accent-rgb), 0.22));
transition: width 0.5s ease; z-index: 0; }
.vd-dlchip-txt { position: relative; z-index: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vd-dlchip-go { opacity: 0.78; font-weight: 700; }
.vd-dlchip.is-active { animation: vdlRise 0.4s both, vdlChipGlow 2.6s ease-in-out 0.5s infinite; }
.vd-dlchip.is-done { border-color: rgba(108, 211, 145, 0.5); background: rgba(108, 211, 145, 0.12); }
.vd-dlchip.is-done .vd-dlchip-bar { background: linear-gradient(90deg, rgba(108, 211, 145, 0.4), rgba(108, 211, 145, 0.22)); }
.vd-dlchip.is-fail { border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.1); }
.vd-dlchip.is-fail .vd-dlchip-bar { background: linear-gradient(90deg, rgba(245, 158, 11, 0.34), rgba(245, 158, 11, 0.18)); }
@keyframes vdlChipGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
50% { box-shadow: 0 0 16px -3px rgba(var(--accent-rgb), 0.6); } }
.vd-links { margin-bottom: 16px; }
.vd-links .artist-hero-badge[title="TVDB"] img { filter: invert(1); }
@ -3338,20 +3360,30 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdl-res-demo { background: rgba(245, 158, 11, 0.14); color: #fbcd7a; }
.vdl-res-err { color: #fbcd7a; }
/* result row: [resolution tile] [body: summary + name + meta] [grab] */
.vdl-res { display: flex; align-items: stretch; gap: 12px; border-radius: 13px;
border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.035);
padding: 11px 12px; margin-bottom: 8px; animation: vdlRise 0.4s both;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; }
.vdl-res:hover { border-color: rgba(var(--accent-rgb), 0.4); background: rgba(255, 255, 255, 0.06); transform: translateY(-1px); }
/* ── result card: redesigned (column so a live tracker can dock under it) ──── */
.vdl-res { position: relative; display: flex; flex-direction: column; border-radius: 14px; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.022));
margin-bottom: 9px; animation: vdlRise 0.4s both;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.2s ease; }
.vdl-res-main { display: flex; align-items: center; gap: 13px; padding: 11px 13px; }
.vdl-res:hover { border-color: rgba(var(--accent-rgb), 0.42); transform: translateY(-1px);
box-shadow: 0 14px 28px -18px rgba(0, 0, 0, 0.9); }
.vdl-res--rejected { opacity: 0.5; }
.vdl-res--rejected:hover { opacity: 0.82; }
/* resolution tile — the quick-scan anchor */
.vdl-res-res { flex-shrink: 0; width: 52px; display: flex; align-items: center; justify-content: center;
border-radius: 10px; font-size: 14px; font-weight: 900; letter-spacing: -0.02em; color: #06210f; }
.vdl-res--rejected:hover { opacity: 0.85; }
/* accepted cards carry a faint green edge so "good" reads at a glance */
.vdl-res--ok::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: linear-gradient(#6cd391, rgba(108, 211, 145, 0.35)); box-shadow: 0 0 14px 0 rgba(108, 211, 145, 0.45); }
.vdl-res--grabbed { border-color: rgba(var(--accent-rgb), 0.6); }
/* resolution badge — the quick-scan anchor */
.vdl-res-res { flex-shrink: 0; width: 56px; height: 48px; display: grid; place-items: center; border-radius: 12px;
font-weight: 900; letter-spacing: -0.02em; color: #06210f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 5px 14px -7px rgba(0, 0, 0, 0.75); }
.vdl-res-res-txt { font-size: 14.5px; }
.vdl-res-res--4k { background: linear-gradient(140deg, #ffd76a, #f0a830); }
.vdl-res-res--1080 { background: linear-gradient(140deg, rgba(var(--accent-rgb), 1), rgba(var(--accent-rgb), 0.7)); }
.vdl-res-res--720 { background: linear-gradient(140deg, #8fb3ff, #5f86d6); }
.vdl-res-res--sd { background: linear-gradient(140deg, rgba(255,255,255,0.4), rgba(255,255,255,0.22)); color: #11141a; }
.vdl-res-res--sd { background: linear-gradient(140deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.22)); color: #11141a; }
.vdl-res-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.vdl-res-line1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vdl-res-summary { font-size: 13.5px; font-weight: 800; color: #fff; letter-spacing: -0.01em; }
@ -3369,15 +3401,45 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdl-res-ico { font-size: 11px; opacity: 0.8; }
.vdl-res-seed { color: #8fe7af; }
.vdl-res-grp { font-family: 'JetBrains Mono', ui-monospace, monospace; color: rgba(255, 255, 255, 0.4); font-weight: 600; }
.vdl-res-grab { flex-shrink: 0; align-self: center; width: 34px; height: 34px; border-radius: 10px; cursor: pointer;
font-size: 16px; font-weight: 900; background: rgb(var(--accent-rgb)); color: #06210f; border: none;
transition: filter 0.15s ease, transform 0.15s ease; }
.vdl-res-grab:hover { filter: brightness(1.12); transform: translateY(-1px) scale(1.04); }
/* Get = the accent hero pill (same language as the source Auto button) */
.vdl-res-grab { flex-shrink: 0; align-self: center; display: inline-flex; align-items: center; gap: 6px;
padding: 9px 14px; border-radius: 11px; cursor: pointer; font-size: 12px; font-weight: 850; letter-spacing: 0.01em;
color: #06210f; border: none;
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.72));
box-shadow: 0 5px 16px -7px rgb(var(--accent-rgb)), inset 0 1px 0 rgba(255, 255, 255, 0.32);
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.2s ease; }
.vdl-res-grab:hover { transform: translateY(-1px); filter: brightness(1.06);
box-shadow: 0 8px 20px -7px rgb(var(--accent-rgb)), inset 0 1px 0 rgba(255, 255, 255, 0.4); }
.vdl-res-grab-ic { font-size: 14px; line-height: 1; }
.vdl-res-grab--busy { opacity: 0.7; cursor: progress; }
.vdl-btn--busy { opacity: 0.7; cursor: progress; }
/* live download tracker docked under the grabbed card */
.vdl-res-track { padding: 10px 13px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.22); animation: vdlRise 0.3s both; }
.vdl-res-track-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.vdl-res-track-state { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; color: #fff; }
.vdl-res-track-pct { font-size: 12px; font-weight: 800; color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; }
.vdl-res-track-go { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 9px;
cursor: pointer; font-size: 11.5px; font-weight: 800; color: #fff; background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18); transition: background 0.15s, border-color 0.15s, transform 0.15s; }
.vdl-res-track-go:hover { background: rgba(255, 255, 255, 0.14); border-color: rgba(255, 255, 255, 0.32); transform: translateY(-1px); }
.vdl-res-track-bar { height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
.vdl-res-track-fill { display: block; height: 100%; width: 0; border-radius: 999px;
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgb(var(--accent-rgb)));
box-shadow: 0 0 10px 0 rgba(var(--accent-rgb), 0.6); transition: width 0.5s ease; }
.vdl-res-track--done .vdl-res-track-fill { background: linear-gradient(90deg, #58d39a, #6cd391); box-shadow: 0 0 10px 0 rgba(108, 211, 145, 0.6); }
.vdl-res-track--fail .vdl-res-track-fill { background: linear-gradient(90deg, #f0a830, #fbcd7a); box-shadow: none; }
.vdl-res-track--done .vdl-res-track-state { color: #8fe7af; }
.vdl-res-track--fail .vdl-res-track-state { color: #fbcd7a; }
.vdl-res-track-spin { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: #fff; animation: vdlSpin 0.7s linear infinite; }
/* per-source block (row + its own results) */
.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; } }
@media (prefers-reduced-motion: reduce) {
.vdl-res, .vdl-res-spin, .vdl-res-track, .vdl-res-track-spin, .vd-dlchip, .vd-dlchip.is-active { animation: none !important; }
}
/* ── Downloads page — reuses the music .adl-* layout; these are the video-only bits ── */
.vdpg-wrap { padding: 4px 0 40px; }

View file

@ -344,6 +344,13 @@
})(gotos[k]);
}
// Jump to a top-level video page from anywhere (e.g. a "Track on Downloads"
// button in the download modal / a detail page's live-progress chip).
document.addEventListener('soulsync:video-navigate', function (e) {
var pageId = e && e.detail && (e.detail.page || e.detail);
if (typeof pageId === 'string') navigate(pageId);
});
// Drill-in: a card fires soulsync:video-open-detail {kind, id, source}. We
// navigate to the matching detail subpage (video-detail.js loads the data)
// and push a real URL — unless we're restoring from the URL (_restore).