soulsync/webui/static/video/video-search.js
BoulderBadgeDad e141e3dcbd Playlist search result: standard 'Add to Watchlist' button (not 'Follow')
The pasted-playlist chip used the YouTube 'Follow' toggle; swapped it for the
app-standard watchlist button (library-artist-watchlist-btn — same look/wording
used across video + music): + Add to Watchlist ↔ ✓ In Watchlist. The search
toggle handler updates the icon/text spans + 'watching' class accordingly. No
global handler keys off that class, so reusing it is purely cosmetic.
2026-06-17 20:42:30 -07:00

355 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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
? '<img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
'onerror="this.outerHTML=\'<div class=&quot;vsr-poster-ph&quot;>' + fallback + '</div>\'">'
: '<div class="vsr-poster-ph">' + fallback + '</div>';
var owned = it.library_id != null;
var ribbon = owned
? '<span class="vsr-ribbon vsr-ribbon--owned">In Library</span>'
: '<span class="vsr-ribbon vsr-ribbon--preview">Preview</span>';
var rating = it.rating
? '<span class="vsr-rating">★ ' + (Math.round(it.rating * 10) / 10) + '</span>' : '';
// 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 '<a class="vsr-card" href="' + href + '" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' + cb +
'<div class="vsr-poster">' + img + ribbon + rating +
'<span class="vsr-peek" aria-hidden="true">i</span></div>' +
'<div class="vsr-info"><span class="vsr-name" title="' + esc(it.title) + '">' + esc(it.title) +
'</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
}
function personCard(it) {
var img = it.poster
? '<img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
'onerror="this.outerHTML=\'<div class=&quot;vsr-poster-ph&quot;>👤</div>\'">'
: '<div class="vsr-poster-ph">👤</div>';
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 '<a class="vsr-card vsr-card--person" href="#" ' +
'data-vsr-open="person" data-vsr-id="' + it.tmdb_id + '">' + cb +
'<div class="vsr-poster">' + img + '</div>' +
'<div class="vsr-info vsr-info--center"><span class="vsr-name" title="' + esc(it.title) + '">' +
esc(it.title) + '</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
}
// 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 += '<div class="vyt-result vyt-result--skel">' +
'<span class="vyt-result-art vyt-skel"></span>' +
'<span class="vyt-result-info"><span class="vyt-skel vyt-skel-line"></span>' +
'<span class="vyt-skel vyt-skel-line vyt-skel-line--sm"></span></span></div>';
}
return '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channels' +
'<span class="vsr-yt-loading">searching…</span></h2>' +
'<div class="vsr-grid vyt-result-grid">' + cards + '</div></div>';
}
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 += '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">' + g.icon + '</span>' + g.label +
'<span class="vsr-group-count">' + items.length + '</span></h2>' +
'<div class="vsr-grid">' +
items.map(g.kind === 'person' ? personCard : titleCard).join('') +
'</div></div>';
});
if (ytChannels && ytChannels.length && window.VideoYoutube) {
html += '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channels' +
'<span class="vsr-group-count">' + ytChannels.length + '</span></h2>' +
'<div class="vsr-grid vyt-result-grid">' +
ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') +
'</div></div>';
} 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 = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">🔥</span>Trending this week</h2>' +
'<div class="vsr-grid">' + trendingCache.map(titleCard).join('') + '</div></div>';
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 = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube playlist</h2>' +
'<div class="vyt-search">' + VideoYoutube.playlistCard(d.playlist, d.following) + '</div></div>';
return;
}
if (!d || !d.success || !d.channel) {
host.innerHTML = '<div class="vsr-group"><div class="vyt-miss">' +
'Couldnt read that link. Paste a channel link like ' +
'<code>youtube.com/@handle</code> or a playlist link.</div></div>';
return;
}
lastChannel = d.channel; lastPlaylist = null;
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channel</h2>' +
'<div class="vyt-search">' + VideoYoutube.searchCard(d.channel, d.following) + '</div></div>';
}).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();
})();