The TMDB-source show detail page rendered an empty action bar — renderActions early-returned for source='tmdb' (a stale "previews have no actions" assumption that predates the curated, tmdb_id-keyed watchlist), so the Watchlist button never showed and the rest was skipped. Now an AIRING show gets the Watchlist button whether it's owned or a TMDB preview (ended/cancelled stay terminal → no button); Trailer renders from the payload; Get Missing stays library-only. Also fixed toggleWatchlist sending a bogus library_id + 404 poster proxy for tmdb previews (data.id is the tmdb id there) — it now omits library_id and uses the proxied TMDB poster, mirroring the card-hover add.
2020 lines
114 KiB
JavaScript
2020 lines
114 KiB
JavaScript
/*
|
||
* 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, '"').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
|
||
? '<img src="' + logo + '" alt="' + fallback + '" onerror="this.parentNode.textContent=\'' + fallback + '\'">'
|
||
: '<span style="font-size:9px;font-weight:700;">' + fallback + '</span>';
|
||
return url
|
||
? '<a class="artist-hero-badge" title="' + title + '" href="' + url + '" target="_blank" rel="noopener noreferrer">' + inner + '</a>'
|
||
: '<div class="artist-hero-badge" title="' + title + '">' + inner + '</div>';
|
||
}
|
||
|
||
// ── 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('<span class="vd-status vd-status--yt">' + (isPl ? 'Playlist' : 'YouTube') + '</span>');
|
||
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('<span>' + esc(d.video_count) + ' videos</span>');
|
||
} else {
|
||
var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('<span>' + subs + ' subscribers</span>');
|
||
// 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('<span>' + views + ' views</span>');
|
||
if (d.handle) meta.push('<span>' + esc(d.handle) + '</span>');
|
||
}
|
||
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 '<span class="vd-genre">' + esc(gn) + '</span>'; }).join('');
|
||
renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d);
|
||
return;
|
||
}
|
||
if (d.source === 'tmdb') {
|
||
meta.push('<span class="vd-status vd-status--preview">Preview</span>');
|
||
} else if (d.kind === 'show') {
|
||
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
|
||
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
|
||
} else {
|
||
meta.push(d.owned ? '<span class="vd-match">In library</span>'
|
||
: '<span class="vd-status">Wanted</span>');
|
||
}
|
||
if (d.rating) meta.push('<span class="vd-score">★ ' + (Math.round(d.rating * 10) / 10) + '</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>');
|
||
if (d.kind === 'show') {
|
||
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.kind === 'show' && d.status) meta.push('<span class="vd-status">' + esc(statusLabel(d.status)) + '</span>');
|
||
if (d.network) meta.push('<span>' + esc(d.network) + '</span>');
|
||
if (d.kind === 'movie' && d.studio) meta.push('<span>' + esc(d.studio) + '</span>');
|
||
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 '<span class="vd-genre">' + esc(gn) + '</span>';
|
||
}).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 '<span class="vd-sub" title="' + esc(subLangLabel(c)) + '">' + esc(subLangLabel(c)) + '</span>';
|
||
});
|
||
if (langs.length > shown.length) {
|
||
chips.push('<span class="vd-sub vd-sub--more">+' + (langs.length - shown.length) + '</span>');
|
||
}
|
||
el.innerHTML = '<span class="vd-sub-label">Subtitles</span>' + 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
|
||
? '<a class="vd-person-link" href="/video-detail/tmdb/person/' + c.tmdb_id +
|
||
'" data-vd-person="' + c.tmdb_id + '">' + esc(c.name) + '</a>'
|
||
: 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 = '<span class="vd-crew-line-k">' + label + '</span> ' +
|
||
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 = '<span class="vd-next-ep-badge">▸ Next Episode</span>' +
|
||
'<span class="vd-next-ep-code">' + esc(code) + '</span>' +
|
||
(ne.name ? '<span class="vd-next-ep-name">' + esc(ne.name) + '</span>' : '') +
|
||
'<span class="vd-next-ep-when">' + esc(fmtDate(ne.air_date)) + '</span>';
|
||
el.hidden = false;
|
||
}
|
||
|
||
function renderRatings(d) {
|
||
var host = q('[data-vd-ratings]');
|
||
if (!host) return;
|
||
var items = [];
|
||
if (d.imdb_rating) {
|
||
items.push('<span class="vd-rt vd-rt--imdb"><span class="vd-rt-tag">IMDb</span>' +
|
||
(Math.round(d.imdb_rating * 10) / 10) + '</span>');
|
||
}
|
||
if (d.rt_rating != null) {
|
||
var fresh = d.rt_rating >= 60;
|
||
items.push('<span class="vd-rt vd-rt--rt"><span class="vd-rt-ic">' +
|
||
(fresh ? '🍅' : '🤢') + '</span>' + d.rt_rating + '%</span>');
|
||
}
|
||
if (d.metacritic != null) {
|
||
var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad';
|
||
items.push('<span class="vd-rt vd-rt--mc vd-rt--mc-' + cls + '">' +
|
||
'<span class="vd-rt-tag">MC</span>' + d.metacritic + '</span>');
|
||
}
|
||
if (d.trakt_rating) {
|
||
var tv = d.trakt_votes ? ' title="' + esc(d.trakt_votes) + ' Trakt votes"' : '';
|
||
items.push('<span class="vd-rt vd-rt--trakt"' + tv + '><span class="vd-rt-tag">Trakt</span>' +
|
||
(Math.round(d.trakt_rating * 10) / 10) + '</span>');
|
||
}
|
||
if (d.tvmaze_rating) {
|
||
items.push('<span class="vd-rt vd-rt--tvmaze"><span class="vd-rt-tag">TVmaze</span>' +
|
||
(Math.round(d.tvmaze_rating * 10) / 10) + '</span>');
|
||
}
|
||
if (d.anilist_score) {
|
||
items.push('<span class="vd-rt vd-rt--anilist"><span class="vd-rt-tag">AniList</span>' +
|
||
d.anilist_score + '%</span>');
|
||
}
|
||
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 '<span class="vd-crew-item"><span class="vd-crew-job">' + esc(job) +
|
||
(byJob[job].length > 1 ? 's' : '') + '</span> ' +
|
||
byJob[job].map(personName).join(', ') + '</span>';
|
||
}).join('');
|
||
}
|
||
var castHost = q('[data-vd-cast]');
|
||
if (castHost) {
|
||
castHost.innerHTML = cast.map(function (p) {
|
||
var img = p.photo
|
||
? '<img class="vd-cast-photo" src="' + esc(p.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
||
: '<span class="vd-cast-photo vd-cast-photo--ph">' + esc((p.name || '?').charAt(0)) + '</span>';
|
||
var inner = img +
|
||
'<span class="vd-cast-name">' + esc(p.name) + '</span>' +
|
||
(p.character ? '<span class="vd-cast-char">' + esc(p.character) + '</span>' : '');
|
||
// 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
|
||
? '<a class="vd-cast-card vd-cast-card--link" href="/video-detail/tmdb/person/' + p.tmdb_id +
|
||
'" data-vd-person="' + p.tmdb_id + '">' + cb + inner + '</a>'
|
||
: '<div class="vd-cast-card">' + inner + '</div>';
|
||
}).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 =
|
||
'<button class="library-artist-watchlist-btn' + (on ? ' watching' : '') + '" type="button" data-vd-act="' +
|
||
(isPl ? 'yt-pl-follow' : 'yt-follow') + '">' +
|
||
'<span class="watchlist-icon">' + (on ? '✓' : '+') + '</span>' +
|
||
'<span class="watchlist-text">' + (on ? 'In Watchlist' : 'Watchlist') + '</span></button>' +
|
||
'<a class="vd-yt-link" href="' + ytUrl + '" target="_blank" rel="noopener">Open on YouTube ↗</a>';
|
||
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 <logo>" (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
|
||
? '<span class="vd-play-ic">▶</span><span>Play on</span>' +
|
||
'<img class="vd-play-logo" src="' + esc(slogo) + '" alt="' + sv + '">'
|
||
: '<span class="vd-play-ic">▶</span><span>Play on ' + sv + '</span>';
|
||
html += '<a class="vd-play-btn" href="' + esc(d.server.url) +
|
||
'" target="_blank" rel="noopener" title="Play on ' + sv + '">' + inner + '</a>';
|
||
}
|
||
if (d.trailer && d.trailer.key) {
|
||
html += '<button class="vd-trailer-btn" type="button" data-vd-act="trailer">' +
|
||
'<span class="vd-trailer-ic">▶</span> Trailer</button>';
|
||
}
|
||
// 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 +=
|
||
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
|
||
'" type="button" data-vd-act="watchlist">' +
|
||
'<span class="watchlist-icon">' + (watching ? '✓' : '+') + '</span>' +
|
||
'<span class="watchlist-text">' + (watching ? 'In Watchlist' : 'Watchlist') + '</span></button>';
|
||
}
|
||
// "Get Missing" filters the OWNED episode list — library shows only.
|
||
if (d.kind === 'show' && d.source !== 'tmdb') {
|
||
html += '<button class="discog-download-btn discog-btn-compact" type="button" data-vd-act="missing">' +
|
||
'<span class="discog-btn-icon">⭳</span><span class="discog-btn-text">Get Missing</span>' +
|
||
'<span class="discog-btn-shimmer"></span></button>';
|
||
}
|
||
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
|
||
? '<div class="vd-detail-grid">' + rows.map(function (r) {
|
||
return '<div class="vd-detail-row"><span class="vd-detail-k">' + esc(r[0]) +
|
||
'</span><span class="vd-detail-v">' + esc(r[1]) + '</span></div>';
|
||
}).join('') + '</div>'
|
||
: '';
|
||
// Multiple versions / editions you own.
|
||
var files = d.files || [];
|
||
if (files.length > 1) {
|
||
html += '<div class="vd-versions"><div class="vd-versions-h">' + files.length + ' versions</div>' +
|
||
files.map(function (v) {
|
||
return '<div class="vd-version">' + esc(fileSummary(v)) + '</div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
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
|
||
? '<img class="vd-sim-poster" src="' + esc(s.poster) + '" alt="" loading="lazy">'
|
||
: '<span class="vd-sim-poster vd-sim-poster--ph">🎬</span>';
|
||
var simKind = s.kind === 'movie' ? 'movie' : 'show';
|
||
var yr = s.year ? '<span class="vd-sim-year">' + esc(s.year) + '</span>' : '';
|
||
var cb = window.VideoGet ? VideoGet.cardButton({ kind: simKind, tmdbId: s.tmdb_id,
|
||
title: s.title, poster: s.poster, status: s.status, source: 'tmdb' }) : '';
|
||
return '<a class="vd-sim-card" href="/video-detail/tmdb/' + simKind + '/' + s.tmdb_id +
|
||
'" data-vd-sim="' + simKind + '" data-vd-sim-id="' + s.tmdb_id + '">' + cb +
|
||
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span>' + yr + '</a>';
|
||
}
|
||
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
|
||
? '<span class="vd-prov-ph vd-prov-server-logo"><img src="' + esc(slogo) + '" alt="' + sv +
|
||
'" onerror="this.parentNode.textContent=\'▶\'"></span>'
|
||
: '<span class="vd-prov-ph vd-prov-play">▶</span>';
|
||
html += '<a class="vd-prov vd-prov--server" href="' + esc(ex.server.url) +
|
||
'" target="_blank" rel="noopener" title="Play on ' + sv + '">' +
|
||
sicon + '<span class="vd-prov-name">Play on ' + sv + '</span></a>';
|
||
}
|
||
// 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 ? '<img src="' + esc(p.logo) + '" alt="' + esc(p.name) + '" loading="lazy">'
|
||
: '<span class="vd-prov-ph">' + esc((p.name || '?').charAt(0)) + '</span>';
|
||
return '<div class="vd-prov vd-prov--badge" title="' + esc(p.name) + '">' + img +
|
||
'<span class="vd-prov-name">' + esc(p.name) + '</span></div>';
|
||
}).join('');
|
||
if (link) {
|
||
html += '<a class="vd-prov vd-prov--more" href="' + esc(link) +
|
||
'" target="_blank" rel="noopener" title="See where to watch (JustWatch)">' +
|
||
'<span class="vd-prov-ph vd-prov-more-ic">↗</span>' +
|
||
'<span class="vd-prov-name">Where to watch</span></a>';
|
||
}
|
||
}
|
||
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 ? '<span class="vd-review-rating">★ ' + review.rating + '/10</span>' : '';
|
||
var date = review.created ? '<span class="vd-review-date">' + esc(review.created) + '</span>' : '';
|
||
var long = review.content.length > 420;
|
||
host.innerHTML = '<div class="vd-review-head">' +
|
||
'<span class="vd-review-author">' + esc(review.author) + '</span>' + rating + date + '</div>' +
|
||
'<p class="vd-review-body" data-vd-review-body>' + esc(review.content) + '</p>' +
|
||
(long ? '<button class="vd-review-more" type="button" data-vd-review-more>Read more</button>' : '');
|
||
}
|
||
|
||
// ── facts / keywords ──────────────────────────────────────────────────────
|
||
var LANGS = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian',
|
||
ja: 'Japanese', ko: 'Korean', zh: 'Chinese', hi: 'Hindi', ru: 'Russian', pt: 'Portuguese',
|
||
sv: 'Swedish', da: 'Danish', nl: 'Dutch', no: 'Norwegian', fi: 'Finnish', pl: 'Polish',
|
||
tr: 'Turkish', ar: 'Arabic', he: 'Hebrew', th: 'Thai', cs: 'Czech' };
|
||
function langName(c) { return LANGS[c] || String(c || '').toUpperCase(); }
|
||
function fmtMoney(n) {
|
||
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||
if (n >= 1e3) return Math.round(n / 1e3) + 'K';
|
||
return String(n);
|
||
}
|
||
function renderFacts(facts, keywords) {
|
||
var sec = q('[data-vd-facts-section]'), host = q('[data-vd-facts]'), kwh = q('[data-vd-keywords]');
|
||
facts = facts || {}; keywords = keywords || [];
|
||
var rows = [];
|
||
if (facts.budget) rows.push(['Budget', '$' + fmtMoney(facts.budget)]);
|
||
if (facts.revenue) rows.push(['Box office', '$' + fmtMoney(facts.revenue)]);
|
||
if (facts.original_language) rows.push(['Language', langName(facts.original_language)]);
|
||
if (facts.countries && facts.countries.length) rows.push(['Country', facts.countries.join(', ')]);
|
||
if (host) {
|
||
host.innerHTML = rows.length ? '<div class="vd-detail-grid">' + rows.map(function (r) {
|
||
return '<div class="vd-detail-row"><span class="vd-detail-k">' + esc(r[0]) +
|
||
'</span><span class="vd-detail-v">' + esc(r[1]) + '</span></div>';
|
||
}).join('') + '</div>' : '';
|
||
}
|
||
if (kwh) {
|
||
kwh.innerHTML = keywords.map(function (k) { return '<span class="vd-kw">' + esc(k) + '</span>'; }).join('');
|
||
}
|
||
if (sec) sec.hidden = !(rows.length || keywords.length);
|
||
}
|
||
|
||
// ── videos (all trailers/teasers/clips) ───────────────────────────────────
|
||
function renderVideos(videos) {
|
||
var sec = q('[data-vd-videos-section]'), host = q('[data-vd-videos]');
|
||
if (!sec || !host) return;
|
||
videos = videos || [];
|
||
if (!videos.length) { sec.hidden = true; return; }
|
||
sec.hidden = false;
|
||
host.innerHTML = videos.map(function (v) {
|
||
var thumb = 'https://img.youtube.com/vi/' + encodeURIComponent(v.key) + '/mqdefault.jpg';
|
||
return '<button class="vd-video-card" type="button" data-vd-video="' + esc(v.key) + '">' +
|
||
'<span class="vd-video-thumb"><img src="' + thumb + '" alt="" loading="lazy">' +
|
||
'<span class="vd-video-play">▶</span></span>' +
|
||
'<span class="vd-video-name">' + esc(v.name || v.type) + '</span>' +
|
||
'<span class="vd-video-type">' + esc(v.type) + '</span></button>';
|
||
}).join('');
|
||
}
|
||
|
||
// ── photos gallery + lightbox ─────────────────────────────────────────────
|
||
var galleryImages = [], lightboxIdx = 0;
|
||
function renderGallery(gallery) {
|
||
var sec = q('[data-vd-gallery-section]'), host = q('[data-vd-gallery]');
|
||
if (!sec || !host) return;
|
||
var imgs = (gallery && gallery.backdrops) ? gallery.backdrops : [];
|
||
galleryImages = imgs.map(function (g) { return g.full; });
|
||
if (!imgs.length) { sec.hidden = true; return; }
|
||
sec.hidden = false;
|
||
host.innerHTML = imgs.map(function (g, i) {
|
||
return '<button class="vd-shot" type="button" data-vd-shot="' + i + '">' +
|
||
'<img src="' + esc(g.thumb) + '" alt="" loading="lazy"></button>';
|
||
}).join('');
|
||
}
|
||
function openLightbox(idx) {
|
||
if (!galleryImages.length) return;
|
||
lightboxIdx = idx;
|
||
var ov = document.getElementById('vd-lightbox');
|
||
if (!ov) {
|
||
ov = document.createElement('div'); ov.id = 'vd-lightbox'; ov.className = 'vd-lightbox';
|
||
ov.addEventListener('click', function (e) {
|
||
if (e.target.closest('[data-vd-lb-prev]')) lightboxStep(-1);
|
||
else if (e.target.closest('[data-vd-lb-next]')) lightboxStep(1);
|
||
else if (e.target === ov || e.target.closest('[data-vd-lb-close]')) closeLightbox();
|
||
});
|
||
document.body.appendChild(ov);
|
||
}
|
||
renderLightbox();
|
||
ov.classList.add('vd-lightbox--open');
|
||
}
|
||
function renderLightbox() {
|
||
var ov = document.getElementById('vd-lightbox'); if (!ov) return;
|
||
ov.innerHTML = '<button class="vd-lb-close" type="button" data-vd-lb-close aria-label="Close">×</button>' +
|
||
'<button class="vd-lb-nav vd-lb-prev" type="button" data-vd-lb-prev aria-label="Previous">‹</button>' +
|
||
'<img class="vd-lb-img" src="' + esc(galleryImages[lightboxIdx]) + '" alt="">' +
|
||
'<button class="vd-lb-nav vd-lb-next" type="button" data-vd-lb-next aria-label="Next">›</button>' +
|
||
'<div class="vd-lb-count">' + (lightboxIdx + 1) + ' / ' + galleryImages.length + '</div>';
|
||
}
|
||
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
|
||
? '<img class="vd-cm-photo" src="' + esc(p.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
||
: '<span class="vd-cm-photo vd-cm-photo--ph">' + esc((p.name || '?').charAt(0)) + '</span>';
|
||
var eps = p.episode_count ? '<span class="vd-cm-eps">' + p.episode_count + ' eps</span>' : '';
|
||
var inner = img + '<span class="vd-cm-name">' + esc(p.name) + '</span>' +
|
||
(p.character ? '<span class="vd-cm-char">' + esc(p.character) + '</span>' : '') + eps;
|
||
return p.tmdb_id
|
||
? '<a class="vd-cm-card" href="/video-detail/tmdb/person/' + p.tmdb_id + '" data-vd-person="' + p.tmdb_id + '">' + inner + '</a>'
|
||
: '<div class="vd-cm-card">' + inner + '</div>';
|
||
}
|
||
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 = '<div class="vd-cm-box"><div class="vd-cm-head"><h3>Cast</h3>' +
|
||
'<button class="vd-cm-close" type="button" data-vd-cm-close aria-label="Close">×</button></div>' +
|
||
'<div class="vd-cm-grid">' + cast.map(castModalCard).join('') + '</div></div>';
|
||
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 = '<div class="vd-trailer-box">' +
|
||
'<button class="vd-trailer-close" type="button" data-vd-trailer-close aria-label="Close">×</button>' +
|
||
'<iframe src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
|
||
'?autoplay=1&rel=0" allow="autoplay; encrypted-media; fullscreen" allowfullscreen></iframe></div>';
|
||
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 = '<iframe allow="autoplay; encrypted-media" frameborder="0" ' +
|
||
'src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
|
||
'?autoplay=1&mute=1&controls=0&modestbranding=1&rel=0&playsinline=1&enablejsapi=1"></iframe>' +
|
||
'<div class="vd-bb-tctrls"><button class="vd-bb-tbtn" type="button" data-vd-bb-mute aria-label="Unmute">🔇</button>' +
|
||
'<button class="vd-bb-tbtn" type="button" data-vd-bb-stop aria-label="Stop">✕</button></div>';
|
||
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 '<button class="vd-vt-btn' + (v.id === seasonView ? ' vd-vt-btn--active' : '') +
|
||
'" type="button" data-vd-view="' + v.id + '" title="' + v.label + '">' +
|
||
'<span class="vd-vt-ic">' + v.ic + '</span></button>';
|
||
}).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 '<div class="vd-rail">' + 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 ? '<img class="vd-rcard-img" src="' + art + '" alt="" loading="lazy"' +
|
||
(fb ? ' data-fb="' + esc(fb) + '"' : '') + ' onerror="' + oe + '">' : '';
|
||
return '<button class="vd-rcard' + on + '" type="button" data-vd-season="' + s.season_number + '">' +
|
||
'<div class="vd-rcard-art">' + img + '<div class="vd-rcard-fb">📺</div>' +
|
||
'<div class="vd-rcard-grad"></div><div class="vd-rcard-pct">' + p + '%</div></div>' +
|
||
'<div class="vd-rcard-info"><span class="vd-rcard-name">' + esc(s.title) + '</span>' +
|
||
'<span class="vd-rcard-sub">' + s.episode_owned + ' / ' + s.episode_total + ' eps</span>' +
|
||
'<span class="vd-rcard-bar"><span style="width:' + p + '%"></span></span></div></button>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function timelineHTML() {
|
||
var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1;
|
||
return '<div class="vd-timeline">' + 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 '<button class="vd-tseg' + on + '" type="button" data-vd-season="' + s.season_number + '" ' +
|
||
'style="flex:' + grow + ' 1 0">' +
|
||
'<span class="vd-tseg-fill" style="width:' + p + '%"></span>' +
|
||
'<span class="vd-tseg-label"><span class="vd-tseg-name">' + esc(s.title) + '</span>' +
|
||
'<span class="vd-tseg-meta">' + s.episode_owned + '/' + s.episode_total + '</span></span></button>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function pillsHTML() {
|
||
return '<div class="vd-pills">' + data.seasons.map(function (s) {
|
||
var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : '';
|
||
return '<button class="vd-pill-btn' + on + '" type="button" data-vd-season="' + s.season_number + '">' +
|
||
esc(s.title) + '<span class="vd-pill-meta">' + s.episode_owned + '/' + s.episode_total + '</span></button>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function dropdownHTML() {
|
||
var cur = seasonByNum(selectedSeason);
|
||
return '<div class="vd-season-select">' +
|
||
'<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-season="' + s.season_number + '">' +
|
||
esc(s.title) + '<span class="vd-ss-opt-meta">' + s.episode_owned + '/' + s.episode_total + '</span></button>';
|
||
}).join('') + '</div></div>';
|
||
}
|
||
|
||
// ── 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
|
||
? '<img class="vd-ep-still" src="' + esc(ep.still_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
|
||
: '';
|
||
var dur = ep.yt_duration ? '<span class="vd-ep-dur">' + esc(ep.yt_duration) + '</span>' : '';
|
||
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 '<div class="vd-ep vd-ep--yt" data-vd-ep-key="' + key + '" data-vd-yt-vid="' + esc(ep.youtube_id) + '">' +
|
||
'<div class="vd-ep-thumb vd-ep-thumb--play" data-vd-yt-play="' + esc(ep.youtube_id) + '" title="Play video">' +
|
||
still + '<span class="vd-ep-thumb-ic">▶</span>' + dur + '</div>' +
|
||
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
|
||
esc(ep.title || 'Untitled') + '</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>' +
|
||
ytWishBtn(ep.youtube_id, wished, false) +
|
||
'<span class="vd-ep-chev" aria-hidden="true">⌄</span></div>' +
|
||
'<div class="vd-ep-extra" data-vd-ep-panel="' + key + '" hidden></div>';
|
||
}
|
||
|
||
// The per-video wishlist toggle — the app-standard watchlist-button chrome
|
||
// (icon + text, accent gradient) so it stops looking bespoke. iconOnly trims
|
||
// the label for the tight playlist-section cards.
|
||
function ytWishBtn(id, wished, iconOnly) {
|
||
return '<button class="library-artist-watchlist-btn vd-yt-wishbtn' +
|
||
(iconOnly ? ' vd-yt-wishbtn--icon' : '') + (wished ? ' watching' : '') +
|
||
'" type="button" data-vd-yt-wish="' + esc(id) + '">' +
|
||
'<span class="watchlist-icon">' + (wished ? '✓' : '+') + '</span>' +
|
||
(iconOnly ? '' : '<span class="watchlist-text">' + (wished ? 'In Wishlist' : 'Wishlist') + '</span>') +
|
||
'</button>';
|
||
}
|
||
|
||
function episodeRow(ep) {
|
||
if (data && data.source === 'youtube') return ytEpisodeRow(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);
|
||
var stillSrc = (data && data.source === 'tmdb')
|
||
? (ep.still_url || '')
|
||
: (ep.has_still ? '/api/video/poster/episode/' + ep.id : '');
|
||
var still = stillSrc
|
||
? '<img class="vd-ep-still" src="' + stillSrc + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
|
||
: '';
|
||
if (ep.rating) meta.push('★ ' + (Math.round(ep.rating * 10) / 10));
|
||
var key = selectedSeason + '_' + ep.episode_number;
|
||
// Row + a sibling expand panel (guest stars etc. load lazily on open).
|
||
return '<div class="vd-ep ' + owned + '" data-vd-ep-key="' + key + '">' +
|
||
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
|
||
'<div class="vd-ep-thumb">' + still + '<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>' +
|
||
'<span class="vd-ep-chev" aria-hidden="true">⌄</span></div>' +
|
||
'<div class="vd-ep-extra" data-vd-ep-panel="' + key + '" hidden></div>';
|
||
}
|
||
|
||
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 = '<div class="vd-ep-extra-empty">No details.</div>'; return; }
|
||
panel.innerHTML = '<div class="vd-ep-extra-empty">Loading…</div>';
|
||
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
|
||
? '<div class="vd-dearrow"><span class="vd-dearrow-tag">DeArrow</span>' +
|
||
esc(v.dearrow_title) + '</div>'
|
||
: '';
|
||
panel.innerHTML = '<div class="vd-ep-extra-body">' + dearrow +
|
||
(stats.length ? '<div class="vd-ep-extra-gh">' + esc(stats.join(' · ')) + '</div>' : '') +
|
||
'<p class="vd-ep-extra-ov">' + esc(v.description || 'No description.') + '</p></div>';
|
||
})
|
||
.catch(function () { panel.innerHTML = '<div class="vd-ep-extra-empty">No details.</div>'; });
|
||
return;
|
||
}
|
||
var tmdb = data && data.tmdb_id;
|
||
var parts = key.split('_');
|
||
if (!tmdb) { panel.innerHTML = '<div class="vd-ep-extra-empty">No extra info.</div>'; return; }
|
||
panel.innerHTML = '<div class="vd-ep-extra-empty">Loading…</div>';
|
||
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 += '<img class="vd-ep-extra-still" src="' + esc(ex.still_url) + '" alt="" loading="lazy">';
|
||
}
|
||
html += '<div class="vd-ep-extra-body">';
|
||
if (ex.overview) html += '<p class="vd-ep-extra-ov">' + esc(ex.overview) + '</p>';
|
||
if (ex.guest_stars && ex.guest_stars.length) {
|
||
html += '<div class="vd-ep-extra-gh">Guest stars</div><div class="vd-ep-guests">' +
|
||
ex.guest_stars.map(function (g) {
|
||
var img = g.photo
|
||
? '<img class="vd-guest-photo" src="' + esc(g.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
||
: '<span class="vd-guest-photo vd-guest-photo--ph">' + esc((g.name || '?').charAt(0)) + '</span>';
|
||
var inner = img + '<span class="vd-guest-name">' + esc(g.name) + '</span>' +
|
||
(g.character ? '<span class="vd-guest-char">' + esc(g.character) + '</span>' : '');
|
||
return g.tmdb_id
|
||
? '<a class="vd-guest" href="/video-detail/tmdb/person/' + g.tmdb_id + '" data-vd-person="' + g.tmdb_id + '">' + inner + '</a>'
|
||
: '<div class="vd-guest">' + inner + '</div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
panel.innerHTML = html || '<div class="vd-ep-extra-empty">No extra info.</div>';
|
||
}
|
||
|
||
function renderSeasonOverview() {
|
||
var el = q('[data-vd-season-overview]');
|
||
if (!el) return;
|
||
var s = seasonByNum(selectedSeason);
|
||
var ov = s && s.overview;
|
||
el.textContent = ov || '';
|
||
el.hidden = !ov;
|
||
}
|
||
|
||
function renderEpisodes() {
|
||
renderSeasonOverview();
|
||
var host = q('[data-vd-episodes]');
|
||
if (!host) return;
|
||
var season = seasonByNum(selectedSeason);
|
||
if (!season) { host.innerHTML = ''; return; }
|
||
var eps = missingOnly ? season.episodes.filter(function (e) { return !e.owned; }) : season.episodes;
|
||
var emptyMsg = (data && data.source === 'youtube')
|
||
? (ytFilter.q ? 'No videos match “' + esc(ytFilter.q) + '”.' : 'No videos here.')
|
||
: 'No ' + (missingOnly ? 'missing ' : '') + 'episodes here. 🎉';
|
||
host.innerHTML = eps.length ? eps.map(episodeRow).join('') : '<div class="vd-ep-empty">' + emptyMsg + '</div>';
|
||
host.classList.remove('vd-ep-anim'); void host.offsetWidth; host.classList.add('vd-ep-anim');
|
||
}
|
||
|
||
function selectSeason(n) {
|
||
selectedSeason = n; menuOpen = false;
|
||
renderSeasonNav(); ensureSeasonEpisodes();
|
||
}
|
||
|
||
// tmdb (preview) shows carry season counts but load episodes lazily per season.
|
||
function ensureSeasonEpisodes() {
|
||
var season = seasonByNum(selectedSeason);
|
||
if (data && data.source === 'tmdb' && season && !season._loaded &&
|
||
!(season.episodes && season.episodes.length)) {
|
||
loadTmdbSeason(season);
|
||
} else {
|
||
renderEpisodes();
|
||
}
|
||
}
|
||
function loadTmdbSeason(season) {
|
||
var host = q('[data-vd-episodes]');
|
||
if (host) host.innerHTML = '<div class="vd-ep-empty">Loading episodes…</div>';
|
||
var sid = data.id, sn = season.season_number;
|
||
fetch(TMDB_URL + 'show/' + sid + '/season/' + sn, { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (se) {
|
||
season._loaded = true;
|
||
if (se && se.episodes) {
|
||
season.episodes = se.episodes;
|
||
season.episode_total = se.episodes.length;
|
||
if (se.overview) season.overview = se.overview;
|
||
}
|
||
if (currentId === sid && selectedSeason === sn) { renderSeasonNav(); renderEpisodes(); }
|
||
})
|
||
.catch(function () {
|
||
season._loaded = true;
|
||
if (currentId === sid && selectedSeason === sn) renderEpisodes();
|
||
});
|
||
}
|
||
function setView(v) {
|
||
seasonView = v; menuOpen = false;
|
||
try { localStorage.setItem(VIEW_KEY, v); } catch (e) { /* ignore */ }
|
||
renderViewToggle(); renderSeasonNav();
|
||
}
|
||
|
||
function showLoading(on) { var l = q('[data-vd-loading]'); if (l) l.hidden = !on; }
|
||
|
||
// ── watchlist (new curated system; airing shows only) ─────────────────────
|
||
function toggleWatchlist() {
|
||
if (!data || data.kind !== 'show' || !data.tmdb_id) return;
|
||
var watching = !!data._vw_watched;
|
||
var apply = function () {
|
||
var url = watching ? '/api/video/watchlist/remove' : '/api/video/watchlist/add';
|
||
// On a TMDB preview, data.id is the tmdb id (NOT a library row) — don't send
|
||
// a bogus library_id or library poster proxy; use the TMDB poster instead.
|
||
var owned = data.source !== 'tmdb';
|
||
var body = watching
|
||
? { kind: 'show', tmdb_id: data.tmdb_id }
|
||
: { kind: 'show', tmdb_id: data.tmdb_id, title: data.title,
|
||
poster_url: owned ? ('/api/video/poster/show/' + data.id) : proxied(data.poster_url) };
|
||
if (owned) body.library_id = data.id;
|
||
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (res) {
|
||
if (!res || !res.success) return;
|
||
data._vw_watched = !watching;
|
||
renderActions(data);
|
||
if (typeof showToast === 'function')
|
||
showToast(!watching ? 'Added to watchlist' : 'Removed from watchlist', !watching ? 'success' : 'info');
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed',
|
||
{ detail: { kind: 'show', id: String(data.tmdb_id), watched: !watching } }));
|
||
}).catch(function () { if (typeof showToast === 'function') showToast('Watchlist update failed', 'error'); });
|
||
};
|
||
if (watching && typeof showConfirmDialog === 'function') {
|
||
showConfirmDialog({ title: 'Remove from Watchlist',
|
||
message: 'Remove “' + (data.title || 'this show') + '” from your watchlist?',
|
||
confirmText: 'Remove', cancelText: 'Cancel', destructive: true })
|
||
.then(function (ok) { if (ok) apply(); });
|
||
} else { apply(); }
|
||
}
|
||
|
||
// ── movie detail (flat) ───────────────────────────────────────────────────
|
||
// ── live download status (a movie being grabbed shows progress here, and the
|
||
// chip jumps to the Downloads page) ─────────────────────────────────────
|
||
var _dlWatch = { id: null, t: null };
|
||
function stopMovieDownloadWatch() {
|
||
if (_dlWatch.t) { clearTimeout(_dlWatch.t); _dlWatch.t = null; }
|
||
_dlWatch.id = null;
|
||
var c = q('[data-vd-dlchip]'); if (c) c.remove();
|
||
}
|
||
function renderMovieDownloadChip(dl) {
|
||
var a = q('[data-vd-actions]'); if (!a) return;
|
||
var chip = q('[data-vd-dlchip]');
|
||
var show = dl && ['downloading', 'queued', 'searching', 'completed', 'failed'].indexOf(dl.status) > -1;
|
||
if (!show) { if (chip) chip.remove(); return; }
|
||
if (!chip) {
|
||
chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
chip.setAttribute('data-vd-dlchip', '');
|
||
chip.title = 'Open the Downloads page';
|
||
chip.addEventListener('click', function () {
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-navigate', { detail: 'video-downloads' }));
|
||
});
|
||
a.parentNode.insertBefore(chip, a); // sits above the action buttons (renderActions won't wipe it)
|
||
}
|
||
var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0));
|
||
if (st === 'completed') pct = 100;
|
||
chip.className = 'vd-dlchip ' + (st === 'completed' ? 'is-done' : (st === 'failed' ? 'is-fail' : 'is-active'));
|
||
var label = st === 'completed' ? 'Downloaded' : st === 'failed' ? 'Download failed'
|
||
: st === 'searching' ? 'Finding a release…' : st === 'queued' ? 'Queued' : 'Downloading';
|
||
var pctTxt = (st === 'downloading' || st === 'queued') ? ' · ' + pct + '%' : '';
|
||
var ic = st === 'completed' ? '✓ ' : st === 'failed' ? '✕ ' : '⤓ ';
|
||
chip.innerHTML =
|
||
'<span class="vd-dlchip-bar" style="width:' + pct + '%"></span>' +
|
||
'<span class="vd-dlchip-txt">' + ic + esc(label) + pctTxt + '<span class="vd-dlchip-go"> · Track ↗</span></span>';
|
||
}
|
||
function watchMovieDownload(id) {
|
||
stopMovieDownloadWatch();
|
||
_dlWatch.id = id;
|
||
(function tick() {
|
||
if (currentId !== id || currentKind !== 'movie') return; // navigated away → stop
|
||
fetch('/api/video/downloads/status?media_id=' + encodeURIComponent(id) +
|
||
'&media_source=' + encodeURIComponent(currentSource || 'library'),
|
||
{ headers: { Accept: 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (res) {
|
||
if (currentId !== id || currentKind !== 'movie') return;
|
||
var dl = res && res.download;
|
||
renderMovieDownloadChip(dl);
|
||
var active = dl && ['downloading', 'queued', 'searching'].indexOf(dl.status) > -1;
|
||
if (active) _dlWatch.t = setTimeout(tick, 1800);
|
||
}).catch(function () { /* keep last state */ });
|
||
})();
|
||
}
|
||
|
||
function loadMovie(id, source) {
|
||
currentKind = 'movie'; currentSource = source || 'library';
|
||
if (!root()) return;
|
||
if (currentId !== id) artAttemptedFor = null;
|
||
currentId = id;
|
||
stopMovieDownloadWatch(); // clear any prior movie's chip
|
||
showLoading(true);
|
||
resetExtras();
|
||
var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = '';
|
||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||
fetch(detailURL('movie', id, currentSource), { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) {
|
||
showLoading(false);
|
||
if (d && d.redirect) { reopen(d.redirect); return; }
|
||
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
|
||
if (currentId !== id || currentKind !== 'movie') return;
|
||
data = d;
|
||
renderBillboard(d);
|
||
renderDetails(d);
|
||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]');
|
||
if (sub) sub.scrollTop = 0;
|
||
if (currentSource === 'tmdb') {
|
||
renderExtras('movie', id, d); // extras ship inside the tmdb payload
|
||
} else {
|
||
maybeRefreshMovie(id);
|
||
loadExtras('movie', id);
|
||
watchMovieDownload(id); // live download progress chip (if any)
|
||
}
|
||
})
|
||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });
|
||
}
|
||
|
||
// An owned title reached via a tmdb URL → bounce to the real library detail.
|
||
// _replace so it REPLACES the tmdb history entry (which would redirect again on
|
||
// Back) instead of pushing a new layer — otherwise Back loops on the redirect.
|
||
function reopen(rd) {
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
|
||
{ detail: { kind: rd.kind, id: rd.id, source: rd.source || 'library', _replace: true } }));
|
||
}
|
||
|
||
// Lazy: backfill a movie's cast/genres/art from TMDB on view if missing.
|
||
function maybeRefreshMovie(id) {
|
||
if (artAttemptedFor === id || !data || data.id !== id) return;
|
||
var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length)
|
||
|| !data.has_backdrop || !data.logo || (data.imdb_id && !data.imdb_rating);
|
||
if (!needs) return;
|
||
artAttemptedFor = id;
|
||
fetch(DETAIL_URL + 'movie/' + id + '/refresh-art',
|
||
{ method: 'POST', headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (res) {
|
||
if (res && res.ok && currentId === id && currentKind === 'movie') {
|
||
fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) {
|
||
if (d && !d.error && currentId === id) {
|
||
// Keep the live extras (server/trailer/next-ep) the
|
||
// detail payload lacks — else Play/Trailer vanish.
|
||
var prev = data || {};
|
||
d.server = prev.server || null;
|
||
d.trailer = prev.trailer || null;
|
||
d.next_episode = prev.next_episode || null;
|
||
data = d; renderBillboard(d); renderDetails(d);
|
||
}
|
||
});
|
||
}
|
||
}).catch(function () { /* best-effort */ });
|
||
}
|
||
|
||
function loadShow(id, source) {
|
||
currentKind = 'show'; currentSource = source || 'library';
|
||
if (!root()) return;
|
||
if (currentId !== id) artAttemptedFor = null;
|
||
currentId = id;
|
||
showLoading(true);
|
||
resetExtras();
|
||
showEpSyncing(false);
|
||
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||
fetch(detailURL('show', id, currentSource), { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) {
|
||
showLoading(false);
|
||
if (d && d.redirect) { reopen(d.redirect); return; }
|
||
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
|
||
if (currentId !== id || currentKind !== 'show') return;
|
||
data = d; menuOpen = false; missingOnly = false;
|
||
selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null;
|
||
var mt = q('[data-vd-missing-toggle]');
|
||
if (mt) { mt.hidden = !(d.seasons && d.seasons.length); mt.classList.remove('vd-missing-toggle--on'); }
|
||
renderBillboard(d);
|
||
renderViewToggle(); renderSeasonNav(); ensureSeasonEpisodes();
|
||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
||
if (sub) sub.scrollTop = 0;
|
||
if (currentSource === 'tmdb') {
|
||
renderExtras('show', id, d);
|
||
} else {
|
||
maybeRefreshArt(id);
|
||
loadExtras('show', id);
|
||
}
|
||
})
|
||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
|
||
}
|
||
|
||
// Lazy art: if any season lacks a poster, pull it from TMDB on view and cache
|
||
// it (once per show), then re-render. Sidesteps "already matched, never re-runs".
|
||
function maybeRefreshArt(id) {
|
||
if (artAttemptedFor === id || !data || data.id !== id) return;
|
||
// Trigger if the full episode list hasn't been pulled yet (so missing
|
||
// episodes show up), or any art is still missing.
|
||
var needs = !data.episodes_synced || !data.logo
|
||
|| (data.seasons || []).some(function (s) { return !s.has_poster; })
|
||
|| (data.imdb_id && !data.imdb_rating);
|
||
if (!needs) return;
|
||
artAttemptedFor = id;
|
||
// The full episode list (owned + missing) is being pulled from TMDB — this
|
||
// can take a while, so show the user it's happening instead of a silent gap.
|
||
if (!data.episodes_synced) showEpSyncing(true);
|
||
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
|
||
{ method: 'POST', headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (res) {
|
||
if (res && res.ok && currentId === id) reloadDetail(id);
|
||
else showEpSyncing(false);
|
||
})
|
||
.catch(function () { showEpSyncing(false); });
|
||
}
|
||
|
||
function showEpSyncing(on, message) {
|
||
var el = q('[data-vd-ep-syncing]');
|
||
if (!el) return;
|
||
var t = el.querySelector('span:last-child');
|
||
// Reuse the indicator for channel date-fetching too; reset to the TMDB copy
|
||
// when no message is passed so the two paths don't leak text into each other.
|
||
if (on && t) t.textContent = message ||
|
||
'Fetching the full episode list from TMDB… missing episodes will fill in shortly.';
|
||
el.hidden = !on;
|
||
}
|
||
|
||
function reloadDetail(id) {
|
||
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) {
|
||
showEpSyncing(false);
|
||
if (!d || d.error || currentId !== id) return;
|
||
// Carry over the live extras (server / trailer / next-episode) the
|
||
// show_detail payload doesn't include, so the Play & Trailer buttons
|
||
// (and the next-ep banner) don't vanish on reload.
|
||
var prev = data || {};
|
||
d.server = prev.server || null;
|
||
d.trailer = prev.trailer || null;
|
||
d.next_episode = prev.next_episode || null;
|
||
data = d;
|
||
if (!seasonByNum(selectedSeason)) {
|
||
selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null;
|
||
}
|
||
renderBillboard(d); renderSeasonNav(); renderEpisodes();
|
||
})
|
||
.catch(function () { showEpSyncing(false); });
|
||
}
|
||
|
||
// ── YouTube channel (rendered through the show pipeline) ──────────────────
|
||
// A channel = a "show": upload YEAR is a season, video an episode. Renders
|
||
// into the SHOW container (currentKind='show') with d.kind='channel' +
|
||
// d.source='youtube' driving every branch above. All TMDB-only sections
|
||
// auto-hide on empty channel data.
|
||
var ytVideoMap = {}; // youtube_id -> raw video (for wish add, main grid + playlists)
|
||
var ytFilter = { q: '', sort: 'newest' }; // channel search + sort
|
||
var ytSearchTimer = null;
|
||
|
||
function ytProx(u) { return (window.VideoYoutube && u) ? VideoYoutube.img(u) : (u || ''); }
|
||
// Upgrade an i.ytimg thumbnail to maxresdefault (1280×720) for the big rail
|
||
// poster — hqdefault (480×360, often sqp-shrunk) looks soft cropped to 2:3.
|
||
function ytHiRes(u) {
|
||
var m = /\/vi\/([^/?]+)\//.exec(u || '');
|
||
return m ? 'https://i.ytimg.com/vi/' + m[1] + '/maxresdefault.jpg' : (u || '');
|
||
}
|
||
|
||
function ytEpisodeOf(v, i) {
|
||
return { episode_number: i + 1, title: v.title, overview: v.description || '',
|
||
air_date: v.published_at, owned: !!v.wished, has_still: false,
|
||
still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id,
|
||
yt_duration: v.duration || '', view_count: v.view_count || 0,
|
||
like_count: v.like_count || 0, dislike_count: v.dislike_count || 0 };
|
||
}
|
||
function ytDurSecs(d) {
|
||
if (!d) return 0;
|
||
return String(d).split(':').reduce(function (acc, n) { return acc * 60 + (parseInt(n, 10) || 0); }, 0);
|
||
}
|
||
// Year-grouped seasons (the default "by year" view). asc → oldest first.
|
||
function ytGroupByYear(videos, ch, asc) {
|
||
var byYear = {};
|
||
videos.forEach(function (v) {
|
||
var yr = (v.published_at && /^\d{4}/.test(v.published_at)) ? parseInt(v.published_at.slice(0, 4), 10) : 0;
|
||
(byYear[yr] = byYear[yr] || []).push(v);
|
||
});
|
||
var years = Object.keys(byYear).map(Number).sort(function (a, b) { return asc ? a - b : b - a; });
|
||
return years.map(function (yr) {
|
||
var vids = byYear[yr].slice().sort(function (a, b) {
|
||
var x = a.published_at || '', y = b.published_at || '';
|
||
return asc ? (x < y ? -1 : x > y ? 1 : 0) : (x > y ? -1 : x < y ? 1 : 0);
|
||
});
|
||
var thumb = '';
|
||
for (var k = 0; k < vids.length; k++) { if (vids[k].thumbnail_url) { thumb = vids[k].thumbnail_url; break; } }
|
||
var poster = thumb ? ytProx(ytHiRes(thumb)) : ''; // maxres for the rail card
|
||
var eps = vids.map(ytEpisodeOf);
|
||
var wishedN = eps.filter(function (e) { return e.owned; }).length;
|
||
var label = yr ? String(yr) : (years.length === 1 ? 'All Videos' : 'Earlier videos');
|
||
return { season_number: yr, title: label, poster_url: poster || ytProx(ch.avatar_url),
|
||
poster_fallback: thumb ? ytProx(thumb) : '', // ← if maxres 404s
|
||
episode_owned: wishedN, episode_total: eps.length, episodes: eps };
|
||
});
|
||
}
|
||
// A search OR a popularity/length sort collapses the year view into one flat,
|
||
// sorted "results" list instead of per-year seasons.
|
||
function ytFlatMode() { return !!ytFilter.q || ytFilter.sort === 'views' || ytFilter.sort === 'longest'; }
|
||
function ytVisibleVideos() {
|
||
var all = (data && data._channel && data._channel.videos) || [];
|
||
var q = (ytFilter.q || '').toLowerCase().trim();
|
||
var vids = q ? all.filter(function (v) { return (v.title || '').toLowerCase().indexOf(q) >= 0; }) : all.slice();
|
||
if (ytFilter.sort === 'views') vids.sort(function (a, b) { return (b.view_count || 0) - (a.view_count || 0); });
|
||
else if (ytFilter.sort === 'longest') vids.sort(function (a, b) { return ytDurSecs(b.duration) - ytDurSecs(a.duration); });
|
||
else if (ytFilter.sort === 'oldest') vids.sort(function (a, b) { var x = a.published_at || '', y = b.published_at || ''; return x < y ? -1 : x > y ? 1 : 0; });
|
||
else vids.sort(function (a, b) { var x = a.published_at || '', y = b.published_at || ''; return x > y ? -1 : x < y ? 1 : 0; });
|
||
return vids;
|
||
}
|
||
function ytFlatSeason(vids) {
|
||
var ch = (data && data._channel) || {};
|
||
var title = ytFilter.q ? (vids.length + ' result' + (vids.length === 1 ? '' : 's'))
|
||
: (ytFilter.sort === 'views' ? 'Most viewed' : 'Longest');
|
||
return { season_number: -1, title: title, poster_url: ytProx(ch.avatar_url),
|
||
episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
|
||
}
|
||
function ytRebuildMap() {
|
||
ytVideoMap = {};
|
||
((data && data._channel && data._channel.videos) || []).forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
|
||
}
|
||
// Re-derive data.seasons from the master list honouring the active filter/sort.
|
||
// force=true always re-renders the grid (a filter change); else only when the
|
||
// viewed season actually changed (so a streaming batch doesn't flicker).
|
||
function ytRegroup(force) {
|
||
if (!data || !data._channel) return;
|
||
var prevSel = selectedSeason, prevObj = seasonByNum(prevSel);
|
||
var prevEp = prevObj ? prevObj.episodes.length : -1;
|
||
ytRebuildMap();
|
||
if (ytFlatMode()) {
|
||
data.seasons = [ytFlatSeason(ytVisibleVideos())];
|
||
selectedSeason = -1;
|
||
} else {
|
||
data.seasons = ytGroupByYear(data._channel.videos.slice(), data._channel, ytFilter.sort === 'oldest');
|
||
if (!seasonByNum(selectedSeason)) selectedSeason = data.seasons.length ? data.seasons[0].season_number : null;
|
||
}
|
||
data.season_count = data.seasons.length;
|
||
data.episode_total = (data._channel.videos || []).length;
|
||
renderSeasonNav();
|
||
var nowObj = seasonByNum(selectedSeason);
|
||
if (force || selectedSeason !== prevSel || !nowObj || nowObj.episodes.length !== prevEp) renderEpisodes();
|
||
if (force) ytRefocusSearch();
|
||
}
|
||
function ytRefocusSearch() {
|
||
var inp = q('[data-vd-yt-search]');
|
||
if (inp && document.activeElement !== inp) { var v = inp.value; inp.focus(); inp.value = ''; inp.value = v; }
|
||
}
|
||
function ytControlsHTML() {
|
||
var sorts = [['newest', 'Newest'], ['oldest', 'Oldest'], ['views', 'Most viewed'], ['longest', 'Longest']];
|
||
return '<div class="vd-yt-controls">' +
|
||
'<div class="vd-yt-search"><span class="vd-yt-search-ic">⌕</span>' +
|
||
'<input class="vd-yt-search-in" type="text" placeholder="Search this channel…" value="' +
|
||
esc(ytFilter.q) + '" data-vd-yt-search></div>' +
|
||
'<select class="vd-yt-sort" data-vd-yt-sort>' + sorts.map(function (s) {
|
||
return '<option value="' + s[0] + '"' + (ytFilter.sort === s[0] ? ' selected' : '') + '>' + esc(s[1]) + '</option>';
|
||
}).join('') + '</select></div>';
|
||
}
|
||
|
||
function ytToShow(resp) {
|
||
var ch = resp.channel || {};
|
||
ytVideoMap = {};
|
||
(ch.videos || []).forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
|
||
var seasons = ytGroupByYear(ch.videos || [], ch, false);
|
||
return { kind: 'channel', source: 'youtube', id: ch.youtube_id, title: ch.title || 'Channel',
|
||
overview: ch.description || '', backdrop_url: ytProx(ch.banner_url), has_backdrop: !!ch.banner_url,
|
||
poster_url: ytProx(ch.avatar_url), has_poster: !!ch.avatar_url, genres: ch.tags || [], handle: ch.handle,
|
||
subscriber_count: ch.subscriber_count, video_count: ch.video_count, view_count: ch.view_count,
|
||
following: !!resp.following, _channel: ch, seasons: seasons, season_count: seasons.length,
|
||
episode_total: (ch.videos || []).length, episode_owned: 0 };
|
||
}
|
||
|
||
// Stream the channel's FULL video catalog in batches via InnerTube (each page
|
||
// fetched once, light on rate limits) and fold it into the year-seasons live:
|
||
// each batch fills missing upload dates on the videos already shown AND appends
|
||
// older ones, re-rendering after every batch. Replaces the old date-only re-poll
|
||
// — this both DATES the recent videos and EXPANDS past the initial ~90 cap.
|
||
var ytLoadAllToken = 0;
|
||
function ytCancelLoad() { ytLoadAllToken++; }
|
||
function ytLoadAllVideos(id, silent) {
|
||
var token = ++ytLoadAllToken;
|
||
var byId = {};
|
||
((data && data._channel && data._channel.videos) || []).forEach(function (v) { byId[v.youtube_id] = v; });
|
||
var cont = null, MAX = 2000; // safety ceiling for pathological channels
|
||
function step() {
|
||
if (token !== ytLoadAllToken || currentId !== id || currentSource !== 'youtube') return;
|
||
// POST so the (huge) continuation token rides in the body, not the URL.
|
||
fetch('/api/video/youtube/channel/' + encodeURIComponent(id) + '/videos', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||
body: JSON.stringify({ continuation: cont || null }),
|
||
})
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (resp) {
|
||
if (token !== ytLoadAllToken || currentId !== id || currentSource !== 'youtube') return;
|
||
if (!resp || !resp.success) { showEpSyncing(false); return; }
|
||
var changed = false;
|
||
(resp.videos || []).forEach(function (v) {
|
||
if (!v.youtube_id) return;
|
||
var ex = byId[v.youtube_id];
|
||
if (ex) { // already shown → backfill missing fields
|
||
if (!ex.published_at && v.published_at) { ex.published_at = v.published_at; changed = true; }
|
||
if (!ex.duration && v.duration) { ex.duration = v.duration; changed = true; }
|
||
if (!ex.view_count && v.view_count) { ex.view_count = v.view_count; changed = true; }
|
||
} else { // older video → add it
|
||
byId[v.youtube_id] = v; data._channel.videos.push(v); changed = true;
|
||
}
|
||
});
|
||
// Re-derive the view from the grown master, honouring the active
|
||
// filter/sort (only re-renders the grid if the viewed season moved).
|
||
if (changed) ytRegroup(false);
|
||
cont = resp.continuation;
|
||
if (cont && data._channel.videos.length < MAX) {
|
||
// Quiet when refreshing a remembered channel; only the first
|
||
// (cache-miss) load shows the "loading full history" banner.
|
||
if (!silent) showEpSyncing(true, 'Loading the channel’s full video history… ' +
|
||
data._channel.videos.length + ' videos so far.');
|
||
setTimeout(step, 120);
|
||
} else {
|
||
showEpSyncing(false);
|
||
}
|
||
})
|
||
.catch(function () { showEpSyncing(false); });
|
||
}
|
||
step();
|
||
}
|
||
|
||
function loadChannel(id) {
|
||
currentKind = 'show'; currentSource = 'youtube'; // render into the show container
|
||
if (currentId !== id) artAttemptedFor = null;
|
||
currentId = id;
|
||
if (!root()) return;
|
||
ytFilter = { q: '', sort: 'newest' }; // a fresh channel starts unfiltered
|
||
ytCancelLoad();
|
||
showLoading(true); resetExtras(); showEpSyncing(false);
|
||
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||
ytResetPlaylists();
|
||
fetch('/api/video/youtube/channel/' + encodeURIComponent(id) + '?limit=90', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (resp) {
|
||
showLoading(false);
|
||
if (!resp || !resp.success) { setText('[data-vd-title]', 'Channel unavailable'); return; }
|
||
if (currentId !== id) return;
|
||
data = ytToShow(resp); menuOpen = false; missingOnly = false;
|
||
selectedSeason = data.seasons.length ? data.seasons[0].season_number : null;
|
||
var mt = q('[data-vd-missing-toggle]');
|
||
if (mt) { mt.hidden = true; mt.classList.remove('vd-missing-toggle--on'); } // n/a for channels
|
||
renderBillboard(data); renderViewToggle(); renderSeasonNav(); ensureSeasonEpisodes();
|
||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
||
if (sub) sub.scrollTop = 0;
|
||
ytLoadPlaylists(id);
|
||
// Stream the rest of the catalog (and fill upload dates) in batches.
|
||
// A remembered channel renders full from cache → refresh quietly.
|
||
ytLoadAllVideos(id, !!resp.from_cache);
|
||
})
|
||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load channel'); });
|
||
}
|
||
|
||
// A playlist → a single FLAT season in the curator's order (a partial set, so
|
||
// no year-grouping, no season nav, no catalog streaming).
|
||
function ytPlaylistToShow(resp) {
|
||
var pl = resp.playlist || {};
|
||
var vids = pl.videos || [];
|
||
ytVideoMap = {};
|
||
vids.forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
|
||
var total = pl.video_count || vids.length;
|
||
// YouTube throttles large-playlist listing for our client — be honest when partial.
|
||
var note = total > vids.length ? 'Showing ' + vids.length + ' of ' + total + ' videos.' : '';
|
||
var season = { season_number: 1, title: 'Videos', poster_url: ytProx(pl.thumbnail_url),
|
||
episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
|
||
return { kind: 'playlist', source: 'youtube', id: pl.playlist_id, title: pl.title || 'Playlist',
|
||
overview: note, poster_url: ytProx(pl.thumbnail_url), has_poster: !!pl.thumbnail_url,
|
||
backdrop_url: ytProx(pl.thumbnail_url), has_backdrop: !!pl.thumbnail_url,
|
||
genres: pl.channel_title ? [pl.channel_title] : [], handle: null,
|
||
subscriber_count: null, view_count: null, video_count: pl.video_count,
|
||
following: !!resp.following, _playlist: pl, seasons: [season], season_count: 1,
|
||
episode_total: vids.length, episode_owned: 0 };
|
||
}
|
||
|
||
function loadPlaylist(id) {
|
||
currentKind = 'show'; currentSource = 'youtube';
|
||
if (currentId !== id) artAttemptedFor = null;
|
||
currentId = id;
|
||
if (!root()) return;
|
||
ytFilter = { q: '', sort: 'newest' };
|
||
ytCancelLoad();
|
||
showLoading(true); resetExtras(); showEpSyncing(false);
|
||
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
||
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||
ytResetPlaylists();
|
||
fetch('/api/video/youtube/playlist/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (resp) {
|
||
showLoading(false);
|
||
if (!resp || !resp.success || !resp.playlist) { setText('[data-vd-title]', 'Playlist unavailable'); return; }
|
||
if (currentId !== id) return;
|
||
data = ytPlaylistToShow(resp); menuOpen = false; missingOnly = false;
|
||
selectedSeason = 1;
|
||
var mt = q('[data-vd-missing-toggle]'); if (mt) { mt.hidden = true; mt.classList.remove('vd-missing-toggle--on'); }
|
||
renderBillboard(data); renderViewToggle(); renderSeasonNav(); 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 playlist'); });
|
||
}
|
||
|
||
function ytFindEp(id) {
|
||
if (!data || !data.seasons) return null;
|
||
for (var i = 0; i < data.seasons.length; i++) {
|
||
var es = data.seasons[i].episodes;
|
||
for (var j = 0; j < es.length; j++) if (es[j].youtube_id === id) return es[j];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function toggleYtWish(btn) {
|
||
var yc = window.VideoYoutube; if (!yc) return;
|
||
var id = btn.getAttribute('data-vd-yt-wish');
|
||
var on = btn.classList.contains('vd-yt-wish--on');
|
||
btn.disabled = true;
|
||
var setOn = function (val) {
|
||
btn.disabled = false;
|
||
if (ytVideoMap[id]) ytVideoMap[id].wished = val;
|
||
var r0 = root(), btns = r0 ? r0.querySelectorAll('[data-vd-yt-wish="' + id + '"]') : [];
|
||
for (var i = 0; i < btns.length; i++) {
|
||
btns[i].classList.toggle('watching', val);
|
||
var ic = btns[i].querySelector('.watchlist-icon'); if (ic) ic.textContent = val ? '✓' : '+';
|
||
var tx = btns[i].querySelector('.watchlist-text'); if (tx) tx.textContent = val ? 'In Wishlist' : 'Wishlist';
|
||
}
|
||
var ep = ytFindEp(id); if (ep) { ep.owned = val; renderSeasonNav(); }
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||
};
|
||
if (on) yc.removeWish('video', id).then(function (d) { setOn(!(d && d.success)); }).catch(function () { btn.disabled = false; });
|
||
else {
|
||
// Channel pages carry _channel; playlist pages carry _playlist — fall back
|
||
// to the playlist's owner channel so + Wish works there too (was a 400).
|
||
var ch = (data && data._channel) || {};
|
||
if (!ch.youtube_id && data && data._playlist) {
|
||
var pl = data._playlist;
|
||
ch = { youtube_id: pl.channel_id || pl.playlist_id, title: pl.channel_title || pl.title || 'Playlist',
|
||
avatar_url: pl.thumbnail_url };
|
||
}
|
||
yc.addVideos({ youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url },
|
||
[ytVideoMap[id] || { youtube_id: id, title: '' }])
|
||
.then(function (d) { setOn(!!(d && d.success)); if (d && d.success && typeof showToast === 'function') showToast('Added to wishlist', 'success'); })
|
||
.catch(function () { btn.disabled = false; });
|
||
}
|
||
}
|
||
|
||
// Hero Watchlist button on a PLAYLIST page → follow/unfollow the playlist.
|
||
function toggleYtPlaylistFollowHero() {
|
||
var yc = window.VideoYoutube; if (!yc || !data) return;
|
||
var pl = data._playlist || {}, on = data.following;
|
||
var bump = function () { document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
|
||
if (on) {
|
||
yc.unfollowPlaylist(data.id).then(function () { data.following = false; renderActions(data); bump(); }).catch(function () { /* ignore */ });
|
||
} else {
|
||
yc.followPlaylist({ playlist_id: data.id, title: pl.title, thumbnail_url: pl.thumbnail_url, videos: pl.videos })
|
||
.then(function (d) {
|
||
if (d && d.success) { data.following = true; renderActions(data);
|
||
if (typeof showToast === 'function') showToast('Added to watchlist', 'success'); }
|
||
bump();
|
||
}).catch(function () { /* ignore */ });
|
||
}
|
||
}
|
||
|
||
function toggleYtFollow() {
|
||
var yc = window.VideoYoutube; if (!yc || !data) return;
|
||
var ch = data._channel || {}, on = data.following;
|
||
if (on) yc.unfollow(data.id).then(function () { data.following = false; renderActions(data);
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }).catch(function () { /* ignore */ });
|
||
else yc.follow({ youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url }).then(function (d) {
|
||
if (d && d.success) {
|
||
data.following = true; renderActions(data); // toggle in place — no page reload
|
||
if (typeof showToast === 'function') showToast('Added to watchlist', 'success');
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||
}
|
||
}).catch(function () { /* ignore */ });
|
||
}
|
||
|
||
// playlists as collapsible rows below the episodes (channel-only section)
|
||
function ytResetPlaylists() {
|
||
// The playlist section lives ONLY in the show-detail DOM, but a movie/show
|
||
// load runs with q() scoped to a DIFFERENT root — so query the show subpage
|
||
// directly. Otherwise a channel's playlists leak onto the next show you open.
|
||
var showRoot = document.querySelector('[data-video-detail="show"]');
|
||
if (!showRoot) return;
|
||
var sec = showRoot.querySelector('[data-vd-yt-pl-section]');
|
||
var host = showRoot.querySelector('[data-vd-yt-playlists]');
|
||
if (host) host.innerHTML = '';
|
||
if (sec) sec.hidden = true;
|
||
}
|
||
function ytPlaylistRow(p) {
|
||
var thumb = p.thumbnail_url
|
||
? '<img class="vc-pl-thumb" src="' + esc(ytProx(p.thumbnail_url)) + '" alt="" loading="lazy">'
|
||
: '<span class="vc-pl-thumb vc-pl-thumb--none">▶</span>';
|
||
var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : '';
|
||
var on = !!p.following;
|
||
var watch = '<button class="library-artist-watchlist-btn vc-pl-watch' + (on ? ' watching' : '') + '" type="button" ' +
|
||
'data-vd-yt-pl-watch="' + esc(p.playlist_id) + '" data-pl-title="' + esc(p.title) + '" data-pl-thumb="' + esc(p.thumbnail_url || '') + '">' +
|
||
'<span class="watchlist-icon">' + (on ? '✓' : '+') + '</span>' +
|
||
'<span class="watchlist-text">' + (on ? 'In Watchlist' : 'Add to Watchlist') + '</span></button>';
|
||
return '<div class="vc-pl vc-pl--collapsed" data-vc-pl="' + esc(p.playlist_id) + '">' +
|
||
'<div class="vc-pl-hd" data-vd-yt-pl-toggle="' + esc(p.playlist_id) + '">' + thumb +
|
||
'<div class="vc-pl-meta"><span class="vc-pl-title">' + esc(p.title) + '</span>' +
|
||
(n ? '<span class="vc-pl-count">' + n + '</span>' : '') + '</div>' + watch +
|
||
'<span class="vc-pl-chev" aria-hidden="true">▾</span></div>' +
|
||
'<div class="vc-pl-vids" data-vd-yt-pl-vids="' + esc(p.playlist_id) + '"></div></div>';
|
||
}
|
||
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
|
||
? '<img src="' + esc(ytProx(v.thumbnail_url)) + '" alt="" loading="lazy">' : '';
|
||
return '<div class="vd-yt-plvid">' +
|
||
'<div class="vd-yt-plvid-thumb" data-vd-yt-play="' + esc(v.youtube_id) + '" title="Play video">' +
|
||
thumb + '<span class="vd-yt-plvid-play">▶</span></div>' +
|
||
'<div class="vd-yt-plvid-title" title="' + esc(v.title) + '">' + esc(v.title || 'Untitled') + '</div>' +
|
||
ytWishBtn(v.youtube_id, v.wished, true) + '</div>';
|
||
}
|
||
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 = '<div class="vc-pl-loading">Loading…</div>';
|
||
fetch('/api/video/youtube/playlist/' + encodeURIComponent(pid), { headers: { Accept: 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) {
|
||
var vids = (d && d.videos) || [];
|
||
host.innerHTML = vids.length ? vids.map(ytPlVideoCard).join('') : '<div class="vc-pl-loading">No videos.</div>';
|
||
})
|
||
.catch(function () { host.removeAttribute('data-loaded'); host.innerHTML = ''; });
|
||
}
|
||
|
||
// ── events ────────────────────────────────────────────────────────────────
|
||
function onOpen(e) {
|
||
if (!e || !e.detail) return;
|
||
var src = e.detail.source || 'library';
|
||
if (e.detail.kind === 'movie') loadMovie(e.detail.id, src);
|
||
else if (e.detail.kind === 'show') loadShow(e.detail.id, src);
|
||
else if (e.detail.kind === 'channel') loadChannel(e.detail.id);
|
||
else if (e.detail.kind === 'playlist') loadPlaylist(e.detail.id);
|
||
// 'person' is handled by video-person.js (same event).
|
||
}
|
||
|
||
function modified(e) {
|
||
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||
}
|
||
|
||
function onClick(e) {
|
||
var muteBtn = e.target.closest('[data-vd-bb-mute]');
|
||
if (muteBtn) { toggleBillboardMute(muteBtn); return; }
|
||
var stopBtn = e.target.closest('[data-vd-bb-stop]');
|
||
if (stopBtn) { stopBillboardTrailer(); return; }
|
||
var r = root(); if (!r) return;
|
||
// In-app drill-ins (real <a> links → modified clicks open new tabs).
|
||
var sim = e.target.closest('[data-vd-sim]');
|
||
if (sim && r.contains(sim)) {
|
||
if (modified(e)) return;
|
||
e.preventDefault();
|
||
var sid = parseInt(sim.getAttribute('data-vd-sim-id'), 10);
|
||
if (!isNaN(sid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
|
||
{ detail: { kind: sim.getAttribute('data-vd-sim'), id: sid, source: 'tmdb' } }));
|
||
return;
|
||
}
|
||
var person = e.target.closest('[data-vd-person]');
|
||
if (person && r.contains(person)) {
|
||
if (modified(e)) return;
|
||
e.preventDefault();
|
||
var pid = parseInt(person.getAttribute('data-vd-person'), 10);
|
||
if (!isNaN(pid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
|
||
{ detail: { kind: 'person', id: pid, source: 'tmdb' } }));
|
||
return;
|
||
}
|
||
var shot = e.target.closest('[data-vd-shot]');
|
||
if (shot && r.contains(shot)) { openLightbox(parseInt(shot.getAttribute('data-vd-shot'), 10) || 0); return; }
|
||
var vid = e.target.closest('[data-vd-video]');
|
||
if (vid && r.contains(vid)) { openTrailer(vid.getAttribute('data-vd-video')); return; }
|
||
var castAll = e.target.closest('[data-vd-cast-all]');
|
||
if (castAll && r.contains(castAll)) { openCastModal(); return; }
|
||
var revMore = e.target.closest('[data-vd-review-more]');
|
||
if (revMore && r.contains(revMore)) {
|
||
var body = q('[data-vd-review-body]');
|
||
if (body) { var open = body.classList.toggle('vd-review-body--open'); revMore.textContent = open ? 'Read less' : 'Read more'; }
|
||
return;
|
||
}
|
||
// YouTube channel interactions (rendered in the show container)
|
||
if (e.target.closest('[data-vd-ext]')) return; // let watch links open
|
||
var ytPlay = e.target.closest('[data-vd-yt-play]'); // play the video inline (reuses the trailer player)
|
||
if (ytPlay && r.contains(ytPlay)) { e.preventDefault(); openTrailer(ytPlay.getAttribute('data-vd-yt-play')); return; }
|
||
var ytWish = e.target.closest('[data-vd-yt-wish]');
|
||
if (ytWish && r.contains(ytWish)) { e.preventDefault(); toggleYtWish(ytWish); return; }
|
||
var ytPlW = e.target.closest('[data-vd-yt-pl-watch]');
|
||
if (ytPlW && r.contains(ytPlW)) { e.preventDefault(); e.stopPropagation(); toggleYtPlaylistWatch(ytPlW); return; }
|
||
var ytPl = e.target.closest('[data-vd-yt-pl-toggle]');
|
||
if (ytPl && r.contains(ytPl)) { toggleYtPlaylist(ytPl); return; }
|
||
var epRow = e.target.closest('[data-vd-ep-key]');
|
||
if (epRow && r.contains(epRow)) { toggleEpisode(epRow); return; }
|
||
var seasonBtn = e.target.closest('[data-vd-season]');
|
||
if (seasonBtn && r.contains(seasonBtn)) { selectSeason(parseInt(seasonBtn.getAttribute('data-vd-season'), 10)); return; }
|
||
var viewBtn = e.target.closest('[data-vd-view]');
|
||
if (viewBtn && r.contains(viewBtn)) { setView(viewBtn.getAttribute('data-vd-view')); return; }
|
||
var ssToggle = e.target.closest('[data-vd-ss-toggle]');
|
||
if (ssToggle && r.contains(ssToggle)) { menuOpen = !menuOpen; renderSeasonNav(); return; }
|
||
var act = e.target.closest('[data-vd-act]');
|
||
if (act && r.contains(act)) {
|
||
var which = act.getAttribute('data-vd-act');
|
||
if (which === 'watchlist') toggleWatchlist();
|
||
else if (which === 'missing') toggleMissing();
|
||
else if (which === 'yt-follow') toggleYtFollow();
|
||
else if (which === 'yt-pl-follow') toggleYtPlaylistFollowHero();
|
||
else if (which === 'trailer' && data && data.trailer) openTrailer(data.trailer.key);
|
||
return;
|
||
}
|
||
var mt = e.target.closest('[data-vd-missing-toggle]');
|
||
if (mt && r.contains(mt)) { toggleMissing(); return; }
|
||
if (menuOpen && !e.target.closest('[data-vd-season-nav]')) { menuOpen = false; renderSeasonNav(); }
|
||
}
|
||
|
||
function toggleMissing() {
|
||
missingOnly = !missingOnly;
|
||
var mt = q('[data-vd-missing-toggle]');
|
||
if (mt) mt.classList.toggle('vd-missing-toggle--on', missingOnly);
|
||
renderEpisodes();
|
||
}
|
||
|
||
function init() {
|
||
document.addEventListener('soulsync:video-open-detail', onOpen);
|
||
document.addEventListener('click', onClick);
|
||
// Channel search (debounced) + sort — only act on the youtube controls.
|
||
document.addEventListener('input', function (e) {
|
||
var inp = e.target && e.target.closest && e.target.closest('[data-vd-yt-search]');
|
||
if (!inp || !data || data.source !== 'youtube') return;
|
||
var v = inp.value;
|
||
if (ytSearchTimer) clearTimeout(ytSearchTimer);
|
||
ytSearchTimer = setTimeout(function () { ytFilter.q = v; ytRegroup(true); }, 200);
|
||
});
|
||
document.addEventListener('change', function (e) {
|
||
var sel = e.target && e.target.closest && e.target.closest('[data-vd-yt-sort]');
|
||
if (!sel || !data || data.source !== 'youtube') return;
|
||
ytFilter.sort = sel.value; ytRegroup(true);
|
||
});
|
||
// Kill the billboard trailer (audio!) when navigating to a non-detail page.
|
||
document.addEventListener('soulsync:video-page-shown', function (e) {
|
||
if (e && e.detail !== 'video-movie-detail' && e.detail !== 'video-show-detail') stopBillboardTrailer();
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') { closeTrailer(); closeLightbox(); closeCastModal(); }
|
||
else if (lightboxOpen()) {
|
||
if (e.key === 'ArrowLeft') lightboxStep(-1);
|
||
else if (e.key === 'ArrowRight') lightboxStep(1);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||
else init();
|
||
})();
|