YouTube channels (4/4): UI — paste-to-follow, channel feeds, watchlist cards

Visual-first slice ties it together (window.VideoYoutube shared helper, all
.vyt-* CSS — music wl-* and the TMDB vwsh-* nebula untouched):
- Search: paste a channel link (or @handle) and instead of a title search you
  get a YouTube Follow chip — avatar, title/handle, a strip of recent stills,
  and a Follow button that follows + wishes recent uploads in one click.
- Wishlist: new YouTube tab. Channel = collapsible header (avatar, count,
  open-on-YouTube, Unfollow), videos = a flat newest-first thumbnail feed with
  per-video remove. Tab badge + sub-count wired.
- Watchlist: new Channels tab — followed channels as avatar cards with a wished-
  video count and an unfollow control; count badge kept fresh across follows.

JS brace-balanced, CSS balanced, 66 youtube+API tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-17 00:47:24 -07:00
parent d30149db14
commit b440f2bcdc
6 changed files with 441 additions and 16 deletions

View file

@ -1021,6 +1021,9 @@
<button class="vwlp-tab" type="button" role="tab" data-vwlp-tab="person">
People <span class="vwlp-tab-n" data-vwlp-count-person>0</span>
</button>
<button class="vwlp-tab" type="button" role="tab" data-vwlp-tab="channel">
Channels <span class="vwlp-tab-n" data-vwlp-count-channel>0</span>
</button>
</div>
</div>
</div>
@ -1073,6 +1076,9 @@
<button class="vwsh-tab" type="button" role="tab" data-vwsh-tab="show">
TV <span class="vwsh-tab-n" data-vwsh-count-show>0</span>
</button>
<button class="vwsh-tab" type="button" role="tab" data-vwsh-tab="youtube">
YouTube <span class="vwsh-tab-n" data-vwsh-count-youtube>0</span>
</button>
</div>
</div>
</div>
@ -9718,6 +9724,9 @@
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
<!-- Shared YouTube-channel helpers (paste-a-link follow, channel/video cards);
load before search/watchlist/wishlist which use window.VideoYoutube -->
<script src="{{ url_for('static', filename='video/video-youtube.js', v=static_v) }}"></script>
<!-- Video search (isolated; in-app multi-search → library/tmdb detail) -->
<script src="{{ url_for('static', filename='video/video-search.js', v=static_v) }}"></script>
<!-- Video discover (isolated; trending hero + lazy genre/decade rails + filter grid) -->

View file

