An airing show is ongoing (follow for new episodes) AND has a back-catalog you may be missing (acquire) — so it needs both controls, not one or the other. VideoGet.cardButton() now returns a control GROUP: person -> eye | movie -> get | airing show -> eye + get | ended show -> get Library + watchlist-page cards route through it (the other surfaces already did). New .vcard-ctrls wrapper lays the buttons out top-right; hover-reveal unchanged. (Before, only ended shows showed the download button — which is why airing shows looked like they had no way to acquire missing episodes.)
213 lines
10 KiB
JavaScript
213 lines
10 KiB
JavaScript
/*
|
|
* SoulSync — Video Watchlist page (isolated).
|
|
*
|
|
* The shows + people you follow, split by a Shows / People tab switcher.
|
|
* Server-paged + searchable like the library (only a page of cards/posters
|
|
* renders at once). Reads /api/video/watchlist?kind=&search=&page=&limit=.
|
|
* Cards reuse the shared VideoWatchlist eye-button (reads as "watched" here;
|
|
* un-follows on click, with a confirm).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var PAGE_ID = 'video-watchlist';
|
|
var LIMIT = 60;
|
|
var state = { loaded: false, tab: 'show', search: '', sort: 'default', page: 1,
|
|
counts: { show: 0, person: 0 } };
|
|
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, '>').replace(/"/g, '"');
|
|
}
|
|
function wlBtn(opts) { return (window.VideoWatchlist) ? VideoWatchlist.btn(opts) : ''; }
|
|
|
|
function statusPill(status) {
|
|
var s = String(status == null ? '' : status).trim().toLowerCase();
|
|
if (!s) return '';
|
|
if (['ended', 'canceled', 'cancelled', 'completed'].indexOf(s) >= 0)
|
|
return '<span class="vwlp-pill vwlp-pill--ended">Ended</span>';
|
|
if (s.indexOf('return') >= 0 || s === 'continuing')
|
|
return '<span class="vwlp-pill vwlp-pill--airing">Airing</span>';
|
|
if (s === 'upcoming' || s.indexOf('production') >= 0 || s.indexOf('planned') >= 0 || s === 'pilot')
|
|
return '<span class="vwlp-pill vwlp-pill--soon">Upcoming</span>';
|
|
return '<span class="vwlp-pill">' + esc(status) + '</span>';
|
|
}
|
|
|
|
function cardHTML(it, kind) {
|
|
// SPA open target: library shows open by library id ('library' source);
|
|
// people + un-owned shows open by tmdb id ('tmdb').
|
|
var source = (kind === 'show' && it.library_id) ? 'library' : 'tmdb';
|
|
var openId = source === 'library' ? it.library_id : it.tmdb_id;
|
|
var href = '/video-detail/' + source + '/' + kind + '/' + openId;
|
|
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 = window.VideoGet
|
|
? VideoGet.cardButton({ kind: kind, tmdbId: it.tmdb_id, libraryId: it.library_id,
|
|
title: it.title, poster: it.poster_url, status: it.status,
|
|
source: it.library_id ? 'library' : 'tmdb' })
|
|
: wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title, poster: it.poster_url, libraryId: it.library_id });
|
|
var pill = kind === 'show' ? statusPill(it.status) : '';
|
|
var meta = (kind === 'show' && it.episode_count)
|
|
? '<span class="vwlp-card-meta">' + (it.owned_count || 0) + '/' + it.episode_count + ' eps</span>' : '';
|
|
return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' +
|
|
'data-vwlp-open="' + kind + '" data-vwlp-source="' + source + '" data-vwlp-openid="' + esc(openId) + '">' +
|
|
'<div class="vwlp-card-art">' + art + '<div class="vwlp-card-scrim"></div>' + pill + btn + '</div>' +
|
|
'<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' +
|
|
esc(it.title) + '</span>' + meta + '</div></a>';
|
|
}
|
|
|
|
function updateNavBadge(counts) {
|
|
var b = $('[data-video-watchlist-badge]'); if (!b) return;
|
|
var n = counts ? ((counts.show || 0) + (counts.person || 0)) : 0;
|
|
b.textContent = n;
|
|
b.classList.toggle('hidden', !n);
|
|
}
|
|
function refreshBadge() {
|
|
fetch('/api/video/watchlist/counts', { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (d) { if (d && d.success) updateNavBadge(d); })
|
|
.catch(function () { /* ignore */ });
|
|
}
|
|
|
|
function setCounts(counts) {
|
|
state.counts = { show: (counts && counts.show) || 0, person: (counts && counts.person) || 0 };
|
|
var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.counts.show;
|
|
var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.counts.person;
|
|
updateNavBadge(state.counts);
|
|
}
|
|
|
|
function updatePagination(p) {
|
|
var box = $('[data-vwlp-pagination]'), prev = $('[data-vwlp-prev]'),
|
|
next = $('[data-vwlp-next]'), info = $('[data-vwlp-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-vwlp-empty]');
|
|
if (empty) empty.classList.toggle('hidden', total > 0);
|
|
var et = $('[data-vwlp-empty-title]');
|
|
if (et && total === 0) {
|
|
et.textContent = state.search
|
|
? 'No matches'
|
|
: (state.tab === 'show' ? 'No shows on your watchlist yet' : 'No people on your watchlist yet');
|
|
}
|
|
}
|
|
|
|
function render(items) {
|
|
// Everything on this page is watched — seed the shared cache so the eyes
|
|
// paint "watched" with no flash.
|
|
if (window.VideoWatchlist) {
|
|
items.forEach(function (it) { VideoWatchlist._watched[state.tab][it.tmdb_id] = true; });
|
|
}
|
|
var grid = $('[data-vwlp-grid]');
|
|
if (grid) {
|
|
grid.innerHTML = items.map(function (it) { return cardHTML(it, state.tab); }).join('');
|
|
if (window.VideoWatchlist) VideoWatchlist.hydrate(grid);
|
|
}
|
|
}
|
|
|
|
function load() {
|
|
state.loaded = true;
|
|
var ld = $('[data-vwlp-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/watchlist?' + 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);
|
|
})
|
|
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); });
|
|
}
|
|
|
|
function setTab(tab) {
|
|
if (tab !== 'show' && tab !== 'person') return;
|
|
state.tab = tab; state.page = 1;
|
|
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);
|
|
load();
|
|
}
|
|
|
|
// A removal anywhere → if we're showing the watchlist, reload the page so the
|
|
// un-followed card drops and counts/pagination stay correct.
|
|
function onChanged() {
|
|
var grid = $('[data-vwlp-grid]');
|
|
if (grid && grid.offsetParent !== null) load(); // visible → reload (refreshes badge via setCounts)
|
|
else refreshBadge(); // not visible → keep the nav badge current
|
|
}
|
|
|
|
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]);
|
|
|
|
var grid = $('[data-vwlp-grid]');
|
|
if (grid) grid.addEventListener('click', onGridClick);
|
|
|
|
var search = $('[data-vwlp-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-vwlp-sort]');
|
|
if (sortSel) sortSel.addEventListener('change', function () {
|
|
state.sort = sortSel.value; state.page = 1; load();
|
|
});
|
|
|
|
var prev = $('[data-vwlp-prev]');
|
|
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
|
|
var next = $('[data-vwlp-next]');
|
|
if (next) next.addEventListener('click', function () { state.page++; load(); });
|
|
|
|
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
|
|
}
|
|
|
|
// Intercept card clicks → in-app SPA navigation (a bare <a href> would do a
|
|
// FULL page reload). The eye button's capture-phase handler already stops its
|
|
// own clicks from reaching here. Mirrors video-library.js.
|
|
function onGridClick(e) {
|
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
var card = e.target.closest('[data-vwlp-open]');
|
|
if (!card) return;
|
|
e.preventDefault();
|
|
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
|
|
detail: {
|
|
kind: card.getAttribute('data-vwlp-open'),
|
|
id: parseInt(card.getAttribute('data-vwlp-openid'), 10),
|
|
source: card.getAttribute('data-vwlp-source') || 'library',
|
|
},
|
|
}));
|
|
}
|
|
|
|
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
|
|
|
|
function init() {
|
|
wire();
|
|
document.addEventListener('soulsync:video-page-shown', onShown);
|
|
refreshBadge(); // seed the nav badge on boot
|
|
}
|
|
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|
else init();
|
|
})();
|