/* * SoulSync — Video detail page (isolated, NETFLIX-style — deliberately NOT the * music/Spotify layout). * * A cinematic billboard (full-bleed backdrop, content bottom-left) with a * per-show accent sampled from the poster, and a SEASON selector with four * switchable views — poster rail / timeline / pills / dropdown — plus a * "Missing only" episode filter. Opened by a card via soulsync:video-open-detail; * video-side.js navigates, this loads + renders. * * Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks * only to /api/video/* — the music side is never touched. */ (function () { 'use strict'; var DETAIL_URL = '/api/video/detail/'; var TMDB_LOGO = 'https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg'; var TVDB_LOGO = 'https://www.svgrepo.com/show/443500/brand-tvdb.svg'; // Real media-server logos for the "Play on your server" watch tile (same // sources as the header server toggle). var SERVER_LOGOS = { Plex: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg', Jellyfin: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png', }; var VIEW_KEY = 'soulsync_vd_season_view'; var VIEWS = [ { id: 'rail', label: 'Rail', ic: '▦' }, { id: 'timeline', label: 'Timeline', ic: '▭' }, { id: 'pills', label: 'Tabs', ic: '◉' }, { id: 'dropdown', label: 'List', ic: '▾' }, ]; var data = null; var selectedSeason = null; var seasonView = 'rail'; var menuOpen = false; var missingOnly = false; var currentId = null; var currentKind = 'show'; var currentSource = 'library'; // 'library' (video.db) or 'tmdb' (live preview) var artAttemptedFor = null; // lazy art refresh runs once per detail view var TMDB_URL = '/api/video/tmdb/'; function detailURL(kind, id, source) { return source === 'tmdb' ? TMDB_URL + kind + '/' + id : DETAIL_URL + kind + '/' + id; } try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function root() { return document.querySelector('[data-video-detail="' + currentKind + '"]'); } function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; } function setText(sel, t) { var n = q(sel); if (n) n.textContent = t || ''; } function runtimeLabel(m) { if (!m) return ''; var h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm'); } function statusLabel(s) { return s === 'continuing' ? 'Continuing' : s === 'ended' ? 'Ended' : s === 'upcoming' ? 'Upcoming' : (s || ''); } function seasonByNum(n) { if (!data) return null; for (var i = 0; i < data.seasons.length; i++) if (data.seasons[i].season_number === n) return data.seasons[i]; return null; } function seasonArt(s) { // tmdb + youtube carry direct (already-proxied for yt) art urls on the payload. if (data && (data.source === 'tmdb' || data.source === 'youtube')) return s.poster_url || data.poster_url || ''; return (s.has_poster && s.id != null) ? '/api/video/poster/season/' + s.id : (data && data.has_poster ? '/api/video/poster/show/' + data.id : ''); } // Source-aware billboard art: library items proxy through /api/video; tmdb // (preview) + youtube items use the (proxied) image URLs in the payload. function bbBackdrop(d) { if (d.source === 'tmdb' || d.source === 'youtube') return d.backdrop_url || d.poster_url || ''; var art = '/' + d.kind + '/' + d.id; return d.has_backdrop ? '/api/video/backdrop' + art : (d.has_poster ? '/api/video/poster' + art : ''); } function bbPoster(d) { // The offscreen poster is canvas-sampled for the accent — must be // same-origin, so tmdb posters proxy; youtube urls are already proxied. if (d.source === 'tmdb') return d.poster_url ? proxied(d.poster_url) : ''; if (d.source === 'youtube') return d.poster_url || ''; return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : ''; } function proxied(url) { return /^https:\/\/image\.tmdb\.org\//.test(url || '') ? '/api/video/img?u=' + encodeURIComponent(url) : (url || ''); } function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; } function badge(logo, fallback, title, url) { var inner = logo ? '' + fallback + '' : '' + fallback + ''; return url ? '' + inner + '' : '
' + inner + '
'; } // ── accent extraction (poster → dominant vibrant colour) ────────────────── function applyAccent(img) { try { var w = 24, h = 24, c = document.createElement('canvas'); c.width = w; c.height = h; var ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); var px = ctx.getImageData(0, 0, w, h).data; var best = null, bestScore = -1, fr = 0, fg = 0, fb = 0, n = 0; for (var i = 0; i < px.length; i += 4) { var r = px[i], g = px[i + 1], b = px[i + 2], a = px[i + 3]; if (a < 128) continue; var mx = Math.max(r, g, b), mn = Math.min(r, g, b), light = (mx + mn) / 2; fr += r; fg += g; fb += b; n++; if (light < 35 || light > 225) continue; var sat = mx === 0 ? 0 : (mx - mn) / mx, score = sat * (mx / 255); if (score > bestScore) { bestScore = score; best = [r, g, b]; } } if (!best && n) best = [Math.round(fr / n), Math.round(fg / n), Math.round(fb / n)]; if (best) { var r0 = root(); if (r0) r0.style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]); } } catch (e) { /* tainted/no image — keep theme accent */ } } // ── billboard ───────────────────────────────────────────────────────────── function renderBillboard(d) { setText('[data-vd-title]', d.title); setText('[data-vd-overview]', d.overview); // Clearlogo replaces the text title when available (Netflix/Plex feel). var logo = q('[data-vd-logo]'); var titleEl = q('[data-vd-title]'); if (logo) { if (d.logo) { logo.src = d.logo; logo.alt = d.title || ''; logo.hidden = false; logo.onerror = function () { logo.hidden = true; if (titleEl) titleEl.classList.remove('vd-title--logo'); }; if (titleEl) titleEl.classList.add('vd-title--logo'); } else { logo.hidden = true; logo.removeAttribute('src'); if (titleEl) titleEl.classList.remove('vd-title--logo'); } } var bg = q('[data-vd-backdrop]'); if (bg) { var url = bbBackdrop(d); bg.style.backgroundImage = url ? "url('" + url + "')" : ''; bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster); bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster); } var poster = q('[data-vd-poster]'); var posterUrl = bbPoster(d); if (poster && posterUrl) { poster.onload = function () { applyAccent(poster); }; poster.src = posterUrl; } var tl = q('[data-vd-tagline]'); if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; } var meta = []; if (d.source === 'youtube') { var isPl = d.kind === 'playlist'; meta.push('' + (isPl ? 'Playlist' : 'YouTube') + ''); var yc = window.VideoYoutube; if (isPl) { // A playlist IS a known, fixed list, so its count is real (unlike a // channel's total). Owner shows as a genre tag below. if (d.video_count != null) meta.push('' + esc(d.video_count) + ' videos'); } else { var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('' + subs + ' subscribers'); // NB: no "N videos" for a CHANNEL — YouTube doesn't expose a reliable total. var views = yc && yc.compactCount(d.view_count); if (views) meta.push('' + views + ' views'); if (d.handle) meta.push('' + esc(d.handle) + ''); } var mm = q('[data-vd-meta]'); if (mm) mm.innerHTML = meta.join(''); renderActions(d); var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = ''; var gg = q('[data-vd-genres]'); if (gg) gg.innerHTML = (d.genres || []).slice(0, 8).map(function (gn) { return '' + esc(gn) + ''; }).join(''); renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d); return; } if (d.source === 'tmdb') { meta.push('Preview'); } else if (d.kind === 'show') { var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0; meta.push('' + ownedPct + '% in library'); } else { meta.push(d.owned ? 'In library' : 'Wanted'); } if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10) + ''); if (d.year) meta.push('' + esc(d.year) + ''); if (d.content_rating) meta.push('' + esc(d.content_rating) + ''); if (d.kind === 'show') { meta.push('' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + ''); meta.push('' + d.episode_total + ' Episodes'); } var rt = runtimeLabel(d.runtime_minutes); if (rt) meta.push('' + esc(rt) + ''); if (d.kind === 'show' && d.status) meta.push('' + esc(statusLabel(d.status)) + ''); if (d.network) meta.push('' + esc(d.network) + ''); if (d.kind === 'movie' && d.studio) meta.push('' + esc(d.studio) + ''); var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join(''); renderActions(d); var l = q('[data-vd-links]'); if (l && d.source === 'tmdb') { l.innerHTML = ''; // preview items keep everything in-app } else if (l) { var badges = []; if (d.imdb_id) badges.push(badge('', 'IMDb', 'IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/')); if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB', 'https://www.themoviedb.org/' + (d.kind === 'movie' ? 'movie' : 'tv') + '/' + d.tmdb_id)); if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series')); if (d.wikidata_url) badges.push(badge('', 'Official Site', 'Official Site', d.wikidata_url)); l.innerHTML = badges.join(''); } var g = q('[data-vd-genres]'); if (g) { g.innerHTML = (d.genres || []).slice(0, 6).map(function (gn) { return '' + esc(gn) + ''; }).join(''); } renderSubtitles(d); renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d); } // Subtitle availability (OpenSubtitles backfill, #video-enrichment): a chip row // of the languages subtitles EXIST in for this title, so you know before you grab // it. Hidden entirely when we have no data. var SUB_LANG_NAMES = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian', pt: 'Portuguese', 'pt-br': 'Portuguese (BR)', nl: 'Dutch', pl: 'Polish', ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', 'zh-cn': 'Chinese', ar: 'Arabic', tr: 'Turkish', sv: 'Swedish', da: 'Danish', fi: 'Finnish', no: 'Norwegian', cs: 'Czech', el: 'Greek', he: 'Hebrew', hi: 'Hindi', hu: 'Hungarian', ro: 'Romanian', th: 'Thai', uk: 'Ukrainian', vi: 'Vietnamese', id: 'Indonesian' }; function subLangLabel(code) { var c = String(code || '').toLowerCase(); return SUB_LANG_NAMES[c] || code.toUpperCase(); } function renderSubtitles(d) { var el = q('[data-vd-subs]'); if (!el) return; var langs = (d && d.subtitle_langs) || []; if (!langs.length) { el.hidden = true; el.innerHTML = ''; return; } var shown = langs.slice(0, 12); var chips = shown.map(function (c) { return '' + esc(subLangLabel(c)) + ''; }); if (langs.length > shown.length) { chips.push('+' + (langs.length - shown.length) + ''); } el.innerHTML = 'Subtitles' + chips.join(''); el.hidden = false; } // "Directed by …" (movie) / "Created by …" (show) surfaced in the hero. // A crew member's name, clickable → person page when we have a TMDB id. function personName(c) { return c.tmdb_id ? '' + esc(c.name) + '' : esc(c.name); } function renderCrewLine(d) { var el = q('[data-vd-crew-line]'); if (!el) return; var key = d.kind === 'movie' ? 'Director' : 'Creator'; var people = (d.crew || []).filter(function (c) { return c.job === key; }).slice(0, 3); if (!people.length) { el.hidden = true; el.innerHTML = ''; return; } var label = (d.kind === 'movie' ? 'Director' : 'Creator') + (people.length > 1 ? 's' : ''); el.innerHTML = '' + label + ' ' + people.map(personName).join(', '); el.hidden = false; } function fmtDate(s) { if (!s) return ''; var p = String(s).split('-'); if (p.length < 3) return s; var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return (months[parseInt(p[1], 10) - 1] || '') + ' ' + parseInt(p[2], 10) + ', ' + p[0]; } // "Next episode" banner for continuing shows (data.next_episode arrives w/ extras). function renderNextEpisode(d) { var el = q('[data-vd-next-ep]'); if (!el) return; var ne = d.next_episode; if (d.kind !== 'show' || !ne || !ne.air_date) { el.hidden = true; el.innerHTML = ''; return; } var code = 'S' + ne.season_number + ' · E' + ne.episode_number; el.innerHTML = '▸ Next Episode' + '' + esc(code) + '' + (ne.name ? '' + esc(ne.name) + '' : '') + '' + esc(fmtDate(ne.air_date)) + ''; el.hidden = false; } function renderRatings(d) { var host = q('[data-vd-ratings]'); if (!host) return; var items = []; if (d.imdb_rating) { items.push('IMDb' + (Math.round(d.imdb_rating * 10) / 10) + ''); } if (d.rt_rating != null) { var fresh = d.rt_rating >= 60; items.push('' + (fresh ? '🍅' : '🤢') + '' + d.rt_rating + '%'); } if (d.metacritic != null) { var cls = d.metacritic >= 61 ? 'good' : d.metacritic >= 40 ? 'mid' : 'bad'; items.push('' + 'MC' + d.metacritic + ''); } if (d.trakt_rating) { var tv = d.trakt_votes ? ' title="' + esc(d.trakt_votes) + ' Trakt votes"' : ''; items.push('Trakt' + (Math.round(d.trakt_rating * 10) / 10) + ''); } if (d.tvmaze_rating) { items.push('TVmaze' + (Math.round(d.tvmaze_rating * 10) / 10) + ''); } if (d.anilist_score) { items.push('AniList' + d.anilist_score + '%'); } host.innerHTML = items.join(''); host.hidden = !items.length; } function renderCast(d) { var section = q('[data-vd-cast-section]'); if (!section) return; var cast = d.cast || [], crew = d.crew || []; if (!cast.length && !crew.length) { section.hidden = true; return; } section.hidden = false; var crewHost = q('[data-vd-crew]'); if (crewHost) { // Group crew by job (Creator / Director / Writer …) → "Job: A, B" with // each name clickable → person page. var byJob = {}; crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c); }); crewHost.innerHTML = Object.keys(byJob).map(function (job) { return '' + esc(job) + (byJob[job].length > 1 ? 's' : '') + ' ' + byJob[job].map(personName).join(', ') + ''; }).join(''); } var castHost = q('[data-vd-cast]'); if (castHost) { castHost.innerHTML = cast.map(function (p) { var img = p.photo ? '' : '' + esc((p.name || '?').charAt(0)) + ''; var inner = img + '' + esc(p.name) + '' + (p.character ? '' + esc(p.character) + '' : ''); // Clickable → in-app person page when we have a TMDB person id. var cb = (p.tmdb_id && window.VideoGet) ? VideoGet.cardButton({ kind: 'person', tmdbId: p.tmdb_id, title: p.name, poster: p.photo }) : ''; return p.tmdb_id ? '' + cb + inner + '' : '
' + inner + '
'; }).join(''); if (window.VideoWatchlist) VideoWatchlist.hydrate(castHost); } } function renderActions(d) { var a = q('[data-vd-actions]'); if (!a) return; if (d.source === 'youtube') { // Same watchlist button as shows/movies (consistency); it follows the // CHANNEL or the PLAYLIST depending on what this page is. var on = !!d.following; var isPl = d.kind === 'playlist'; var ytUrl = isPl ? 'https://www.youtube.com/playlist?list=' + esc(d.id) : 'https://www.youtube.com/channel/' + esc(d.id); a.innerHTML = '' + 'Open on YouTube ↗'; return; } // The watchlist eye applies only to airing shows (movies + ended shows are // terminal — they get acquisition, not a "watch for new" follow). var isAiringShow = d.kind === 'show' && d.tmdb_id && (!window.VideoGet || VideoGet.isAiring(d.status)); var watching = !!d._vw_watched; // Lazily resolve the real watched state once (airing library shows are on // by default), then re-render — see the new curated watchlist system. if (isAiringShow && !d._vw_checked && window.VideoWatchlist) { d._vw_checked = true; fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'show', tmdb_ids: [d.tmdb_id] }) }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (res && res.results) { d._vw_watched = !!res.results[String(d.tmdb_id)]; if (data === d) renderActions(d); } }).catch(function () { /* keep default state */ }); } var html = ''; // Primary CTA: play it on your media server (owned items; arrives with // extras). The logo IS the brand name — "Play on " (no redundant word). if (d.server && d.server.url) { var sv = esc(d.server.server || 'Server'); var slogo = SERVER_LOGOS[d.server.server]; var inner = slogo ? 'Play on' + '' : 'Play on ' + sv + ''; html += '' + inner + ''; } if (d.trailer && d.trailer.key) { html += ''; } // Watchlist (follow an AIRING show to wishlist its new episodes) applies whether // the show is owned or a TMDB preview — the curated watchlist is keyed by // tmdb_id. Ended/cancelled shows are terminal (isAiringShow=false) → no button. if (isAiringShow) { html += ''; } // "Get Missing" filters the OWNED episode list — library shows only. if (d.kind === 'show' && d.source !== 'tmdb') { html += ''; } a.innerHTML = html; } function mediaRes(r) { if (!r) return ''; r = String(r).toLowerCase(); if (r.indexOf('2160') > -1 || r === '4k') return '4K'; if (r.indexOf('1080') > -1) return '1080p'; if (r.indexOf('720') > -1) return '720p'; if (r.indexOf('480') > -1 || r.indexOf('576') > -1) return 'SD'; return r.toUpperCase(); } function prettyCodec(c) { if (!c) return ''; var l = String(c).toLowerCase(); if (l.indexOf('hevc') > -1 || l.indexOf('265') > -1) return 'HEVC'; if (l.indexOf('264') > -1 || l === 'avc') return 'H.264'; if (l.indexOf('av1') > -1) return 'AV1'; if (l.indexOf('vp9') > -1) return 'VP9'; return String(c).toUpperCase(); } function prettySource(s) { var map = { bluray: 'Blu-ray', 'web-dl': 'WEB-DL', webdl: 'WEB-DL', webrip: 'WEBRip', hdtv: 'HDTV', youtube: 'YouTube', dvd: 'DVD', remux: 'Remux' }; return map[String(s || '').toLowerCase()] || String(s || ''); } function fmtBytes(n) { if (!n) return ''; var gb = n / 1073741824; return gb >= 1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round(n / 1048576) + ' MB'; } function fileSummary(v) { return [mediaRes(v.resolution), prettyCodec(v.video_codec), v.audio_codec ? String(v.audio_codec).toUpperCase() : '', fmtBytes(v.size_bytes), v.release_source ? prettySource(v.release_source) : ''].filter(Boolean).join(' · '); } function renderDetails(d) { var host = q('[data-vd-details]'); if (!host) return; var rows = []; if (d.release_date) rows.push(['Released', d.release_date]); if (d.runtime_minutes) rows.push(['Runtime', runtimeLabel(d.runtime_minutes)]); if (d.studio) rows.push(['Studio', d.studio]); if (d.status) rows.push(['Status', statusLabel(d.status)]); if (d.rating_critic) rows.push(['Critic score', Math.round(d.rating_critic) + '%']); // Your media — the technical specs we scanned (Plex-grade). var f = d.file; if (f) { if (f.resolution) rows.push(['Quality', mediaRes(f.resolution)]); if (f.video_codec) rows.push(['Video', prettyCodec(f.video_codec)]); if (f.audio_codec) rows.push(['Audio', String(f.audio_codec).toUpperCase()]); if (f.release_source) rows.push(['Source', prettySource(f.release_source)]); if (f.size_bytes) rows.push(['Size', fmtBytes(f.size_bytes)]); } var html = rows.length ? '
' + rows.map(function (r) { return '
' + esc(r[0]) + '' + esc(r[1]) + '
'; }).join('') + '
' : ''; // Multiple versions / editions you own. var files = d.files || []; if (files.length > 1) { html += '
' + files.length + ' versions
' + files.map(function (v) { return '
' + esc(fileSummary(v)) + '
'; }).join('') + '
'; } host.innerHTML = html; } // ── live TMDB extras (trailer / where-to-watch / similar) ───────────────── function resetExtras() { ['[data-vd-providers-section]', '[data-vd-similar-section]', '[data-vd-collection-section]', '[data-vd-next-ep]', '[data-vd-crew-line]', '[data-vd-season-overview]', '[data-vd-facts-section]', '[data-vd-videos-section]', '[data-vd-gallery-section]', '[data-vd-review-section]', '[data-vd-cast-all]'].forEach(function (s) { var n = q(s); if (n) n.hidden = true; }); // Clear any YouTube-channel playlists from the show DOM so they don't leak // onto the next movie/show you open (the section is reused across loads). ytResetPlaylists(); galleryImages = []; stopBillboardTrailer(); } function loadExtras(kind, id) { fetch(DETAIL_URL + kind + '/' + id + '/extras', { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (ex) { if (ex) renderExtras(kind, id, ex); }) .catch(function () { /* best-effort */ }); } function simCard(s) { var poster = s.poster ? '' : '🎬'; var simKind = s.kind === 'movie' ? 'movie' : 'show'; var yr = s.year ? '' + esc(s.year) + '' : ''; var cb = window.VideoGet ? VideoGet.cardButton({ kind: simKind, tmdbId: s.tmdb_id, title: s.title, poster: s.poster, status: s.status, source: 'tmdb' }) : ''; return '' + cb + poster + '' + esc(s.title) + '' + yr + ''; } function renderRow(sectionSel, hostSel, items) { var sec = q(sectionSel), host = q(hostSel); if (!sec || !host) return; if (!items || !items.length) { sec.hidden = true; return; } sec.hidden = false; host.innerHTML = items.map(simCard).join(''); if (window.VideoWatchlist) VideoWatchlist.hydrate(host); } function renderExtras(kind, id, ex) { if (!data || data.id !== id || currentKind !== kind) return; data.trailer = ex.trailer || null; data.server = ex.server || null; data.next_episode = ex.next_episode || null; renderActions(data); renderNextEpisode(data); var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]'); if (ps && ph) { var html = ''; // If it's on your media server, that's the best place to watch — lead // with a "Play on Plex/Jellyfin" tile that deep-links to the item. if (ex.server && ex.server.url) { var sv = esc(ex.server.server || 'Server'); var slogo = SERVER_LOGOS[ex.server.server]; var sicon = slogo ? '' : ''; html += '' + sicon + 'Play on ' + sv + ''; } // Streaming providers: TMDB only gives ONE aggregate 'where to watch' // link (not per-provider), so showing N identical links is misleading. // Render the logos as availability BADGES, then a single link to the // watch page. (Drop a provider matching your server tile, e.g. Plex.) var link = ex.providers_link || ''; var srvName = (ex.server && ex.server.server || '').toLowerCase(); var provs = (ex.providers || []).filter(function (p) { return (p.name || '').toLowerCase() !== srvName; }); if (provs.length) { html += provs.map(function (p) { var img = p.logo ? '' + esc(p.name) + '' : '' + esc((p.name || '?').charAt(0)) + ''; return '
' + img + '' + esc(p.name) + '
'; }).join(''); if (link) { html += '' + '' + 'Where to watch'; } } ps.hidden = !html; ph.innerHTML = html; if (!ps.hidden) { loadPrefs(function (p) { var h = ps.querySelector('.vd-section-h'); if (h) h.textContent = 'Where to Watch' + (p && p.watch_region ? ' · ' + p.watch_region : ''); }); } } // Franchise / collection (movies) — the other films in the set. var cs = q('[data-vd-collection-section]'), ch = q('[data-vd-collection]'), ct = q('[data-vd-collection-title]'); var coll = ex.collection; if (cs && ch) { if (coll && coll.items && coll.items.length) { cs.hidden = false; if (ct) ct.textContent = coll.name || 'Collection'; ch.innerHTML = coll.items.map(simCard).join(''); if (window.VideoWatchlist) VideoWatchlist.hydrate(ch); } else { cs.hidden = true; } } // "More Like This" — recommendations (better-curated), falling back to similar. var more = (ex.recommendations && ex.recommendations.length) ? ex.recommendations : ex.similar; renderRow('[data-vd-similar-section]', '[data-vd-similar]', more); data.cast_full = ex.cast_full || null; renderCastAll(data); renderFacts(ex.facts, ex.keywords); renderVideos(ex.videos); renderGallery(ex.gallery); renderReview(ex.review); maybeAutoplayBillboard(); } function renderReview(review) { var sec = q('[data-vd-review-section]'), host = q('[data-vd-review]'); if (!sec || !host) return; if (!review || !review.content) { sec.hidden = true; return; } sec.hidden = false; var rating = review.rating ? '★ ' + review.rating + '/10' : ''; var date = review.created ? '' + esc(review.created) + '' : ''; var long = review.content.length > 420; host.innerHTML = '
' + '' + esc(review.author) + '' + rating + date + '
' + '

' + esc(review.content) + '

' + (long ? '' : ''); } // ── 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 ? '
' + rows.map(function (r) { return '
' + esc(r[0]) + '' + esc(r[1]) + '
'; }).join('') + '
' : ''; } if (kwh) { kwh.innerHTML = keywords.map(function (k) { return '' + esc(k) + ''; }).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 ''; }).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 ''; }).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 = '' + '' + '' + '' + '
' + (lightboxIdx + 1) + ' / ' + galleryImages.length + '
'; } function lightboxStep(dir) { if (!galleryImages.length) return; lightboxIdx = (lightboxIdx + dir + galleryImages.length) % galleryImages.length; renderLightbox(); } function closeLightbox() { var ov = document.getElementById('vd-lightbox'); if (ov) { ov.classList.remove('vd-lightbox--open'); ov.innerHTML = ''; } } function lightboxOpen() { var ov = document.getElementById('vd-lightbox'); return ov && ov.classList.contains('vd-lightbox--open'); } // ── full cast modal ─────────────────────────────────────────────────────── function renderCastAll(d) { var btn = q('[data-vd-cast-all]'); if (!btn) return; var n = (d.cast_full || []).length; btn.hidden = n === 0; if (n) btn.textContent = 'View all ' + n; } function castModalCard(p) { var img = p.photo ? '' : '' + esc((p.name || '?').charAt(0)) + ''; var eps = p.episode_count ? '' + p.episode_count + ' eps' : ''; var inner = img + '' + esc(p.name) + '' + (p.character ? '' + esc(p.character) + '' : '') + eps; return p.tmdb_id ? '' + inner + '' : '
' + inner + '
'; } function openCastModal() { var cast = (data && data.cast_full) || []; if (!cast.length) return; var ov = document.getElementById('vd-cast-modal'); if (!ov) { ov = document.createElement('div'); ov.id = 'vd-cast-modal'; ov.className = 'vd-cast-modal'; ov.addEventListener('click', function (e) { var card = e.target.closest('[data-vd-person]'); if (card) { if (modified(e)) return; e.preventDefault(); var pid = parseInt(card.getAttribute('data-vd-person'), 10); closeCastModal(); if (!isNaN(pid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'person', id: pid, source: 'tmdb' } })); return; } if (e.target === ov || e.target.closest('[data-vd-cm-close]')) closeCastModal(); }); document.body.appendChild(ov); } ov.innerHTML = '

Cast

' + '
' + '
' + cast.map(castModalCard).join('') + '
'; ov.classList.add('vd-cast-modal--open'); } function closeCastModal() { var ov = document.getElementById('vd-cast-modal'); if (ov) { ov.classList.remove('vd-cast-modal--open'); ov.innerHTML = ''; } } // ── trailer modal (YouTube embed) ───────────────────────────────────────── function openTrailer(key) { if (!key) return; stopBillboardTrailer(); // don't double up audio with the billboard var ov = document.getElementById('vd-trailer-overlay'); if (!ov) { ov = document.createElement('div'); ov.id = 'vd-trailer-overlay'; ov.className = 'vd-trailer-overlay'; ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('[data-vd-trailer-close]')) closeTrailer(); }); document.body.appendChild(ov); } ov.innerHTML = '
' + '' + '
'; ov.classList.add('vd-trailer-overlay--open'); } function closeTrailer() { var ov = document.getElementById('vd-trailer-overlay'); if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; } } // ── billboard autoplay trailer (opt-in setting) ─────────────────────────── var prefs = null, bbTrailerTimer = null, bbMuted = true; var bbMsgHandler = null, bbRevealTimer = null; function loadPrefs(cb) { if (prefs) { cb(prefs); return; } fetch('/api/video/prefs', { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { prefs = d || {}; cb(prefs); }) .catch(function () { prefs = {}; cb(prefs); }); } function maybeAutoplayBillboard() { stopBillboardTrailer(); if (!data || !data.trailer || !data.trailer.key) return; var key = data.trailer.key, id = currentId, kind = currentKind; loadPrefs(function (p) { if (!p || !p.billboard_autoplay || currentId !== id || currentKind !== kind) return; bbTrailerTimer = setTimeout(function () { if (currentId === id && currentKind === kind) startBillboardTrailer(key); }, 2600); }); } function startBillboardTrailer(key) { var bb = q('.vd-billboard'); if (!bb || bb.querySelector('[data-vd-bb-trailer]')) return; bbMuted = true; bb.classList.remove('vd-billboard--restoring'); var wrap = document.createElement('div'); wrap.className = 'vd-bb-trailer'; wrap.setAttribute('data-vd-bb-trailer', ''); wrap.innerHTML = '' + '
' + '
'; bb.appendChild(wrap); var iframe = wrap.querySelector('iframe'); // Ask YouTube to report playback state, then reveal ONLY once it's truly // PLAYING (state 1) — so the wipe doesn't fire over a black/buffering frame // — and restore the backdrop when it ENDS (state 0). function handshake() { try { iframe.contentWindow.postMessage( '{"event":"listening","id":"vd-bb","channel":"widget"}', '*'); } catch (e) { /* cross-origin not ready yet */ } } iframe.addEventListener('load', function () { handshake(); setTimeout(handshake, 500); }); bbMsgHandler = function (e) { if (!iframe.contentWindow || e.source !== iframe.contentWindow) return; var msg; try { msg = JSON.parse(e.data); } catch (x) { return; } var st = null; if (msg && msg.event === 'infoDelivery' && msg.info && typeof msg.info.playerState === 'number') st = msg.info.playerState; else if (msg && msg.event === 'onStateChange' && typeof msg.info === 'number') st = msg.info; if (st === 1) revealBillboardTrailer(bb); else if (st === 0) restoreBillboard(bb); }; window.addEventListener('message', bbMsgHandler); // Safety net: if YouTube never reports PLAYING (blocked handshake), reveal // anyway so a playing-but-hidden trailer doesn't sit behind the backdrop. bbRevealTimer = setTimeout(function () { revealBillboardTrailer(bb); }, 4500); } function revealBillboardTrailer(bb) { clearTimeout(bbRevealTimer); bbRevealTimer = null; bb.classList.remove('vd-billboard--restoring'); bb.classList.add('vd-billboard--trailer'); } function restoreBillboard(bb) { // Trailer finished → fade the original backdrop back in, then tear down. bb.classList.remove('vd-billboard--trailer'); bb.classList.add('vd-billboard--restoring'); setTimeout(function () { if (bb.classList.contains('vd-billboard--restoring')) stopBillboardTrailer(); }, 950); } function stopBillboardTrailer() { clearTimeout(bbTrailerTimer); bbTrailerTimer = null; clearTimeout(bbRevealTimer); bbRevealTimer = null; if (bbMsgHandler) { window.removeEventListener('message', bbMsgHandler); bbMsgHandler = null; } var ws = document.querySelectorAll('[data-vd-bb-trailer]'); for (var i = 0; i < ws.length; i++) ws[i].remove(); var bbs = document.querySelectorAll('.vd-billboard--trailer, .vd-billboard--restoring'); for (var j = 0; j < bbs.length; j++) bbs[j].classList.remove('vd-billboard--trailer', 'vd-billboard--restoring'); } function toggleBillboardMute(btn) { bbMuted = !bbMuted; var iframe = document.querySelector('[data-vd-bb-trailer] iframe'); if (iframe && iframe.contentWindow) { iframe.contentWindow.postMessage(JSON.stringify( { event: 'command', func: bbMuted ? 'mute' : 'unMute', args: [] }), '*'); } btn.textContent = bbMuted ? '🔇' : '🔊'; } // ── season selector (4 views) ───────────────────────────────────────────── function renderViewToggle() { var host = q('[data-vd-view-toggle]'); if (!host) return; if (data && data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no view toggle host.innerHTML = VIEWS.map(function (v) { return ''; }).join(''); } function renderSeasonNav() { var host = q('[data-vd-season-nav]'); if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; } if (data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no season nav if (data.source === 'youtube') { // channels: search + sort controls above the // year nav — which still honours the view toggle (rail posters, timeline, // tabs, list). Flat mode (search / most-viewed / longest) hides the nav. host.className = 'vd-season-nav vd-season-nav--yt vd-season-nav--' + seasonView; var nav = ytFlatMode() ? '' : seasonView === 'rail' ? railHTML() : seasonView === 'timeline' ? timelineHTML() : seasonView === 'pills' ? pillsHTML() : dropdownHTML(); host.innerHTML = ytControlsHTML() + nav; return; } host.className = 'vd-season-nav vd-season-nav--' + seasonView; if (seasonView === 'rail') host.innerHTML = railHTML(); else if (seasonView === 'timeline') host.innerHTML = timelineHTML(); else if (seasonView === 'pills') host.innerHTML = pillsHTML(); else host.innerHTML = dropdownHTML(); } function railHTML() { return '
' + data.seasons.map(function (s) { var art = seasonArt(s), p = pct(s); var on = s.season_number === selectedSeason ? ' vd-rcard--active' : ''; // YouTube posters carry a lower-res fallback (maxres → original) so a // missing maxresdefault.jpg downgrades instead of vanishing. var fb = (s.poster_fallback && s.poster_fallback !== art) ? s.poster_fallback : ''; var oe = fb ? 'var f=this.getAttribute(\'data-fb\');if(f){this.removeAttribute(\'data-fb\');this.src=f;}else{this.style.display=\'none\';}' : 'this.style.display=\'none\''; var img = art ? '' : ''; return ''; }).join('') + '
'; } function timelineHTML() { var total = data.seasons.reduce(function (a, s) { return a + Math.max(1, s.episode_total); }, 0) || 1; return '
' + data.seasons.map(function (s) { var p = pct(s), grow = Math.max(1, s.episode_total); var on = s.season_number === selectedSeason ? ' vd-tseg--active' : ''; return ''; }).join('') + '
'; } function pillsHTML() { return '
' + data.seasons.map(function (s) { var on = s.season_number === selectedSeason ? ' vd-pill-btn--active' : ''; return ''; }).join('') + '
'; } function dropdownHTML() { var cur = seasonByNum(selectedSeason); return '
' + '' + '
' + data.seasons.map(function (s) { var on = s.season_number === selectedSeason ? ' vd-ss-opt--active' : ''; return ''; }).join('') + '
'; } // ── episodes ────────────────────────────────────────────────────────────── // A YouTube video as an "episode": still + title + date, a Wish toggle instead // of owned/missing, expand → full description (lazy). function ytEpisodeRow(ep) { var key = selectedSeason + '_' + ep.episode_number; var still = ep.still_url ? '' : ''; var dur = ep.yt_duration ? '' + esc(ep.yt_duration) + '' : ''; var meta = []; var yc0 = window.VideoYoutube; if (ep.view_count) { meta.push((yc0 ? yc0.compactCount(ep.view_count) : ep.view_count) + ' views'); } if (ep.like_count) { meta.push('👍 ' + (yc0 ? yc0.compactCount(ep.like_count) : ep.like_count)); } if (ep.dislike_count) { meta.push('👎 ' + (yc0 ? yc0.compactCount(ep.dislike_count) : ep.dislike_count)); } if (ep.air_date) meta.push(fmtDate(ep.air_date)); var wished = !!ep.owned; return '
' + '
' + still + '' + dur + '
' + '
' + esc(ep.title || 'Untitled') + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
' + ytWishBtn(ep.youtube_id, wished, false) + '
' + ''; } // 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 ''; } 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 ? '' : ''; 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 '
' + '
' + (ep.episode_number != null ? ep.episode_number : '') + '
' + '
' + still + '
' + '
' + esc(ep.title || 'Episode ' + ep.episode_number) + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
' + '
' + (ep.owned ? 'Owned' : 'Missing') + '
' + '
' + ''; } function toggleEpisode(row) { var key = row.getAttribute('data-vd-ep-key'); var panel = q('[data-vd-ep-panel="' + key + '"]'); if (!panel) return; panel.hidden = !panel.hidden; row.classList.toggle('vd-ep--open', !panel.hidden); if (!panel.hidden && !panel.getAttribute('data-loaded')) { panel.setAttribute('data-loaded', '1'); loadEpisodeExtra(key, panel); } } function loadEpisodeExtra(key, panel) { // YouTube: the row carries the video id → fetch its full metadata. if (data && data.source === 'youtube') { var row = q('[data-vd-ep-key="' + key + '"]'); var vid = row && row.getAttribute('data-vd-yt-vid'); if (!vid) { panel.innerHTML = '
No details.
'; return; } panel.innerHTML = '
Loading…
'; fetch('/api/video/youtube/video/' + encodeURIComponent(vid), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var v = (d && d.video) || {}; var yc = window.VideoYoutube, stats = []; var lk = yc && yc.compactCount(v.like_count); if (lk) stats.push(lk + ' likes'); var vw = yc && yc.compactCount(v.view_count); if (vw) stats.push(vw + ' views'); var dearrow = v.dearrow_title ? '
DeArrow' + esc(v.dearrow_title) + '
' : ''; panel.innerHTML = '
' + dearrow + (stats.length ? '
' + esc(stats.join(' · ')) + '
' : '') + '

