Video get-modal: poster + ratings bar + owned-movie handling + next-up

- Owned movie shows 'In your library' (with best version quality) and the
  footer flips to 'Re-download' instead of '+ Add to Wishlist'.
- Poster thumbnail floats over the hero (hue halo + own rise animation),
  works for both library and TMDB sources.
- Ratings strip: branded IMDb / Rotten Tomatoes / Metacritic chips from the
  existing payload fields; renders only what's present.
- Airing shows show the soonest upcoming episode above the selector.
This commit is contained in:
BoulderBadgeDad 2026-06-16 12:46:07 -07:00
parent d722224b26
commit 0071358125
2 changed files with 141 additions and 5 deletions

View file

@ -20,6 +20,15 @@
function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); }
// A stable per-title hue so each modal glows in its own colour (the "vibe").
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
function resLabel(res) {
if (!res) return '';
res = String(res).toLowerCase();
if (res.indexOf('2160') > -1 || res === '4k') return '4K';
if (res.indexOf('1080') > -1) return '1080p';
if (res.indexOf('720') > -1) return '720p';
if (res.indexOf('480') > -1 || res.indexOf('576') > -1) return 'SD';
return res.toUpperCase();
}
// A show is "airing" (eye) unless its status says it's finished (get-symbol).
function isAiring(status) {
@ -84,6 +93,7 @@
'<button class="vgm-close" type="button" data-vgm-close aria-label="Close">&times;</button>' +
'<div class="vgm-hero" data-vgm-hero>' +
'<div class="vgm-hero-scrim"></div>' +
'<img class="vgm-poster" data-vgm-poster alt="" hidden>' +
'<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>' +
@ -92,7 +102,10 @@
'</div>' +
'</div>' +
'<div class="vgm-body">' +
'<div class="vgm-ratings" data-vgm-ratings hidden></div>' +
'<p class="vgm-overview" data-vgm-overview>Loading details…</p>' +
'<div class="vgm-owned" data-vgm-owned hidden></div>' +
'<div class="vgm-next" data-vgm-next hidden></div>' +
'<div class="vgm-eps" data-vgm-eps hidden></div>' +
'<div class="vgm-follow" data-vgm-follow hidden></div>' +
'</div>' +
@ -129,11 +142,13 @@
if (e.target.closest('[data-vgm-wishlist]')) {
// v1: visual only — everything funnels through the wishlist; the
// real write + download-from-wishlist come later.
var n = modalState ? modalState.sel.size : 0;
var n = (modalState && modalState.sel) ? modalState.sel.size : 0;
var aw = ov.querySelector('[data-vgm-add-watch]');
var follow = (aw && aw.checked) ? ' + Watchlist' : '';
toast((modalState ? (n + ' episode' + (n === 1 ? '' : 's') + ' — ') : '') +
'Wishlist' + follow + ' coming soon', 'info');
var ownedMovie = modalState && modalState.kind === 'movie' && modalState.owned;
var verb = ownedMovie ? 'Re-download' : 'Wishlist';
toast(((modalState && modalState.sel) ? (n + ' episode' + (n === 1 ? '' : 's') + ' — ') : '') +
verb + follow + ' coming soon', 'info');
}
});
ov.addEventListener('change', function (e) {
@ -209,6 +224,11 @@
if (cnt) cnt.textContent = n + ' episode' + (n === 1 ? '' : 's') + ' selected';
if (addLbl) addLbl.textContent = '+ Add ' + n + ' to Wishlist';
if (add) add.disabled = n === 0;
} else if (modalState && modalState.kind === 'movie' && modalState.owned) {
// Already owned — the action is a re-grab, not a new want.
if (cnt) cnt.textContent = '';
if (addLbl) addLbl.textContent = 'Re-download';
if (add) add.disabled = false;
} else {
if (cnt) cnt.textContent = '';
if (addLbl) addLbl.textContent = '+ Add to Wishlist';
@ -283,6 +303,17 @@
: (d.backdrop_url || d.backdrop || '');
if (hero && bg) hero.style.backgroundImage = "url('" + bg + "')";
// Poster thumbnail floating over the hero (the premium "card" feel).
var poster = q('[data-vgm-poster]');
var pUrl = (o.source !== 'tmdb' && d.has_poster)
? '/api/video/poster/' + o.kind + '/' + id
: (d.poster_url || d.poster || '');
if (poster && pUrl) {
poster.onload = function () { if (hero) hero.classList.add('vgm-has-poster'); poster.hidden = false; };
poster.onerror = function () { poster.hidden = true; if (hero) hero.classList.remove('vgm-has-poster'); };
poster.src = pUrl;
}
var t = q('[data-vgm-title]'); if (t && d.title) t.textContent = d.title;
if (d.title) modalEl.style.setProperty('--vgm-h', hueOf(d.title)); // refine vibe from the real title
var eyebrow = [d.network, d.studio, d.year, d.status, d.content_rating].filter(Boolean).map(esc).join(' · ');
@ -307,10 +338,67 @@
else { ov.textContent = 'No synopsis available yet.'; ov.classList.add('vgm-overview--none'); }
}
if (o.kind === 'show') { renderEpisodes(d); maybeFollow(d); } // selector + follow offer
renderRatings(d); // IMDb / Rotten Tomatoes / Metacritic chips (both kinds)
if (o.kind === 'show') {
modalState = modalState || { kind: 'show', sel: new Set() };
renderEpisodes(d); renderNext(d); maybeFollow(d); // selector + next-up + follow offer
} else {
modalState = { kind: 'movie', owned: !!d.owned }; // owned movies → re-download, not wishlist
renderOwned(d);
}
updateFooter();
}
// Ratings strip: branded chips for whichever scores the payload carries.
function renderRatings(d) {
var box = modalEl && modalEl.querySelector('[data-vgm-ratings]'); if (!box) return;
function chip(cls, src, val) {
return '<span class="vgm-rating vgm-rating--' + cls + '">' +
'<span class="vgm-rating-src">' + src + '</span>' +
'<span class="vgm-rating-val">' + esc(val) + '</span></span>';
}
var chips = [];
if (d.imdb_rating) chips.push(chip('imdb', 'IMDb', (Math.round(d.imdb_rating * 10) / 10)));
if (d.rt_rating) chips.push(chip('rt', 'Rotten Tomatoes', d.rt_rating + '%'));
if (d.metacritic) chips.push(chip('mc', 'Metacritic', d.metacritic));
if (!chips.length) { box.hidden = true; return; }
box.innerHTML = chips.join('');
box.hidden = false;
}
// Owned movie → an "in your library" note (with the best version's quality)
// instead of pretending it's a fresh wishlist want.
function renderOwned(d) {
var box = modalEl && modalEl.querySelector('[data-vgm-owned]'); if (!box) return;
if (!d.owned) { box.hidden = true; return; }
var f = d.file || {};
var bits = [resLabel(f.resolution), f.quality, (f.audio_codec || '').toUpperCase()].filter(Boolean);
box.innerHTML = '<span class="vgm-owned-ic">✓</span>' +
'<span class="vgm-owned-txt"><strong>In your library</strong>' +
(bits.length ? ' · ' + esc(bits.join(' · ')) : '') + '</span>';
box.hidden = false;
}
// Airing show → the soonest not-yet-aired episode, so "what's next" is right
// up front (ties the modal to the watchlist's whole reason for existing).
function renderNext(d) {
var box = modalEl && modalEl.querySelector('[data-vgm-next]'); if (!box) return;
if (!isAiring(d.status)) { box.hidden = true; return; }
var today = isoToday(), best = null;
(d.seasons || []).forEach(function (s) {
(s.episodes || []).forEach(function (e) {
if (e.air_date && e.air_date > today && (!best || e.air_date < best.air_date))
best = { s: s.season_number, e: e.episode_number, air_date: e.air_date, title: e.title };
});
});
if (!best) { box.hidden = true; return; }
box.innerHTML = '<span class="vgm-next-ic">▶</span>' +
'<span class="vgm-next-txt"><strong>Next episode</strong> · S' + best.s + ' · E' + best.e +
(best.title ? ' — ' + esc(best.title) : '') + '</span>' +
'<span class="vgm-next-date">' + esc(fmtDate(best.air_date)) + '</span>';
box.hidden = false;
}
// Airing show you don't follow yet → offer to start watching it (default on),
// so grabbing episodes also keeps you current. Hidden for ended shows (can't
// "watch" for new) and shows you already follow.

View file

@ -1953,6 +1953,49 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.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%; } }
/* ── Get-modal: poster + ratings + owned-note + next-up ───────────────────── */
/* Poster floats over the hero bottom-left; content shifts right to clear it. */
.vgm-poster { position: absolute; left: 28px; bottom: 18px; z-index: 2; width: 104px; aspect-ratio: 2 / 3;
object-fit: cover; border-radius: 10px; background: #16161d;
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.4); }
.vgm-poster[hidden] { display: none; }
.vgm-hero.vgm-has-poster .vgm-hero-content { padding-left: 152px; }
@media (max-width: 560px) { .vgm-poster { width: 78px; left: 20px; }
.vgm-hero.vgm-has-poster .vgm-hero-content { padding-left: 112px; } }
/* Ratings strip — branded source chips (IMDb / RT / Metacritic). */
.vgm-ratings { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.vgm-ratings[hidden] { display: none; }
.vgm-rating { display: inline-flex; align-items: baseline; gap: 6px; padding: 5px 11px; border-radius: 9px;
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.09); }
.vgm-rating-src { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.5); }
.vgm-rating-val { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: -0.01em; }
.vgm-rating--imdb { background: rgba(245, 197, 24, 0.1); border-color: rgba(245, 197, 24, 0.32); }
.vgm-rating--imdb .vgm-rating-src { color: #f5c518; }
.vgm-rating--rt { background: rgba(250, 50, 11, 0.1); border-color: rgba(250, 50, 11, 0.3); }
.vgm-rating--rt .vgm-rating-src { color: #ff6446; }
.vgm-rating--mc { background: rgba(108, 211, 145, 0.1); border-color: rgba(108, 211, 145, 0.3); }
.vgm-rating--mc .vgm-rating-src { color: #6cd391; }
/* Owned-movie note — "in your library" with the best version's quality. */
.vgm-owned { display: flex; align-items: center; gap: 9px; margin-top: 16px; padding: 12px 14px; border-radius: 12px;
background: rgba(108, 211, 145, 0.08); border: 1px solid rgba(108, 211, 145, 0.26); }
.vgm-owned[hidden] { display: none; }
.vgm-owned-ic { font-size: 14px; font-weight: 900; color: #6cd391; flex-shrink: 0; }
.vgm-owned-txt { font-size: 13px; color: rgba(255, 255, 255, 0.7); }
.vgm-owned-txt strong { color: #8fe7af; font-weight: 800; }
/* Next-episode line — the soonest upcoming episode for an airing show. */
.vgm-next { display: flex; align-items: center; gap: 11px; margin-top: 16px; padding: 12px 14px; border-radius: 12px;
background: rgba(var(--accent-rgb, 88 101 242), 0.09); border: 1px solid rgba(var(--accent-rgb, 88 101 242), 0.26); }
.vgm-next[hidden] { display: none; }
.vgm-next-ic { font-size: 11px; color: rgb(var(--accent-rgb, 88 101 242)); flex-shrink: 0; }
.vgm-next-txt { font-size: 13px; color: rgba(255, 255, 255, 0.78); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vgm-next-txt strong { color: #fff; font-weight: 800; }
.vgm-next-date { margin-left: auto; flex-shrink: 0; font-size: 12px; font-weight: 800; color: #c4cbff; }
/* Watchlist card richness — status pill + episode-count meta + sort select */
.vwlp-pill { position: absolute; top: 8px; left: 8px; z-index: 3;
font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em;
@ -2077,6 +2120,11 @@ body[data-side="video"] #soulsync-toggle { display: none; }
@keyframes vgmRise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
.vgm-title { text-shadow: 0 2px 24px rgba(0, 0, 0, 0.65), 0 0 42px hsla(var(--vgm-h, 230), 80%, 55%, 0.28); }
/* poster — hue-aware halo + its own rise (it sits outside hero-content) */
.vgm-poster { box-shadow: 0 14px 34px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.4),
0 0 28px -6px hsla(var(--vgm-h, 230), 80%, 58%, 0.5); }
.vgm-overlay.vgm-open .vgm-poster { animation: vgmRise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) 0.12s both; }
/* primary CTA — hue-aware gradient + glow + lift */
.vgm-btn--primary {
background: linear-gradient(135deg, hsl(var(--vgm-h, 230), 70%, 58%), hsl(var(--vgm-h, 230), 72%, 47%));
@ -2105,7 +2153,7 @@ body[data-side="video"] #soulsync-toggle { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.vgm-hero, .vgm-hero::after, .vgm-follow-row,
.vgm-hero, .vgm-hero::after, .vgm-follow-row, .vgm-overlay.vgm-open .vgm-poster,
.vgm-overlay.vgm-open .vgm-hero-content > * { animation: none; }
.vgm-modal { transition: opacity 0.2s ease; }
}