diff --git a/api/video/downloads.py b/api/video/downloads.py index 893c867d..54a6244b 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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 diff --git a/tests/test_video_api.py b/tests/test_video_api.py index f3a53f28..6da0b9b3 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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 diff --git a/tests/test_video_download_tracking.py b/tests/test_video_download_tracking.py new file mode 100644 index 00000000..60b269c7 --- /dev/null +++ b/tests/test_video_download_tracking.py @@ -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 diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 19bf0b0d..d201dff6 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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 = + '' + + '' + ic + esc(label) + pctTxt + ' · Track ↗'; + } + 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'); }); diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 8e8208c8..07547fa4 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -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 '▲ ' + (r.seeders || 0) + ' seeders'; } - // 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 ? '✓ Meets profile' : '✕ ' + esc(r.rejected || 'Filtered') + ''; - return '
' + - '
' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '
' + - '
' + - '
' + - '' + esc(summary) + '' + tags + - verdict + - '
' + - '
' + esc(r.title) + '
' + - '
' + - '💾' + r.size_gb + ' GB' + - resAvailHTML(r) + - (r.group ? '' + esc(r.group) + '' : '') + + var grab = (r.accepted && r.username) + ? '' + : ''; + return '
' + + '
' + + '
' + + '' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '
' + + '
' + + '
' + + '' + esc(summary) + '' + tags + + verdict + + '
' + + '
' + esc(r.title) + '
' + + '
' + + '💾' + r.size_gb + ' GB' + + resAvailHTML(r) + + (r.group ? '' + esc(r.group) + '' : '') + + '
' + '
' + + grab + '
' + - (r.accepted && r.username ? '' : '') + '
'; } + // 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 = + '
' + + 'Starting…' + + '' + + '' + + '
' + + '
'; + 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') + ? '' : ''; + 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 diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 9cc48d41..fbdde21d 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -650,5 +650,5 @@ return parts.length ? '' + parts.join('') + '' : ''; } - window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal, cardButton: cardButton }; + window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal, close: closeModal, cardButton: cardButton }; })(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index adb942bd..4d8a5fd5 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; } diff --git a/webui/static/video/video-side.js b/webui/static/video/video-side.js index 093951b8..fa5aa4d9 100644 --- a/webui/static/video/video-side.js +++ b/webui/static/video/video-side.js @@ -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).