diff --git a/database/video_database.py b/database/video_database.py index 83bf1dc1..baf382b8 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1456,7 +1456,7 @@ class VideoDatabase: }.get(sort, title_key) if is_shows: - select = ("SELECT s.id, s.title, s.year, s.tmdb_id, " + select = ("SELECT s.id, s.title, s.year, s.tmdb_id, s.status, " "(s.poster_url IS NOT NULL AND s.poster_url <> '') AS has_poster, " "(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, " "(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count " diff --git a/webui/index.html b/webui/index.html index aaf16768..968241ff 100644 --- a/webui/index.html +++ b/webui/index.html @@ -9557,6 +9557,8 @@ + + diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js new file mode 100644 index 00000000..d2185be2 --- /dev/null +++ b/webui/static/video/video-get-modal.js @@ -0,0 +1,160 @@ +/* + * SoulSync — Video "get" button + detail/download modal (shared). + * + * The terminal-content counterpart to the watchlist eye: movies and ENDED shows + * can't be "watched for new episodes," so instead of an eye they get a download + * symbol that opens a rich detail modal — the future home of "Add to Wishlist" + * / "Download". v1 is VISUAL ONLY: the modal renders real detail data, but the + * action buttons are stubs (no backend wiring yet). + * + * Renderers call VideoGet.btn({kind, source, openId, title}); VideoGet.isAiring() + * is the shared status test that decides eye-vs-get. Self-contained. + */ +(function () { + 'use strict'; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); } + + // A show is "airing" (eye) unless its status says it's finished (get-symbol). + function isAiring(status) { + var s = String(status == null ? '' : status).trim().toLowerCase(); + if (!s) return false; // unknown status → treat as terminal (get), not watch + return ['ended', 'canceled', 'cancelled', 'completed'].indexOf(s) === -1; + } + + function dlSvg() { + return '' + + ''; + } + + function btn(opts) { + if (!opts || !opts.openId) return ''; + var kind = opts.kind === 'movie' ? 'movie' : 'show'; + return ''; + } + + // ── modal ───────────────────────────────────────────────────────────────── + var modalEl = null, keyHandler = null; + + function closeModal() { + if (!modalEl) return; + modalEl.classList.remove('vgm-open'); + document.body.style.removeProperty('overflow'); + if (keyHandler) { document.removeEventListener('keydown', keyHandler); keyHandler = null; } + var el = modalEl; modalEl = null; + setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220); + } + + function openModal(o) { + closeModal(); + var ov = document.createElement('div'); + ov.className = 'vgm-overlay'; + ov.innerHTML = + ''; + document.body.appendChild(ov); + document.body.style.overflow = 'hidden'; + modalEl = ov; + requestAnimationFrame(function () { ov.classList.add('vgm-open'); }); + + ov.addEventListener('click', function (e) { + if (e.target === ov || e.target.closest('[data-vgm-close]')) { closeModal(); return; } + if (e.target.closest('[data-vgm-open]')) { + closeModal(); + document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { + detail: { kind: o.kind, id: parseInt(o.id, 10), source: o.source || 'library' }, + })); + return; + } + if (e.target.closest('[data-vgm-wishlist]')) { + // v1: visual only — real wishlist population is a later phase. + toast('Wishlist coming soon', 'info'); + } + }); + keyHandler = function (e) { if (e.key === 'Escape') closeModal(); }; + document.addEventListener('keydown', keyHandler); + + var url = (o.source === 'tmdb') + ? '/api/video/tmdb/' + o.kind + '/' + o.id + : '/api/video/detail/' + o.kind + '/' + o.id; + fetch(url, { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (modalEl && d) fill(d, o); }) + .catch(function () { /* keep the title-only shell */ }); + } + + function fill(d, o) { + var q = function (s) { return modalEl.querySelector(s); }; + var id = o.id; + // backdrop (library art routes; tmdb payloads carry urls) + var hero = q('[data-vgm-hero]'); + var bg = (o.source !== 'tmdb' && d.has_backdrop) + ? '/api/video/backdrop/' + o.kind + '/' + id + '?w=1280' + : (d.backdrop_url || d.backdrop || ''); + if (hero && bg) hero.style.backgroundImage = "url('" + bg + "')"; + + var t = q('[data-vgm-title]'); if (t && d.title) t.textContent = d.title; + var eyebrow = [d.network, d.studio, d.year, d.status, d.content_rating].filter(Boolean).map(esc).join(' · '); + var eb = q('[data-vgm-eyebrow]'); if (eb) eb.textContent = eyebrow; + + var meta = []; + if (d.runtime_minutes) meta.push(d.runtime_minutes + ' min'); + if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10)); + if (d.tagline) meta.push('“' + d.tagline + '”'); + var mt = q('[data-vgm-meta]'); if (mt) mt.innerHTML = meta.map(function (x) { + return '' + esc(x) + ''; + }).join(''); + + var g = q('[data-vgm-genres]'); + if (g && d.genres && d.genres.length) g.innerHTML = d.genres.slice(0, 5).map(function (x) { + return '' + esc(x) + ''; + }).join(''); + + var ov = q('[data-vgm-overview]'); + if (ov) { + if (d.overview) { ov.textContent = d.overview; ov.classList.remove('vgm-overview--none'); } + else { ov.textContent = 'No synopsis available yet.'; ov.classList.add('vgm-overview--none'); } + } + } + + // One capture-phase handler — the get button sits inside a card . + document.addEventListener('click', function (e) { + var b = e.target.closest && e.target.closest('.vget-btn'); + if (!b) return; + e.preventDefault(); + e.stopPropagation(); + openModal({ + kind: b.getAttribute('data-vget-kind'), + source: b.getAttribute('data-vget-source') || 'library', + id: b.getAttribute('data-vget-id'), + title: b.getAttribute('data-vget-title') || '', + }); + }, true); + + window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal }; +})(); diff --git a/webui/static/video/video-library.js b/webui/static/video/video-library.js index 8b693ae7..196ff4a2 100644 --- a/webui/static/video/video-library.js +++ b/webui/static/video/video-library.js @@ -65,15 +65,24 @@ fallback + '\'">' : '
' + fallback + '
'; - // #watchlist: TV shows get a hover "follow" eye (movies don't — they're - // wishlist, not watchlist). Injected inside the positioned poster box. - if (kind === 'show' && it.tmdb_id && window.VideoWatchlist) { - var wlb = VideoWatchlist.btn({ - kind: 'show', tmdbId: it.tmdb_id, title: it.title, - poster: it.has_poster ? ('/api/video/poster/show/' + it.id) : '', libraryId: it.id - }); - if (wlb) img = img.replace(/<\/div>$/, wlb + ''); + // Contextual overlay control, injected inside the positioned poster box: + // airing show -> watchlist eye (monitor for new episodes) + // movie / ended show -> "get" download symbol (opens the detail modal) + var ctrl = ''; + if (kind === 'movie') { + ctrl = window.VideoGet ? VideoGet.btn({ kind: 'movie', source: 'library', openId: it.id, title: it.title }) : ''; + } else if (kind === 'show') { + var airing = !window.VideoGet || VideoGet.isAiring(it.status); + if (airing && it.tmdb_id && window.VideoWatchlist) { + ctrl = VideoWatchlist.btn({ + kind: 'show', tmdbId: it.tmdb_id, title: it.title, + poster: it.has_poster ? ('/api/video/poster/show/' + it.id) : '', libraryId: it.id + }); + } else if (window.VideoGet) { + ctrl = VideoGet.btn({ kind: 'show', source: 'library', openId: it.id, title: it.title }); + } } + if (ctrl) img = img.replace(/<\/div>$/, ctrl + ''); var badge = ''; if (kind === 'movie') { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 295ac665..ebc5f931 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1888,3 +1888,67 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vwlp-page-info { font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.6); min-width: 110px; text-align: center; } @media (max-width: 900px) { .vwlp-toolbar { padding-left: 18px; padding-right: 18px; } } + +/* ══════════════════════════════════════════════════════════════════════════ + "Get" button (.vget-btn) + detail/download modal (.vgm-*) — the terminal- + content counterpart to the watchlist eye (movies + ended shows). + ══════════════════════════════════════════════════════════════════════════ */ +.vget-btn { + position: absolute; top: 8px; right: 8px; z-index: 4; + width: 30px; height: 30px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.16); color: rgba(255, 255, 255, 0.88); + cursor: pointer; opacity: 0; transform: translateY(-2px); + transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease, border-color 0.18s ease; } +.vget-btn:hover { background: rgba(var(--accent-rgb, 88 101 242), 0.92); color: #fff; + border-color: rgba(var(--accent-rgb, 88 101 242), 0.92); } +.library-artist-card:hover .vget-btn, .vwlp-card:hover .vget-btn, +.vsr-card:hover .vget-btn, .vd-sim-card:hover .vget-btn { opacity: 1; transform: none; } +@media (hover: none) { .vget-btn { opacity: 1; transform: none; } } + +/* modal */ +.vgm-overlay { position: fixed; inset: 0; z-index: 9100; display: flex; align-items: center; justify-content: center; + padding: 24px; background: rgba(5, 5, 8, 0.72); backdrop-filter: blur(9px); opacity: 0; transition: opacity 0.2s ease; } +.vgm-overlay.vgm-open { opacity: 1; } +.vgm-modal { position: relative; width: min(680px, 100%); max-height: 90vh; overflow-y: auto; + border-radius: 22px; background: #101015; border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 50px 130px rgba(0, 0, 0, 0.72); transform: translateY(16px) scale(0.985); + transition: transform 0.26s cubic-bezier(0.2, 0.7, 0.2, 1); } +.vgm-overlay.vgm-open .vgm-modal { transform: none; } +.vgm-modal::-webkit-scrollbar { width: 9px; } +.vgm-modal::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 5px; } +.vgm-close { position: absolute; top: 14px; right: 14px; z-index: 3; width: 36px; height: 36px; border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.18); background: rgba(0, 0, 0, 0.45); color: #fff; font-size: 22px; + cursor: pointer; backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; + line-height: 1; transition: all 0.15s ease; } +.vgm-close:hover { background: rgba(0, 0, 0, 0.72); border-color: rgba(255, 255, 255, 0.4); } +.vgm-hero { position: relative; min-height: 260px; border-radius: 22px 22px 0 0; overflow: hidden; + display: flex; align-items: flex-end; background-size: cover; background-position: center 22%; background-color: #16161d; } +.vgm-hero-scrim { position: absolute; inset: 0; + background: linear-gradient(0deg, #101015 4%, rgba(16, 16, 21, 0.5) 52%, rgba(16, 16, 21, 0.1)), + radial-gradient(130% 110% at 0% 100%, rgba(var(--accent-rgb, 88 101 242), 0.32), transparent 60%); } +.vgm-hero-content { position: relative; z-index: 1; padding: 26px 28px 20px; width: 100%; } +.vgm-eyebrow { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.6); } +.vgm-eyebrow:empty { display: none; } +.vgm-title { font-size: 30px; font-weight: 900; letter-spacing: -0.025em; line-height: 1.05; margin: 8px 0 0; + text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55); } +.vgm-meta { display: flex; gap: 16px; margin-top: 11px; flex-wrap: wrap; } +.vgm-meta-item { font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.78); } +.vgm-genres { display: flex; gap: 7px; margin-top: 12px; flex-wrap: wrap; } +.vgm-genres:empty { display: none; } +.vgm-genre { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; + background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.82); } +.vgm-body { padding: 20px 28px 4px; } +.vgm-overview { font-size: 14px; line-height: 1.65; color: rgba(255, 255, 255, 0.82); margin: 0; } +.vgm-overview--none { color: rgba(255, 255, 255, 0.4); font-style: italic; } +.vgm-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 20px 28px 26px; } +.vgm-btn { font-size: 13px; font-weight: 800; padding: 11px 20px; border-radius: 12px; cursor: pointer; + border: 1px solid transparent; transition: all 0.15s ease; } +.vgm-btn--ghost { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); } +.vgm-btn--ghost:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.vgm-btn--primary { background: rgb(var(--accent-rgb, 88 101 242)); color: #fff; + box-shadow: 0 8px 24px rgba(var(--accent-rgb, 88 101 242), 0.4); } +.vgm-btn--primary:hover { filter: brightness(1.08); transform: translateY(-1px); } +@media (max-width: 560px) { .vgm-actions { flex-direction: column-reverse; } .vgm-btn { width: 100%; } }