@ -25,6 +25,7 @@
var timer = null;
var wired = false;
var trendingCache = null; // null = not fetched; [] = fetched/empty
var lastChannel = null; // resolved YouTube channel awaiting a Follow
function $(sel) { return document.querySelector(sel); }
function esc(s) {
@ -146,6 +147,34 @@
});
}
// A pasted YouTube channel link → resolve + render a Follow chip instead of
// a normal title search (the obscure-channel entry point).
function runChannel(ref) {
var seq = ++reqSeq;
show('[data-video-search-loading]', true);
VideoYoutube.resolve(ref).then(function (d) {
if (seq !== reqSeq) return;
show('[data-video-search-loading]', false);
show('[data-video-search-hint]', false);
var host = $('[data-video-search-results]'); if (!host) return;
if (!d || !d.success || !d.channel) {
show('[data-video-search-empty]', false);
host.innerHTML = '<div class="vsr-group"><div class="vyt-miss">' +
'Couldnt read that channel. Paste a channel link like ' +
'<code>youtube.com/@handle</code>.</div></div>';
return;
}
lastChannel = d.channel;
show('[data-video-search-empty]', false);
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channel</h2>' +
'<div class="vyt-search">' + VideoYoutube.searchCard(d.channel, d.following) + '</div></div>';
}).catch(function () {
if (seq !== reqSeq) return;
show('[data-video-search-loading]', false);
});
}
function onInput(val) {
var q = (val || '').trim();
lastQuery = q;
@ -156,9 +185,35 @@
showIdle(); // back to the trending rail
return;
}
if (window.VideoYoutube && VideoYoutube.isChannelRef(q)) {
timer = setTimeout(function () { runChannel(q); }, 360);
return;
}
timer = setTimeout(function () { runSearch(q); }, 320);
}
// Follow / un-follow the resolved channel chip.
function toggleFollow(btn) {
if (!lastChannel) return;
var on = btn.classList.contains('vyt-follow--on');
btn.disabled = true;
var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
if (on) {
VideoYoutube.unfollow(lastChannel.youtube_id).then(function () {
btn.classList.remove('vyt-follow--on'); btn.innerHTML = '+ Follow'; done();
}).catch(function () { btn.disabled = false; });
} else {
VideoYoutube.follow(lastChannel).then(function (d) {
if (d && d.success) {
btn.classList.add('vyt-follow--on'); btn.innerHTML = '✓ Following';
if (typeof showToast === 'function')
showToast('Following ' + lastChannel.title + ' · ' + (d.added_videos || 0) + ' videos added', 'success');
}
done();
}).catch(function () { btn.disabled = false; });
}
}
function openCard(card) {
var kind = card.getAttribute('data-vsr-open');
var id = parseInt(card.getAttribute('data-vsr-id'), 10);
@ -182,6 +237,8 @@
if (results) {
results.addEventListener('click', function (e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var fb = e.target.closest('[data-vyt-follow]');
if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; }
var card = e.target.closest('[data-vsr-open]');
if (!card || !results.contains(card)) return;
e.preventDefault();

View file

@ -2707,3 +2707,95 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.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; }
}
/* YouTube channels (search chip · wishlist channel blocks · watchlist cards)
Scoped .vyt-* never touches music wl-* or the TMDB vwsh-* nebula. */
.vyt-avatar--ph { display: inline-flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #ff3b3b, #b3122a); color: #fff; font-weight: 800; }
/* ── Search page: the pasted-channel Follow chip ─────────────────────────── */
.vyt-search { margin-top: 4px; }
.vyt-chip { border-radius: 16px; border: 1px solid rgba(255, 59, 59, 0.28);
background: linear-gradient(180deg, rgba(255, 59, 59, 0.07), rgba(255, 255, 255, 0.02));
padding: 16px 18px; max-width: 720px; }
.vyt-chip-head { display: flex; align-items: center; gap: 15px; }
.vyt-chip-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; object-fit: cover;
font-size: 20px; border: 2px solid rgba(255, 59, 59, 0.5); }
.vyt-chip-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.vyt-chip-badge { font-size: 10.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
color: #ff6b6b; }
.vyt-chip-title { font-size: 19px; font-weight: 800; color: #fff; line-height: 1.15;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vyt-chip-sub { font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.5); }
.vyt-follow { flex-shrink: 0; border: 0; cursor: pointer; border-radius: 999px; padding: 10px 20px;
font-size: 13px; font-weight: 800; background: #ff3b3b; color: #fff; transition: all 0.15s ease;
box-shadow: 0 6px 18px rgba(255, 59, 59, 0.35); }
.vyt-follow:hover { transform: translateY(-1px); background: #ff5252; }
.vyt-follow:disabled { opacity: 0.6; cursor: default; }
.vyt-follow--on { background: rgba(255, 255, 255, 0.1); color: #6cd391; box-shadow: none; }
.vyt-strip { display: flex; gap: 7px; margin-top: 14px; overflow: hidden; }
.vyt-strip-cell { flex: 1 1 0; min-width: 0; aspect-ratio: 16 / 9; border-radius: 7px; overflow: hidden;
background: #16161d; }
.vyt-strip-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vyt-miss { padding: 22px; border-radius: 14px; background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.6); font-size: 13.5px; }
.vyt-miss code { background: rgba(255, 255, 255, 0.08); padding: 2px 7px; border-radius: 6px; color: #fff; }
/* ── Wishlist YouTube tab: channel block = header + flat video feed ───────── */
.vyt-field { display: flex; flex-direction: column; gap: 16px; }
.vyt-chan { border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.02); overflow: hidden; }
.vyt-chan-hd { display: flex; align-items: center; gap: 14px; padding: 13px 16px; cursor: pointer;
transition: background 0.15s ease; }
.vyt-chan-hd:hover { background: rgba(255, 59, 59, 0.06); }
.vyt-chan-avatar { flex-shrink: 0; width: 46px; height: 46px; border-radius: 50%; object-fit: cover;
font-size: 16px; border: 2px solid rgba(255, 59, 59, 0.45); }
.vyt-chan-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.vyt-chan-title { font-size: 15.5px; font-weight: 800; color: #fff; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vyt-chan-sub { font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.42); }
.vyt-chan-link { flex-shrink: 0; font-size: 11.5px; font-weight: 800; color: #ff6b6b; text-decoration: none;
padding: 6px 11px; border-radius: 999px; border: 1px solid rgba(255, 59, 59, 0.3); }
.vyt-chan-link:hover { background: rgba(255, 59, 59, 0.12); }
.vyt-chan-unfollow { flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.12); cursor: pointer;
background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.6); font-size: 11.5px; font-weight: 700;
padding: 6px 13px; border-radius: 999px; transition: all 0.15s ease; }
.vyt-chan-unfollow:hover { background: rgba(239, 68, 68, 0.85); color: #fff; border-color: transparent; }
.vyt-chan-chev { flex-shrink: 0; color: rgba(255, 255, 255, 0.35); transition: transform 0.2s ease; }
.vyt-chan--collapsed .vyt-chan-chev { transform: rotate(-90deg); }
.vyt-vids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px;
padding: 4px 16px 18px; }
.vyt-chan--collapsed .vyt-vids { display: none; }
.vyt-vid { border-radius: 11px; overflow: hidden; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06); }
.vyt-vid-thumb { position: relative; aspect-ratio: 16 / 9; background: #16161d; }
.vyt-vid-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vyt-vid-thumb--none { background: rgba(255, 255, 255, 0.04); }
.vyt-vid-thumb--none::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; font-size: 22px; color: rgba(255, 59, 59, 0.4); }
.vyt-vid-rm { position: absolute; top: 7px; right: 7px; width: 24px; height: 24px; border-radius: 50%;
border: none; background: rgba(0, 0, 0, 0.55); color: rgba(255, 255, 255, 0.75); font-size: 12px;
cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; }
.vyt-vid:hover .vyt-vid-rm { opacity: 1; }
.vyt-vid-rm:hover { background: rgba(239, 68, 68, 0.9); color: #fff; }
.vyt-vid-body { padding: 9px 11px 11px; }
.vyt-vid-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
font-size: 12.5px; font-weight: 700; color: #fff; line-height: 1.3; }
.vyt-vid-date { display: block; margin-top: 4px; font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.4); }
/* ── Watchlist Channels tab: avatar cards ────────────────────────────────── */
.vyt-wgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; }
.vyt-wcard { position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; }
.vyt-wcard-art { display: block; width: 118px; height: 118px; border-radius: 50%; overflow: hidden;
border: 2px solid rgba(255, 59, 59, 0.4); transition: transform 0.15s ease, box-shadow 0.15s ease; }
.vyt-wcard-art:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(255, 59, 59, 0.35); }
.vyt-wcard-avatar { width: 100%; height: 100%; object-fit: cover; font-size: 34px; }
.vyt-wcard-unfollow { position: absolute; top: 4px; right: 50%; margin-right: -59px; width: 26px; height: 26px;
border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.6); color: rgba(255, 255, 255, 0.7);
font-size: 12px; cursor: pointer; opacity: 0; transition: all 0.15s ease; }
.vyt-wcard:hover .vyt-wcard-unfollow { opacity: 1; }
.vyt-wcard-unfollow:hover { background: rgba(239, 68, 68, 0.9); color: #fff; }
.vyt-wcard-info { margin-top: 11px; display: flex; flex-direction: column; gap: 2px; max-width: 100%; }
.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); }

View file

@ -13,9 +13,23 @@
var PAGE_ID = 'video-watchlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'show', search: '', sort: 'default', page: 1,
counts: { show: 0, person: 0 } };
counts: { show: 0, person: 0 }, channelCount: 0 };
var searchTimer = null;
// A followed YouTube channel card (avatar, title, wished-video count, unfollow).
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>' +
'<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) + '">' +
esc(ch.title) + '</span><span class="vyt-wcard-meta">' + n + ' video' + (n === 1 ? '' : 's') +
'</span></div></div>';
}
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) {
return String(s == null ? '' : s)
@ -97,27 +111,52 @@
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');
et.textContent = state.search ? 'No matches'
: state.tab === 'show' ? 'No shows on your watchlist yet'
: state.tab === 'person' ? 'No people on your watchlist yet'
: 'No channels followed yet — paste a channel link on the Search page';
}
}
function render(items) {
var grid = $('[data-vwlp-grid]');
if (state.tab === 'channel') {
grid.classList.add('vyt-wgrid');
grid.innerHTML = items.map(channelCard).join('');
return;
}
grid.classList.remove('vyt-wgrid');
// 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);
}
}
// Followed YouTube channels live on their own endpoint, not /watchlist.
function loadChannels() {
var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden');
fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (ld) ld.classList.add('hidden');
var chans = (d && d.channels) || [];
state.channelCount = chans.length;
var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = chans.length;
render(chans);
updatePagination(null);
updateEmpty(chans.length);
})
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); });
}
function load() {
state.loaded = true;
if (state.tab === 'channel') { loadChannels(); return; }
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 });
@ -137,7 +176,7 @@
}
function setTab(tab) {
if (tab !== 'show' && tab !== 'person') return;
if (tab !== 'show' && tab !== 'person' && tab !== 'channel') return;
state.tab = tab; state.page = 1;
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++)
@ -181,12 +220,40 @@
if (next) next.addEventListener('click', function () { state.page++; load(); });
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
// Following a channel fires the wishlist-changed event — keep the
// Channels tab + badge current too.
document.addEventListener('soulsync:video-wishlist-changed', function () {
if (state.tab === 'channel') { var g = $('[data-vwlp-grid]'); if (g && g.offsetParent !== null) { load(); return; } }
refreshChannelCount();
});
}
function refreshChannelCount() {
fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var n = (d && d.channels) ? d.channels.length : 0;
state.channelCount = n;
var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = n;
})
.catch(function () { /* ignore */ });
}
// 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) {
var unf = e.target.closest('[data-vyt-wunfollow]');
if (unf && window.VideoYoutube) {
e.preventDefault(); e.stopPropagation();
unf.disabled = true;
VideoYoutube.unfollow(unf.getAttribute('data-vyt-wunfollow')).then(function () {
if (typeof showToast === 'function') showToast('Unfollowed', 'info');
load();
}).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 card = e.target.closest('[data-vwlp-open]');
if (!card) return;
@ -200,12 +267,13 @@
}));
}
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); refreshChannelCount(); } }
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onShown);
refreshBadge(); // seed the nav badge on boot
refreshChannelCount();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);

