A curated follow-list for the video side, mirroring the music watchlist. v1 is
membership only — the monitoring/discovery engine is a later phase.
Backend:
- video_watchlist table (kind 'show'|'person', keyed on tmdb_id — the stable
cross-context id both carry; library_id kept when owned). NOT the existing
shows.monitored flag (that defaults to 1 / is library-only / has no people).
- VideoDatabase: add/remove/list/state/counts (upsert COALESCEs library_id +
poster so a TMDB re-add can't wipe known data).
- /api/video/watchlist {GET, /add, /remove, /check, /counts}.
- query_library now selects s.tmdb_id so show cards can carry the key.
Frontend:
- video-watchlist-btn.js: shared eye button (the music ya-watchlist-btn mirror)
— build/toggle/hydrate, one delegated capture-phase click handler, broadcasts
soulsync:video-watchlist-changed so pages can react.
- Watchlist page (new subpage + video-watchlist.js): Shows / People tab switcher,
poster grid to detail-page quality, reloads each visit, drops cards on unfollow.
- Wired the eye onto library TV-show cards (movies excluded — wishlist, not
watch) + hydrate on render.
Tests: 6 new (DB upsert/COALESCE/state/counts + endpoint roundtrip/validation).
76 video tests green. Other card surfaces (cast, search, similar, filmography)
are the same VideoWatchlist.btn(...) one-liner — wired next.
122 lines
6 KiB
JavaScript
122 lines
6 KiB
JavaScript
/*
|
|
* SoulSync — Video Watchlist page (isolated).
|
|
*
|
|
* The shows + people you follow, split by a Shows / People tab switcher. Reads
|
|
* /api/video/watchlist; cards reuse the shared VideoWatchlist eye-button (here it
|
|
* reads as "watched" and un-follows on click). v1 is membership only — the
|
|
* monitoring/discovery engine that turns follows into downloads comes later.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var PAGE_ID = 'video-watchlist';
|
|
var state = { loaded: false, tab: 'show', data: { show: [], person: [] } };
|
|
|
|
function $(s, r) { return (r || document).querySelector(s); }
|
|
function esc(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
function wlBtn(opts) { return (window.VideoWatchlist) ? VideoWatchlist.btn(opts) : ''; }
|
|
|
|
function cardHTML(it, kind) {
|
|
var href = kind === 'person'
|
|
? '/video-detail/tmdb/person/' + it.tmdb_id
|
|
: (it.library_id ? '/video-detail/library/show/' + it.library_id
|
|
: '/video-detail/tmdb/show/' + it.tmdb_id);
|
|
var ph = kind === 'person' ? '👤' : '📺'; // 👤 / 📺
|
|
var art = it.poster_url
|
|
? '<img class="vwlp-card-img" src="' + esc(it.poster_url) + '" alt="" loading="lazy" ' +
|
|
'onload="this.classList.add(\'vwlp-loaded\')" onerror="this.style.display=\'none\'">'
|
|
: '<div class="vwlp-card-ph">' + ph + '</div>';
|
|
var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title,
|
|
poster: it.poster_url, libraryId: it.library_id });
|
|
return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' +
|
|
'data-vwlp-card="' + kind + '" data-vwlp-id="' + esc(it.tmdb_id) + '">' +
|
|
'<div class="vwlp-card-art">' + art + '<div class="vwlp-card-scrim"></div>' + btn + '</div>' +
|
|
'<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' +
|
|
esc(it.title) + '</span></div></a>';
|
|
}
|
|
|
|
function updateEmpty() {
|
|
var n = state.data[state.tab].length;
|
|
['show', 'person'].forEach(function (k) {
|
|
var g = $('[data-vwlp-grid="' + k + '"]');
|
|
if (g) g.classList.toggle('hidden', k !== state.tab || state.data[k].length === 0);
|
|
});
|
|
var empty = $('[data-vwlp-empty]');
|
|
if (empty) empty.classList.toggle('hidden', n > 0);
|
|
var et = $('[data-vwlp-empty-title]');
|
|
if (et && n === 0) et.textContent = state.tab === 'show'
|
|
? 'No shows on your watchlist yet' : 'No people on your watchlist yet';
|
|
}
|
|
|
|
function setTab(tab) {
|
|
state.tab = tab;
|
|
var tabs = document.querySelectorAll('[data-vwlp-tab]');
|
|
for (var i = 0; i < tabs.length; i++)
|
|
tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab);
|
|
updateEmpty();
|
|
}
|
|
|
|
function render() {
|
|
// Seed the shared cache so every button paints "watched" with no flash
|
|
// (everything on this page is, by definition, followed).
|
|
if (window.VideoWatchlist) {
|
|
state.data.show.forEach(function (it) { VideoWatchlist._watched.show[it.tmdb_id] = true; });
|
|
state.data.person.forEach(function (it) { VideoWatchlist._watched.person[it.tmdb_id] = true; });
|
|
}
|
|
var sg = $('[data-vwlp-grid="show"]'), pg = $('[data-vwlp-grid="person"]');
|
|
if (sg) sg.innerHTML = state.data.show.map(function (it) { return cardHTML(it, 'show'); }).join('');
|
|
if (pg) pg.innerHTML = state.data.person.map(function (it) { return cardHTML(it, 'person'); }).join('');
|
|
var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.data.show.length;
|
|
var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.data.person.length;
|
|
setTab(state.tab);
|
|
}
|
|
|
|
function load() {
|
|
state.loaded = true;
|
|
var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden');
|
|
fetch('/api/video/watchlist', { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (d) {
|
|
if (ld) ld.classList.add('hidden');
|
|
state.data = (d && d.success)
|
|
? { show: d.shows || [], person: d.people || [] }
|
|
: { show: [], person: [] };
|
|
render();
|
|
})
|
|
.catch(function () { if (ld) ld.classList.add('hidden'); state.data = { show: [], person: [] }; render(); });
|
|
}
|
|
|
|
// When an item is un-followed (here or anywhere), drop its card + fix counts.
|
|
function onChanged(e) {
|
|
var det = (e && e.detail) || {};
|
|
if (det.watched) return; // additions are picked up on next page load
|
|
var kind = det.kind, id = String(det.id);
|
|
if (!state.data[kind]) return;
|
|
state.data[kind] = state.data[kind].filter(function (it) { return String(it.tmdb_id) !== id; });
|
|
var card = document.querySelector('.vwlp-card[data-vwlp-card="' + kind + '"][data-vwlp-id="' + id + '"]');
|
|
if (card && card.parentNode) card.parentNode.removeChild(card);
|
|
var c = $('[data-vwlp-count-' + kind + ']'); if (c) c.textContent = state.data[kind].length;
|
|
updateEmpty();
|
|
}
|
|
|
|
function wire() {
|
|
var tabs = document.querySelectorAll('[data-vwlp-tab]');
|
|
for (var i = 0; i < tabs.length; i++) (function (b) {
|
|
b.addEventListener('click', function () { setTab(b.getAttribute('data-vwlp-tab')); });
|
|
})(tabs[i]);
|
|
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
|
|
}
|
|
|
|
function onShown(e) { if (e && e.detail === PAGE_ID) load(); } // reload each visit → stays fresh
|
|
|
|
function init() {
|
|
wire();
|
|
document.addEventListener('soulsync:video-page-shown', onShown);
|
|
}
|
|
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|
else init();
|
|
})();
|