' + esc(v.description || 'No description.') + '

'; }) .catch(function () { panel.innerHTML = '
No details.
'; }); return; } var tmdb = data && data.tmdb_id; var parts = key.split('_'); if (!tmdb) { panel.innerHTML = '
No extra info.
'; return; } panel.innerHTML = '
Loading…
'; fetch('/api/video/episode/' + tmdb + '/' + parts[0] + '/' + parts[1], { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (ex) { renderEpisodeExtra(panel, ex && !ex.error ? ex : {}); }) .catch(function () { panel.innerHTML = ''; }); } function renderEpisodeExtra(panel, ex) { var html = ''; if (ex.still_url) { html += ''; } html += '
'; if (ex.overview) html += '

' + esc(ex.overview) + '

'; if (ex.guest_stars && ex.guest_stars.length) { html += '
Guest stars
' + ex.guest_stars.map(function (g) { var img = g.photo ? '' : '' + esc((g.name || '?').charAt(0)) + ''; var inner = img + '' + esc(g.name) + '' + (g.character ? '' + esc(g.character) + '' : ''); return g.tmdb_id ? '' + inner + '' : '
' + inner + '
'; }).join('') + '
'; } html += '
'; panel.innerHTML = html || '
No extra info.
'; } 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('') : '
' + emptyMsg + '
'; 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 = '
Loading episodes…
'; 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 = '' + '' + ic + esc(label) + pctTxt + ' · Track ↗'; } 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 '
' + '' + '
'; } 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 ? '' : ''; var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : ''; var on = !!p.following; var watch = ''; return '
' + '
' + thumb + '
' + esc(p.title) + '' + (n ? '' + n + '' : '') + '
' + watch + '
' + '
'; } function toggleYtPlaylistWatch(btn) { var yc = window.VideoYoutube; if (!yc) return; var pid = btn.getAttribute('data-vd-yt-pl-watch'), on = btn.classList.contains('watching'); btn.disabled = true; var setBtn = function (s) { btn.classList.toggle('watching', s); var ic = btn.querySelector('.watchlist-icon'); if (ic) ic.textContent = s ? '✓' : '+'; var tx = btn.querySelector('.watchlist-text'); if (tx) tx.textContent = s ? 'In Watchlist' : 'Add to Watchlist'; btn.disabled = false; }; var bump = function () { document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }; if (on) { yc.unfollowPlaylist(pid).then(function () { setBtn(false); bump(); }).catch(function () { btn.disabled = false; }); } else { yc.followPlaylist({ playlist_id: pid, title: btn.getAttribute('data-pl-title'), thumbnail_url: btn.getAttribute('data-pl-thumb') }).then(function (d) { if (d && d.success) { setBtn(true); bump(); if (typeof showToast === 'function') showToast('Added to watchlist', 'success'); } else btn.disabled = false; }).catch(function () { btn.disabled = false; }); } } function ytLoadPlaylists(cid) { var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]'); if (!host) return; fetch('/api/video/youtube/playlists/' + encodeURIComponent(cid), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { var pls = (d && d.playlists) || []; if (!pls.length || currentId !== cid) return; host.innerHTML = pls.map(ytPlaylistRow).join(''); if (sec) sec.hidden = false; }) .catch(function () { /* best-effort */ }); } function ytPlVideoCard(v) { ytVideoMap[v.youtube_id] = v; var thumb = v.thumbnail_url ? '' : ''; return '
' + '
' + thumb + '
' + '
' + esc(v.title || 'Untitled') + '
' + ytWishBtn(v.youtube_id, v.wished, true) + '
'; } function toggleYtPlaylist(el) { var pid = el.getAttribute('data-vd-yt-pl-toggle'); var blk = el.closest('.vc-pl'); if (!blk) return; var opened = blk.classList.toggle('vc-pl--collapsed') === false; if (!opened) return; var host = q('[data-vd-yt-pl-vids="' + pid + '"]'); if (!host || host.getAttribute('data-loaded')) return; host.setAttribute('data-loaded', '1'); host.innerHTML = '
Loading…
'; 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('') : '
No videos.
'; }) .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 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(); })();