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:
parent
afe3d0d04d
commit
7e504e03c6
8 changed files with 386 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
tests/test_video_download_tracking.py
Normal file
73
tests/test_video_download_tracking.py
Normal 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
|
||||
|
|
@ -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'); });
|
||||
|
|
|
|||
|
|
@ -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) || 'Couldn’t 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: couldn’t 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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue