diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index e21fd3b4..38dbf04b 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -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 @@ '' + '
' + '
' + + '' + '
' + '
' + '

' + esc(o.title || 'Loading…') + '

' + @@ -92,7 +102,10 @@ '
' + '
' + '
' + + '' + '

Loading details…

' + + '' + + '' + '' + '' + '
' + @@ -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 '' + + '' + src + '' + + '' + esc(val) + ''; + } + 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 = '' + + 'In your library' + + (bits.length ? ' · ' + esc(bits.join(' · ')) : '') + ''; + 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 = '' + + 'Next episode · S' + best.s + ' · E' + best.e + + (best.title ? ' — ' + esc(best.title) : '') + '' + + '' + esc(fmtDate(best.air_date)) + ''; + 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. diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 096de680..2e00571c 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; } }