Video: contextual get-symbol button + detail/download modal (visual only)
The terminal-content counterpart to the watchlist eye. On library cards: - airing show -> watchlist eye (monitor for new episodes) - movie / ended show -> a 'get' download symbol that opens a detail modal - video-get-modal.js: VideoGet.btn() + VideoGet.isAiring() (the shared status test), and the modal — hero backdrop, eyebrow, title, meta (runtime/rating/ tagline), genres, overview, pulled from the existing detail endpoint. Action buttons are VISUAL STUBS for now: 'Open full page' navigates; 'Add to Wishlist' just toasts 'coming soon' (real population is a later phase). - query_library now selects s.status so cards can pick eye vs get. - CSS for .vget-btn (hover-reveal, accent on hover) + the .vgm-* modal, styled to match the calendar episode modal. 82 video tests green (status is an additive column).
This commit is contained in:
parent
4287385af6
commit
1b23b7da78
5 changed files with 244 additions and 9 deletions
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -9557,6 +9557,8 @@
|
|||
<!-- Video watchlist button (shared; the add-to-watchlist control used by every
|
||||
show-poster + person card — must load before the card renderers) -->
|
||||
<script src="{{ url_for('static', filename='video/video-watchlist-btn.js', v=static_v) }}"></script>
|
||||
<!-- Video "get" button + detail/download modal (shared; movies + ended shows) -->
|
||||
<script src="{{ url_for('static', filename='video/video-get-modal.js', v=static_v) }}"></script>
|
||||
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
|
||||
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
|
||||
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
|
||||
|
|
|
|||
160
webui/static/video/video-get-modal.js
Normal file
160
webui/static/video/video-get-modal.js
Normal file
|
|
@ -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, '>').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 '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
|
||||
}
|
||||
|
||||
function btn(opts) {
|
||||
if (!opts || !opts.openId) return '';
|
||||
var kind = opts.kind === 'movie' ? 'movie' : 'show';
|
||||
return '<button type="button" class="vget-btn"' +
|
||||
' data-vget-kind="' + kind + '" data-vget-source="' + esc(opts.source || 'library') + '"' +
|
||||
' data-vget-id="' + esc(opts.openId) + '" data-vget-title="' + esc(opts.title || '') + '"' +
|
||||
' title="Get this ' + kind + '" aria-label="Get this ' + kind + '">' + dlSvg() + '</button>';
|
||||
}
|
||||
|
||||
// ── 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 =
|
||||
'<div class="vgm-modal" role="dialog" aria-modal="true">' +
|
||||
'<button class="vgm-close" type="button" data-vgm-close aria-label="Close">×</button>' +
|
||||
'<div class="vgm-hero" data-vgm-hero>' +
|
||||
'<div class="vgm-hero-scrim"></div>' +
|
||||
'<div class="vgm-hero-content">' +
|
||||
'<div class="vgm-eyebrow" data-vgm-eyebrow></div>' +
|
||||
'<h2 class="vgm-title" data-vgm-title>' + esc(o.title || 'Loading…') + '</h2>' +
|
||||
'<div class="vgm-meta" data-vgm-meta></div>' +
|
||||
'<div class="vgm-genres" data-vgm-genres></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vgm-body">' +
|
||||
'<p class="vgm-overview" data-vgm-overview>Loading details…</p>' +
|
||||
'</div>' +
|
||||
'<div class="vgm-actions">' +
|
||||
'<button class="vgm-btn vgm-btn--ghost" type="button" data-vgm-open>Open full page →</button>' +
|
||||
'<button class="vgm-btn vgm-btn--primary" type="button" data-vgm-wishlist>+ Add to Wishlist</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
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 '<span class="vgm-meta-item">' + esc(x) + '</span>';
|
||||
}).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 '<span class="vgm-genre">' + esc(x) + '</span>';
|
||||
}).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 <a>.
|
||||
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 };
|
||||
})();
|
||||
|
|
@ -65,15 +65,24 @@
|
|||
fallback + '</div>\'"></div>'
|
||||
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
|
||||
|
||||
// #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 + '</div>');
|
||||
// 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 + '</div>');
|
||||
|
||||
var badge = '';
|
||||
if (kind === 'movie') {
|
||||
|
|
|
|||
|
|
@ -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%; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue