soulsync/webui/static/video/video-watchlist-btn.js
BoulderBadgeDad e5a4dda117 Video watchlist (shows + people): DB + endpoints + button + page (v1, no scan yet)
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.
2026-06-16 01:06:24 -07:00

138 lines
6.8 KiB
JavaScript

/*
* SoulSync — Video watchlist button (shared).
*
* One source of truth so every TV-show poster and person card gets the SAME
* "add to watchlist" control + behaviour — the video mirror of the music
* ya-watchlist-btn (eye icon, top-right, `.active` = watched).
*
* Renderers call `VideoWatchlist.btn({kind, tmdbId, title, poster, libraryId})`
* to emit the markup. A single delegated click handler toggles add/remove and
* flips the visual; `VideoWatchlist.hydrate(root)` batch-checks watched state
* after a render. Self-contained — inert until a `.vwl-btn` exists in the DOM.
*
* kind is 'show' or 'person' ONLY (movies + episodes are wishlist, not watch).
*/
(function () {
'use strict';
// Client-side cache of watched tmdb_ids per kind (so freshly-rendered cards
// can paint correctly before a hydrate round-trip returns).
var WATCHED = { show: {}, person: {} };
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function eyeSvg(on) {
return '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? 'currentColor' : 'none') +
'" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>' +
'<circle cx="12" cy="12" r="3"/></svg>';
}
function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); }
// Build the button markup. Returns '' for invalid input (so it can be
// string-concatenated unconditionally by callers).
function btn(opts) {
if (!opts || !opts.tmdbId) return '';
if (opts.kind !== 'show' && opts.kind !== 'person') return '';
var on = !!WATCHED[opts.kind][opts.tmdbId];
return '<button type="button" class="vwl-btn' + (on ? ' active' : '') + '"' +
' data-vwl-kind="' + opts.kind + '" data-vwl-id="' + esc(opts.tmdbId) + '"' +
' data-vwl-title="' + esc(opts.title || '') + '"' +
' data-vwl-poster="' + esc(opts.poster || '') + '"' +
(opts.libraryId ? ' data-vwl-libid="' + esc(opts.libraryId) + '"' : '') +
' title="' + (on ? 'On watchlist' : 'Add to watchlist') + '"' +
' aria-label="' + (on ? 'On watchlist' : 'Add to watchlist') + '">' + eyeSvg(on) + '</button>';
}
function paint(b, on) {
b.classList.toggle('active', on);
b.title = on ? 'On watchlist' : 'Add to watchlist';
b.setAttribute('aria-label', b.title);
var svg = b.querySelector('svg');
if (svg) svg.setAttribute('fill', on ? 'currentColor' : 'none');
}
// Reflect a (kind, id) across EVERY matching button in the DOM — the same
// show can appear in the library, similar rail, search, etc. at once.
function syncAll(kind, id, on) {
if (on) WATCHED[kind][id] = true; else delete WATCHED[kind][id];
var nodes = document.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"][data-vwl-id="' + id + '"]');
for (var i = 0; i < nodes.length; i++) paint(nodes[i], on);
// Let interested pages (e.g. the Watchlist page) react — e.g. drop a card
// when it's un-followed, or refresh counts.
document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed', {
detail: { kind: kind, id: id, watched: on }
}));
}
function toggle(b) {
var kind = b.getAttribute('data-vwl-kind'), id = b.getAttribute('data-vwl-id');
if (!kind || !id || b.disabled) return;
var on = b.classList.contains('active');
b.disabled = true;
var done = function () { b.disabled = false; };
if (on) {
fetch('/api/video/watchlist/remove', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: kind, tmdb_id: Number(id) })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d && d.success) { syncAll(kind, id, false); toast('Removed from watchlist', 'info'); }
}).catch(function () { toast('Watchlist update failed', 'error'); }).then(done);
} else {
var body = {
kind: kind, tmdb_id: Number(id),
title: b.getAttribute('data-vwl-title') || '',
poster_url: b.getAttribute('data-vwl-poster') || ''
};
var lib = b.getAttribute('data-vwl-libid');
if (lib) body.library_id = Number(lib);
fetch('/api/video/watchlist/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).then(function (d) {
if (d && d.success) { syncAll(kind, id, true); toast('Added to watchlist', 'success'); }
else { toast((d && d.error) || 'Could not add', 'error'); }
}).catch(function () { toast('Watchlist update failed', 'error'); }).then(done);
}
}
// Batch-check watched state for every un-painted button under `root`.
function hydrate(root) {
root = root || document;
['show', 'person'].forEach(function (kind) {
var nodes = root.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"]');
if (!nodes.length) return;
var ids = [], seen = {};
for (var i = 0; i < nodes.length; i++) {
var id = nodes[i].getAttribute('data-vwl-id');
if (id && !seen[id]) { seen[id] = 1; ids.push(Number(id)); }
}
if (!ids.length) return;
fetch('/api/video/watchlist/check', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: kind, tmdb_ids: ids })
}).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
if (!d || !d.results) return;
for (var k in d.results) { if (d.results[k]) WATCHED[kind][k] = true; }
var ns = root.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"]');
for (var j = 0; j < ns.length; j++) {
var bid = ns[j].getAttribute('data-vwl-id');
if (bid && d.results[bid]) paint(ns[j], true);
}
}).catch(function () { /* non-critical */ });
});
}
// One capture-phase handler for the whole document: a watchlist button lives
// inside a card <a>, so we must stop the click before it navigates.
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('.vwl-btn');
if (!b) return;
e.preventDefault();
e.stopPropagation();
toggle(b);
}, true);
window.VideoWatchlist = { btn: btn, hydrate: hydrate, _watched: WATCHED };
})();