/* * SoulSync — Video Wishlist page (isolated). * * The curated 'get this' list, split by a Movies / TV tab. Movies render as a * poster grid; TV groups into collapsible show → season → episode rows with * wanted/done roll-ups and a remove (✕) at every level. Server-paged + searchable * like the other pages. Reads /api/video/wishlist; removes via /wishlist/remove. * Self-contained IIFE, no globals. */ (function () { 'use strict'; var PAGE_ID = 'video-wishlist'; var LIMIT = 60; var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1, counts: { movie: 0, show: 0, episode: 0 }, ytChannel: 0, ytVideo: 0, showData: {}, showInfo: {} }; var searchTimer = null; function $(s, r) { return (r || document).querySelector(s); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; } var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; function fmtDate(iso) { var p = String(iso || '').split('-'); if (p.length < 3) return ''; return MO[(+p[1] || 1) - 1] + ' ' + (+p[2] || 1); } var STATUS = { wanted: ['Wanted', 'vwsh-st--wanted'], searching: ['Searching', 'vwsh-st--searching'], downloading: ['Downloading', 'vwsh-st--downloading'], downloaded: ['Done', 'vwsh-st--done'], failed: ['Failed', 'vwsh-st--failed'], }; function statusPill(status) { var s = STATUS[status] || STATUS.wanted; return '' + s[0] + ''; } function rmBtn(scope, attrs) { return ''; } // ── movie card ──────────────────────────────────────────────────────────── function movieCard(it) { var owned = it.library_id != null; var art = it.poster_url ? '' : '
🎬
'; var meta = [it.year, owned ? 'In library' : null].filter(Boolean).join(' · '); return '
' + '
' + art + '
' + statusPill(it.status) + rmBtn('movie', ' data-tmdb="' + esc(it.tmdb_id) + '"') + '
' + '
' + esc(it.title) + '' + (meta ? '' + esc(meta) + '' : '') + '
'; } // ── show orb (the "Nebula": show→artist, season→album, episode→track) ───── function initials(s) { var w = String(s || '').replace(/[^A-Za-z0-9 ]/g, '').split(' ').filter(Boolean); var i = w.slice(0, 2).map(function (x) { return x[0]; }).join(''); return (i || String(s || '?').slice(0, 2)).toUpperCase(); } function orbSize(n) { return n >= 10 ? 'orb-lg' : n >= 4 ? 'orb-md' : 'orb-sm'; } function nebulaOrb(sh, idx) { // Source-aware: a TMDB show opens its show page; a YouTube channel (source // 'youtube') opens the in-app channel page. YEAR is the "season", video the // "episode" — the data is already shaped that way, so the same render runs. var yt = sh.source === 'youtube'; // YouTube art goes through our image proxy (reliable load); tmdb passes through. var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; }; var src = sh.library_id != null ? 'library' : 'tmdb'; var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id; var openAttrs = yt ? 'data-vwsh-open-channel data-yt="' + esc(sh.youtube_id) + '"' : 'data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '"'; var hue = hueOf(sh.title); var total = sh.wanted || 0; // Channel avatar can be missing (flat listing doesn't always surface it) — // fall back to the newest video's thumbnail so the orb is never blank. var poster = sh.poster_url || (yt && (sh.seasons || [])[0] ? sh.seasons[0].poster_url : null); var img = poster ? '' : '
' + esc(initials(sh.title)) + '
'; // Episodes are shown grouped under a clickable season header (header → // show/channel page); each episode card SELECTS it (drives the info bar). // Season = a poster panel on the LEFT with the episode grid to its RIGHT. var seasons = (sh.seasons || []).map(function (se) { var n = se.episodes.length; var posterUrl = se.poster_url || sh.poster_url || null; var thumb = posterUrl ? '' : '📺'; var cards = (se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join(''); var sName = yt ? (se.season_number ? se.season_number : 'Undated') : ('Season ' + se.season_number); var sRm = yt ? 'data-vwsh-rm="yt-season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"' : 'data-vwsh-rm="season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"'; return '
' + '
' + '
' + thumb + '
' + '
' + esc(sName) + '
' + '
' + n + (yt ? ' video' : ' episode') + (n === 1 ? '' : 's') + '
' + '
' + (yt ? 'View channel' : 'View show') + ' →
' + '' + '
' + '
' + cards + '
' + '
'; }).join(''); var eps = total + (yt ? ' video' : ' episode') + (total === 1 ? '' : 's'); // --orb-hue on the GROUP so the music orb styles + my cinematic-expand // backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded. var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue + (poster ? ";--vwsh-poster:url('" + esc(poster) + "')" : ''); var prog = total ? Math.max(0, Math.min(1, (sh.done || 0) / total)) : 0; // #4 acquisition progress // Header is a 3-column row that FLANKS the poster: synopsis (left) · poster // (middle) · cast (right). When collapsed (or no data) the side columns are // empty → hidden → just the centered bubble, so the nebula grid is unchanged. var showRm = yt ? 'data-vwsh-rm="yt-channel" data-yt="' + esc(sh.youtube_id) + '"' : 'data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '"'; return '
' + '' + '
' + '
' + '
' + '
' + esc(sh.title) + '
' + eps + '
' + '
' + '
' + img + '
' + '
' + '
' + '
' + esc(sh.title) + '
' + '
' + eps + (sh.done ? ' · ' + sh.done + ' done' : '') + '
' + '
' + '
' + '
' + '
' + seasons + '
' + '
'; } // ── info bar: synopsis + clickable cast, contextual to the selected episode ─ function castBubbles(arr) { return (arr || []).slice(0, 8).map(function (c) { var photo = c.photo ? '' : ''; return ''; }).join(''); } function findEpisode(tmdb, sNum, eNum) { var sh = state.showData[tmdb], ep = null; if (sh) (sh.seasons || []).forEach(function (se) { if (se.season_number === sNum) (se.episodes || []).forEach(function (x) { if (x.episode_number === eNum) { ep = x; ep.season_number = sNum; } // episodes don't carry their season # }); }); return ep; } // sel = a selected episode object (episode synopsis + guest cast), or null (show synopsis + show cast). function renderInfoBar(group, tmdb, sel) { var synEl = group && group.querySelector('[data-vwsh-syn]'); var castEl = group && group.querySelector('[data-vwsh-cast]'); if (!synEl || !castEl) return; // YouTube: no cast. A selected video shows its FULL metadata — description, // duration, views — fetched lazily from yt-dlp on first select (like the // TV nebula lazy-loads guest stars), then cached on the episode object. if (group.getAttribute('data-vwsh-source') === 'youtube') { castEl.innerHTML = ''; if (!sel) { synEl.innerHTML = ''; return; } var vd = sel._ytd; // undefined = not fetched, null = fetched/empty var bits = []; var yd = fmtDate((vd && vd.published_at) || sel.air_date); if (yd) bits.push(esc(yd)); if (vd && window.VideoYoutube) { var du = VideoYoutube.fmtDuration(vd.duration_seconds); if (du) bits.push(esc(du)); var vc = VideoYoutube.compactCount(vd.view_count); if (vc) bits.push(esc(vc) + ' views'); } var desc = (vd && vd.description) || sel.overview || ''; var watch = 'https://www.youtube.com/watch?v=' + encodeURIComponent(sel.source_id); synEl.innerHTML = (bits.length ? '' + bits.join(' · ') + '' : '') + (desc ? esc(desc) : (sel._ytd === undefined ? 'Loading details…' : 'No description for this video.')) + 'Watch on YouTube ↗'; if (sel._ytd === undefined) { sel._ytd = null; // mark in-flight so we fetch once fetch('/api/video/youtube/video/' + encodeURIComponent(sel.source_id), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { sel._ytd = (d && d.video) || null; var cur = group.querySelector('.vwsh-epc--sel'); if (cur && cur.getAttribute('data-src-id') === sel.source_id) renderInfoBar(group, tmdb, sel); }) .catch(function () { sel._ytd = null; }); } return; } var si = state.showInfo[tmdb] || {}; var eyebrow, overview, castArr; if (sel) { eyebrow = 'S' + sel.season_number + ' · E' + sel.episode_number; overview = sel.overview || 'No synopsis for this episode.'; // Episode cast = its guest stars (episode-specific) THEN the show regulars, // deduped — most episodes have no guest stars, so show the regulars too. var seen = {}; castArr = (sel._guests || []).concat(si.cast || []).filter(function (c) { var k = c.tmdb_id || c.name; if (seen[k]) return false; seen[k] = 1; return true; }); } else { eyebrow = ''; overview = si.overview || ''; castArr = si.cast || []; } // Side columns are independent so each hides (:empty) when it has nothing. synEl.innerHTML = (eyebrow || overview) ? ((eyebrow ? '' + esc(eyebrow) + '' : '') + esc(overview)) : ''; castEl.innerHTML = castArr.length ? castBubbles(castArr) : ''; // lazily fetch the episode's guest stars, then re-render if still selected if (sel && sel._guests === undefined) { sel._guests = null; fetch('/api/video/episode/' + tmdb + '/' + sel.season_number + '/' + sel.episode_number, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { sel._guests = (d && d.guest_stars) || []; var cur = group.querySelector('.vwsh-epc--sel'); if (cur && +cur.getAttribute('data-s') === sel.season_number && +cur.getAttribute('data-e') === sel.episode_number) renderInfoBar(group, tmdb, sel); }) .catch(function () { sel._guests = []; }); } } // Lazily load the show's synopsis + cast when its orb first expands. function loadShowInfo(group) { if (!group) return; var tmdb = parseInt(group.getAttribute('data-vwsh-tmdb'), 10); renderInfoBar(group, tmdb, null); // paint the View-show button immediately if (group.getAttribute('data-vwsh-source') === 'youtube') return; // no tmdb detail for channels if (group.getAttribute('data-vwsh-info-loaded')) return; group.setAttribute('data-vwsh-info-loaded', '1'); var sh = state.showData[tmdb]; if (!sh) return; var url = sh.library_id != null ? '/api/video/detail/show/' + sh.library_id : '/api/video/tmdb/show/' + tmdb; fetch(url, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!d) return; state.showInfo[tmdb] = { overview: d.overview || '', cast: d.cast || [] }; if (!group.querySelector('.vwsh-epc--sel')) renderInfoBar(group, tmdb, null); // unless an episode is selected }) .catch(function () { /* best-effort */ }); } // A single episode card. Clicking it SELECTS the episode (drives the info bar); // the "View show" button in the info bar is what navigates. function epCard(sh, se, e) { var yt = sh.source === 'youtube'; var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; }; var t = e.title || (yt ? 'Untitled' : ('Episode ' + e.episode_number)); var st = STATUS[e.status] ? e.status : 'wanted'; var date = fmtDate(e.air_date); // TMDB shows the SxEx label; a YouTube video shows just its upload date. var metaTxt = yt ? (date || 'Video') : ('S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '')); var thumb = e.still_url ? '' : ''; var rm = yt ? 'data-vwsh-rm="yt-video" data-id="' + esc(e.source_id) + '"' : 'data-vwsh-rm="episode" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"'; return '
' + thumb + '
' + '
' + esc(t) + '
' + '
' + (yt ? esc(metaTxt) : metaTxt) + '
' + '
' + '' + '
'; } function render(items) { var grid = $('[data-vwsh-grid]'); if (!grid) return; // YouTube uses the SAME nebula as TV (channel=show, year=season, video=episode). var nebula = state.tab === 'show' || state.tab === 'youtube'; grid.classList.toggle('wl-nebula-field', nebula); grid.classList.toggle('vwsh-nebula', nebula); // video-only scope so music wl-* is untouched grid.classList.toggle('vwsh-grid--movies', state.tab === 'movie'); state.showData = {}; if (nebula) items.forEach(function (sh) { state.showData[sh.tmdb_id] = sh; }); // for the episode area grid.innerHTML = nebula ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('') : items.map(movieCard).join(''); } // ── counts / badges / pager ─────────────────────────────────────────────── // The nav badge is the WHOLE wishlist (movies + episodes + YouTube videos). movie/episode // and YouTube counts arrive from SEPARATE loads, and on the dashboard only one of them // runs — so neither setter can compute the whole total from its partial state without // clobbering the other (the bug: badge shows the right number, then 'switches' to the // TV-only count). The endpoint /wishlist/counts is the single source of truth for the // grand total, so the setters just (re)sync the nav badge from it. function setCounts(counts) { state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0, episode: (counts && counts.episode) || 0 }; var cm = $('[data-vwsh-count-movie]'); if (cm) cm.textContent = state.counts.movie; var cs = $('[data-vwsh-count-show]'); if (cs) cs.textContent = state.counts.show; refreshBadge(); // authoritative grand total updateSub(); updateClearBtn(); } function setYtCounts(counts) { state.ytChannel = (counts && counts.channel) || 0; state.ytVideo = (counts && counts.video) || 0; var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo; refreshBadge(); // authoritative grand total updateSub(); updateClearBtn(); } // Show "Clear all" only when the active tab actually has items. function updateClearBtn() { var btn = $('[data-vwsh-clear]'); if (!btn) return; var has = state.tab === 'movie' ? state.counts.movie > 0 : state.tab === 'show' ? state.counts.show > 0 : (state.ytVideo > 0 || state.ytChannel > 0); btn.hidden = !has; } // Keep the YouTube tab badge fresh without switching to the tab. function refreshYtCount() { fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (d && d.counts) setYtCounts(d.counts); }) .catch(function () { /* ignore */ }); } function updateSub() { var el = $('[data-vwsh-sub]'); if (!el) return; var c = state.counts; if (state.tab === 'youtube') { el.textContent = state.ytChannel + ' channel' + (state.ytChannel === 1 ? '' : 's') + ' · ' + state.ytVideo + ' video' + (state.ytVideo === 1 ? '' : 's'); return; } el.textContent = state.tab === 'show' ? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's') : c.movie + ' movie' + (c.movie === 1 ? '' : 's'); } function updateBadges(total) { var n = total || 0; ['[data-video-wishlist-badge]', '[data-video-badge="wishlist"]'].forEach(function (sel) { document.querySelectorAll(sel).forEach(function (b) { b.textContent = n; b.classList.toggle('hidden', !n); }); }); } function refreshBadge() { fetch('/api/video/wishlist/counts', { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (d && d.success) updateBadges(d.total); }) .catch(function () { /* ignore */ }); } function updatePagination(p) { var box = $('[data-vwsh-pagination]'), prev = $('[data-vwsh-prev]'), next = $('[data-vwsh-next]'), info = $('[data-vwsh-pageinfo]'); if (!box) return; if (!p || p.total_pages <= 1) { box.classList.add('hidden'); return; } if (prev) prev.disabled = !p.has_prev; if (next) next.disabled = !p.has_next; if (info) info.textContent = 'Page ' + p.page + ' of ' + p.total_pages; box.classList.remove('hidden'); } function updateEmpty(total) { var empty = $('[data-vwsh-empty]'); if (empty) empty.classList.toggle('hidden', total > 0); var et = $('[data-vwsh-empty-title]'); if (et && total === 0) { et.textContent = state.search ? 'No matches' : state.tab === 'movie' ? 'No movies on your wishlist yet' : state.tab === 'show' ? 'No TV episodes on your wishlist yet' : 'No channels followed yet — paste a channel link on the Search page'; } } function loadYoutube() { var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden'); var params = new URLSearchParams({ search: state.search, sort: state.sort, page: state.page, limit: LIMIT }); fetch('/api/video/youtube/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (ld) ld.classList.add('hidden'); if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; } setYtCounts(d.counts); var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length }; state.page = p.page; render(d.items || []); updatePagination(p); updateEmpty(p.total_count); }) .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); }); } function load() { state.loaded = true; if (state.tab === 'youtube') { loadYoutube(); return; } var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden'); var params = new URLSearchParams({ kind: state.tab, search: state.search, sort: state.sort, page: state.page, limit: LIMIT }); fetch('/api/video/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (ld) ld.classList.add('hidden'); if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; } setCounts(d.counts); var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length }; state.page = p.page; render(d.items || []); updatePagination(p); updateEmpty(p.total_count); maybeBackfillArt(d.items || []); }) .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); }); } // Rows added before art-capture have no episode still / season poster — fetch // them once (cheap: one tmdb_season call per show/season server-side), reload. var artBackfilled = false; function maybeBackfillArt(items) { if (state.tab !== 'show' || artBackfilled) return; var missing = (items || []).some(function (sh) { return (sh.seasons || []).some(function (se) { return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url || !e.overview; }); }); }); if (!missing) return; artBackfilled = true; fetch('/api/video/wishlist/backfill-art', { method: 'POST', headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (res && res.updated > 0) load(); }) .catch(function () { /* best-effort */ }); } function setTab(tab) { if (tab !== 'movie' && tab !== 'show' && tab !== 'youtube') return; state.tab = tab; state.page = 1; state.search = ''; var si = $('[data-vwsh-search]'); if (si) si.value = ''; var tabs = document.querySelectorAll('[data-vwsh-tab]'); for (var i = 0; i < tabs.length; i++) tabs[i].classList.toggle('vwsh-tab--on', tabs[i].getAttribute('data-vwsh-tab') === tab); updateClearBtn(); load(); } // Empty the whole current tab (movies / TV / YouTube), after a confirm. function clearAll() { var kind = state.tab; var label = kind === 'movie' ? 'movies' : kind === 'show' ? 'TV episodes' : 'YouTube videos'; var go = function () { fetch('/api/video/wishlist/clear', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: kind }), }).then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (res && res.success) { if (typeof showToast === 'function') showToast('Cleared ' + (res.removed || 0) + ' ' + label + ' from your wishlist', 'success'); load(); } else if (typeof showToast === 'function') { showToast('Could not clear wishlist', 'error'); } }).catch(function () { if (typeof showToast === 'function') showToast('Could not clear wishlist', 'error'); }); }; if (typeof showConfirmDialog === 'function') { showConfirmDialog({ title: 'Clear wishlist', message: 'Remove ALL ' + label + ' from your wishlist? This can’t be undone.', confirmText: 'Clear all', cancelText: 'Cancel', destructive: true, }).then(function (ok) { if (ok) go(); }); } else { go(); } } // ── remove (TMDB scopes via /wishlist/remove; YouTube scopes via youtube) ── function doRemove(btn) { var scope = btn.getAttribute('data-vwsh-rm'); btn.disabled = true; var after = function () { if (typeof showToast === 'function') showToast('Removed from wishlist', 'info'); load(); }; var afterYt = function () { after(); document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }; var fail = function () { btn.disabled = false; }; if (scope === 'yt-video') { VideoYoutube.removeWish('video', btn.getAttribute('data-id')).then(afterYt).catch(fail); return; } if (scope === 'yt-channel') { // remove the channel's videos AND unfollow it var cid = btn.getAttribute('data-yt'); VideoYoutube.unfollow(cid).then(function () { return VideoYoutube.removeWish('channel', cid); }) .then(afterYt).catch(fail); return; } if (scope === 'yt-season') { // a "year" = remove every wished video in it var sh = state.showData[parseInt(btn.getAttribute('data-tmdb'), 10)]; var yr = parseInt(btn.getAttribute('data-s'), 10), ids = []; if (sh) (sh.seasons || []).forEach(function (se) { if (se.season_number === yr) (se.episodes || []).forEach(function (ep) { if (ep.source_id) ids.push(ep.source_id); }); }); if (!ids.length) { fail(); return; } Promise.all(ids.map(function (id) { return VideoYoutube.removeWish('video', id); })).then(afterYt).catch(fail); return; } var body = { scope: scope, tmdb_id: parseInt(btn.getAttribute('data-tmdb'), 10) }; if (btn.hasAttribute('data-s')) body.season_number = parseInt(btn.getAttribute('data-s'), 10); if (btn.hasAttribute('data-e')) body.episode_number = parseInt(btn.getAttribute('data-e'), 10); fetch('/api/video/wishlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!d || !d.success) { fail(); return; } after(); }) .catch(fail); } function onGridClick(e) { var rm = e.target.closest('[data-vwsh-rm]'); if (rm) { e.preventDefault(); e.stopPropagation(); doRemove(rm); return; } if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var openCh = e.target.closest('[data-vwsh-open-channel]'); if (openCh) { // YouTube channel → in-app channel page document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'channel', source: 'youtube', id: openCh.getAttribute('data-yt') } })); return; } var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie], [data-vwsh-open-person]'); if (open) { var person = open.hasAttribute('data-vwsh-open-person'); document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: person ? 'person' : (open.hasAttribute('data-vwsh-open-show') ? 'show' : 'movie'), id: parseInt(person ? open.getAttribute('data-id') : open.getAttribute('data-vwsh-id'), 10), source: person ? 'tmdb' : (open.getAttribute('data-vwsh-src') || 'tmdb') }, })); return; } var epc = e.target.closest('[data-vwsh-ep]'); if (epc) { // episode → SELECT it (drives the info bar); click again to deselect var eg = epc.closest('.wl-orb-group'); var etmdb = parseInt(epc.getAttribute('data-tmdb'), 10); var eWasSel = epc.classList.contains('vwsh-epc--sel'); if (eg) { var es = eg.querySelectorAll('.vwsh-epc'); for (var j = 0; j < es.length; j++) es[j].classList.remove('vwsh-epc--sel'); } if (eWasSel) { renderInfoBar(eg, etmdb, null); return; } // back to show synopsis + cast epc.classList.add('vwsh-epc--sel'); renderInfoBar(eg, etmdb, findEpisode(etmdb, parseInt(epc.getAttribute('data-s'), 10), parseInt(epc.getAttribute('data-e'), 10))); return; } var orb = e.target.closest('[data-vwsh-orb]'); if (orb) { // show → reveal seasons + lazily load the synopsis/cast info bar var g = orb.closest('.wl-orb-group'); if (!g) return; // expand → load info bar; collapse → clear it (the synopsis/cast live in // the always-present header, so they must be wiped or they linger). if (g.classList.toggle('expanded')) loadShowInfo(g); else clearInfoBar(g); } } // Empty the info bar + drop any episode selection (so the :empty side columns // collapse). Used when an orb closes. function clearInfoBar(group) { if (!group) return; var syn = group.querySelector('[data-vwsh-syn]'); if (syn) syn.innerHTML = ''; var cast = group.querySelector('[data-vwsh-cast]'); if (cast) cast.innerHTML = ''; var sel = group.querySelectorAll('.vwsh-epc--sel'); for (var i = 0; i < sel.length; i++) sel[i].classList.remove('vwsh-epc--sel'); } function wire() { var tabs = document.querySelectorAll('[data-vwsh-tab]'); for (var i = 0; i < tabs.length; i++) (function (b) { b.addEventListener('click', function () { setTab(b.getAttribute('data-vwsh-tab')); }); })(tabs[i]); var grid = $('[data-vwsh-grid]'); if (grid) grid.addEventListener('click', onGridClick); var search = $('[data-vwsh-search]'); if (search) search.addEventListener('input', function () { if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.search = search.value.trim(); state.page = 1; load(); }, 250); }); var sortSel = $('[data-vwsh-sort]'); if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); }); var clearBtn = $('[data-vwsh-clear]'); if (clearBtn) clearBtn.addEventListener('click', clearAll); var prev = $('[data-vwsh-prev]'); if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } }); var next = $('[data-vwsh-next]'); if (next) next.addEventListener('click', function () { state.page++; load(); }); // Adds elsewhere (the get-modal) refresh the badge + page if visible. document.addEventListener('soulsync:video-wishlist-changed', function () { var g = $('[data-vwsh-grid]'); if (g && g.offsetParent !== null) load(); else { refreshBadge(); refreshYtCount(); } }); } function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); refreshYtCount(); } } // The nav badge also has to track wishlist changes that happen SERVER-SIDE — when a // download finishes it removes its item from the wishlist, and that fires no frontend // 'changed' event. So poll the authoritative count: quicker while downloads are active // (the downloads page exposes _vdpgAnyActive), slower when idle, paused when hidden. function scheduleBadgePoll() { var active = (typeof window._vdpgAnyActive === 'function') && window._vdpgAnyActive(); if (scheduleBadgePoll._t) clearTimeout(scheduleBadgePoll._t); scheduleBadgePoll._t = setTimeout(function () { if (!document.hidden) refreshBadge(); scheduleBadgePoll(); }, active ? 8000 : 30000); } function init() { wire(); document.addEventListener('soulsync:video-page-shown', onShown); refreshBadge(); refreshYtCount(); scheduleBadgePoll(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();