/*
* SoulSync — Video detail page (isolated, NETFLIX-style — deliberately NOT the
* music/Spotify layout).
*
* A cinematic billboard (full-bleed backdrop, content bottom-left) with a
* per-show accent sampled from the poster, and a SEASON selector with four
* switchable views — poster rail / timeline / pills / dropdown — plus a
* "Missing only" episode filter. Opened by a card via soulsync:video-open-detail;
* video-side.js navigates, this loads + renders.
*
* Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks
* only to /api/video/* — the music side is never touched.
*/
(function () {
'use strict';
var DETAIL_URL = '/api/video/detail/';
var TMDB_LOGO = 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg';
var TVDB_LOGO = 'https://www.svgrepo.com/show/443500/brand-tvdb.svg';
// Real media-server logos for the "Play on your server" watch tile (same
// sources as the header server toggle).
var SERVER_LOGOS = {
Plex: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg',
Jellyfin: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png',
};
var VIEW_KEY = 'soulsync_vd_season_view';
var VIEWS = [
{ id: 'rail', label: 'Rail', ic: '▦' },
{ id: 'timeline', label: 'Timeline', ic: '▭' },
{ id: 'pills', label: 'Tabs', ic: '◉' },
{ id: 'dropdown', label: 'List', ic: '▾' },
];
var data = null;
var selectedSeason = null;
var seasonView = 'rail';
var menuOpen = false;
var missingOnly = false;
var currentId = null;
var currentKind = 'show';
var currentSource = 'library'; // 'library' (video.db) or 'tmdb' (live preview)
var artAttemptedFor = null; // lazy art refresh runs once per detail view
var TMDB_URL = '/api/video/tmdb/';
function detailURL(kind, id, source) {
return source === 'tmdb' ? TMDB_URL + kind + '/' + id : DETAIL_URL + kind + '/' + id;
}
try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function root() { return document.querySelector('[data-video-detail="' + currentKind + '"]'); }
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
function setText(sel, t) { var n = q(sel); if (n) n.textContent = t || ''; }
function runtimeLabel(m) {
if (!m) return '';
var h = Math.floor(m / 60), mm = m % 60;
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
}
function statusLabel(s) {
return s === 'continuing' ? 'Continuing' : s === 'ended' ? 'Ended'
: s === 'upcoming' ? 'Upcoming' : (s || '');
}
function seasonByNum(n) {
if (!data) return null;
for (var i = 0; i < data.seasons.length; i++) if (data.seasons[i].season_number === n) return data.seasons[i];
return null;
}
function seasonArt(s) {
if (data && data.source === 'tmdb') 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) items use the direct image URLs in the payload.
function bbBackdrop(d) {
if (d.source === 'tmdb') 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) {
if (d.source === 'tmdb') return d.poster_url || '';
return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : '';
}
function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; }
function badge(logo, fallback, title, url) {
var inner = logo
? ''
: '' + fallback + '';
return url
? '' + inner + ''
: '
' + inner + '
';
}
// ── accent extraction (poster → dominant vibrant colour) ──────────────────
function applyAccent(img) {
try {
var w = 24, h = 24, c = document.createElement('canvas'); c.width = w; c.height = h;
var ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, w, h);
var px = ctx.getImageData(0, 0, w, h).data;
var best = null, bestScore = -1, fr = 0, fg = 0, fb = 0, n = 0;
for (var i = 0; i < px.length; i += 4) {
var r = px[i], g = px[i + 1], b = px[i + 2], a = px[i + 3];
if (a < 128) continue;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), light = (mx + mn) / 2;
fr += r; fg += g; fb += b; n++;
if (light < 35 || light > 225) continue;
var sat = mx === 0 ? 0 : (mx - mn) / mx, score = sat * (mx / 255);
if (score > bestScore) { bestScore = score; best = [r, g, b]; }
}
if (!best && n) best = [Math.round(fr / n), Math.round(fg / n), Math.round(fb / n)];
if (best) { var r0 = root(); if (r0) r0.style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]); }
} catch (e) { /* tainted/no image — keep theme accent */ }
}
// ── billboard ─────────────────────────────────────────────────────────────
function renderBillboard(d) {
setText('[data-vd-title]', d.title);
setText('[data-vd-overview]', d.overview);
// Clearlogo replaces the text title when available (Netflix/Plex feel).
var logo = q('[data-vd-logo]');
var titleEl = q('[data-vd-title]');
if (logo) {
if (d.logo) {
logo.src = d.logo; logo.alt = d.title || ''; logo.hidden = false;
logo.onerror = function () { logo.hidden = true; if (titleEl) titleEl.classList.remove('vd-title--logo'); };
if (titleEl) titleEl.classList.add('vd-title--logo');
} else {
logo.hidden = true; logo.removeAttribute('src');
if (titleEl) titleEl.classList.remove('vd-title--logo');
}
}
var bg = q('[data-vd-backdrop]');
if (bg) {
var url = bbBackdrop(d);
bg.style.backgroundImage = url ? "url('" + url + "')" : '';
bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster);
bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster);
}
var poster = q('[data-vd-poster]');
var posterUrl = bbPoster(d);
if (poster && posterUrl) {
poster.onload = function () { applyAccent(poster); };
poster.src = posterUrl;
}
var tl = q('[data-vd-tagline]');
if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; }
var meta = [];
if (d.source === 'tmdb') {
meta.push('Preview');
} else if (d.kind === 'show') {
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
meta.push('' + ownedPct + '% in library');
} else {
meta.push(d.owned ? 'In library'
: 'Wanted');
}
if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10) + '');
if (d.year) meta.push('' + esc(d.year) + '');
if (d.content_rating) meta.push('' + esc(d.content_rating) + '');
if (d.kind === 'show') {
meta.push('' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '');
meta.push('' + d.episode_total + ' Episodes');
}
var rt = runtimeLabel(d.runtime_minutes);
if (rt) meta.push('' + esc(rt) + '');
if (d.kind === 'show' && d.status) meta.push('' + esc(statusLabel(d.status)) + '');
if (d.network) meta.push('' + esc(d.network) + '');
if (d.kind === 'movie' && d.studio) meta.push('' + esc(d.studio) + '');
var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join('');
renderActions(d);
var l = q('[data-vd-links]');
if (l && d.source === 'tmdb') {
l.innerHTML = ''; // preview items keep everything in-app
} else if (l) {
var badges = [];
if (d.imdb_id) badges.push(badge('', 'IMDb', 'IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/'));
if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB',
'https://www.themoviedb.org/' + (d.kind === 'movie' ? 'movie' : 'tv') + '/' + d.tmdb_id));
if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series'));
l.innerHTML = badges.join('');
}
var g = q('[data-vd-genres]');
if (g) {
g.innerHTML = (d.genres || []).slice(0, 6).map(function (gn) {
return '' + esc(gn) + '';
}).join('');
}
renderRatings(d);
renderCast(d);
}
function renderRatings(d) {
var host = q('[data-vd-ratings]');
if (!host) return;
var items = [];
if (d.imdb_rating) {
items.push('IMDb' +
(Math.round(d.imdb_rating * 10) / 10) + '');
}
if (d.rt_rating != null) {
var fresh = d.rt_rating >= 60;
items.push('' +
(fresh ? '🍅' : '🤢') + '' + d.rt_rating + '%');
}
if (d.metacritic != null) {
var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad';
items.push('' +
'MC' + d.metacritic + '');
}
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".
var byJob = {};
crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c.name); });
crewHost.innerHTML = Object.keys(byJob).map(function (job) {
return '' + esc(job) +
(byJob[job].length > 1 ? 's' : '') + ' ' + esc(byJob[job].join(', ')) + '';
}).join('');
}
var castHost = q('[data-vd-cast]');
if (castHost) {
castHost.innerHTML = cast.map(function (p) {
var img = p.photo
? ''
: '' + esc((p.name || '?').charAt(0)) + '';
var inner = img +
'' + esc(p.name) + '' +
(p.character ? '' + esc(p.character) + '' : '');
// Clickable → in-app person page when we have a TMDB person id.
return p.tmdb_id
? '' + inner + ''
: '
' + inner + '
';
}).join('');
}
}
function renderActions(d) {
var a = q('[data-vd-actions]');
if (!a) return;
var watching = !!d.monitored;
var html = '';
if (d.trailer && d.trailer.key) {
html += '';
}
// Preview (tmdb, un-owned) items have no library row to monitor — acquisition
// (add-to-watchlist / get-missing) lands with the downloads phase.
if (d.source === 'tmdb') { a.innerHTML = html; return; }
html +=
'';
if (d.kind === 'show') { // "Get Missing" filters the episode list (show-only)
html += '';
}
a.innerHTML = html;
}
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) + '%']);
if (d.file && d.file.resolution) rows.push(['Quality', String(d.file.resolution).toUpperCase()]);
host.innerHTML = rows.length
? '
' + rows.map(function (r) {
return '
' + esc(r[0]) +
'' + esc(r[1]) + '
';
}).join('') + '
'
: '';
}
// ── live TMDB extras (trailer / where-to-watch / similar) ─────────────────
function resetExtras() {
['[data-vd-providers-section]', '[data-vd-similar-section]'].forEach(function (s) {
var n = q(s); if (n) n.hidden = true;
});
}
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 renderExtras(kind, id, ex) {
if (!data || data.id !== id || currentKind !== kind) return;
data.trailer = ex.trailer || null;
renderActions(data);
var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]');
if (ps && ph) {
var html = '';
// If it's on your media server, that's the best place to watch — lead
// with a "Play on Plex/Jellyfin" tile that deep-links to the item.
if (ex.server && ex.server.url) {
var sv = esc(ex.server.server || 'Server');
var slogo = SERVER_LOGOS[ex.server.server];
var sicon = slogo
? ''
: '▶';
html += '' +
sicon + 'Play on ' + sv + '';
}
// Streaming providers (JustWatch via TMDB) link to the where-to-watch page.
var link = ex.providers_link || '';
if (ex.providers && ex.providers.length) {
html += ex.providers.map(function (p) {
var img = p.logo ? ''
: '' + esc((p.name || '?').charAt(0)) + '';
var inner = img + '' + esc(p.name) + '';
return link
? '' + inner + ''
: '
' + inner + '
';
}).join('');
}
ps.hidden = !html;
ph.innerHTML = html;
}
var ss = q('[data-vd-similar-section]'), sh = q('[data-vd-similar]');
if (ss && sh) {
if (ex.similar && ex.similar.length) {
ss.hidden = false;
sh.innerHTML = ex.similar.map(function (s) {
var poster = s.poster
? ''
: '🎬';
// In-app: open the TMDB-backed detail (which redirects to the
// library detail if we already own it). No external links.
var simKind = s.kind === 'movie' ? 'movie' : 'show';
return '' +
poster + '' + esc(s.title) + '';
}).join('');
} else { ss.hidden = true; }
}
}
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
function openTrailer(key) {
if (!key) return;
var ov = document.getElementById('vd-trailer-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'vd-trailer-overlay';
ov.className = 'vd-trailer-overlay';
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.closest('[data-vd-trailer-close]')) closeTrailer();
});
document.body.appendChild(ov);
}
ov.innerHTML = '
' +
'' +
'
';
ov.classList.add('vd-trailer-overlay--open');
}
function closeTrailer() {
var ov = document.getElementById('vd-trailer-overlay');
if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; }
}
// ── season selector (4 views) ─────────────────────────────────────────────
function renderViewToggle() {
var host = q('[data-vd-view-toggle]');
if (!host) return;
host.innerHTML = VIEWS.map(function (v) {
return '';
}).join('');
}
function renderSeasonNav() {
var host = q('[data-vd-season-nav]');
if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; }
host.className = 'vd-season-nav vd-season-nav--' + seasonView;
if (seasonView === 'rail') host.innerHTML = railHTML();
else if (seasonView === 'timeline') host.innerHTML = timelineHTML();
else if (seasonView === 'pills') host.innerHTML = pillsHTML();
else host.innerHTML = dropdownHTML();
}
function railHTML() {
return '
' + data.seasons.map(function (s) {
var art = seasonArt(s), p = pct(s);
var on = s.season_number === selectedSeason ? ' vd-rcard--active' : '';
var img = art ? '' : '';
return '';
}).join('') + '
';
}
function timelineHTML() {
var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1;
return '
' + data.seasons.map(function (s) {
var p = pct(s), grow = Math.max(1, s.episode_total);
var on = s.season_number === selectedSeason ? ' vd-tseg--active' : '';
return '';
}).join('') + '
';
}
function pillsHTML() {
return '
' + data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : '';
return '';
}).join('') + '
';
}
function dropdownHTML() {
var cur = seasonByNum(selectedSeason);
return '
' +
'' +
'
' +
data.seasons.map(function (s) {
var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : '';
return '';
}).join('') + '
';
}
// ── episodes ──────────────────────────────────────────────────────────────
function episodeRow(ep) {
var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
var meta = [];
var rt = runtimeLabel(ep.runtime_minutes); if (rt) meta.push(rt);
if (ep.air_date) meta.push(ep.air_date);
var stillSrc = (data && data.source === 'tmdb')
? (ep.still_url || '')
: (ep.has_still ? '/api/video/poster/episode/' + ep.id : '');
var still = stillSrc
? ''
: '';
return '