soulsync/webui/static/video/video-detail.js
BoulderBadgeDad 81f2127e71 video: redesign TV-detail as a Netflix billboard (break from the music layout)
Per feedback, this drops the Spotify/artist-page parallel entirely and goes
Netflix:
- Full-bleed billboard (edge-to-edge — breaks out of the host's 40px padding),
  big backdrop with Ken-Burns drift + layered scrims, oversized title, a Netflix
  meta row (owned% · year · rating · seasons · runtime · status), 3-line synopsis,
  and action buttons (Watchlist / Get Missing / external links).
- Per-show accent colour sampled from the poster (canvas) → drives the primary
  button glow, status, episode hover — the SoulSync 'vibe', per title.
- Custom season dropdown + rich episode rows (index, 16:9 thumb w/ hover play,
  title · runtime, 2-line synopsis, Owned/Missing) that fade/stagger in on season
  change.
- Backdrop falls back to a cover-cropped poster; episode stills + genres + cast
  arrive with the 'capture everything' phase. Watchlist/Get-Missing are visual
  pending their endpoints. Shell tests updated.
2026-06-14 16:38:24 -07:00

233 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SoulSync — Video detail page (isolated, NETFLIX-style — deliberately NOT the
* music/Spotify layout).
*
* A cinematic billboard (full-bleed backdrop, content anchored bottom-left), a
* per-show accent colour sampled from the poster, a custom season dropdown, and
* rich episode rows that fade in on season change. Opened by a card via
* soulsync:video-open-detail; video-side.js navigates, this loads + renders.
*
* Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks
* only to /api/video/* — the music side is never touched.
*/
(function () {
'use strict';
var DETAIL_URL = '/api/video/detail/';
var data = null;
var selectedSeason = null;
var menuOpen = false;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function root() { return document.querySelector('[data-video-detail="show"]'); }
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
function setText(sel, t) { var n = q(sel); if (n) n.textContent = t || ''; }
function runtimeLabel(m) {
if (!m) return '';
var h = Math.floor(m / 60), mm = m % 60;
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
}
function statusLabel(s) {
return s === 'continuing' ? 'Continuing' : s === 'ended' ? 'Ended'
: s === 'upcoming' ? 'Upcoming' : (s || '');
}
function seasonByNum(n) {
if (!data) return null;
for (var i = 0; i < data.seasons.length; i++) if (data.seasons[i].season_number === n) return data.seasons[i];
return null;
}
// ── accent extraction (poster → dominant vibrant colour) ──────────────────
function applyAccent(img) {
try {
var w = 24, h = 24;
var c = document.createElement('canvas'); c.width = w; c.height = h;
var ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
var px = ctx.getImageData(0, 0, w, h).data;
var best = null, bestScore = -1, fr = 0, fg = 0, fb = 0, n = 0;
for (var i = 0; i < px.length; i += 4) {
var r = px[i], g = px[i + 1], b = px[i + 2], a = px[i + 3];
if (a < 128) continue;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b);
var light = (mx + mn) / 2;
fr += r; fg += g; fb += b; n++;
if (light < 35 || light > 225) continue; // skip near-black/white
var sat = mx === 0 ? 0 : (mx - mn) / mx;
var score = sat * (mx / 255); // vibrant + bright
if (score > bestScore) { bestScore = score; best = [r, g, b]; }
}
if (!best && n) best = [Math.round(fr / n), Math.round(fg / n), Math.round(fb / n)];
if (best) {
var r0 = root();
if (r0) r0.style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]);
}
} catch (e) { /* tainted/no image — keep theme accent */ }
}
// ── billboard ─────────────────────────────────────────────────────────────
function renderBillboard(d) {
setText('[data-vd-title]', d.title);
setText('[data-vd-overview]', d.overview);
var bg = q('[data-vd-backdrop]');
if (bg) {
var url = d.has_backdrop ? '/api/video/backdrop/show/' + d.id
: (d.has_poster ? '/api/video/poster/show/' + d.id : '');
bg.style.backgroundImage = url ? "url('" + url + "')" : '';
bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster);
bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster);
}
// offscreen poster → accent colour
var poster = q('[data-vd-poster]');
if (poster && d.has_poster) {
poster.onload = function () { applyAccent(poster); };
poster.src = '/api/video/poster/show/' + d.id;
}
// meta row (Netflix style): owned% · year · rating · seasons · runtime · status
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
var meta = [];
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
if (d.year) meta.push('<span>' + esc(d.year) + '</span>');
if (d.content_rating) meta.push('<span class="vd-meta-rating">' + esc(d.content_rating) + '</span>');
meta.push('<span>' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '</span>');
meta.push('<span>' + d.episode_total + ' Episodes</span>');
var rt = runtimeLabel(d.runtime_minutes);
if (rt) meta.push('<span>' + esc(rt) + '</span>');
if (d.status) meta.push('<span class="vd-status">' + esc(statusLabel(d.status)) + '</span>');
if (d.network) meta.push('<span>' + esc(d.network) + '</span>');
var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join('');
// actions — Netflix buttons + real external links
var links = [];
if (d.imdb_id) links.push(['IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/']);
if (d.tmdb_id) links.push(['TMDB', 'https://www.themoviedb.org/tv/' + d.tmdb_id]);
if (d.tvdb_id) links.push(['TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series']);
var a = q('[data-vd-actions]');
if (a) {
a.innerHTML =
'<button class="vd-btn vd-btn--primary" type="button" data-vd-act="watchlist">' +
'<span class="vd-btn-ic"></span> Watchlist</button>' +
'<button class="vd-btn vd-btn--ghost" type="button" data-vd-act="download">' +
'<span class="vd-btn-ic">⭳</span> Get Missing</button>' +
links.map(function (l) {
return '<a class="vd-btn vd-btn--link" href="' + l[1] + '" target="_blank" rel="noopener">' +
esc(l[0]) + ' ↗</a>';
}).join('');
}
var g = q('[data-vd-genres]');
if (g) g.innerHTML = d.network ? '' : ''; // genres land with "capture everything"
}
// ── season dropdown ───────────────────────────────────────────────────────
function renderSeasonSelect() {
var host = q('[data-vd-season-select]');
if (!host || !data) return;
var cur = seasonByNum(selectedSeason);
host.innerHTML =
'<button class="vd-ss-btn" type="button" data-vd-ss-toggle>' +
'<span>' + esc(cur ? cur.title : 'Season') + '</span><span class="vd-ss-caret">▾</span></button>' +
'<div class="vd-ss-menu' + (menuOpen ? ' vd-ss-menu--open' : '') + '">' +
data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : '';
return '<button class="vd-ss-opt' + on + '" type="button" data-vd-ss-pick="' + s.season_number + '">' +
esc(s.title) + '<span class="vd-ss-opt-meta">' + s.episode_owned + '/' + s.episode_total + '</span></button>';
}).join('') + '</div>';
}
// ── episodes ──────────────────────────────────────────────────────────────
function episodeRow(ep) {
var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
var meta = [];
var rt = runtimeLabel(ep.runtime_minutes);
if (rt) meta.push(rt);
if (ep.air_date) meta.push(ep.air_date);
return '<div class="vd-ep ' + owned + '">' +
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
'<div class="vd-ep-thumb"><span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-info">' +
'<div class="vd-ep-top"><span class="vd-ep-title">' +
esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +
(ep.overview ? '<p class="vd-ep-desc">' + esc(ep.overview) + '</p>' : '') +
'</div>' +
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div>' +
'</div>';
}
function renderEpisodes() {
var host = q('[data-vd-episodes]');
if (!host) return;
var season = seasonByNum(selectedSeason);
if (!season) { host.innerHTML = ''; return; }
host.innerHTML = season.episodes.map(episodeRow).join('');
// re-trigger the fade/slide-in animation
host.classList.remove('vd-ep-anim');
void host.offsetWidth;
host.classList.add('vd-ep-anim');
}
function selectSeason(n) {
selectedSeason = n;
menuOpen = false;
renderSeasonSelect();
renderEpisodes();
}
function showLoading(on) { var l = q('[data-vd-loading]'); if (l) l.hidden = !on; }
function loadShow(id) {
if (!root()) return;
showLoading(true);
var ep = q('[data-vd-episodes]'); if (ep) ep.innerHTML = '';
var ss = q('[data-vd-season-select]'); if (ss) ss.innerHTML = '';
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
data = d;
menuOpen = false;
selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null;
renderBillboard(d);
renderSeasonSelect();
renderEpisodes();
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
if (sub) sub.scrollTop = 0;
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
}
// ── events ────────────────────────────────────────────────────────────────
function onOpen(e) {
if (!e || !e.detail || e.detail.kind !== 'show') return;
loadShow(e.detail.id);
}
function onClick(e) {
var r = root(); if (!r) return;
var toggle = e.target.closest('[data-vd-ss-toggle]');
if (toggle && r.contains(toggle)) { menuOpen = !menuOpen; renderSeasonSelect(); return; }
var pick = e.target.closest('[data-vd-ss-pick]');
if (pick && r.contains(pick)) { selectSeason(parseInt(pick.getAttribute('data-vd-ss-pick'), 10)); return; }
// click-away closes the menu
if (menuOpen && !e.target.closest('[data-vd-season-select]')) { menuOpen = false; renderSeasonSelect(); }
}
function init() {
document.addEventListener('soulsync:video-open-detail', onOpen);
document.addEventListener('click', onClick);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();