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.
233 lines
12 KiB
JavaScript
233 lines
12 KiB
JavaScript
/*
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
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();
|
||
}
|
||
})();
|