YouTube next-level (UI): channel detail page + wishlist YouTube tab = TV nebula

Two big pieces:

1) Wishlist YouTube tab now renders through the EXACT TV nebula (channel = show
   orb, upload YEAR = season, video = episode) instead of a flat list. Made the
   nebula source-aware: a youtube orb/season/label opens the in-app channel page
   (not tmdb); season name shows the year; episode meta shows the upload date;
   removes route through the youtube source_id endpoints (video / year / whole
   channel); the info bar shows a selected video's description (no cast/no tmdb
   fetch). Identical look — music wl-* + TMDB path untouched.

2) New in-app YouTube channel detail page (video-channel.js, sibling of
   video-person.js) — opens via open-detail {kind:'channel'} from the watchlist
   card, the wishlist orb/season, and deep links (/video-detail/youtube/channel/
   <id>; router now accepts string ids + the new page). Banner hero, avatar,
   subs/handle/video stats, description, Follow toggle, and a video grid where
   each upload can be wished individually (duration + views + watch-on-YouTube).
   Watchlist channel cards now open this page instead of bouncing to YouTube.

video-side.css .vc-*; JS brace-balanced; backend tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-17 01:20:13 -07:00
parent 8941da8b60
commit 5ab3ec90a8
7 changed files with 422 additions and 88 deletions

View file

@ -1346,6 +1346,45 @@
<!-- Person detail (drill-in from a cast/crew member; not a nav page).
Bio + filmography, every credit links back into SoulSync. Built
by video/video-person.js, styled by .vp-* in video-side.css. -->
<!-- YouTube channel detail (isolated; opens like a show/movie via
soulsync:video-open-detail {kind:'channel'}). Built by
video/video-channel.js, styled by .vc-* in video-side.css. -->
<section class="video-subpage" data-video-subpage="video-channel-detail" hidden>
<div class="vc-page" data-video-channel>
<div class="vc-banner" data-vc-banner aria-hidden="true"></div>
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button>
<div class="vd-loading" data-vc-loading hidden>
<div class="loading-spinner"></div><div class="loading-text">Loading channel&hellip;</div>
</div>
<div class="vc-hero">
<div class="vc-avatar-wrap">
<img class="vc-avatar" data-vc-avatar alt="" hidden>
<span class="vc-avatar-ph" data-vc-avatar-ph aria-hidden="true">&#9654;</span>
</div>
<div class="vc-hero-info">
<div class="vc-eyebrow">YouTube channel</div>
<h1 class="vc-name" data-vc-name></h1>
<div class="vc-meta" data-vc-meta></div>
<p class="vc-desc" data-vc-desc></p>
<div class="vc-actions">
<button class="vc-follow" type="button" data-vc-follow>+ Follow</button>
<a class="vc-yt-link" data-vc-yt target="_blank" rel="noopener">Open on YouTube &#8599;</a>
</div>
</div>
</div>
<div class="vc-body">
<div class="vc-videos-head">
<h2 class="vc-videos-title">Videos</h2>
<span class="vc-videos-count" data-vc-count></span>
</div>
<div class="vc-videos" data-vc-videos></div>
<div class="vc-empty" data-vc-empty hidden>No videos found for this channel.</div>
</div>
</div>
</section>
<section class="video-subpage" data-video-subpage="video-person-detail" hidden>
<div class="vp-page" data-video-person data-has-bg="0">
<div class="vp-ambient" data-vp-ambient aria-hidden="true"></div>
@ -9734,6 +9773,8 @@
<script src="{{ url_for('static', filename='video/video-calendar.js', v=static_v) }}"></script>
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>
<!-- Video YouTube channel page (isolated; opens like a show via open-detail) -->
<script src="{{ url_for('static', filename='video/video-channel.js', v=static_v) }}"></script>
<!-- Video watchlist page (isolated; tabbed shows / people you follow) -->
<script src="{{ url_for('static', filename='video/video-watchlist.js', v=static_v) }}"></script>
<!-- Video wishlist page (isolated; tabbed movies / TV, grouped show tree) -->

View file

