From a3adf5e63c536629738142e7d6b75a874f31407a Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 14:18:43 -0700 Subject: [PATCH] Video Manage-Workers modal: realign to the music modal's exact design The video modal reused music's shell but had drifted into a parallel mini-design: it emitted its own markup (.em-ph-main / .em-pause-btn / .em-item / .em-pg / .vem-logo / .vem-icon) styled separately in video-side.css, instead of the music classes that already exist in style.css. So the panel header, list rows, rail icons and pager all looked 'redesigned'. Now the modal emits music's real markup and inherits its global .em-* styling: - Panel header -> .em-hero (glow + .em-icon--lg + .em-pill + .em-ph-sub) - Unmatched list -> .em-row (status stripe, art+glyph, 'tried Nd ago', ghost Retry) - Rail icon -> .em-icon chip (with --i stagger); pager -> .em-pager + .em-btn - Pause -> .em-btn/.em-btn--go; coverage cards gain the First/Done/N-left badges - Open/close entrance+exit animation (.em-in / .em-closing), dropping .em-in after it plays so the 3s rail re-render doesn't replay the stagger - Panel sets --accent-rgb so music's accent-keyed rules recolor per worker Removed the now-dead bespoke CSS. Episodes stay a coverage card (per the backend: episode art cascades from a show match; /priority only accepts movie/show), so the top bar is correctly Movies/Shows/Auto. Music side untouched (shared classes are global; video overrides were all #vem-overlay-scoped or now deleted). --- .../static/video/video-enrichment-manager.js | 173 +++++++++++++----- webui/static/video/video-side.css | 106 +---------- 2 files changed, 132 insertions(+), 147 deletions(-) diff --git a/webui/static/video/video-enrichment-manager.js b/webui/static/video/video-enrichment-manager.js index 1dabf288..327f273f 100644 --- a/webui/static/video/video-enrichment-manager.js +++ b/webui/static/video/video-enrichment-manager.js @@ -49,6 +49,35 @@ } function byId(id) { return document.getElementById(id); } + // The music modal's circular accent icon chip (.em-icon), reused verbatim so + // the video rail/hero match it. Logo → , otherwise a colored glyph/letter. + function workerIcon(w, lg) { + var cls = 'em-icon' + (lg ? ' em-icon--lg' : ''); + var inner; + if (LOGOS[w.id]) { + var filt = w.id === 'tvdb' ? ' style="filter:invert(1)"' : ''; + inner = ''; + } else { + inner = '' + esc(w.glyph || w.name.charAt(0)) + ''; + } + return '' + inner + ''; + } + + // "3d ago" for a SQLite UTC timestamp; '' when never attempted (mirrors music). + function relTime(value) { + if (!value) return ''; + var raw = String(value); + var ts = Date.parse(raw.replace(' ', 'T') + (raw.indexOf('Z') >= 0 ? '' : 'Z')); + if (isNaN(ts)) return ''; + var secs = Math.max(0, (Date.now() - ts) / 1000); + if (secs < 60) return 'just now'; + var mins = secs / 60; if (mins < 60) return Math.floor(mins) + 'm ago'; + var hrs = mins / 60; if (hrs < 24) return Math.floor(hrs) + 'h ago'; + var days = hrs / 24; if (days < 30) return Math.floor(days) + 'd ago'; + var months = days / 30; if (months < 12) return Math.floor(months) + 'mo ago'; + return Math.floor(months / 12) + 'y ago'; + } + function statusInfo(s) { if (!s || !s.enabled) return { cls: 'disabled', label: 'Not configured' }; if (s.running && !s.paused && !s.idle) return { cls: 'running', label: 'Running' }; @@ -118,17 +147,14 @@ function renderRail() { var rail = byId('vem-rail'); if (!rail) return; - rail.innerHTML = WORKERS.map(function (w) { + rail.innerHTML = WORKERS.map(function (w, i) { var s = state.statuses[w.id]; var info = statusInfo(s); var pct = overallPct(s); var cov = pct == null ? '' : ''; - var icon = LOGOS[w.id] - ? '' - : '' + (w.glyph || '★') + ''; - return ''; @@ -140,17 +166,12 @@ } function renderYoutubePanel(panel) { + var w = workerDef('youtube') || {}; var s = state.statuses.youtube || {}; - var info = statusInfo(s); var prog = (s.progress && s.progress.channels) || {}; - var current = (s.current_item && s.current_item.name) - ? 'channel: ' + esc(s.current_item.name) + '' : ''; panel.innerHTML = - '
' + - '
' + - 'YouTube Dates' + info.label + '' + current + '
' + - '
' + - '
Fetches real upload dates for the YouTube channels you follow, so the channel page can group videos into year-seasons. Runs in the background when you follow or open a channel — bulk via a no-key proxy, falling back to per-video; cached so it\'s a one-time pass per channel.
' + + '
' + heroHtml(w, s) + '
' + + '
Fetches real upload dates for the YouTube channels you follow, so the channel page can group videos into year-seasons. Runs in the background when you follow or open a channel — bulk via YouTube\'s own InnerTube API, falling back to per-video; cached so it\'s a one-time pass per channel.
' + '
' + '
' + (prog.matched || 0) + 'channels enriched
' + '
' + (s.dates_cached || 0) + 'dates cached
' + @@ -162,13 +183,18 @@ var panel = byId('vem-panel'); if (!panel) return; // Theme the panel to the selected worker's accent (like the music modal). + // Music's shared .em-btn/.em-tab/.em-seg rules key off --accent-rgb, so set + // it here too (scoped to the panel) — that's what makes them recolor. var w = WORKERS.find(function (x) { return x.id === state.selected; }) || WORKERS[0]; panel.style.setProperty('--em-accent', w.color); panel.style.setProperty('--em-accent-rgb', w.rgb); + panel.style.setProperty('--accent-rgb', w.rgb); + panel.style.setProperty('--accent-light-rgb', w.rgb); if (state.selected === 'youtube') { renderYoutubePanel(panel); return; } panel.innerHTML = '
' + - ''; + '
' + + '
' + (isEpisode + ? 'Episode stills backfill automatically once their show is matched.' + : 'Failed lookups auto-retry later · “Retry” re-queues immediately.') + '
'; } function renderList() { var host = byId('vem-unmatched-list'); if (!host) return; var data = state.unmatched || { items: [], total: 0 }; + var isEpisode = state.kind === 'episode'; if (!data.items.length) { - host.innerHTML = '
Nothing unmatched here 🎉
'; + host.innerHTML = '
' + + (state.statusFilter === 'unmatched' ? '🎉' : '🔍') + '
' + + '
' + (state.statusFilter === 'unmatched' + ? 'Everything here is matched for this source.' + : 'Nothing matches this filter.') + '
'; } else { + // No per-item status flag, so colour the stripe from the active filter. + var rowCls = state.statusFilter === 'not_found' ? 'em-row em-row--nf' : 'em-row em-row--pend'; host.innerHTML = data.items.map(function (it) { - var poster = it.has_poster - ? '' - : '' + (GLYPH[state.kind] || '•') + ''; - return '
' + poster + - '' + esc(it.title) + '' + - '' + (it.year || '') + '' + - '
'; + var glyph = GLYPH[state.kind] || '•'; + var pic = it.has_poster + ? '' + : ''; + var img = '
' + glyph + pic + '
'; + var rel = relTime(it.last_attempted); + var meta = []; + if (it.year) meta.push('' + esc(String(it.year)) + ''); + meta.push('' + (rel ? 'tried ' + rel : 'never tried') + ''); + var action = isEpisode ? '' + : ''; + return '
' + img + + '
' + esc(it.title) + '
' + + '
' + meta.join(' ') + '
' + + '
' + action + '
'; }).join(''); } var pager = byId('vem-pager'); if (pager) { - var pages = Math.max(1, Math.ceil((data.total || 0) / state.pageSize)); - pager.innerHTML = (data.total || 0) > state.pageSize - ? '' + - '' + (state.page + 1) + ' / ' + pages + '' + - '' + var total = data.total || 0; + var from = total ? state.page * state.pageSize + 1 : 0; + var to = Math.min((state.page + 1) * state.pageSize, total); + pager.innerHTML = total > state.pageSize + ? '' + + '' + from + '–' + to + ' of ' + total.toLocaleString() + '' + + '' : ''; } } @@ -432,7 +502,15 @@ function open() { var overlay = ensureOverlay(); - overlay.classList.remove('hidden'); + overlay.classList.remove('hidden', 'em-closing'); + // Re-trigger the music modal's entrance animation even on reuse. Drop the + // class once it's played so the 3s rail re-render doesn't replay the + // per-row stagger (.em-in .em-worker-row) on every poll. + var modal = overlay.querySelector('.enrichment-manager-modal'); + if (modal) { + modal.classList.remove('em-in'); void modal.offsetWidth; modal.classList.add('em-in'); + setTimeout(function () { modal.classList.remove('em-in'); }, 700); + } document.body.classList.add('em-scroll-lock'); state.open = true; refreshAll().then(function () { @@ -456,8 +534,11 @@ var overlay = byId('vem-overlay'); state.open = false; if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } - if (overlay) overlay.classList.add('hidden'); document.body.classList.remove('em-scroll-lock'); + if (!overlay) return; + // Brief fade/scale-out (music's .em-closing), then hide. + overlay.classList.add('em-closing'); + setTimeout(function () { overlay.classList.add('hidden'); overlay.classList.remove('em-closing'); }, 170); } document.addEventListener('soulsync:video-open-workers', open); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 230c5dde..54a066a2 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -267,99 +267,11 @@ body[data-side="video"] .dashboard-header-sweep { .video-svc-logo { width: 18px; height: 18px; object-fit: contain; margin-right: 2px; } .video-svc-logo--tvdb { filter: invert(1); } -/* ── Video Manage-Workers modal: only the bits not covered by music's global - .em-* classes, ALL scoped to #vem-overlay so music is never affected. ───── */ -#vem-overlay .vem-logo { width: 22px; height: 22px; object-fit: contain; } -#vem-overlay .vem-logo--tvdb { filter: invert(1); } -#vem-overlay .vem-icon { - width: 34px; - height: 34px; - border-radius: 9px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 800; - color: #fff; - flex-shrink: 0; -} -#vem-overlay .em-panel-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; } -#vem-overlay .em-ph-main { display: flex; align-items: center; gap: 8px; font-size: 16px; } -#vem-overlay .em-ph-status { color: var(--text-secondary, #9aa0aa); font-size: 13px; } -#vem-overlay .em-ph-current { color: var(--text-secondary, #9aa0aa); font-size: 12px; opacity: 0.85; } -#vem-overlay .em-pause-btn { - margin-left: auto; - padding: 7px 16px; - border-radius: 9px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.06); - color: var(--text-primary, #e8e8ea); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} -#vem-overlay .em-pause-btn:disabled { opacity: 0.45; cursor: default; } -#vem-overlay .em-unmatched-controls { display: flex; align-items: center; justify-content: space-between; margin: 14px 0 8px; } -#vem-overlay .em-section-sub { color: var(--text-secondary, #9aa0aa); font-size: 13px; } -#vem-overlay .em-retry-all-btn { - padding: 6px 14px; - border-radius: 8px; - border: 1px solid rgba(var(--accent-rgb, 88 101 242), 0.4); - background: rgba(var(--accent-rgb, 88 101 242), 0.12); - color: rgb(var(--accent-light-rgb, var(--accent-rgb, 88 101 242))); - font-size: 12px; - font-weight: 600; - cursor: pointer; -} -#vem-overlay .em-item { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.05); - margin-bottom: 6px; -} -#vem-overlay .em-item-img { - width: 38px; - height: 56px; - object-fit: cover; - border-radius: 6px; - background: rgba(255, 255, 255, 0.06); - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - flex-shrink: 0; -} -#vem-overlay .em-item-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } -#vem-overlay .em-item-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#vem-overlay .em-item-sub { font-size: 12px; color: var(--text-secondary, #9aa0aa); } -#vem-overlay .em-item-retry { - padding: 5px 12px; - border-radius: 7px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: transparent; - color: var(--text-secondary, #9aa0aa); - font-size: 12px; - cursor: pointer; -} -#vem-overlay .em-item-retry:hover { color: #fff; border-color: rgba(255, 255, 255, 0.25); } -#vem-overlay .em-empty { padding: 30px; text-align: center; color: var(--text-secondary, #9aa0aa); } -#vem-overlay .em-pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 12px; } -#vem-overlay .em-pg { - width: 30px; - height: 30px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.05); - color: var(--text-primary, #e8e8ea); - cursor: pointer; -} -#vem-overlay .em-pg:disabled { opacity: 0.4; cursor: default; } -#vem-overlay .em-pg-info { font-size: 13px; color: var(--text-secondary, #9aa0aa); } +/* ── Video Manage-Workers modal ───────────────────────────────────────────── + The modal now emits the music modal's exact markup (.em-hero / .em-row / + .em-icon / .em-btn / .em-pager / .em-card), so it inherits music's global + .em-* styling with no bespoke rules here. Live-status glows are further down; + the YouTube-dates panel (its own no-queue layout) keeps its .vem-yt-* block. */ /* ── Subpages: one built video page shown at a time ────────────────────── */ /* Each real page is a .video-subpage inside #video-page-host; the controller @@ -685,13 +597,6 @@ body[data-side="video"] .dashboard-header-sweep { box-shadow: inset 0 0 0 1px rgba(var(--row-accent), 0.45), 0 0 22px rgba(var(--row-accent), 0.20); } -/* "Now processing" chip pulses in the worker's accent — clear live signal. */ -#vem-overlay .em-ph-current { - color: rgb(var(--em-accent-rgb)); - text-shadow: 0 0 12px rgba(var(--em-accent-rgb), 0.55); - animation: vemNowPulse 1.8s ease-in-out infinite; -} -@keyframes vemNowPulse { 0%, 100% { opacity: 0.78; } 50% { opacity: 1; } } /* Active "Process first" / kind selection glow in the worker accent. */ #vem-overlay .em-global-tabs button.active, #vem-overlay .em-card--current { @@ -849,7 +754,6 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); transition: transform 0.3s ease; } .video-enrich-button:hover .video-enrich-glyph { transform: scale(1.1); } -#vem-overlay .vem-glyph { font-size: 20px; line-height: 1; } /* ══════════════════════════════════════════════════════════════════════════