View file

@ -13,7 +13,29 @@
var PAGE_ID = 'video-wishlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1,
counts: { movie: 0, show: 0, episode: 0 }, showData: {}, showInfo: {} };
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); }
@ -228,14 +250,17 @@
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
grid.classList.toggle('vwsh-grid--movies', !shows);
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 = shows
? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
: items.map(movieCard).join('');
grid.innerHTML = yt
? items.map(ytChannelBlock).join('')
: shows ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
: items.map(movieCard).join('');
}
// ── counts / badges / pager ───────────────────────────────────────────────
@ -247,9 +272,27 @@
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
updateSub();
}
function setYtCounts(counts) {
state.ytChannel = (counts && counts.channel) || 0;
state.ytVideo = (counts && counts.video) || 0;
var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo;
updateSub();
}
// Keep the YouTube tab badge fresh without switching to the tab.
function refreshYtCount() {
fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.counts) setYtCounts(d.counts); })
.catch(function () { /* ignore */ });
}
function updateSub() {
var el = $('[data-vwsh-sub]'); if (!el) return;
var c = state.counts;
if (state.tab === 'youtube') {
el.textContent = state.ytChannel + ' channel' + (state.ytChannel === 1 ? '' : 's') +
' · ' + state.ytVideo + ' video' + (state.ytVideo === 1 ? '' : 's');
return;
}
el.textContent = state.tab === 'show'
? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's')
: c.movie + ' movie' + (c.movie === 1 ? '' : 's');
@ -283,12 +326,33 @@
var et = $('[data-vwsh-empty-title]');
if (et && total === 0) {
et.textContent = state.search ? 'No matches'
: (state.tab === 'movie' ? 'No movies on your wishlist yet' : 'No TV episodes on your wishlist yet');
: state.tab === 'movie' ? 'No movies on your wishlist yet'
: state.tab === 'show' ? 'No TV episodes on your wishlist yet'
: 'No channels followed yet — paste a channel link on the Search page';
}
}
function loadYoutube() {
var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden');
var params = new URLSearchParams({ search: state.search, sort: state.sort, page: state.page, limit: LIMIT });
fetch('/api/video/youtube/wishlist?' + 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; }
setYtCounts(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 load() {
state.loaded = true;
if (state.tab === 'youtube') { loadYoutube(); return; }
var ld = $('[data-vwsh-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/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } })
@ -326,7 +390,7 @@
}
function setTab(tab) {
if (tab !== 'movie' && tab !== 'show') return;
if (tab !== 'movie' && tab !== 'show' && tab !== 'youtube') return;
state.tab = tab; state.page = 1; state.search = '';
var si = $('[data-vwsh-search]'); if (si) si.value = '';
var tabs = document.querySelectorAll('[data-vwsh-tab]');
@ -353,7 +417,29 @@
.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; });
}
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;
@ -405,16 +491,17 @@
// Adds elsewhere (the get-modal) refresh the badge + page if visible.
document.addEventListener('soulsync:video-wishlist-changed', function () {
var g = $('[data-vwsh-grid]');
if (g && g.offsetParent !== null) load(); else refreshBadge();
if (g && g.offsetParent !== null) load(); else { refreshBadge(); refreshYtCount(); }
});
}
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); refreshYtCount(); } }
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onShown);
refreshBadge();
refreshYtCount();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);

