/* * SoulSync — Video Search page (isolated, in-app). * * Debounced multi-search via /api/video/search (movies / shows / people from * TMDB). Movie/show results link to the OWNED library detail when we already * have them (library_id), otherwise to the TMDB-backed detail. People open the * in-app person page. Everything stays inside SoulSync — no external links. * * Reuses the library card classes (.library-artist-card). Self-contained IIFE, * no globals, event-delegated, no inline handlers. Talks only to /api/video/*. */ (function () { 'use strict'; var PAGE_ID = 'video-search'; var SEARCH_URL = '/api/video/search'; var GROUPS = [ { kind: 'movie', label: 'Movies', icon: '🎬' }, { kind: 'show', label: 'TV Shows', icon: '📺' }, { kind: 'person', label: 'People', icon: '👤' }, ]; var lastQuery = ''; var reqSeq = 0; // guards against out-of-order responses var timer = null; var wired = false; var trendingCache = null; // null = not fetched; [] = fetched/empty var lastChannel = null; // resolved YouTube channel awaiting a Follow var lastPlaylist = null; // resolved YouTube playlist awaiting Add-to-watchlist function $(sel) { return document.querySelector(sel); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function show(sel, on) { var n = $(sel); if (n) n.classList.toggle('hidden', !on); } // Netflix-style poster card with owned/preview ribbon + hover affordance. function titleCard(it) { var fallback = it.kind === 'movie' ? '🎬' : '📺'; var img = it.poster ? '' : '
' + fallback + '
'; var owned = it.library_id != null; var ribbon = owned ? 'In Library' : 'Preview'; var rating = it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) + '' : ''; // Owned → real library detail; otherwise the TMDB-backed (preview) detail. var source = owned ? 'library' : 'tmdb'; var id = owned ? it.library_id : it.tmdb_id; var href = '/video-detail/' + source + '/' + it.kind + '/' + id; var sub = [it.year, it.kind === 'movie' ? 'Movie' : 'TV'].filter(Boolean).join(' · '); var cb = window.VideoGet ? VideoGet.cardButton({ kind: it.kind, tmdbId: it.tmdb_id, libraryId: it.library_id, title: it.title, poster: it.poster, status: it.status, source: source }) : ''; return '' + cb + '
' + img + ribbon + rating + '
' + '
' + esc(it.title) + '' + esc(sub) + '
'; } function personCard(it) { var img = it.poster ? '' : '
👤
'; var sub = it.known_for ? it.known_for : (it.department || ''); var cb = window.VideoGet ? VideoGet.cardButton({ kind: 'person', tmdbId: it.tmdb_id, title: it.title, poster: it.poster }) : ''; return '' + cb + '
' + img + '
' + '
' + esc(it.title) + '' + esc(sub) + '
'; } // TMDB groups (movies/shows/people) + a YouTube channels group, each painted // as soon as its source resolves (they're fetched in parallel). While YouTube // is still in flight a "Searching YouTube…" skeleton group shows — so an empty // TMDB result never flashes "No results" before the channels arrive. function ytSkeletonGroup() { var cards = ''; for (var i = 0; i < 4; i++) { cards += '
' + '' + '' + '
'; } return '

' + 'YouTube channels' + 'searching…

' + '
' + cards + '
'; } function render(results, ytChannels, ytSearching) { var host = $('[data-video-search-results]'); if (!host) return; var html = ''; GROUPS.forEach(function (g) { var items = (results || []).filter(function (r) { return r.kind === g.kind; }); if (!items.length) return; html += '

' + '' + g.label + '' + items.length + '

' + '
' + items.map(g.kind === 'person' ? personCard : titleCard).join('') + '
'; }); if (ytChannels && ytChannels.length && window.VideoYoutube) { html += '

' + 'YouTube channels' + '' + ytChannels.length + '

' + '
' + ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') + '
'; } else if (ytSearching) { html += ytSkeletonGroup(); } var any = html.length > 0; show('[data-video-search-hint]', false); show('[data-video-search-empty]', !any); host.innerHTML = any ? html : ''; if (any && window.VideoWatchlist) VideoWatchlist.hydrate(host); } // Idle state: a "Trending this week" rail so the page isn't a blank box. function renderTrending() { var host = $('[data-video-search-results]'); if (!host || !trendingCache || !trendingCache.length) return; show('[data-video-search-hint]', false); show('[data-video-search-empty]', false); host.innerHTML = '

' + 'Trending this week

' + '
' + trendingCache.map(titleCard).join('') + '
'; if (window.VideoWatchlist) VideoWatchlist.hydrate(host); } function loadTrending() { if (trendingCache !== null) { if (!lastQuery) renderTrending(); return; } fetch('/api/video/trending', { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { trendingCache = (d && d.results) ? d.results : []; if (!lastQuery) renderTrending(); }) .catch(function () { trendingCache = []; }); } function showIdle() { if (trendingCache && trendingCache.length) { renderTrending(); return; } show('[data-video-search-empty]', false); show('[data-video-search-hint]', true); var host = $('[data-video-search-results]'); if (host) host.innerHTML = ''; loadTrending(); } var curResults = null, curYt = null, ytSearching = false; // TMDB + YouTube halves of the active query function paint(seq) { if (seq !== reqSeq) return; render(curResults || [], curYt, ytSearching); } function runSearch(q) { var seq = ++reqSeq; curResults = null; curYt = null; ytSearching = !!(window.VideoYoutube && q.length >= 2); show('[data-video-search-loading]', true); fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (seq !== reqSeq) return; // a newer query superseded this one show('[data-video-search-loading]', false); curResults = (d && d.results) ? d.results : []; paint(seq); }) .catch(function () { if (seq !== reqSeq) return; show('[data-video-search-loading]', false); curResults = []; paint(seq); }); // YouTube channels in parallel (best-effort) — its own group, shown as a // "searching…" skeleton until it resolves so nothing flashes "No results". if (ytSearching) { VideoYoutube.searchChannels(q) .then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; ytSearching = false; paint(seq); }) .catch(function () { if (seq !== reqSeq) return; curYt = []; ytSearching = false; paint(seq); }); } } // A pasted YouTube channel OR playlist link → resolve + render a Follow chip // instead of a normal title search (the obscure-channel / playlist entry point). function runChannel(ref) { var seq = ++reqSeq; show('[data-video-search-loading]', true); VideoYoutube.resolve(ref).then(function (d) { if (seq !== reqSeq) return; show('[data-video-search-loading]', false); show('[data-video-search-hint]', false); show('[data-video-search-empty]', false); var host = $('[data-video-search-results]'); if (!host) return; if (d && d.success && d.playlist) { lastPlaylist = d.playlist; lastChannel = null; host.innerHTML = '

' + 'YouTube playlist

' + '
'; return; } if (!d || !d.success || !d.channel) { host.innerHTML = '
' + 'Couldn’t read that link. Paste a channel link like ' + 'youtube.com/@handle or a playlist link.
'; return; } lastChannel = d.channel; lastPlaylist = null; host.innerHTML = '

' + 'YouTube channel

' + '
'; }).catch(function () { if (seq !== reqSeq) return; show('[data-video-search-loading]', false); }); } function onInput(val) { var q = (val || '').trim(); lastQuery = q; if (timer) clearTimeout(timer); if (!q) { reqSeq++; // cancel any in-flight render show('[data-video-search-loading]', false); showIdle(); // back to the trending rail return; } if (window.VideoYoutube && (VideoYoutube.isChannelRef(q) || VideoYoutube.isPlaylistRef(q))) { timer = setTimeout(function () { runChannel(q); }, 360); return; } timer = setTimeout(function () { runSearch(q); }, 320); } // Follow / un-follow the resolved channel chip. function toggleFollow(btn) { if (!lastChannel) return; var on = btn.classList.contains('vyt-follow--on'); btn.disabled = true; var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }; if (on) { VideoYoutube.unfollow(lastChannel.youtube_id).then(function () { btn.classList.remove('vyt-follow--on'); btn.innerHTML = '+ Follow'; done(); }).catch(function () { btn.disabled = false; }); } else { VideoYoutube.follow(lastChannel).then(function (d) { if (d && d.success) { btn.classList.add('vyt-follow--on'); btn.innerHTML = '✓ Following'; if (typeof showToast === 'function') showToast('Added ' + lastChannel.title + ' to watchlist', 'success'); } done(); }).catch(function () { btn.disabled = false; }); } } // Add / remove the resolved playlist chip to the watchlist (standard watchlist button). function setPlBtn(btn, on) { btn.classList.toggle('watching', on); var ic = btn.querySelector('.watchlist-icon'); if (ic) ic.textContent = on ? '✓' : '+'; var tx = btn.querySelector('.watchlist-text'); if (tx) tx.textContent = on ? 'In Watchlist' : 'Add to Watchlist'; } function togglePlaylistFollow(btn) { if (!lastPlaylist) return; var on = btn.classList.contains('watching'); btn.disabled = true; var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }; if (on) { VideoYoutube.unfollowPlaylist(lastPlaylist.playlist_id).then(function () { setPlBtn(btn, false); done(); }).catch(function () { btn.disabled = false; }); } else { VideoYoutube.followPlaylist(lastPlaylist).then(function (d) { if (d && d.success) { setPlBtn(btn, true); if (typeof showToast === 'function') showToast('Added ' + lastPlaylist.title + ' to watchlist', 'success'); } done(); }).catch(function () { btn.disabled = false; }); } } function openCard(card) { var kind = card.getAttribute('data-vsr-open'); var id = parseInt(card.getAttribute('data-vsr-id'), 10); if (isNaN(id)) return; if (kind === 'person') { document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'person', id: id, source: 'tmdb' } })); } else { document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: kind, id: id, source: card.getAttribute('data-vsr-source') || 'tmdb' } })); } } function wire() { if (wired) return; wired = true; var input = $('[data-video-search-input]'); if (input) input.addEventListener('input', function () { onInput(input.value); }); var results = $('[data-video-search-results]'); if (results) { results.addEventListener('click', function (e) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var fb = e.target.closest('[data-vyt-follow]'); if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; } var pfb = e.target.closest('[data-vyt-follow-playlist]'); if (pfb && results.contains(pfb)) { e.preventDefault(); togglePlaylistFollow(pfb); return; } var ytc = e.target.closest('[data-vyt-open-channel]'); if (ytc && results.contains(ytc)) { e.preventDefault(); document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'channel', source: 'youtube', id: ytc.getAttribute('data-vyt-open-channel') } })); return; } var ytp = e.target.closest('[data-vyt-playlist]'); // the chip (not its button) → open detail if (ytp && results.contains(ytp)) { e.preventDefault(); document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'playlist', source: 'youtube', id: ytp.getAttribute('data-vyt-playlist') } })); return; } var card = e.target.closest('[data-vsr-open]'); if (!card || !results.contains(card)) return; e.preventDefault(); openCard(card); }); } } function onPageShown(e) { if (!e || e.detail !== PAGE_ID) return; wire(); var input = $('[data-video-search-input]'); if (input) { try { input.focus(); } catch (err) { /* ignore */ } } if (!lastQuery) loadTrending(); // fill the idle page } function init() { document.addEventListener('soulsync:video-page-shown', onPageShown); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();