/* * SoulSync โ€” Video Person page (isolated, in-app). * * Drill-in for a cast/crew member (from a detail page or a search result). Shows * bio + a filmography grid; every credit links back into SoulSync โ€” the owned * library detail when we have it, otherwise the TMDB-backed preview detail. No * external links. * * Opened by soulsync:video-open-detail {kind:'person', id, source:'tmdb'}; * video-side.js navigates to the person subpage and this loads + renders. * Self-contained IIFE, no globals, event-delegated. */ (function () { 'use strict'; var PERSON_URL = '/api/video/person/'; var data = null; var currentId = null; var tab = 'all'; // kind filter: all | movie | show var own = 'all'; // ownership filter: all | owned | missing var dept = 'all'; // department filter: all | Acting | Directing | โ€ฆ var sortBy = 'newest'; // newest | oldest | popularity var ROLE_NOUNS = { Acting: 'Actor', Directing: 'Director', Writing: 'Writer', Production: 'Producer', Sound: 'Composer', Camera: 'Cinematographer', Editing: 'Editor', Creator: 'Creator', }; function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function root() { return document.querySelector('[data-video-person]'); } 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 showLoading(on) { var l = q('[data-vp-loading]'); if (l) l.hidden = !on; } function creditCard(c) { var fallback = c.kind === 'movie' ? '๐ŸŽฌ' : '๐Ÿ“บ'; var img = c.poster ? '' : '
' + fallback + '
'; var owned = c.library_id != null; var ribbon = owned ? 'In Library' : 'Preview'; var source = owned ? 'library' : 'tmdb'; var id = owned ? c.library_id : c.tmdb_id; var href = '/video-detail/' + source + '/' + c.kind + '/' + id; var sub = [c.year, c.role].filter(Boolean).join(' ยท '); var cb = window.VideoGet ? VideoGet.cardButton({ kind: c.kind, tmdbId: c.tmdb_id, libraryId: c.library_id, title: c.title, poster: c.poster, status: c.status, source: source }) : ''; return '' + cb + '
' + img + ribbon + '
' + '
' + esc(c.title) + '' + esc(sub) + '
'; } function tabBtn(attr, key, active, label, count) { return ''; } function renderDept() { var host = q('[data-vp-dept]'); if (!host || !data) return; var counts = {}; (data.credits || []).forEach(function (c) { var dp = c.department || 'Other'; counts[dp] = (counts[dp] || 0) + 1; }); var depts = Object.keys(counts); // Only worth a row for multi-hyphenates (actor-directors etc.). if (depts.length < 2) { host.innerHTML = ''; host.hidden = true; return; } host.hidden = false; depts.sort(function (a, b) { return counts[b] - counts[a]; }); var html = tabBtn('data-vp-dept', 'all', dept === 'all', 'All', countWith({ dept: 'all' })); html += depts.map(function (dp) { return tabBtn('data-vp-dept', dp, dp === dept, dp, countWith({ dept: dp })); }).join(''); host.innerHTML = html; } function renderTabs() { var host = q('[data-vp-tabs]'); if (!host || !data) return; var defs = [['all', 'All'], ['movie', 'Movies'], ['show', 'TV']]; host.innerHTML = defs.map(function (d) { return [d[0], d[1], countWith({ tab: d[0] })]; }) .filter(function (d) { return d[2] > 0 || d[0] === 'all'; }) .map(function (d) { return tabBtn('data-vp-tab', d[0], d[0] === tab, d[1], d[2]); }).join(''); } function renderOwn() { var host = q('[data-vp-own]'); if (!host || !data) return; var defs = [['all', 'All'], ['owned', 'In Library'], ['missing', 'Missing']]; host.innerHTML = defs.map(function (d) { return tabBtn('data-vp-own', d[0], d[0] === own, d[1], countWith({ own: d[0] })); }).join(''); } function applyFilters() { renderDept(); renderTabs(); renderOwn(); renderKnownFor(); renderCredits(); } // โ”€โ”€ filters (department + kind + ownership) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function matchKind(c, k) { return k === 'all' || c.kind === k; } function isOwned(c) { return c.library_id != null; } function matchOwn(c, o) { return o === 'all' || (o === 'owned' ? isOwned(c) : !isOwned(c)); } function matchDept(c, dp) { return dp === 'all' || (c.department || '') === dp; } function filtered() { return (data.credits || []).filter(function (c) { return matchDept(c, dept) && matchKind(c, tab) && matchOwn(c, own); }); } // Count credits under the active filters, overriding one dimension โ€” so every // chip's count reflects what you'd actually get if you clicked it. function countWith(o) { var dp = 'dept' in o ? o.dept : dept, k = 'tab' in o ? o.tab : tab, ow = 'own' in o ? o.own : own; return (data.credits || []).filter(function (c) { return matchDept(c, dp) && matchKind(c, k) && matchOwn(c, ow); }).length; } function renderKnownFor() { var section = q('[data-vp-known-section]'), host = q('[data-vp-known]'); if (!section || !host || !data) return; // Credits arrive popularity-sorted โ†’ the top few of the FILTERED set are // the "known for" (so it tracks the owned/missing + kind filters too). var top = filtered().slice(0, 10); section.hidden = top.length < 3; // only worth a rail if there are a few host.innerHTML = top.map(creditCard).join(''); if (window.VideoWatchlist) VideoWatchlist.hydrate(host); } function renderCredits() { var host = q('[data-vp-credits]'), empty = q('[data-vp-credits-empty]'); if (!host || !data) return; var credits = filtered(); if (sortBy === 'popularity') { credits.sort(function (a, b) { return (b.popularity || 0) - (a.popularity || 0); }); } else if (sortBy === 'oldest') { credits.sort(function (a, b) { return (a.date || '9999').localeCompare(b.date || '9999'); }); } else { credits.sort(function (a, b) { return (b.date || '').localeCompare(a.date || ''); }); } host.innerHTML = credits.map(creditCard).join(''); if (window.VideoWatchlist) VideoWatchlist.hydrate(host); if (empty) { empty.hidden = credits.length > 0; if (!credits.length) { empty.textContent = own === 'owned' ? 'Nothing from this person in your library yet.' : own === 'missing' ? 'You already have everything here. ๐ŸŽ‰' : 'No titles here.'; } } } function lifespan(d) { if (!d.birthday && !d.deathday) return ''; var by = (d.birthday || '').slice(0, 4); var dy = (d.deathday || '').slice(0, 4); return dy ? (by + ' โ€“ ' + dy) : (by ? 'Born ' + by : ''); } // Per-person accent: sample the portrait's dominant vibrant colour (via the // same-origin image proxy, so the cross-origin canvas isn't tainted). function applyAccent(photoUrl) { var page = root(); if (!page || !photoUrl) return; var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { 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 && root()) root().style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]); } catch (e) { /* tainted / no image โ€” keep theme accent */ } }; img.src = '/api/video/img?u=' + encodeURIComponent(photoUrl); } // โ”€โ”€ photos gallery + lightbox (reuses the shared .vd-lightbox styles) โ”€โ”€โ”€โ”€โ”€โ”€ var photoFulls = [], lbIdx = 0; function renderPhotos(photos) { var sec = q('[data-vp-photos-section]'), host = q('[data-vp-photos]'); photos = photos || []; photoFulls = photos.map(function (p) { return p.full; }); if (!sec || !host) return; if (!photos.length) { sec.hidden = true; return; } sec.hidden = false; host.innerHTML = photos.map(function (p, i) { return ''; }).join(''); } function openLightbox(idx) { if (!photoFulls.length) return; lbIdx = idx; var ov = document.getElementById('vp-lightbox'); if (!ov) { ov = document.createElement('div'); ov.id = 'vp-lightbox'; ov.className = 'vd-lightbox'; ov.addEventListener('click', function (e) { if (e.target.closest('[data-vp-lb-prev]')) lbStep(-1); else if (e.target.closest('[data-vp-lb-next]')) lbStep(1); else if (e.target === ov || e.target.closest('[data-vp-lb-close]')) closeLightbox(); }); document.body.appendChild(ov); } renderLb(); ov.classList.add('vd-lightbox--open'); } function renderLb() { var ov = document.getElementById('vp-lightbox'); if (!ov) return; ov.innerHTML = '' + '' + '' + '' + '
' + (lbIdx + 1) + ' / ' + photoFulls.length + '
'; } function lbStep(dir) { lbIdx = (lbIdx + dir + photoFulls.length) % photoFulls.length; renderLb(); } function closeLightbox() { var ov = document.getElementById('vp-lightbox'); if (ov) { ov.classList.remove('vd-lightbox--open'); ov.innerHTML = ''; } } function lbOpen() { var ov = document.getElementById('vp-lightbox'); return ov && ov.classList.contains('vd-lightbox--open'); } function computeAge(birthday, deathday) { if (!birthday) return null; var b = new Date(birthday), end = deathday ? new Date(deathday) : new Date(); if (isNaN(b.getTime()) || isNaN(end.getTime())) return null; var age = end.getFullYear() - b.getFullYear(); var mo = end.getMonth() - b.getMonth(); if (mo < 0 || (mo === 0 && end.getDate() < b.getDate())) age--; return (age >= 0 && age < 130) ? age : null; } function render(d) { data = d; tab = 'all'; own = 'all'; dept = 'all'; sortBy = 'newest'; var ss = document.querySelector('[data-vp-sort]'); if (ss) ss.value = 'newest'; var photo = q('[data-vp-photo]'), ph = q('[data-vp-photo-ph]'); if (photo) { if (d.photo) { photo.src = d.photo; photo.hidden = false; if (ph) ph.hidden = true; photo.onerror = function () { photo.hidden = true; if (ph) ph.hidden = false; }; } else { photo.hidden = true; if (ph) ph.hidden = false; } } // Cinematic ambient backdrop sampled from the portrait (blurred in CSS). var page = root(), amb = q('[data-vp-ambient]'); if (page) page.setAttribute('data-has-bg', d.photo ? '1' : '0'); if (amb) amb.style.setProperty('--vp-bg', d.photo ? "url('" + d.photo + "')" : 'none'); applyAccent(d.photo); setText('[data-vp-name]', d.name); // Role tagline (a friendlier noun than the raw department). var role = q('[data-vp-role]'); if (role) { var noun = ROLE_NOUNS[d.known_for] || d.known_for || ''; role.textContent = noun; role.hidden = !noun; } var meta = []; var ls = lifespan(d), age = computeAge(d.birthday, d.deathday); if (d.deathday) { if (ls) meta.push(ls); if (age != null) meta.push('aged ' + age); } else if (age != null) { meta.push(age + ' years old'); } else if (ls) { meta.push(ls); } if (d.place_of_birth) meta.push(d.place_of_birth); var n = (d.credits || []).length; if (n) meta.push(n + (n === 1 ? ' credit' : ' credits')); var m = q('[data-vp-meta]'); if (m) m.innerHTML = meta.map(function (x) { return '' + esc(x) + ''; }).join(''); var aka = q('[data-vp-aka]'); if (aka) { var names = d.also_known_as || []; aka.textContent = names.length ? 'Also known as: ' + names.join(' ยท ') : ''; aka.hidden = !names.length; } var bio = q('[data-vp-bio]'), more = q('[data-vp-bio-more]'); if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; bio.classList.remove('vp-bio--open'); } if (more) { more.hidden = !((d.biography || '').length > 320); more.textContent = 'Read more'; } renderWatchlist(d); renderPhotos(d.photos); applyFilters(); var sub = document.querySelector('.video-subpage[data-video-subpage="video-person-detail"]'); if (sub) sub.scrollTop = 0; } // โ”€โ”€ watchlist (follow a person โ€” same button as the movie/show pages) โ”€โ”€โ”€โ”€โ”€โ”€ // persons are tmdb-only, so currentId (the navigation id) IS the tmdb person id. function pid() { return currentId || (data && data.id); } function renderWatchlist(d) { var host = q('[data-vp-actions]'); if (!host) return; var on = !!d._vw_watched; host.innerHTML = ''; // Resolve the real watched state once (lazy), then re-render the button. var id = pid(); if (!d._vw_checked && id) { d._vw_checked = true; fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'person', tmdb_ids: [id] }) }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (res && res.results && data === d) { d._vw_watched = !!res.results[String(id)]; renderWatchlist(d); } }).catch(function () { /* keep default (off) */ }); } } function toggleWatch() { var d = data; if (!d) return; var id = pid(); if (!id) return; var on = !!d._vw_watched; var url = on ? '/api/video/watchlist/remove' : '/api/video/watchlist/add'; var body = on ? { kind: 'person', tmdb_id: id } : { kind: 'person', tmdb_id: id, title: d.name, poster_url: d.photo || null }; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (!res || res.success === false) { if (typeof showToast === 'function') showToast('Watchlist update failed', 'error'); return; } d._vw_watched = !on; renderWatchlist(d); if (typeof showToast === 'function') showToast(!on ? 'Added to watchlist' : 'Removed from watchlist', !on ? 'success' : 'info'); document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed', { detail: { kind: 'person', id: String(id), watched: !on } })); }).catch(function () { if (typeof showToast === 'function') showToast('Watchlist update failed', 'error'); }); } function load(id) { if (!root()) return; currentId = id; var pg = root(); if (pg) pg.style.removeProperty('--vd-accent-rgb'); // reset per-person accent showLoading(true); setText('[data-vp-name]', ''); var m = q('[data-vp-meta]'); if (m) m.innerHTML = ''; var c = q('[data-vp-credits]'); if (c) c.innerHTML = ''; var t = q('[data-vp-tabs]'); if (t) t.innerHTML = ''; var o = q('[data-vp-own]'); if (o) o.innerHTML = ''; var dp = q('[data-vp-dept]'); if (dp) dp.innerHTML = ''; var ce = q('[data-vp-credits-empty]'); if (ce) ce.hidden = true; var ks = q('[data-vp-known-section]'); if (ks) ks.hidden = true; var k = q('[data-vp-known]'); if (k) k.innerHTML = ''; var psec = q('[data-vp-photos-section]'); if (psec) psec.hidden = true; var pho = q('[data-vp-photos]'); if (pho) pho.innerHTML = ''; var ak = q('[data-vp-aka]'); if (ak) ak.hidden = true; photoFulls = []; fetch(PERSON_URL + id, { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { showLoading(false); if (currentId !== id) return; if (!d || d.error) { setText('[data-vp-name]', 'Not found'); return; } render(d); }) .catch(function () { showLoading(false); setText('[data-vp-name]', 'Could not load'); }); } function onOpen(e) { if (!e || !e.detail || e.detail.kind !== 'person') return; load(e.detail.id); } function onClick(e) { var r = root(); if (!r) return; var watchBtn = e.target.closest('[data-vp-watch]'); if (watchBtn && r.contains(watchBtn)) { toggleWatch(); return; } var kindBtn = e.target.closest('[data-vp-tab]'); if (kindBtn && r.contains(kindBtn)) { tab = kindBtn.getAttribute('data-vp-tab'); applyFilters(); return; } var ownBtn = e.target.closest('[data-vp-own]'); if (ownBtn && r.contains(ownBtn)) { own = ownBtn.getAttribute('data-vp-own'); applyFilters(); return; } var deptBtn = e.target.closest('[data-vp-dept]'); if (deptBtn && r.contains(deptBtn)) { dept = deptBtn.getAttribute('data-vp-dept'); applyFilters(); return; } var shot = e.target.closest('[data-vp-shot]'); if (shot && r.contains(shot)) { openLightbox(parseInt(shot.getAttribute('data-vp-shot'), 10) || 0); return; } var moreBtn = e.target.closest('[data-vp-bio-more]'); if (moreBtn && r.contains(moreBtn)) { var bio = q('[data-vp-bio]'); if (bio) { var open = bio.classList.toggle('vp-bio--open'); moreBtn.textContent = open ? 'Read less' : 'Read more'; } return; } var card = e.target.closest('[data-vp-open]'); if (card && r.contains(card)) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); var id = parseInt(card.getAttribute('data-vp-cid'), 10); if (isNaN(id)) return; document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: card.getAttribute('data-vp-open'), id: id, source: card.getAttribute('data-vp-source') || 'tmdb' }, })); } } function init() { document.addEventListener('soulsync:video-open-detail', onOpen); document.addEventListener('click', onClick); document.addEventListener('keydown', function (e) { if (!lbOpen()) return; if (e.key === 'Escape') closeLightbox(); else if (e.key === 'ArrowLeft') lbStep(-1); else if (e.key === 'ArrowRight') lbStep(1); }); var sortSel = document.querySelector('[data-vp-sort]'); if (sortSel) sortSel.addEventListener('change', function () { sortBy = sortSel.value; renderCredits(); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();