/*
* SoulSync — Video detail page (isolated, NETFLIX-style — deliberately NOT the
* music/Spotify layout).
*
* A cinematic billboard (full-bleed backdrop, content bottom-left) with a
* per-show accent sampled from the poster, and a SEASON selector with four
* switchable views — poster rail / timeline / pills / dropdown — plus a
* "Missing only" episode filter. 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 TMDB_LOGO = 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg';
var TVDB_LOGO = 'https://www.svgrepo.com/show/443500/brand-tvdb.svg';
// Real media-server logos for the "Play on your server" watch tile (same
// sources as the header server toggle).
var SERVER_LOGOS = {
Plex: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg',
Jellyfin: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png',
};
var VIEW_KEY = 'soulsync_vd_season_view';
var VIEWS = [
{ id: 'rail', label: 'Rail', ic: '▦' },
{ id: 'timeline', label: 'Timeline', ic: '▭' },
{ id: 'pills', label: 'Tabs', ic: '◉' },
{ id: 'dropdown', label: 'List', ic: '▾' },
];
var data = null;
var selectedSeason = null;
var seasonView = 'rail';
var menuOpen = false;
var missingOnly = false;
var currentId = null;
var currentKind = 'show';
var currentSource = 'library'; // 'library' (video.db) or 'tmdb' (live preview)
var artAttemptedFor = null; // lazy art refresh runs once per detail view
var TMDB_URL = '/api/video/tmdb/';
function detailURL(kind, id, source) {
return source === 'tmdb' ? TMDB_URL + kind + '/' + id : DETAIL_URL + kind + '/' + id;
}
try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function root() { return document.querySelector('[data-video-detail="' + currentKind + '"]'); }
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;
}
function seasonArt(s) {
// tmdb + youtube carry direct (already-proxied for yt) art urls on the payload.
if (data && (data.source === 'tmdb' || data.source === 'youtube')) return s.poster_url || data.poster_url || '';
return (s.has_poster && s.id != null) ? '/api/video/poster/season/' + s.id
: (data && data.has_poster ? '/api/video/poster/show/' + data.id : '');
}
// Source-aware billboard art: library items proxy through /api/video; tmdb
// (preview) + youtube items use the (proxied) image URLs in the payload.
function bbBackdrop(d) {
if (d.source === 'tmdb' || d.source === 'youtube') return d.backdrop_url || d.poster_url || '';
var art = '/' + d.kind + '/' + d.id;
return d.has_backdrop ? '/api/video/backdrop' + art : (d.has_poster ? '/api/video/poster' + art : '');
}
function bbPoster(d) {
// The offscreen poster is canvas-sampled for the accent — must be
// same-origin, so tmdb posters proxy; youtube urls are already proxied.
if (d.source === 'tmdb') return d.poster_url ? proxied(d.poster_url) : '';
if (d.source === 'youtube') return d.poster_url || '';
return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : '';
}
function proxied(url) {
return /^https:\/\/image\.tmdb\.org\//.test(url || '')
? '/api/video/img?u=' + encodeURIComponent(url) : (url || '');
}
function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; }
function badge(logo, fallback, title, url) {
var inner = logo
? ''
: '' + fallback + '';
return url
? '' + inner + ''
: '
' + inner + '
';
}
// ── accent extraction (poster → dominant vibrant colour) ──────────────────
function applyAccent(img) {
try {
var w = 24, h = 24, 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), light = (mx + mn) / 2;
fr += r; fg += g; fb += b; n++;
if (light < 35 || light > 225) continue;
var sat = mx === 0 ? 0 : (mx - mn) / mx, score = sat * (mx / 255);
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);
// Clearlogo replaces the text title when available (Netflix/Plex feel).
var logo = q('[data-vd-logo]');
var titleEl = q('[data-vd-title]');
if (logo) {
if (d.logo) {
logo.src = d.logo; logo.alt = d.title || ''; logo.hidden = false;
logo.onerror = function () { logo.hidden = true; if (titleEl) titleEl.classList.remove('vd-title--logo'); };
if (titleEl) titleEl.classList.add('vd-title--logo');
} else {
logo.hidden = true; logo.removeAttribute('src');
if (titleEl) titleEl.classList.remove('vd-title--logo');
}
}
var bg = q('[data-vd-backdrop]');
if (bg) {
var url = bbBackdrop(d);
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);
}
var poster = q('[data-vd-poster]');
var posterUrl = bbPoster(d);
if (poster && posterUrl) {
poster.onload = function () { applyAccent(poster); };
poster.src = posterUrl;
}
var tl = q('[data-vd-tagline]');
if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; }
var meta = [];
if (d.source === 'youtube') {
var isPl = d.kind === 'playlist';
meta.push('' + (isPl ? 'Playlist' : 'YouTube') + '');
var yc = window.VideoYoutube;
if (isPl) {
// A playlist IS a known, fixed list, so its count is real (unlike a
// channel's total). Owner shows as a genre tag below.
if (d.video_count != null) meta.push('' + esc(d.video_count) + ' videos');
} else {
var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('' + subs + ' subscribers');
// NB: no "N videos" for a CHANNEL — YouTube doesn't expose a reliable total.
var views = yc && yc.compactCount(d.view_count); if (views) meta.push('' + views + ' views');
if (d.handle) meta.push('' + esc(d.handle) + '');
}
var mm = q('[data-vd-meta]'); if (mm) mm.innerHTML = meta.join('');
renderActions(d);
var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = '';
var gg = q('[data-vd-genres]');
if (gg) gg.innerHTML = (d.genres || []).slice(0, 8).map(function (gn) { return '' + esc(gn) + ''; }).join('');
renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d);
return;
}
if (d.source === 'tmdb') {
meta.push('Preview');
} else if (d.kind === 'show') {
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
meta.push('' + ownedPct + '% in library');
} else {
meta.push(d.owned ? 'In library'
: 'Wanted');
}
if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10) + '');
if (d.year) meta.push('' + esc(d.year) + '');
if (d.content_rating) meta.push('' + esc(d.content_rating) + '');
if (d.kind === 'show') {
meta.push('' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '');
meta.push('' + d.episode_total + ' Episodes');
}
var rt = runtimeLabel(d.runtime_minutes);
if (rt) meta.push('' + esc(rt) + '');
if (d.kind === 'show' && d.status) meta.push('' + esc(statusLabel(d.status)) + '');
if (d.network) meta.push('' + esc(d.network) + '');
if (d.kind === 'movie' && d.studio) meta.push('' + esc(d.studio) + '');
var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join('');
renderActions(d);
var l = q('[data-vd-links]');
if (l && d.source === 'tmdb') {
l.innerHTML = ''; // preview items keep everything in-app
} else if (l) {
var badges = [];
if (d.imdb_id) badges.push(badge('', 'IMDb', 'IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/'));
if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB',
'https://www.themoviedb.org/' + (d.kind === 'movie' ? 'movie' : 'tv') + '/' + d.tmdb_id));
if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series'));
if (d.wikidata_url) badges.push(badge('', 'Official Site', 'Official Site', d.wikidata_url));
l.innerHTML = badges.join('');
}
var g = q('[data-vd-genres]');
if (g) {
g.innerHTML = (d.genres || []).slice(0, 6).map(function (gn) {
return '' + esc(gn) + '';
}).join('');
}
renderSubtitles(d);
renderRatings(d);
renderCrewLine(d);
renderNextEpisode(d);
renderCast(d);
}
// Subtitle availability (OpenSubtitles backfill, #video-enrichment): a chip row
// of the languages subtitles EXIST in for this title, so you know before you grab
// it. Hidden entirely when we have no data.
var SUB_LANG_NAMES = {
en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian',
pt: 'Portuguese', 'pt-br': 'Portuguese (BR)', nl: 'Dutch', pl: 'Polish',
ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', 'zh-cn': 'Chinese',
ar: 'Arabic', tr: 'Turkish', sv: 'Swedish', da: 'Danish', fi: 'Finnish',
no: 'Norwegian', cs: 'Czech', el: 'Greek', he: 'Hebrew', hi: 'Hindi',
hu: 'Hungarian', ro: 'Romanian', th: 'Thai', uk: 'Ukrainian', vi: 'Vietnamese',
id: 'Indonesian'
};
function subLangLabel(code) {
var c = String(code || '').toLowerCase();
return SUB_LANG_NAMES[c] || code.toUpperCase();
}
function renderSubtitles(d) {
var el = q('[data-vd-subs]');
if (!el) return;
var langs = (d && d.subtitle_langs) || [];
if (!langs.length) { el.hidden = true; el.innerHTML = ''; return; }
var shown = langs.slice(0, 12);
var chips = shown.map(function (c) {
return '' + esc(subLangLabel(c)) + '';
});
if (langs.length > shown.length) {
chips.push('+' + (langs.length - shown.length) + '');
}
el.innerHTML = 'Subtitles' + chips.join('');
el.hidden = false;
}
// "Directed by …" (movie) / "Created by …" (show) surfaced in the hero.
// A crew member's name, clickable → person page when we have a TMDB id.
function personName(c) {
return c.tmdb_id
? '' + esc(c.name) + ''
: esc(c.name);
}
function renderCrewLine(d) {
var el = q('[data-vd-crew-line]');
if (!el) return;
var key = d.kind === 'movie' ? 'Director' : 'Creator';
var people = (d.crew || []).filter(function (c) { return c.job === key; }).slice(0, 3);
if (!people.length) { el.hidden = true; el.innerHTML = ''; return; }
var label = (d.kind === 'movie' ? 'Director' : 'Creator') + (people.length > 1 ? 's' : '');
el.innerHTML = '' + label + ' ' +
people.map(personName).join(', ');
el.hidden = false;
}
function fmtDate(s) {
if (!s) return '';
var p = String(s).split('-');
if (p.length < 3) return s;
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return (months[parseInt(p[1], 10) - 1] || '') + ' ' + parseInt(p[2], 10) + ', ' + p[0];
}
// "Next episode" banner for continuing shows (data.next_episode arrives w/ extras).
function renderNextEpisode(d) {
var el = q('[data-vd-next-ep]');
if (!el) return;
var ne = d.next_episode;
if (d.kind !== 'show' || !ne || !ne.air_date) { el.hidden = true; el.innerHTML = ''; return; }
var code = 'S' + ne.season_number + ' · E' + ne.episode_number;
el.innerHTML = '▸ Next Episode' +
'' + esc(code) + '' +
(ne.name ? '' + esc(ne.name) + '' : '') +
'' + esc(fmtDate(ne.air_date)) + '';
el.hidden = false;
}
function renderRatings(d) {
var host = q('[data-vd-ratings]');
if (!host) return;
var items = [];
if (d.imdb_rating) {
items.push('IMDb' +
(Math.round(d.imdb_rating * 10) / 10) + '');
}
if (d.rt_rating != null) {
var fresh = d.rt_rating >= 60;
items.push('' +
(fresh ? '🍅' : '🤢') + '' + d.rt_rating + '%');
}
if (d.metacritic != null) {
var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad';
items.push('' +
'MC' + d.metacritic + '');
}
if (d.trakt_rating) {
var tv = d.trakt_votes ? ' title="' + esc(d.trakt_votes) + ' Trakt votes"' : '';
items.push('Trakt' +
(Math.round(d.trakt_rating * 10) / 10) + '');
}
if (d.tvmaze_rating) {
items.push('TVmaze' +
(Math.round(d.tvmaze_rating * 10) / 10) + '');
}
if (d.anilist_score) {
items.push('AniList' +
d.anilist_score + '%');
}
host.innerHTML = items.join('');
host.hidden = !items.length;
}
function renderCast(d) {
var section = q('[data-vd-cast-section]');
if (!section) return;
var cast = d.cast || [], crew = d.crew || [];
if (!cast.length && !crew.length) { section.hidden = true; return; }
section.hidden = false;
var crewHost = q('[data-vd-crew]');
if (crewHost) {
// Group crew by job (Creator / Director / Writer …) → "Job: A, B" with
// each name clickable → person page.
var byJob = {};
crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c); });
crewHost.innerHTML = Object.keys(byJob).map(function (job) {
return '' + esc(job) +
(byJob[job].length > 1 ? 's' : '') + ' ' +
byJob[job].map(personName).join(', ') + '';
}).join('');
}
var castHost = q('[data-vd-cast]');
if (castHost) {
castHost.innerHTML = cast.map(function (p) {
var img = p.photo
? ''
: '' + esc((p.name || '?').charAt(0)) + '';
var inner = img +
'' + esc(p.name) + '' +
(p.character ? '' + esc(p.character) + '' : '');
// Clickable → in-app person page when we have a TMDB person id.
var cb = (p.tmdb_id && window.VideoGet)
? VideoGet.cardButton({ kind: 'person', tmdbId: p.tmdb_id, title: p.name, poster: p.photo }) : '';
return p.tmdb_id
? '' + cb + inner + ''
: '
' + inner + '
';
}).join('');
if (window.VideoWatchlist) VideoWatchlist.hydrate(castHost);
}
}
function renderActions(d) {
var a = q('[data-vd-actions]');
if (!a) return;
if (d.source === 'youtube') {
// Same watchlist button as shows/movies (consistency); it follows the
// CHANNEL or the PLAYLIST depending on what this page is.
var on = !!d.following;
var isPl = d.kind === 'playlist';
var ytUrl = isPl
? 'https://www.youtube.com/playlist?list=' + esc(d.id)
: 'https://www.youtube.com/channel/' + esc(d.id);
a.innerHTML =
'' +
'Open on YouTube ↗';
return;
}
// The watchlist eye applies only to airing shows (movies + ended shows are
// terminal — they get acquisition, not a "watch for new" follow).
var isAiringShow = d.kind === 'show' && d.tmdb_id && (!window.VideoGet || VideoGet.isAiring(d.status));
var watching = !!d._vw_watched;
// Lazily resolve the real watched state once (airing library shows are on
// by default), then re-render — see the new curated watchlist system.
if (isAiringShow && !d._vw_checked && window.VideoWatchlist) {
d._vw_checked = true;
fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'show', tmdb_ids: [d.tmdb_id] }) })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (res && res.results) { d._vw_watched = !!res.results[String(d.tmdb_id)]; if (data === d) renderActions(d); }
}).catch(function () { /* keep default state */ });
}
var html = '';
// Primary CTA: play it on your media server (owned items; arrives with
// extras). The logo IS the brand name — "Play on " (no redundant word).
if (d.server && d.server.url) {
var sv = esc(d.server.server || 'Server');
var slogo = SERVER_LOGOS[d.server.server];
var inner = slogo
? '▶Play on' +
''
: '▶Play on ' + sv + '';
html += '' + inner + '';
}
if (d.trailer && d.trailer.key) {
html += '';
}
// Watchlist (follow an AIRING show to wishlist its new episodes) applies whether
// the show is owned or a TMDB preview — the curated watchlist is keyed by
// tmdb_id. Ended/cancelled shows are terminal (isAiringShow=false) → no button.
if (isAiringShow) {
html +=
'';
}
// "Get Missing" filters the OWNED episode list — library shows only.
if (d.kind === 'show' && d.source !== 'tmdb') {
html += '';
}
a.innerHTML = html;
}
function mediaRes(r) {
if (!r) return '';
r = String(r).toLowerCase();
if (r.indexOf('2160') > -1 || r === '4k') return '4K';
if (r.indexOf('1080') > -1) return '1080p';
if (r.indexOf('720') > -1) return '720p';
if (r.indexOf('480') > -1 || r.indexOf('576') > -1) return 'SD';
return r.toUpperCase();
}
function prettyCodec(c) {
if (!c) return '';
var l = String(c).toLowerCase();
if (l.indexOf('hevc') > -1 || l.indexOf('265') > -1) return 'HEVC';
if (l.indexOf('264') > -1 || l === 'avc') return 'H.264';
if (l.indexOf('av1') > -1) return 'AV1';
if (l.indexOf('vp9') > -1) return 'VP9';
return String(c).toUpperCase();
}
function prettySource(s) {
var map = { bluray: 'Blu-ray', 'web-dl': 'WEB-DL', webdl: 'WEB-DL', webrip: 'WEBRip',
hdtv: 'HDTV', youtube: 'YouTube', dvd: 'DVD', remux: 'Remux' };
return map[String(s || '').toLowerCase()] || String(s || '');
}
function fmtBytes(n) {
if (!n) return '';
var gb = n / 1073741824;
return gb >= 1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round(n / 1048576) + ' MB';
}
function fileSummary(v) {
return [mediaRes(v.resolution), prettyCodec(v.video_codec),
v.audio_codec ? String(v.audio_codec).toUpperCase() : '', fmtBytes(v.size_bytes),
v.release_source ? prettySource(v.release_source) : ''].filter(Boolean).join(' · ');
}
function renderDetails(d) {
var host = q('[data-vd-details]');
if (!host) return;
var rows = [];
if (d.release_date) rows.push(['Released', d.release_date]);
if (d.runtime_minutes) rows.push(['Runtime', runtimeLabel(d.runtime_minutes)]);
if (d.studio) rows.push(['Studio', d.studio]);
if (d.status) rows.push(['Status', statusLabel(d.status)]);
if (d.rating_critic) rows.push(['Critic score', Math.round(d.rating_critic) + '%']);
// Your media — the technical specs we scanned (Plex-grade).
var f = d.file;
if (f) {
if (f.resolution) rows.push(['Quality', mediaRes(f.resolution)]);
if (f.video_codec) rows.push(['Video', prettyCodec(f.video_codec)]);
if (f.audio_codec) rows.push(['Audio', String(f.audio_codec).toUpperCase()]);
if (f.release_source) rows.push(['Source', prettySource(f.release_source)]);
if (f.size_bytes) rows.push(['Size', fmtBytes(f.size_bytes)]);
}
var html = rows.length
? '
' + rows.map(function (r) {
return '
' + esc(r[0]) +
'' + esc(r[1]) + '
';
}).join('') + '
'
: '';
// Multiple versions / editions you own.
var files = d.files || [];
if (files.length > 1) {
html += '
' + files.length + ' versions
' +
files.map(function (v) {
return '
' + esc(fileSummary(v)) + '
';
}).join('') + '
';
}
host.innerHTML = html;
}
// ── live TMDB extras (trailer / where-to-watch / similar) ─────────────────
function resetExtras() {
['[data-vd-providers-section]', '[data-vd-similar-section]', '[data-vd-collection-section]',
'[data-vd-next-ep]', '[data-vd-crew-line]', '[data-vd-season-overview]',
'[data-vd-facts-section]', '[data-vd-videos-section]', '[data-vd-gallery-section]',
'[data-vd-review-section]', '[data-vd-cast-all]'].forEach(function (s) {
var n = q(s); if (n) n.hidden = true;
});
// Clear any YouTube-channel playlists from the show DOM so they don't leak
// onto the next movie/show you open (the section is reused across loads).
ytResetPlaylists();
galleryImages = [];
stopBillboardTrailer();
}
function loadExtras(kind, id) {
fetch(DETAIL_URL + kind + '/' + id + '/extras', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (ex) { if (ex) renderExtras(kind, id, ex); })
.catch(function () { /* best-effort */ });
}
function simCard(s) {
var poster = s.poster
? ''
: '🎬';
var simKind = s.kind === 'movie' ? 'movie' : 'show';
var yr = s.year ? '' + esc(s.year) + '' : '';
var cb = window.VideoGet ? VideoGet.cardButton({ kind: simKind, tmdbId: s.tmdb_id,
title: s.title, poster: s.poster, status: s.status, source: 'tmdb' }) : '';
return '' + cb +
poster + '' + esc(s.title) + '' + yr + '';
}
function renderRow(sectionSel, hostSel, items) {
var sec = q(sectionSel), host = q(hostSel);
if (!sec || !host) return;
if (!items || !items.length) { sec.hidden = true; return; }
sec.hidden = false;
host.innerHTML = items.map(simCard).join('');
if (window.VideoWatchlist) VideoWatchlist.hydrate(host);
}
function renderExtras(kind, id, ex) {
if (!data || data.id !== id || currentKind !== kind) return;
data.trailer = ex.trailer || null;
data.server = ex.server || null;
data.next_episode = ex.next_episode || null;
renderActions(data);
renderNextEpisode(data);
var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]');
if (ps && ph) {
var html = '';
// If it's on your media server, that's the best place to watch — lead
// with a "Play on Plex/Jellyfin" tile that deep-links to the item.
if (ex.server && ex.server.url) {
var sv = esc(ex.server.server || 'Server');
var slogo = SERVER_LOGOS[ex.server.server];
var sicon = slogo
? ''
: '▶';
html += '' +
sicon + 'Play on ' + sv + '';
}
// Streaming providers: TMDB only gives ONE aggregate 'where to watch'
// link (not per-provider), so showing N identical links is misleading.
// Render the logos as availability BADGES, then a single link to the
// watch page. (Drop a provider matching your server tile, e.g. Plex.)
var link = ex.providers_link || '';
var srvName = (ex.server && ex.server.server || '').toLowerCase();
var provs = (ex.providers || []).filter(function (p) {
return (p.name || '').toLowerCase() !== srvName;
});
if (provs.length) {
html += provs.map(function (p) {
var img = p.logo ? ''
: '' + esc((p.name || '?').charAt(0)) + '';
return '
' + img +
'' + esc(p.name) + '
';
}).join('');
if (link) {
html += '' +
'↗' +
'Where to watch';
}
}
ps.hidden = !html;
ph.innerHTML = html;
if (!ps.hidden) {
loadPrefs(function (p) {
var h = ps.querySelector('.vd-section-h');
if (h) h.textContent = 'Where to Watch' + (p && p.watch_region ? ' · ' + p.watch_region : '');
});
}
}
// Franchise / collection (movies) — the other films in the set.
var cs = q('[data-vd-collection-section]'), ch = q('[data-vd-collection]'), ct = q('[data-vd-collection-title]');
var coll = ex.collection;
if (cs && ch) {
if (coll && coll.items && coll.items.length) {
cs.hidden = false;
if (ct) ct.textContent = coll.name || 'Collection';
ch.innerHTML = coll.items.map(simCard).join('');
if (window.VideoWatchlist) VideoWatchlist.hydrate(ch);
} else { cs.hidden = true; }
}
// "More Like This" — recommendations (better-curated), falling back to similar.
var more = (ex.recommendations && ex.recommendations.length) ? ex.recommendations : ex.similar;
renderRow('[data-vd-similar-section]', '[data-vd-similar]', more);
data.cast_full = ex.cast_full || null;
renderCastAll(data);
renderFacts(ex.facts, ex.keywords);
renderVideos(ex.videos);
renderGallery(ex.gallery);
renderReview(ex.review);
maybeAutoplayBillboard();
}
function renderReview(review) {
var sec = q('[data-vd-review-section]'), host = q('[data-vd-review]');
if (!sec || !host) return;
if (!review || !review.content) { sec.hidden = true; return; }
sec.hidden = false;
var rating = review.rating ? '★ ' + review.rating + '/10' : '';
var date = review.created ? '' + esc(review.created) + '' : '';
var long = review.content.length > 420;
host.innerHTML = '
';
}
function lightboxStep(dir) {
if (!galleryImages.length) return;
lightboxIdx = (lightboxIdx + dir + galleryImages.length) % galleryImages.length;
renderLightbox();
}
function closeLightbox() {
var ov = document.getElementById('vd-lightbox');
if (ov) { ov.classList.remove('vd-lightbox--open'); ov.innerHTML = ''; }
}
function lightboxOpen() {
var ov = document.getElementById('vd-lightbox');
return ov && ov.classList.contains('vd-lightbox--open');
}
// ── full cast modal ───────────────────────────────────────────────────────
function renderCastAll(d) {
var btn = q('[data-vd-cast-all]');
if (!btn) return;
var n = (d.cast_full || []).length;
btn.hidden = n === 0;
if (n) btn.textContent = 'View all ' + n;
}
function castModalCard(p) {
var img = p.photo
? ''
: '' + esc((p.name || '?').charAt(0)) + '';
var eps = p.episode_count ? '' + p.episode_count + ' eps' : '';
var inner = img + '' + esc(p.name) + '' +
(p.character ? '' + esc(p.character) + '' : '') + eps;
return p.tmdb_id
? '' + inner + ''
: '
' + inner + '
';
}
function openCastModal() {
var cast = (data && data.cast_full) || [];
if (!cast.length) return;
var ov = document.getElementById('vd-cast-modal');
if (!ov) {
ov = document.createElement('div'); ov.id = 'vd-cast-modal'; ov.className = 'vd-cast-modal';
ov.addEventListener('click', function (e) {
var card = e.target.closest('[data-vd-person]');
if (card) {
if (modified(e)) return;
e.preventDefault();
var pid = parseInt(card.getAttribute('data-vd-person'), 10);
closeCastModal();
if (!isNaN(pid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: 'person', id: pid, source: 'tmdb' } }));
return;
}
if (e.target === ov || e.target.closest('[data-vd-cm-close]')) closeCastModal();
});
document.body.appendChild(ov);
}
ov.innerHTML = '
Cast
' +
'
' +
'
' + cast.map(castModalCard).join('') + '
';
ov.classList.add('vd-cast-modal--open');
}
function closeCastModal() {
var ov = document.getElementById('vd-cast-modal');
if (ov) { ov.classList.remove('vd-cast-modal--open'); ov.innerHTML = ''; }
}
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
function openTrailer(key) {
if (!key) return;
stopBillboardTrailer(); // don't double up audio with the billboard
var ov = document.getElementById('vd-trailer-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'vd-trailer-overlay';
ov.className = 'vd-trailer-overlay';
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.closest('[data-vd-trailer-close]')) closeTrailer();
});
document.body.appendChild(ov);
}
ov.innerHTML = '
' +
'' +
'
';
ov.classList.add('vd-trailer-overlay--open');
}
function closeTrailer() {
var ov = document.getElementById('vd-trailer-overlay');
if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; }
}
// ── billboard autoplay trailer (opt-in setting) ───────────────────────────
var prefs = null, bbTrailerTimer = null, bbMuted = true;
var bbMsgHandler = null, bbRevealTimer = null;
function loadPrefs(cb) {
if (prefs) { cb(prefs); return; }
fetch('/api/video/prefs', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { prefs = d || {}; cb(prefs); })
.catch(function () { prefs = {}; cb(prefs); });
}
function maybeAutoplayBillboard() {
stopBillboardTrailer();
if (!data || !data.trailer || !data.trailer.key) return;
var key = data.trailer.key, id = currentId, kind = currentKind;
loadPrefs(function (p) {
if (!p || !p.billboard_autoplay || currentId !== id || currentKind !== kind) return;
bbTrailerTimer = setTimeout(function () {
if (currentId === id && currentKind === kind) startBillboardTrailer(key);
}, 2600);
});
}
function startBillboardTrailer(key) {
var bb = q('.vd-billboard'); if (!bb || bb.querySelector('[data-vd-bb-trailer]')) return;
bbMuted = true;
bb.classList.remove('vd-billboard--restoring');
var wrap = document.createElement('div');
wrap.className = 'vd-bb-trailer'; wrap.setAttribute('data-vd-bb-trailer', '');
wrap.innerHTML = '' +
'
' +
'
';
bb.appendChild(wrap);
var iframe = wrap.querySelector('iframe');
// Ask YouTube to report playback state, then reveal ONLY once it's truly
// PLAYING (state 1) — so the wipe doesn't fire over a black/buffering frame
// — and restore the backdrop when it ENDS (state 0).
function handshake() {
try {
iframe.contentWindow.postMessage(
'{"event":"listening","id":"vd-bb","channel":"widget"}', '*');
} catch (e) { /* cross-origin not ready yet */ }
}
iframe.addEventListener('load', function () { handshake(); setTimeout(handshake, 500); });
bbMsgHandler = function (e) {
if (!iframe.contentWindow || e.source !== iframe.contentWindow) return;
var msg; try { msg = JSON.parse(e.data); } catch (x) { return; }
var st = null;
if (msg && msg.event === 'infoDelivery' && msg.info && typeof msg.info.playerState === 'number') st = msg.info.playerState;
else if (msg && msg.event === 'onStateChange' && typeof msg.info === 'number') st = msg.info;
if (st === 1) revealBillboardTrailer(bb);
else if (st === 0) restoreBillboard(bb);
};
window.addEventListener('message', bbMsgHandler);
// Safety net: if YouTube never reports PLAYING (blocked handshake), reveal
// anyway so a playing-but-hidden trailer doesn't sit behind the backdrop.
bbRevealTimer = setTimeout(function () { revealBillboardTrailer(bb); }, 4500);
}
function revealBillboardTrailer(bb) {
clearTimeout(bbRevealTimer); bbRevealTimer = null;
bb.classList.remove('vd-billboard--restoring');
bb.classList.add('vd-billboard--trailer');
}
function restoreBillboard(bb) {
// Trailer finished → fade the original backdrop back in, then tear down.
bb.classList.remove('vd-billboard--trailer');
bb.classList.add('vd-billboard--restoring');
setTimeout(function () {
if (bb.classList.contains('vd-billboard--restoring')) stopBillboardTrailer();
}, 950);
}
function stopBillboardTrailer() {
clearTimeout(bbTrailerTimer); bbTrailerTimer = null;
clearTimeout(bbRevealTimer); bbRevealTimer = null;
if (bbMsgHandler) { window.removeEventListener('message', bbMsgHandler); bbMsgHandler = null; }
var ws = document.querySelectorAll('[data-vd-bb-trailer]');
for (var i = 0; i < ws.length; i++) ws[i].remove();
var bbs = document.querySelectorAll('.vd-billboard--trailer, .vd-billboard--restoring');
for (var j = 0; j < bbs.length; j++) bbs[j].classList.remove('vd-billboard--trailer', 'vd-billboard--restoring');
}
function toggleBillboardMute(btn) {
bbMuted = !bbMuted;
var iframe = document.querySelector('[data-vd-bb-trailer] iframe');
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(JSON.stringify(
{ event: 'command', func: bbMuted ? 'mute' : 'unMute', args: [] }), '*');
}
btn.textContent = bbMuted ? '🔇' : '🔊';
}
// ── season selector (4 views) ─────────────────────────────────────────────
function renderViewToggle() {
var host = q('[data-vd-view-toggle]');
if (!host) return;
if (data && data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no view toggle
host.innerHTML = VIEWS.map(function (v) {
return '';
}).join('');
}
function renderSeasonNav() {
var host = q('[data-vd-season-nav]');
if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; }
if (data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no season nav
if (data.source === 'youtube') { // channels: search + sort controls above the
// year nav — which still honours the view toggle (rail posters, timeline,
// tabs, list). Flat mode (search / most-viewed / longest) hides the nav.
host.className = 'vd-season-nav vd-season-nav--yt vd-season-nav--' + seasonView;
var nav = ytFlatMode() ? ''
: seasonView === 'rail' ? railHTML()
: seasonView === 'timeline' ? timelineHTML()
: seasonView === 'pills' ? pillsHTML()
: dropdownHTML();
host.innerHTML = ytControlsHTML() + nav;
return;
}
host.className = 'vd-season-nav vd-season-nav--' + seasonView;
if (seasonView === 'rail') host.innerHTML = railHTML();
else if (seasonView === 'timeline') host.innerHTML = timelineHTML();
else if (seasonView === 'pills') host.innerHTML = pillsHTML();
else host.innerHTML = dropdownHTML();
}
function railHTML() {
return '
' + data.seasons.map(function (s) {
var art = seasonArt(s), p = pct(s);
var on = s.season_number === selectedSeason ? ' vd-rcard--active' : '';
// YouTube posters carry a lower-res fallback (maxres → original) so a
// missing maxresdefault.jpg downgrades instead of vanishing.
var fb = (s.poster_fallback && s.poster_fallback !== art) ? s.poster_fallback : '';
var oe = fb
? 'var f=this.getAttribute(\'data-fb\');if(f){this.removeAttribute(\'data-fb\');this.src=f;}else{this.style.display=\'none\';}'
: 'this.style.display=\'none\'';
var img = art ? '' : '';
return '';
}).join('') + '
';
}
function timelineHTML() {
var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1;
return '
' + data.seasons.map(function (s) {
var p = pct(s), grow = Math.max(1, s.episode_total);
var on = s.season_number === selectedSeason ? ' vd-tseg--active' : '';
return '';
}).join('') + '
';
}
function pillsHTML() {
return '
' + data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : '';
return '';
}).join('') + '
';
}
function dropdownHTML() {
var cur = seasonByNum(selectedSeason);
return '
' +
'' +
'
' +
data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : '';
return '';
}).join('') + '
';
}
// ── episodes ──────────────────────────────────────────────────────────────
// A YouTube video as an "episode": still + title + date, a Wish toggle instead
// of owned/missing, expand → full description (lazy).
function ytEpisodeRow(ep) {
var key = selectedSeason + '_' + ep.episode_number;
var still = ep.still_url
? ''
: '';
var dur = ep.yt_duration ? '' + esc(ep.yt_duration) + '' : '';
var meta = [];
var yc0 = window.VideoYoutube;
if (ep.view_count) { meta.push((yc0 ? yc0.compactCount(ep.view_count) : ep.view_count) + ' views'); }
if (ep.like_count) { meta.push('👍 ' + (yc0 ? yc0.compactCount(ep.like_count) : ep.like_count)); }
if (ep.dislike_count) { meta.push('👎 ' + (yc0 ? yc0.compactCount(ep.dislike_count) : ep.dislike_count)); }
if (ep.air_date) meta.push(fmtDate(ep.air_date));
var wished = !!ep.owned;
return '
' +
'';
}
function toggleEpisode(row) {
var key = row.getAttribute('data-vd-ep-key');
var panel = q('[data-vd-ep-panel="' + key + '"]');
if (!panel) return;
panel.hidden = !panel.hidden;
row.classList.toggle('vd-ep--open', !panel.hidden);
if (!panel.hidden && !panel.getAttribute('data-loaded')) {
panel.setAttribute('data-loaded', '1');
loadEpisodeExtra(key, panel);
}
}
function loadEpisodeExtra(key, panel) {
// YouTube: the row carries the video id → fetch its full metadata.
if (data && data.source === 'youtube') {
var row = q('[data-vd-ep-key="' + key + '"]');
var vid = row && row.getAttribute('data-vd-yt-vid');
if (!vid) { panel.innerHTML = '
No details.
'; return; }
panel.innerHTML = '
Loading…
';
fetch('/api/video/youtube/video/' + encodeURIComponent(vid), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var v = (d && d.video) || {};
var yc = window.VideoYoutube, stats = [];
var lk = yc && yc.compactCount(v.like_count); if (lk) stats.push(lk + ' likes');
var vw = yc && yc.compactCount(v.view_count); if (vw) stats.push(vw + ' views');
var dearrow = v.dearrow_title
? '
DeArrow' +
esc(v.dearrow_title) + '
'
: '';
panel.innerHTML = '
' + dearrow +
(stats.length ? '
' + esc(stats.join(' · ')) + '
' : '') +
'
' + esc(v.description || 'No description.') + '
';
})
.catch(function () { panel.innerHTML = '
No details.
'; });
return;
}
var tmdb = data && data.tmdb_id;
var parts = key.split('_');
if (!tmdb) { panel.innerHTML = '
No extra info.
'; return; }
panel.innerHTML = '
Loading…
';
fetch('/api/video/episode/' + tmdb + '/' + parts[0] + '/' + parts[1],
{ headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (ex) { renderEpisodeExtra(panel, ex && !ex.error ? ex : {}); })
.catch(function () { panel.innerHTML = ''; });
}
function renderEpisodeExtra(panel, ex) {
var html = '';
if (ex.still_url) {
html += '';
}
html += '
';
if (ex.overview) html += '
' + esc(ex.overview) + '
';
if (ex.guest_stars && ex.guest_stars.length) {
html += '
';
}
function toggleYtPlaylistWatch(btn) {
var yc = window.VideoYoutube; if (!yc) return;
var pid = btn.getAttribute('data-vd-yt-pl-watch'), on = btn.classList.contains('watching');
btn.disabled = true;
var setBtn = function (s) {
btn.classList.toggle('watching', s);
var ic = btn.querySelector('.watchlist-icon'); if (ic) ic.textContent = s ? '✓' : '+';
var tx = btn.querySelector('.watchlist-text'); if (tx) tx.textContent = s ? 'In Watchlist' : 'Add to Watchlist';
btn.disabled = false;
};
var bump = function () { document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
if (on) {
yc.unfollowPlaylist(pid).then(function () { setBtn(false); bump(); }).catch(function () { btn.disabled = false; });
} else {
yc.followPlaylist({ playlist_id: pid, title: btn.getAttribute('data-pl-title'),
thumbnail_url: btn.getAttribute('data-pl-thumb') }).then(function (d) {
if (d && d.success) { setBtn(true); bump(); if (typeof showToast === 'function') showToast('Added to watchlist', 'success'); }
else btn.disabled = false;
}).catch(function () { btn.disabled = false; });
}
}
function ytLoadPlaylists(cid) {
var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]');
if (!host) return;
fetch('/api/video/youtube/playlists/' + encodeURIComponent(cid), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var pls = (d && d.playlists) || [];
if (!pls.length || currentId !== cid) return;
host.innerHTML = pls.map(ytPlaylistRow).join('');
if (sec) sec.hidden = false;
})
.catch(function () { /* best-effort */ });
}
function ytPlVideoCard(v) {
ytVideoMap[v.youtube_id] = v;
var thumb = v.thumbnail_url
? '' : '';
return '
' +
'
' +
thumb + '▶
' +
'
' + esc(v.title || 'Untitled') + '
' +
ytWishBtn(v.youtube_id, v.wished, true) + '
';
}
function toggleYtPlaylist(el) {
var pid = el.getAttribute('data-vd-yt-pl-toggle');
var blk = el.closest('.vc-pl'); if (!blk) return;
var opened = blk.classList.toggle('vc-pl--collapsed') === false;
if (!opened) return;
var host = q('[data-vd-yt-pl-vids="' + pid + '"]');
if (!host || host.getAttribute('data-loaded')) return;
host.setAttribute('data-loaded', '1');
host.innerHTML = '