soulsync/webui/static/video/video-library.js
BoulderBadgeDad 0598f5fbda video: real-link routing for detail pages (parity with music artist-detail)
Mirrors the music side instead of a custom scheme: library cards are genuine
<a href='/video-detail/<source>/<kind>/<id>'> links, so reload / new-tab / Back /
Forward all work. Left-clicks are intercepted into SPA nav + history.pushState;
modifier-clicks fall through to the real URL.

- popstate restores the detail from the URL; the '← Library' back button is real
  history.back(); deep-link / reload to a /video-detail/... URL is restored on load
  (path captured before applySide can clear it). Server already serves the SPA for
  these paths (permissive catch-all) — no backend change.
- The path carries a SOURCE segment ('library' = a video.db id today; 'tmdb' /
  search results not yet in the library come later) — your library-vs-search split.
- Coexists with music's pathname router (only touches /video-detail/* and its own
  popstate); music's link/popstate handlers ignore these paths.

Tests: real-link cards, pushState/popstate routing, source segment.
2026-06-14 22:13:51 -07:00

212 lines
9.4 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 Library page (isolated).
*
* Reuses the music library's look + paging model: server-side search / AZ /
* sort / owned-wanted filter / pagination (75 per page) via /api/video/library,
* rendering one page of music-style .library-artist-card cards (with a poster
* proxy + resolution badge). Cards are NOT clickable yet. IIFE, no globals.
*/
(function () {
'use strict';
var PAGE_ID = 'video-library';
var LIBRARY_URL = '/api/video/library';
var LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('');
var state = { tab: 'movies', search: '', letter: 'all', sort: 'title',
status: 'all', page: 1, limit: 75, loaded: false };
var searchTimer = null;
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 resLabel(res) {
if (!res) return '';
res = String(res).toLowerCase();
if (res.indexOf('2160') > -1 || res === '4k') return '4K';
if (res.indexOf('1080') > -1) return '1080p';
if (res.indexOf('720') > -1) return '720p';
if (res.indexOf('480') > -1 || res.indexOf('576') > -1) return 'SD';
return res.toUpperCase();
}
function buildAlphabet() {
var host = $('[data-video-lib-alphabet]');
if (!host || host.childNodes.length) return;
var defs = [['all', 'All']].concat(
LETTERS.map(function (l) { return [l, l.toUpperCase()]; }), [['#', '#']]);
for (var i = 0; i < defs.length; i++) {
var b = document.createElement('button');
b.className = 'alphabet-btn' + (defs[i][0] === 'all' ? ' active' : '');
b.setAttribute('data-letter', defs[i][0]);
b.textContent = defs[i][1];
(function (letter, btn) {
btn.addEventListener('click', function () {
state.letter = letter;
var all = host.querySelectorAll('.alphabet-btn');
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === btn);
reload();
});
})(defs[i][0], b);
host.appendChild(b);
}
}
function cardHTML(it, kind) {
var fallback = kind === 'movie' ? '🎬' : '📺';
var img = it.has_poster
? '<div class="library-artist-image"><img src="/api/video/poster/' + kind + '/' + it.id +
'" alt="" loading="lazy" onerror="this.parentNode.innerHTML=\'<div class=&quot;library-artist-image-fallback&quot;>' +
fallback + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
var badge = '';
if (kind === 'movie') {
var rl = resLabel(it.resolution);
if (rl) badge = '<div class="video-card-badge">' + rl + '</div>';
}
var meta = [];
if (it.year) meta.push(String(it.year));
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
// A REAL link (like the music artist cards) so reload / new-tab / Back all
// work; the click handler intercepts plain left-clicks into SPA nav.
var href = '/video-detail/library/' + kind + '/' + it.id;
var hook = ' href="' + href + '" data-video-card-open="' + kind + '" data-video-card-id="' + it.id + '"';
return '<a class="library-artist-card video-card--clickable"' + hook + '>' + img + badge +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(meta.join(' · ')) + '</span></div></div></a>';
}
function showLoading(on) {
var l = $('[data-video-lib-loading]');
if (l) l.classList.toggle('hidden', !on);
}
function renderItems(items, kind) {
var grid = $('[data-video-lib-grid]');
var empty = $('[data-video-lib-empty]');
if (grid) grid.innerHTML = items.map(function (it) { return cardHTML(it, kind); }).join('');
if (empty) empty.classList.toggle('hidden', items.length > 0);
}
function updatePagination(p) {
var box = $('[data-video-lib-pagination]');
var prev = $('[data-video-lib-prev]');
var next = $('[data-video-lib-next]');
var info = $('[data-video-lib-pageinfo]');
if (!box) return;
if (!p) { 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.toggle('hidden', p.total_pages <= 1);
}
function setCount(n) {
var c = $('[data-video-lib-count]');
var lbl = $('[data-video-lib-count-label]');
if (c) c.textContent = n;
if (lbl) lbl.textContent = state.tab === 'movies' ? 'Movies' : 'Shows';
}
function load() {
state.loaded = true;
showLoading(true);
var apiKind = state.tab === 'movies' ? 'movies' : 'shows'; // query param (plural)
var cardKind = state.tab === 'movies' ? 'movie' : 'show'; // card + poster URL (singular)
var params = new URLSearchParams({
kind: apiKind, search: state.search, letter: state.letter, sort: state.sort,
status: state.status, page: state.page, limit: state.limit });
fetch(LIBRARY_URL + '?' + params.toString(), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { renderItems([], cardKind); updatePagination(null); setCount(0); return; }
renderItems(d.items || [], cardKind);
updatePagination(d.pagination);
setCount(d.pagination ? d.pagination.total_count : 0);
})
.catch(function () { showLoading(false); renderItems([], cardKind); updatePagination(null); setCount(0); });
}
function reload() { state.page = 1; load(); }
function wire() {
// Card click → drill into the detail page (event-delegated on the grid).
var grid = $('[data-video-lib-grid]');
if (grid) {
grid.addEventListener('click', function (e) {
// Let modified clicks (new tab / window) use the real href.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var card = e.target.closest('[data-video-card-open]');
if (!card || !grid.contains(card)) return;
e.preventDefault();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: card.getAttribute('data-video-card-open'),
id: parseInt(card.getAttribute('data-video-card-id'), 10), source: 'library' },
}));
});
}
var tabs = document.querySelectorAll('[data-video-lib-tab]');
for (var i = 0; i < tabs.length; i++) {
(function (tab) {
tab.addEventListener('click', function () {
state.tab = tab.getAttribute('data-video-lib-tab');
var all = document.querySelectorAll('[data-video-lib-tab]');
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === tab);
var s = $('[data-video-lib-search]');
if (s) s.placeholder = 'Search ' + (state.tab === 'movies' ? 'movies' : 'shows') + '...';
reload();
});
})(tabs[i]);
}
var search = $('[data-video-lib-search]');
if (search) {
search.addEventListener('input', function () {
state.search = search.value || '';
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(reload, 300);
});
}
var sort = $('[data-video-lib-sort]');
if (sort) sort.addEventListener('change', function () { state.sort = sort.value; reload(); });
var status = $('[data-video-lib-status]');
if (status) status.addEventListener('change', function () { state.status = status.value; reload(); });
var prev = $('[data-video-lib-prev]');
if (prev) prev.addEventListener('click', function () {
if (state.page > 1) { state.page--; load(); }
});
var next = $('[data-video-lib-next]');
if (next) next.addEventListener('click', function () { state.page++; load(); });
}
function onScanDone() { if (state.loaded) load(); }
function onPageShown(e) {
if (!e || e.detail !== PAGE_ID) return;
if (!state.loaded) load();
}
function init() {
buildAlphabet();
wire();
document.addEventListener('soulsync:video-page-shown', onPageShown);
document.addEventListener('soulsync:video-scan-done', onScanDone);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();