View file

@ -0,0 +1,112 @@
/* Shared YouTube-channel helpers for the video side: detect a pasted channel
* link, render channel/video cards, and follow/unfollow. Used by the search
* page (paste a link Follow), the wishlist YouTube tab, and the watchlist.
* Scoped under window.VideoYoutube; all CSS is .vyt-* (never touches music wl-*).
*/
(function () {
'use strict';
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
// A pasted YouTube *channel* reference: a full channel URL or a bare @handle.
// Deliberately NOT bare words, so normal title searches don't trigger it.
function isChannelRef(q) {
q = (q || '').trim();
if (/^@[\w.\-]{2,}$/.test(q)) return true;
if (!/youtube\.com/i.test(q)) return false;
return /youtube\.com\/(@[\w.\-]+|channel\/UC[\w\-]+|c\/[\w.\-]+|user\/[\w.\-]+)/i.test(q);
}
function fmtDate(iso) {
if (!iso) return '';
var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
if (isNaN(d)) return '';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function initials(s) {
var w = String(s || '').trim().split(/\s+/).filter(Boolean);
return (w.slice(0, 2).map(function (x) { return x[0]; }).join('') || '▶').toUpperCase();
}
function avatar(ch, cls) {
if (ch && ch.poster_url || (ch && ch.avatar_url)) {
return '<img class="' + cls + '" src="' + esc(ch.poster_url || ch.avatar_url) +
'" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
}
return '<span class="' + cls + ' vyt-avatar--ph">' + esc(initials(ch && ch.title)) + '</span>';
}
// A single wished video tile (thumbnail, title, date, remove).
function videoCard(v) {
var thumb = v.still_url
? '<img class="vyt-vid-img" src="' + esc(v.still_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vyt-vid-thumb--none\')">'
: '';
var date = fmtDate(v.published_at);
return '<div class="vyt-vid" data-vyt-vid="' + esc(v.youtube_id) + '">' +
'<div class="vyt-vid-thumb' + (v.still_url ? '' : ' vyt-vid-thumb--none') + '">' + thumb +
'<button class="vyt-vid-rm" type="button" data-vyt-rm="video" data-id="' + esc(v.youtube_id) +
'" title="Remove">&#10005;</button></div>' +
'<div class="vyt-vid-body">' +
'<span class="vyt-vid-title" title="' + esc(v.title) + '">' + esc(v.title || 'Untitled') + '</span>' +
(date ? '<span class="vyt-vid-date">' + esc(date) + '</span>' : '') +
'</div></div>';
}
// The search-page result: avatar, title/handle, a strip of recent stills, Follow.
function searchCard(ch, following) {
var strip = (ch.videos || []).slice(0, 6).map(function (v) {
return v.thumbnail_url
? '<span class="vyt-strip-cell"><img src="' + esc(v.thumbnail_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.style.display=\'none\'"></span>'
: '';
}).join('');
var sub = [];
if (ch.handle) sub.push(esc(ch.handle));
if (ch.video_count != null) sub.push(esc(ch.video_count) + ' videos');
return '<div class="vyt-chip" data-vyt-channel="' + esc(ch.youtube_id) + '">' +
'<div class="vyt-chip-head">' +
avatar(ch, 'vyt-chip-avatar') +
'<div class="vyt-chip-meta">' +
'<span class="vyt-chip-badge">YouTube channel</span>' +
'<span class="vyt-chip-title">' + esc(ch.title) + '</span>' +
(sub.length ? '<span class="vyt-chip-sub">' + sub.join(' · ') + '</span>' : '') +
'</div>' +
followBtn(following) +
'</div>' +
(strip ? '<div class="vyt-strip">' + strip + '</div>' : '') +
'</div>';
}
function followBtn(following) {
return '<button class="vyt-follow' + (following ? ' vyt-follow--on' : '') + '" type="button" ' +
'data-vyt-follow>' + (following ? '✓ Following' : '+ Follow') + '</button>';
}
function post(url, body) {
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}) }).then(function (r) { return r.ok ? r.json() : null; });
}
function follow(channel) { return post('/api/video/youtube/follow', { channel: channel }); }
function unfollow(youtubeId) { return post('/api/video/youtube/unfollow', { youtube_id: youtubeId }); }
function removeWish(scope, sourceId) {
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
}
function resolve(ref) {
return fetch('/api/video/youtube/resolve?url=' + encodeURIComponent(ref),
{ headers: { Accept: 'application/json' } }).then(function (r) { return r.ok ? r.json() : (r.status === 404 ? r.json() : null); });
}
window.VideoYoutube = {
esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar,
videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
follow: follow, unfollow: unfollow, removeWish: removeWish, resolve: resolve,
};
})();