Wishlist TV: filmstrip reels (replace the lame collapsible tree)

The music wishlist has its artist 'nebula'; the TV side now has its own rich,
TV-native metaphor. Each show is a reel: poster + one horizontal film strip per
season (dark band with sprocket-hole borders) made of episode 'cells'. A cell
shows E#, glows in the show's hue, and widens on hover to reveal the title;
remove ×s sit on each cell, each season label, and the show header. Poster/title
open the show detail. Status tints the cells (wanted/downloading/done/failed).
Movies tab unchanged.

query_wishlist(show) now also returns library_id so reels open the owned detail
when applicable. Backend suites green.
This commit is contained in:
BoulderBadgeDad 2026-06-16 16:54:19 -07:00
parent bae8060c09
commit 1b456d548a
3 changed files with 101 additions and 64 deletions

View file

@ -1612,7 +1612,7 @@ class VideoDatabase:
"SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
show_rows = conn.execute(
"SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
"COUNT(*) AS wanted, "
"MAX(library_id) AS library_id, COUNT(*) AS wanted, "
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
"MAX(date_added) AS last_added "
"FROM video_wishlist" + wsql +
@ -1632,8 +1632,8 @@ class VideoDatabase:
seasons = [{"season_number": sn, "episodes": by_season[sn]}
for sn in sorted(by_season)]
items.append({"kind": "show", "tmdb_id": sr["tmdb_id"], "title": sr["title"],
"poster_url": sr["poster_url"], "wanted": sr["wanted"],
"done": sr["done"] or 0, "seasons": seasons})
"poster_url": sr["poster_url"], "library_id": sr["library_id"],
"wanted": sr["wanted"], "done": sr["done"] or 0, "seasons": seasons})
finally:
conn.close()
total_pages = max(1, (total + limit - 1) // limit)

View file

@ -2485,34 +2485,61 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vwsh-movie-title { display: block; font-size: 13.5px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-movie-meta { display: block; font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 2px; }
/* shows tree */
.vwsh-grid--shows { display: flex; flex-direction: column; gap: 12px; }
.vwsh-show { border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 14px; background: rgba(255, 255, 255, 0.02); overflow: hidden; }
.vwsh-show-head { display: flex; align-items: center; gap: 13px; padding: 12px 14px; cursor: pointer; transition: background 0.15s; }
.vwsh-show-head:hover { background: rgba(255, 255, 255, 0.03); }
.vwsh-show-chev { color: rgba(255, 255, 255, 0.4); transition: transform 0.2s; font-size: 14px; }
.vwsh-show--open .vwsh-show-chev { transform: rotate(180deg); }
.vwsh-show-art { width: 42px; height: 60px; border-radius: 7px; overflow: hidden; flex-shrink: 0; background: #16161d; }
.vwsh-show-img { width: 100%; height: 100%; object-fit: cover; }
.vwsh-show-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 20px; opacity: 0.4; }
.vwsh-show-titles { flex: 1; min-width: 0; }
.vwsh-show-title { display: block; font-size: 15px; font-weight: 800; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-show-meta { display: block; font-size: 12.5px; color: rgba(255, 255, 255, 0.45); margin-top: 3px; }
.vwsh-show-body { display: none; padding: 0 14px 10px; }
.vwsh-show--open .vwsh-show-body { display: block; }
/* shows = filmstrip reels: poster + per-season film strips of episode cells */
.vwsh-grid--shows { display: flex; flex-direction: column; gap: 14px; }
.vwsh-reel { position: relative; display: flex; gap: 16px; padding: 16px; border-radius: 16px; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.02); }
.vwsh-reel::before { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
background: radial-gradient(120% 110% at 0% 0%, hsl(var(--vwsh-h, 230) 70% 45% / 0.12), transparent 55%); }
.vwsh-reel > * { position: relative; z-index: 1; }
.vwsh-reel-poster { width: 92px; flex-shrink: 0; aspect-ratio: 2 / 3; border-radius: 10px; overflow: hidden; cursor: pointer;
background: #16161d; border: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
transition: transform 0.18s ease, box-shadow 0.18s ease; }
.vwsh-reel-poster:hover { transform: translateY(-3px); box-shadow: 0 16px 36px rgba(0, 0, 0, 0.55), 0 0 22px hsl(var(--vwsh-h, 230) 70% 50% / 0.4); }
.vwsh-reel-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vwsh-reel-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 30px; opacity: 0.4; }
.vwsh-reel-body { flex: 1; min-width: 0; }
.vwsh-reel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.vwsh-reel-titles { min-width: 0; cursor: pointer; }
.vwsh-reel-title { display: block; font-size: 17px; font-weight: 800; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-reel-titles:hover .vwsh-reel-title { color: hsl(var(--vwsh-h, 230) 80% 76%); }
.vwsh-reel-meta { display: block; font-size: 12.5px; color: rgba(255, 255, 255, 0.45); margin-top: 3px; }
.vwsh-reel-head .vwsh-rm { margin-left: auto; }
.vwsh-season { border-top: 1px solid rgba(255, 255, 255, 0.05); }
.vwsh-season-head { display: flex; align-items: center; gap: 10px; padding: 10px 4px; cursor: pointer; }
.vwsh-season-chev { color: rgba(255, 255, 255, 0.35); transition: transform 0.2s; font-size: 12px; }
.vwsh-season--open .vwsh-season-chev { transform: rotate(180deg); }
.vwsh-season-name { font-size: 13.5px; font-weight: 700; color: rgba(255, 255, 255, 0.9); }
.vwsh-season-meta { font-size: 12px; color: rgba(255, 255, 255, 0.4); margin-left: auto; }
.vwsh-season-eps { display: none; padding: 2px 0 6px; }
.vwsh-season--open .vwsh-season-eps { display: block; }
.vwsh-ep { display: flex; align-items: center; gap: 10px; padding: 7px 8px 7px 22px; border-radius: 8px; }
.vwsh-ep:hover { background: rgba(255, 255, 255, 0.03); }
.vwsh-ep-code { font-size: 11.5px; font-weight: 800; color: rgba(255, 255, 255, 0.4); min-width: 56px; flex-shrink: 0; }
.vwsh-ep-title { flex: 1; min-width: 0; font-size: 13px; color: rgba(255, 255, 255, 0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwsh-strips { display: flex; flex-direction: column; gap: 12px; }
.vwsh-strip { display: flex; align-items: stretch; gap: 10px; }
.vwsh-strip-label { flex-shrink: 0; align-self: center; display: inline-flex; align-items: center; gap: 4px;
min-width: 30px; font-size: 12px; font-weight: 800; color: rgba(255, 255, 255, 0.6); }
.vwsh-strip-label .vwsh-rm { width: 20px; height: 20px; font-size: 13px; opacity: 0; }
.vwsh-strip:hover .vwsh-strip-label .vwsh-rm { opacity: 1; }
/* the film band — dark with sprocket-hole borders top + bottom */
.vwsh-strip-track { position: relative; flex: 1; min-width: 0; display: flex; gap: 5px; overflow-x: auto;
padding: 12px 10px; border-radius: 7px; background: #0b0b0e; border: 1px solid rgba(255, 255, 255, 0.06); scrollbar-width: thin; }
.vwsh-strip-track::before, .vwsh-strip-track::after { content: ''; position: absolute; left: 8px; right: 8px; height: 6px;
pointer-events: none; background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.16) 0 6px, transparent 6px 13px); }
.vwsh-strip-track::before { top: 3px; }
.vwsh-strip-track::after { bottom: 3px; }
.vwsh-strip-track::-webkit-scrollbar { height: 6px; }
.vwsh-strip-track::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 3px; }
/* episode = a film cell; hover widens it to reveal the title */
.vwsh-cell { position: relative; flex: 0 0 auto; width: 46px; height: 64px; border-radius: 4px;
display: flex; align-items: center; gap: 7px; padding: 0 9px; overflow: hidden;
background: linear-gradient(160deg, hsl(var(--vwsh-h, 230) 58% 30%), hsl(var(--vwsh-h, 230) 52% 15%));
border: 1px solid hsl(var(--vwsh-h, 230) 60% 50% / 0.35);
transition: width 0.22s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.15s ease, box-shadow 0.15s ease; }
.vwsh-cell:hover { width: 170px; transform: translateY(-2px); z-index: 3; box-shadow: 0 10px 24px hsl(var(--vwsh-h, 230) 70% 40% / 0.5); }
.vwsh-cell-num { flex-shrink: 0; font-size: 13px; font-weight: 900; color: #fff; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); }
.vwsh-cell-title { font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.92); white-space: nowrap;
opacity: 0; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.18s ease 0.05s; }
.vwsh-cell:hover .vwsh-cell-title { opacity: 1; }
.vwsh-cell .vwsh-rm { position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; font-size: 13px;
opacity: 0; background: rgba(0, 0, 0, 0.5); }
.vwsh-cell:hover .vwsh-rm { opacity: 1; }
.vwsh-cell--downloading { background: linear-gradient(160deg, #2a4a8f, #16243f); border-color: rgba(100, 149, 237, 0.5); }
.vwsh-cell--downloaded { background: linear-gradient(160deg, #1d6b3f, #0e3320); border-color: rgba(34, 197, 94, 0.5); }
.vwsh-cell--failed { background: linear-gradient(160deg, #7a2b2b, #3a1414); border-color: rgba(248, 113, 113, 0.5); }
/* status pills + remove button */
.vwsh-st { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; padding: 3px 8px; border-radius: 6px; flex-shrink: 0; }
@ -2536,4 +2563,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
@media (max-width: 900px) {
.vwsh-head, .vwsh-toolbar, .vwsh-body { padding-left: 18px; padding-right: 18px; }
.vwsh-grid--movies { grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 18px 14px; }
.vwsh-reel { gap: 12px; padding: 12px; }
.vwsh-reel-poster { width: 64px; }
.vwsh-cell:hover { width: 144px; }
}
@media (hover: none) {
.vwsh-cell { width: 150px; }
.vwsh-cell-title { opacity: 1; }
.vwsh-cell .vwsh-rm, .vwsh-strip-label .vwsh-rm { opacity: 1; }
}

View file

@ -20,6 +20,7 @@
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
var STATUS = {
wanted: ['Wanted', 'vwsh-st--wanted'], searching: ['Searching', 'vwsh-st--searching'],
@ -52,40 +53,44 @@
'</div></div>';
}
// ── show group (collapsible show → season → episode) ──────────────────────
function showGroup(sh) {
// ── show reel (poster + per-season film strips of episode cells) ──────────
function reelCard(sh) {
var src = sh.library_id != null ? 'library' : 'tmdb';
var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id;
var hook = ' data-vwsh-open-show="1" data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '"';
var poster = sh.poster_url
? '<img class="vwsh-show-img" src="' + esc(sh.poster_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '<div class="vwsh-show-ph">📺</div>';
? '<img class="vwsh-reel-img" src="' + esc(sh.poster_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '<div class="vwsh-reel-ph">📺</div>';
var done = sh.done ? ' · ' + sh.done + ' done' : '';
var seasons = (sh.seasons || []).map(function (se) {
var eps = (se.episodes || []).map(function (e) {
return '<div class="vwsh-ep">' +
'<span class="vwsh-ep-code">S' + se.season_number + '·E' + e.episode_number + '</span>' +
'<span class="vwsh-ep-title">' + esc(e.title || ('Episode ' + e.episode_number)) + '</span>' +
statusPill(e.status) +
var strips = (sh.seasons || []).map(function (se) {
var cells = (se.episodes || []).map(function (e) {
var st = STATUS[e.status] ? e.status : 'wanted';
var t = e.title || ('Episode ' + e.episode_number);
return '<div class="vwsh-cell vwsh-cell--' + st + '" title="S' + se.season_number + '·E' + e.episode_number + ' — ' + esc(t) + '">' +
'<span class="vwsh-cell-num">E' + e.episode_number + '</span>' +
'<span class="vwsh-cell-title">' + esc(t) + '</span>' +
rmBtn('episode', ' data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"') +
'</div>';
}).join('');
return '<div class="vwsh-season">' +
'<div class="vwsh-season-head" data-vwsh-season-toggle>' +
'<span class="vwsh-season-chev" aria-hidden="true">⌄</span>' +
'<span class="vwsh-season-name">Season ' + se.season_number + '</span>' +
'<span class="vwsh-season-meta">' + se.episodes.length + ' wanted</span>' +
return '<div class="vwsh-strip">' +
'<div class="vwsh-strip-label">S' + se.season_number +
rmBtn('season', ' data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"') +
'</div>' +
'<div class="vwsh-season-eps">' + eps + '</div>' +
'<div class="vwsh-strip-track">' + cells + '</div>' +
'</div>';
}).join('');
return '<div class="vwsh-show" data-vwsh-show="' + esc(sh.tmdb_id) + '">' +
'<div class="vwsh-show-head" data-vwsh-show-toggle>' +
'<span class="vwsh-show-chev" aria-hidden="true">⌄</span>' +
'<div class="vwsh-show-art">' + poster + '</div>' +
'<div class="vwsh-show-titles"><span class="vwsh-show-title" title="' + esc(sh.title) + '">' + esc(sh.title) + '</span>' +
'<span class="vwsh-show-meta">' + sh.wanted + ' wanted' + done + '</span></div>' +
rmBtn('show', ' data-tmdb="' + esc(sh.tmdb_id) + '"') +
return '<div class="vwsh-reel" style="--vwsh-h:' + hueOf(sh.title) + '">' +
'<div class="vwsh-reel-poster"' + hook + '>' + poster + '</div>' +
'<div class="vwsh-reel-body">' +
'<div class="vwsh-reel-head">' +
'<div class="vwsh-reel-titles"' + hook + '>' +
'<span class="vwsh-reel-title" title="' + esc(sh.title) + '">' + esc(sh.title) + '</span>' +
'<span class="vwsh-reel-meta">' + sh.wanted + ' wanted' + done + '</span>' +
'</div>' +
rmBtn('show', ' data-tmdb="' + esc(sh.tmdb_id) + '"') +
'</div>' +
'<div class="vwsh-strips">' + strips + '</div>' +
'</div>' +
'<div class="vwsh-show-body">' + seasons + '</div>' +
'</div>';
}
@ -94,7 +99,7 @@
var shows = state.tab === 'show';
grid.classList.toggle('vwsh-grid--shows', shows);
grid.classList.toggle('vwsh-grid--movies', !shows);
grid.innerHTML = items.map(shows ? showGroup : movieCard).join('');
grid.innerHTML = items.map(shows ? reelCard : movieCard).join('');
}
// ── counts / badges / pager ───────────────────────────────────────────────
@ -187,16 +192,13 @@
function onGridClick(e) {
var rm = e.target.closest('[data-vwsh-rm]');
if (rm) { e.preventDefault(); e.stopPropagation(); doRemove(rm); return; }
var st = e.target.closest('[data-vwsh-season-toggle]');
if (st) { st.parentNode.classList.toggle('vwsh-season--open'); return; }
var sh = e.target.closest('[data-vwsh-show-toggle]');
if (sh) { sh.parentNode.classList.toggle('vwsh-show--open'); return; }
var mv = e.target.closest('[data-vwsh-open-movie]');
if (mv) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie]');
if (open) {
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'movie', id: parseInt(mv.getAttribute('data-vwsh-id'), 10),
source: mv.getAttribute('data-vwsh-src') || 'tmdb' },
detail: { kind: open.hasAttribute('data-vwsh-open-show') ? 'show' : 'movie',
id: parseInt(open.getAttribute('data-vwsh-id'), 10),
source: open.getAttribute('data-vwsh-src') || 'tmdb' },
}));
}
}