@ -0,0 +1,173 @@
/*
* SoulSync Video YouTube Channel detail page (isolated).
*
* Opens in-app like a show/movie via soulsync:video-open-detail {kind:'channel',
* source:'youtube', id:<channelId>}. Renders a banner hero (avatar, stats,
* description, Follow), and the channel's uploads as a grid where each video can
* be wished individually. Separate module mirrors video-person.js listening
* only for kind==='channel'. Styled by .vc-* in video-side.css.
*/
(function () {
'use strict';
var PAGE_ID = 'video-channel-detail';
var YT = function () { return window.VideoYoutube; };
var state = { id: null, channel: null, videos: {} };
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) { return YT() ? YT().esc(s) : String(s == null ? '' : s); }
function show(el, on) { if (el) el.hidden = !on; }
function watchUrl(vid) { return 'https://www.youtube.com/watch?v=' + encodeURIComponent(vid); }
function channelUrl(id) { return 'https://www.youtube.com/channel/' + encodeURIComponent(id); }
function videoCard(v) {
var dur = YT().fmtDuration(v.duration_seconds);
var thumb = v.thumbnail_url
? '<img class="vc-vid-img" src="' + esc(v.thumbnail_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vc-vid-thumb--none\')">'
: '';
var bits = [];
var d = YT().fmtDate(v.published_at); if (d) bits.push(esc(d));
var vc = YT().compactCount(v.view_count); if (vc) bits.push(esc(vc) + ' views');
var wished = !!v.wished;
return '<div class="vc-vid" data-vc-vid="' + esc(v.youtube_id) + '">' +
'<a class="vc-vid-thumb' + (v.thumbnail_url ? '' : ' vc-vid-thumb--none') + '" href="' + watchUrl(v.youtube_id) +
'" target="_blank" rel="noopener" data-vc-ext>' + thumb +
(dur ? '<span class="vc-vid-dur">' + esc(dur) + '</span>' : '') +
'<span class="vc-vid-play" aria-hidden="true">&#9654;</span></a>' +
'<div class="vc-vid-body">' +
'<div class="vc-vid-title" title="' + esc(v.title) + '">' + esc(v.title || 'Untitled') + '</div>' +
(bits.length ? '<div class="vc-vid-meta">' + bits.join(' · ') + '</div>' : '') +
'</div>' +
'<button class="vc-wish' + (wished ? ' vc-wish--on' : '') + '" type="button" data-vc-wish="' +
esc(v.youtube_id) + '">' + (wished ? '✓ Wished' : '+ Wish') + '</button>' +
'</div>';
}
function render(d) {
var ch = d.channel || {};
state.channel = ch;
state.videos = {};
(ch.videos || []).forEach(function (v) { state.videos[v.youtube_id] = v; });
var banner = $('[data-vc-banner]');
if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + ch.banner_url + "')" : '';
var page = $('[data-video-channel]'); if (page) page.setAttribute('data-has-banner', ch.banner_url ? '1' : '0');
var av = $('[data-vc-avatar]'), avph = $('[data-vc-avatar-ph]');
if (av) {
if (ch.avatar_url) { av.src = ch.avatar_url; show(av, true); if (avph) avph.hidden = true; }
else { show(av, false); if (avph) avph.hidden = false; }
}
var name = $('[data-vc-name]'); if (name) name.textContent = ch.title || 'Channel';
var meta = $('[data-vc-meta]');
if (meta) {
var m = [];
if (ch.handle) m.push(esc(ch.handle));
var subs = YT().compactCount(ch.subscriber_count); if (subs) m.push(subs + ' subscribers');
if (ch.video_count != null) m.push(esc(ch.video_count) + ' videos');
meta.innerHTML = m.join('<span class="vc-dot">·</span>');
}
var desc = $('[data-vc-desc]');
if (desc) { desc.textContent = ch.description || ''; desc.hidden = !ch.description; }
var yt = $('[data-vc-yt]'); if (yt) yt.href = channelUrl(ch.youtube_id);
setFollow(!!d.following);
var count = $('[data-vc-count]'); if (count) count.textContent = (ch.videos || []).length + ' shown';
var grid = $('[data-vc-videos]');
if (grid) grid.innerHTML = (ch.videos || []).map(videoCard).join('');
show($('[data-vc-empty]'), !(ch.videos || []).length);
}
function setFollow(on) {
var b = $('[data-vc-follow]'); if (!b) return;
b.classList.toggle('vc-follow--on', on);
b.textContent = on ? '✓ Following' : '+ Follow';
}
function load(id) {
state.id = id;
var ld = $('[data-vc-loading]'); show(ld, true);
var grid = $('[data-vc-videos]'); if (grid) grid.innerHTML = '';
var name = $('[data-vc-name]'); if (name) name.textContent = '';
var meta = $('[data-vc-meta]'); if (meta) meta.innerHTML = '';
var desc = $('[data-vc-desc]'); if (desc) { desc.textContent = ''; desc.hidden = true; }
fetch('/api/video/youtube/channel/' + encodeURIComponent(id) + '?limit=60',
{ headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
show(ld, false);
if (!d || !d.success) {
var nm = $('[data-vc-name]'); if (nm) nm.textContent = 'Channel unavailable';
show($('[data-vc-empty]'), true);
return;
}
render(d);
})
.catch(function () { show(ld, false); });
}
// ── interactions ──────────────────────────────────────────────────────────
function channelStub() {
var c = state.channel || {};
return { youtube_id: c.youtube_id, title: c.title, avatar_url: c.avatar_url };
}
function toggleWish(btn) {
if (!YT()) return;
var id = btn.getAttribute('data-vc-wish');
var v = state.videos[id]; if (!v) return;
var on = btn.classList.contains('vc-wish--on');
btn.disabled = true;
var setOn = function (val) {
btn.classList.toggle('vc-wish--on', val); btn.textContent = val ? '✓ Wished' : '+ Wish';
btn.disabled = false; v.wished = val;
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
};
if (on) {
YT().removeWish('video', id).then(function (d) { setOn(!(d && d.success)); if (d && d.success && typeof showToast === 'function') showToast('Removed from wishlist', 'info'); })
.catch(function () { btn.disabled = false; });
} else {
YT().addVideos(channelStub(), [v]).then(function (d) {
setOn(!!(d && d.success));
if (d && d.success && typeof showToast === 'function') showToast('Added to wishlist', 'success');
}).catch(function () { btn.disabled = false; });
}
}
function toggleFollow(btn) {
if (!YT()) return;
var on = btn.classList.contains('vc-follow--on');
btn.disabled = true;
var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); load(state.id); };
if (on) YT().unfollow(state.id).then(done).catch(function () { btn.disabled = false; });
else YT().follow(channelStub()).then(function (d) {
if (d && d.success && typeof showToast === 'function')
showToast('Following · ' + (d.added_videos || 0) + ' videos added', 'success');
done();
}).catch(function () { btn.disabled = false; });
}
function onClick(e) {
var wish = e.target.closest('[data-vc-wish]');
if (wish) { e.preventDefault(); toggleWish(wish); return; }
var fb = e.target.closest('[data-vc-follow]');
if (fb) { e.preventDefault(); toggleFollow(fb); return; }
// [data-vc-ext] anchors (thumbnail / Open on YouTube) fall through to navigate.
}
function onOpen(e) {
if (!e || !e.detail || e.detail.kind !== 'channel') return;
load(e.detail.id);
}
function init() {
var page = $('[data-video-channel]');
if (page) page.addEventListener('click', onClick);
document.addEventListener('soulsync:video-open-detail', onOpen);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View file

@ -2799,3 +2799,72 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vyt-wcard-title { font-size: 13.5px; font-weight: 800; color: #fff; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vyt-wcard-meta { font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.42); }
/* ══ YouTube channel detail page (.vc-*) — opens like a show/movie ════════════ */
.vc-page { position: relative; max-width: 1240px; margin: 0 auto; padding: 0 30px 60px; }
.vc-banner { position: absolute; top: 0; left: 0; right: 0; height: 260px; z-index: 0;
background-size: cover; background-position: center; }
.vc-banner::after { content: ''; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(10,10,14,0.35), rgba(10,10,14,0.85) 70%, var(--bg, #0b0b0f)); }
.vc-page[data-has-banner="0"] .vc-banner {
background: radial-gradient(120% 150% at 20% 0%, rgba(255,59,59,0.22), transparent 60%); }
.vc-page .vd-back { position: relative; z-index: 2; margin: 18px 0 0; }
.vc-hero { position: relative; z-index: 1; display: flex; align-items: flex-end; gap: 26px;
padding-top: 120px; }
.vc-avatar-wrap { flex-shrink: 0; width: 132px; height: 132px; border-radius: 50%; overflow: hidden;
border: 4px solid rgba(255,59,59,0.55); box-shadow: 0 16px 40px rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center; background: #16161d; }
.vc-avatar { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-avatar-ph { font-size: 46px; color: rgba(255,59,59,0.7); }
.vc-hero-info { flex: 1; min-width: 0; padding-bottom: 6px; }
.vc-eyebrow { font-size: 11.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: #ff6b6b; }
.vc-name { font-size: 40px; font-weight: 900; letter-spacing: -0.02em; color: #fff; margin: 6px 0 0; line-height: 1.05; }
.vc-meta { margin-top: 9px; font-size: 13.5px; font-weight: 600; color: rgba(255,255,255,0.62); }
.vc-meta .vc-dot { margin: 0 8px; opacity: 0.5; }
.vc-desc { margin: 14px 0 0; max-width: 760px; font-size: 13.5px; line-height: 1.55; color: rgba(255,255,255,0.62);
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-line; }
.vc-actions { display: flex; align-items: center; gap: 14px; margin-top: 18px; }
.vc-follow { border: 0; cursor: pointer; border-radius: 999px; padding: 11px 24px; font-size: 13.5px; font-weight: 800;
background: #ff3b3b; color: #fff; box-shadow: 0 8px 22px rgba(255,59,59,0.4); transition: all 0.15s ease; }
.vc-follow:hover { transform: translateY(-1px); background: #ff5252; }
.vc-follow:disabled { opacity: 0.6; cursor: default; }
.vc-follow--on { background: rgba(255,255,255,0.1); color: #6cd391; box-shadow: none; }
.vc-yt-link { font-size: 13px; font-weight: 800; color: #ff6b6b; text-decoration: none; padding: 10px 16px;
border-radius: 999px; border: 1px solid rgba(255,59,59,0.35); }
.vc-yt-link:hover { background: rgba(255,59,59,0.12); }
.vc-body { position: relative; z-index: 1; margin-top: 40px; }
.vc-videos-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.vc-videos-title { font-size: 20px; font-weight: 800; color: #fff; margin: 0; }
.vc-videos-count { font-size: 12.5px; font-weight: 600; color: rgba(255,255,255,0.4); }
.vc-videos { display: grid; grid-template-columns: repeat(auto-fill, minmax(264px, 1fr)); gap: 20px 16px; }
.vc-vid { display: flex; flex-direction: column; }
.vc-vid-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden;
background: #16161d; border: 1px solid rgba(255,255,255,0.07); }
.vc-vid-img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.25s ease; }
.vc-vid-thumb:hover .vc-vid-img { transform: scale(1.05); }
.vc-vid-thumb--none { background: rgba(255,255,255,0.04); }
.vc-vid-thumb--none .vc-vid-img { display: none; }
.vc-vid-thumb--none::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; font-size: 26px; color: rgba(255,59,59,0.4); }
.vc-vid-dur { position: absolute; right: 7px; bottom: 7px; background: rgba(0,0,0,0.82); color: #fff;
font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 5px; }
.vc-vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 30px; color: #fff; opacity: 0; transition: opacity 0.2s ease; text-shadow: 0 2px 14px rgba(0,0,0,0.7); }
.vc-vid-thumb:hover .vc-vid-play { opacity: 0.92; }
.vc-vid-body { margin-top: 9px; flex: 1; }
.vc-vid-title { font-size: 13.5px; font-weight: 700; color: #fff; line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vc-vid-meta { margin-top: 4px; font-size: 11.5px; font-weight: 600; color: rgba(255,255,255,0.42); }
.vc-wish { margin-top: 9px; align-self: flex-start; border: 1px solid rgba(255,255,255,0.14); cursor: pointer;
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.78); font-size: 12px; font-weight: 800;
padding: 7px 14px; border-radius: 999px; transition: all 0.15s ease; }
.vc-wish:hover { background: rgba(255,59,59,0.18); border-color: rgba(255,59,59,0.5); color: #fff; }
.vc-wish:disabled { opacity: 0.6; cursor: default; }
.vc-wish--on { background: rgba(108,211,145,0.16); border-color: rgba(108,211,145,0.4); color: #6cd391; }
.vc-empty { padding: 40px; text-align: center; color: rgba(255,255,255,0.5); font-size: 14px; }
@media (max-width: 720px) {
.vc-page { padding: 0 16px 50px; }
.vc-hero { flex-direction: column; align-items: flex-start; gap: 16px; padding-top: 90px; }
.vc-name { font-size: 28px; }
}

View file

@ -31,16 +31,21 @@
// reload / Back / Forward / open-in-new-tab all work. ``source`` is 'library'
// (a video.db id) today; 'tmdb' (a search result not yet in the library) later.
var DETAIL_BASE = '/video-detail/';
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1 };
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1,
'video-channel-detail': 1 };
function buildDetailPath(source, kind, id) {
return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + id;
return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + encodeURIComponent(id);
}
function parseDetailPath(pathname) {
if (!pathname || pathname.indexOf(DETAIL_BASE) !== 0) return null;
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
if (p.length < 3) return null;
var kind = p[1], id = parseInt(p[2], 10);
var kind = p[1];
if (kind === 'channel') { // YouTube channel ids are strings (UC…), not numeric
return { source: decodeURIComponent(p[0]), kind: kind, id: decodeURIComponent(p[2]) };
}
var id = parseInt(p[2], 10);
if ((kind !== 'movie' && kind !== 'show' && kind !== 'person') || isNaN(id)) return null;
return { source: decodeURIComponent(p[0]), kind: kind, id: id };
}
@ -65,6 +70,10 @@
var n = document.querySelector('[data-video-person] [data-vp-name]');
return n ? (n.textContent || '').trim() : '';
}
if (pageId === 'video-channel-detail') {
var cn = document.querySelector('[data-video-channel] [data-vc-name]');
return cn ? (cn.textContent || '').trim() : '';
}
var host = pageId === 'video-movie-detail' ? '[data-video-detail="movie"]' : '[data-video-detail="show"]';
var t = document.querySelector(host + ' [data-vd-title]');
return t ? (t.textContent || '').trim() : '';
@ -303,6 +312,7 @@
if (d.kind === 'movie') navigate('video-movie-detail');
else if (d.kind === 'show') navigate('video-show-detail');
else if (d.kind === 'person') navigate('video-person-detail');
else if (d.kind === 'channel') navigate('video-channel-detail');
else return;
if (d._replace) {
// A redirect (e.g. a tmdb link that's actually owned) → replace the

View file

@ -16,13 +16,13 @@
counts: { show: 0, person: 0 }, channelCount: 0 };
var searchTimer = null;
// A followed YouTube channel card (avatar, title, wished-video count, unfollow).
// A followed YouTube channel card. Clicking it opens the in-app channel page
// (like any show/movie); the ✕ unfollows.
function channelCard(ch) {
var av = window.VideoYoutube ? VideoYoutube.avatar(ch, 'vyt-wcard-avatar') : '';
var n = ch.video_count || 0;
return '<div class="vyt-wcard" data-vyt-chan="' + esc(ch.youtube_id) + '">' +
'<a class="vyt-wcard-art" href="https://www.youtube.com/channel/' + esc(ch.youtube_id) +
'" target="_blank" rel="noopener" title="Open on YouTube">' + av + '</a>' +
return '<div class="vyt-wcard" data-vyt-open-channel="' + esc(ch.youtube_id) + '" title="Open channel">' +
'<div class="vyt-wcard-art">' + av + '</div>' +
'<button class="vyt-wcard-unfollow" type="button" data-vyt-wunfollow="' + esc(ch.youtube_id) +
'" title="Unfollow">&#10005;</button>' +
'<div class="vyt-wcard-info"><span class="vyt-wcard-title" title="' + esc(ch.title) + '">' +
@ -253,8 +253,14 @@
}).catch(function () { unf.disabled = false; });
return;
}
if (e.target.closest('.vyt-wcard-art')) return; // let the YouTube link open
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var ch = e.target.closest('[data-vyt-open-channel]');
if (ch) {
e.preventDefault();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'channel', source: 'youtube', id: ch.getAttribute('data-vyt-open-channel') } }));
return;
}
var card = e.target.closest('[data-vwlp-open]');
if (!card) return;
e.preventDefault();

View file

@ -16,26 +16,6 @@
counts: { movie: 0, show: 0, episode: 0 }, ytChannel: 0, ytVideo: 0,
showData: {}, showInfo: {} };
// ── YouTube channel block (channel = header, videos = flat feed) ───────────
function ytChannelBlock(ch) {
var av = window.VideoYoutube ? VideoYoutube.avatar(ch, 'vyt-chan-avatar') : '';
var vids = (window.VideoYoutube && (ch.videos || []).map(VideoYoutube.videoCard).join('')) || '';
var n = ch.video_count || (ch.videos || []).length;
return '<div class="vyt-chan" data-vyt-chan="' + esc(ch.youtube_id) + '">' +
'<div class="vyt-chan-hd" data-vyt-toggle>' + av +
'<div class="vyt-chan-meta">' +
'<span class="vyt-chan-title">' + esc(ch.title) + '</span>' +
'<span class="vyt-chan-sub">' + n + ' video' + (n === 1 ? '' : 's') + '</span>' +
'</div>' +
'<a class="vyt-chan-link" href="https://www.youtube.com/channel/' + esc(ch.youtube_id) +
'" target="_blank" rel="noopener" title="Open on YouTube" data-vyt-ext>YouTube ↗</a>' +
'<button class="vyt-chan-unfollow" type="button" data-vyt-rm="channel" data-id="' +
esc(ch.youtube_id) + '" title="Unfollow and clear its videos">Unfollow</button>' +
'<span class="vyt-chan-chev" aria-hidden="true">▾</span>' +
'</div>' +
'<div class="vyt-vids">' + vids + '</div>' +
'</div>';
}
var searchTimer = null;
function $(s, r) { return (r || document).querySelector(s); }
@ -91,8 +71,15 @@
function orbSize(n) { return n >= 10 ? 'orb-lg' : n >= 4 ? 'orb-md' : 'orb-sm'; }
function nebulaOrb(sh, idx) {
// Source-aware: a TMDB show opens its show page; a YouTube channel (source
// 'youtube') opens the in-app channel page. YEAR is the "season", video the
// "episode" — the data is already shaped that way, so the same render runs.
var yt = sh.source === 'youtube';
var src = sh.library_id != null ? 'library' : 'tmdb';
var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id;
var openAttrs = yt
? 'data-vwsh-open-channel data-yt="' + esc(sh.youtube_id) + '"'
: 'data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '"';
var hue = hueOf(sh.title);
var total = sh.wanted || 0;
var img = sh.poster_url
@ -100,27 +87,29 @@
'onerror="this.outerHTML=\'<div class=&quot;wl-orb-initials&quot;>' + esc(initials(sh.title)) + '</div>\'">'
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
// Episodes are shown grouped under a clickable season header (header →
// show page); each episode card SELECTS the episode (drives the info bar).
// Season = a poster panel on the LEFT (→ show page) with the episode grid
// flowing to its RIGHT, so the horizontal space is actually used.
// show/channel page); each episode card SELECTS it (drives the info bar).
// Season = a poster panel on the LEFT with the episode grid to its RIGHT.
var seasons = (sh.seasons || []).map(function (se) {
var n = se.episodes.length;
var posterUrl = se.poster_url || sh.poster_url || null;
var thumb = posterUrl ? '<img src="' + esc(posterUrl) + '" alt="">' : '<span class="vwsh-szn-ph">📺</span>';
var cards = (se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join('');
var sName = yt ? (se.season_number ? se.season_number : 'Undated') : ('Season ' + se.season_number);
var sRm = yt
? 'data-vwsh-rm="yt-season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"'
: 'data-vwsh-rm="season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"';
return '<div class="vwsh-szn">' +
'<div class="vwsh-szn-side" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="Open show page">' +
'<div class="vwsh-szn-side" ' + openAttrs + ' title="' + (yt ? 'Open channel page' : 'Open show page') + '">' +
'<div class="vwsh-szn-poster">' + thumb + '</div>' +
'<div class="vwsh-szn-name">Season ' + se.season_number + '</div>' +
'<div class="vwsh-szn-count">' + n + ' episode' + (n === 1 ? '' : 's') + '</div>' +
'<div class="vwsh-szn-go">View show &rarr;</div>' +
'<button class="vwsh-szn-rm" type="button" data-vwsh-rm="season" ' +
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" title="Remove season">&#10005;</button>' +
'<div class="vwsh-szn-name">' + esc(sName) + '</div>' +
'<div class="vwsh-szn-count">' + n + (yt ? ' video' : ' episode') + (n === 1 ? '' : 's') + '</div>' +
'<div class="vwsh-szn-go">' + (yt ? 'View channel' : 'View show') + ' &rarr;</div>' +
'<button class="vwsh-szn-rm" type="button" ' + sRm + ' title="Remove">&#10005;</button>' +
'</div>' +
'<div class="vwsh-ep-grid">' + cards + '</div>' +
'</div>';
}).join('');
var eps = total + ' episode' + (total === 1 ? '' : 's');
var eps = total + (yt ? ' video' : ' episode') + (total === 1 ? '' : 's');
// --orb-hue on the GROUP so the music orb styles + my cinematic-expand
// backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded.
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
@ -129,8 +118,12 @@
// Header is a 3-column row that FLANKS the poster: synopsis (left) · poster
// (middle) · cast (right). When collapsed (or no data) the side columns are
// empty → hidden → just the centered bubble, so the nebula grid is unchanged.
return '<div class="wl-orb-group" data-vwsh-group data-vwsh-tmdb="' + esc(sh.tmdb_id) + '" style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '" title="Remove show">&#10005;</button>' +
var showRm = yt
? 'data-vwsh-rm="yt-channel" data-yt="' + esc(sh.youtube_id) + '"'
: 'data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '"';
return '<div class="wl-orb-group" data-vwsh-group data-vwsh-tmdb="' + esc(sh.tmdb_id) + '" ' +
'data-vwsh-source="' + (yt ? 'youtube' : 'tmdb') + '" style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" ' + showRm + ' title="Remove">&#10005;</button>' +
'<div class="vwsh-xhead">' +
'<div class="vwsh-info-syn" data-vwsh-syn></div>' +
'<div class="vwsh-xhead-mid">' +
@ -139,7 +132,7 @@
'<div class="wl-orb-glow"></div>' + img + '<div class="wl-orb-ring"></div>' +
'<div class="vwsh-prog"></div>' +
'</div>' +
'<div class="wl-orb-label" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
'<div class="wl-orb-label" ' + openAttrs + ' title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
'<div class="wl-orb-meta">' + eps + (sh.done ? ' · ' + sh.done + ' done' : '') + '</div>' +
'</div>' +
'<div class="vwsh-info-cast" data-vwsh-cast></div>' +
@ -175,6 +168,17 @@
var synEl = group && group.querySelector('[data-vwsh-syn]');
var castEl = group && group.querySelector('[data-vwsh-cast]');
if (!synEl || !castEl) return;
// YouTube: a video carries its own description; there's no cast and no
// tmdb episode endpoint, so just paint the selected video's synopsis.
if (group.getAttribute('data-vwsh-source') === 'youtube') {
if (sel) {
var yd = fmtDate(sel.air_date);
synEl.innerHTML = (yd ? '<span class="vwsh-info-eyebrow">' + esc(yd) + '</span>' : '') +
esc(sel.overview || 'No description for this video.');
} else { synEl.innerHTML = ''; }
castEl.innerHTML = '';
return;
}
var si = state.showInfo[tmdb] || {};
var eyebrow, overview, castArr;
if (sel) {
@ -212,6 +216,7 @@
if (!group) return;
var tmdb = parseInt(group.getAttribute('data-vwsh-tmdb'), 10);
renderInfoBar(group, tmdb, null); // paint the View-show button immediately
if (group.getAttribute('data-vwsh-source') === 'youtube') return; // no tmdb detail for channels
if (group.getAttribute('data-vwsh-info-loaded')) return;
group.setAttribute('data-vwsh-info-loaded', '1');
var sh = state.showData[tmdb]; if (!sh) return;
@ -229,38 +234,41 @@
// A single episode card. Clicking it SELECTS the episode (drives the info bar);
// the "View show" button in the info bar is what navigates.
function epCard(sh, se, e) {
var t = e.title || ('Episode ' + e.episode_number);
var yt = sh.source === 'youtube';
var t = e.title || (yt ? 'Untitled' : ('Episode ' + e.episode_number));
var st = STATUS[e.status] ? e.status : 'wanted';
var date = fmtDate(e.air_date);
// TMDB shows the SxEx label; a YouTube video shows just its upload date.
var metaTxt = yt ? (date || 'Video') : ('S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : ''));
var thumb = e.still_url
? '<span class="vwsh-epc-thumb"><img src="' + esc(e.still_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vwsh-epc-thumb--none\')"></span>'
: '<span class="vwsh-epc-thumb vwsh-epc-thumb--none"></span>';
return '<div class="vwsh-epc" data-vwsh-ep data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '">' + thumb +
var rm = yt
? 'data-vwsh-rm="yt-video" data-id="' + esc(e.source_id) + '"'
: 'data-vwsh-rm="episode" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"';
return '<div class="vwsh-epc" data-vwsh-ep data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"' +
(yt ? ' data-src-id="' + esc(e.source_id) + '"' : '') + '>' + thumb +
'<div class="vwsh-epc-body">' +
'<div class="vwsh-epc-title" title="' + esc(t) + '">' + esc(t) + '</div>' +
'<div class="vwsh-epc-meta"><span class="vwsh-ep-dot vwsh-ep-dot--' + st + '"></span>' +
'S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '') + '</div>' +
'<div class="vwsh-epc-meta"><span class="vwsh-ep-dot vwsh-ep-dot--' + st + '"></span>' + (yt ? esc(metaTxt) : metaTxt) + '</div>' +
'</div>' +
'<button class="vwsh-epc-rm" type="button" data-vwsh-rm="episode" ' +
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">&#10005;</button>' +
'<button class="vwsh-epc-rm" type="button" ' + rm + ' title="Remove">&#10005;</button>' +
'</div>';
}
function render(items) {
var grid = $('[data-vwsh-grid]'); if (!grid) return;
var shows = state.tab === 'show';
var yt = state.tab === 'youtube';
grid.classList.toggle('wl-nebula-field', shows);
grid.classList.toggle('vwsh-nebula', shows); // video-only scope so music wl-* is untouched
// YouTube uses the SAME nebula as TV (channel=show, year=season, video=episode).
var nebula = state.tab === 'show' || state.tab === 'youtube';
grid.classList.toggle('wl-nebula-field', nebula);
grid.classList.toggle('vwsh-nebula', nebula); // video-only scope so music wl-* is untouched
grid.classList.toggle('vwsh-grid--movies', state.tab === 'movie');
grid.classList.toggle('vyt-field', yt);
state.showData = {};
if (shows) items.forEach(function (sh) { state.showData[sh.tmdb_id] = sh; }); // for the episode area
grid.innerHTML = yt
? items.map(ytChannelBlock).join('')
: shows ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
: items.map(movieCard).join('');
if (nebula) items.forEach(function (sh) { state.showData[sh.tmdb_id] = sh; }); // for the episode area
grid.innerHTML = nebula
? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
: items.map(movieCard).join('');
}
// ── counts / badges / pager ───────────────────────────────────────────────
@ -399,50 +407,56 @@
load();
}
// ── remove ────────────────────────────────────────────────────────────────
// ── remove (TMDB scopes via /wishlist/remove; YouTube scopes via youtube) ──
function doRemove(btn) {
var scope = btn.getAttribute('data-vwsh-rm');
btn.disabled = true;
var after = function () {
if (typeof showToast === 'function') showToast('Removed from wishlist', 'info');
load();
};
var afterYt = function () { after(); document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
var fail = function () { btn.disabled = false; };
if (scope === 'yt-video') {
VideoYoutube.removeWish('video', btn.getAttribute('data-id')).then(afterYt).catch(fail); return;
}
if (scope === 'yt-channel') { // remove the channel's videos AND unfollow it
var cid = btn.getAttribute('data-yt');
VideoYoutube.unfollow(cid).then(function () { return VideoYoutube.removeWish('channel', cid); })
.then(afterYt).catch(fail); return;
}
if (scope === 'yt-season') { // a "year" = remove every wished video in it
var sh = state.showData[parseInt(btn.getAttribute('data-tmdb'), 10)];
var yr = parseInt(btn.getAttribute('data-s'), 10), ids = [];
if (sh) (sh.seasons || []).forEach(function (se) {
if (se.season_number === yr) (se.episodes || []).forEach(function (ep) { if (ep.source_id) ids.push(ep.source_id); });
});
if (!ids.length) { fail(); return; }
Promise.all(ids.map(function (id) { return VideoYoutube.removeWish('video', id); })).then(afterYt).catch(fail);
return;
}
var body = { scope: scope, tmdb_id: parseInt(btn.getAttribute('data-tmdb'), 10) };
if (btn.hasAttribute('data-s')) body.season_number = parseInt(btn.getAttribute('data-s'), 10);
if (btn.hasAttribute('data-e')) body.episode_number = parseInt(btn.getAttribute('data-e'), 10);
btn.disabled = true;
fetch('/api/video/wishlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.success) { btn.disabled = false; return; }
if (typeof showToast === 'function') showToast('Removed from wishlist', 'info');
load(); // re-render + counts + pager stay correct
})
.catch(function () { btn.disabled = false; });
}
// YouTube remove: a single video, or unfollow a channel (which also clears it).
function doYtRemove(btn) {
if (!window.VideoYoutube) return;
var scope = btn.getAttribute('data-vyt-rm'), id = btn.getAttribute('data-id');
btn.disabled = true;
var after = function () {
if (typeof showToast === 'function') showToast(scope === 'channel' ? 'Unfollowed' : 'Removed', 'info');
load(); document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
};
var p = scope === 'channel'
? VideoYoutube.unfollow(id).then(function () { return VideoYoutube.removeWish('channel', id); })
: VideoYoutube.removeWish('video', id);
p.then(after).catch(function () { btn.disabled = false; });
.then(function (d) { if (!d || !d.success) { fail(); return; } after(); })
.catch(fail);
}
function onGridClick(e) {
var ytRm = e.target.closest('[data-vyt-rm]');
if (ytRm) { e.preventDefault(); e.stopPropagation(); doYtRemove(ytRm); return; }
var ytTog = e.target.closest('[data-vyt-toggle]');
if (ytTog && !e.target.closest('[data-vyt-ext]')) {
var blk = ytTog.closest('.vyt-chan'); if (blk) blk.classList.toggle('vyt-chan--collapsed'); return;
}
if (e.target.closest('[data-vyt-ext]')) return; // let the YouTube link open
var rm = e.target.closest('[data-vwsh-rm]');
if (rm) { e.preventDefault(); e.stopPropagation(); doRemove(rm); return; }
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var openCh = e.target.closest('[data-vwsh-open-channel]');
if (openCh) { // YouTube channel → in-app channel page
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'channel', source: 'youtube', id: openCh.getAttribute('data-yt') } }));
return;
}
var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie], [data-vwsh-open-person]');
if (open) {
var person = open.hasAttribute('data-vwsh-open-person');

View file

@ -98,6 +98,26 @@
function removeWish(scope, sourceId) {
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
}
function addVideos(channel, videos) {
return post('/api/video/youtube/wishlist/add', { channel: channel, videos: videos });
}
// mm:ss / h:mm:ss from a seconds count
function fmtDuration(sec) {
sec = parseInt(sec, 10);
if (!sec || sec < 0) return '';
var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
var mm = (h && m < 10) ? '0' + m : '' + m, ss = s < 10 ? '0' + s : '' + s;
return (h ? h + ':' + mm : m + '') + ':' + ss;
}
function compactCount(n) {
n = parseInt(n, 10);
if (!n && n !== 0) return '';
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return '' + n;
}
function resolve(ref) {
return fetch('/api/video/youtube/resolve?url=' + encodeURIComponent(ref),
@ -106,7 +126,8 @@
window.VideoYoutube = {
esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar,
fmtDuration: fmtDuration, compactCount: compactCount,
videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
follow: follow, unfollow: unfollow, removeWish: removeWish, resolve: resolve,
follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve,
};
})();