From b440f2bcdc825d7625d58e15ebef439ac268f29c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 00:47:24 -0700 Subject: [PATCH] =?UTF-8?q?YouTube=20channels=20(4/4):=20UI=20=E2=80=94=20?= =?UTF-8?q?paste-to-follow,=20channel=20feeds,=20watchlist=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- webui/index.html | 9 +++ webui/static/video/video-search.js | 57 +++++++++++++ webui/static/video/video-side.css | 92 +++++++++++++++++++++ webui/static/video/video-watchlist.js | 82 +++++++++++++++++-- webui/static/video/video-wishlist.js | 105 +++++++++++++++++++++--- webui/static/video/video-youtube.js | 112 ++++++++++++++++++++++++++ 6 files changed, 441 insertions(+), 16 deletions(-) create mode 100644 webui/static/video/video-youtube.js diff --git a/webui/index.html b/webui/index.html index a683ebf0..0a6534e9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1021,6 +1021,9 @@ + @@ -1073,6 +1076,9 @@ + @@ -9718,6 +9724,9 @@ + + diff --git a/webui/static/video/video-search.js b/webui/static/video/video-search.js index c5f8fb18..505ed236 100644 --- a/webui/static/video/video-search.js +++ b/webui/static/video/video-search.js @@ -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 = '
' + + 'Couldn’t read that channel. Paste a channel link like ' + + 'youtube.com/@handle.
'; + return; + } + lastChannel = d.channel; + show('[data-video-search-empty]', false); + host.innerHTML = '

' + + 'YouTube channel

' + + '
'; + }).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(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index dc94829f..1643a7f9 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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); } diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js index df2ace66..186ae4ce 100644 --- a/webui/static/video/video-watchlist.js +++ b/webui/static/video/video-watchlist.js @@ -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 '
' + + '' + av + '' + + '' + + '
' + + esc(ch.title) + '' + n + ' video' + (n === 1 ? '' : 's') + + '
'; + } + 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 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); diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index 3277402b..8cfa3bd9 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -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 '
' + + '' + + '
' + vids + '
' + + '
'; + } 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); diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js new file mode 100644 index 00000000..bd8fc9da --- /dev/null +++ b/webui/static/video/video-youtube.js @@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ''; + } + return '' + esc(initials(ch && ch.title)) + ''; + } + + // A single wished video tile (thumbnail, title, date, remove). + function videoCard(v) { + var thumb = v.still_url + ? '' + : ''; + var date = fmtDate(v.published_at); + return '
' + + '
' + thumb + + '
' + + '
' + + '' + esc(v.title || 'Untitled') + '' + + (date ? '' + esc(date) + '' : '') + + '
'; + } + + // 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 + ? '' + : ''; + }).join(''); + var sub = []; + if (ch.handle) sub.push(esc(ch.handle)); + if (ch.video_count != null) sub.push(esc(ch.video_count) + ' videos'); + return '
' + + '
' + + avatar(ch, 'vyt-chip-avatar') + + '
' + + 'YouTube channel' + + '' + esc(ch.title) + '' + + (sub.length ? '' + sub.join(' · ') + '' : '') + + '
' + + followBtn(following) + + '
' + + (strip ? '
' + strip + '
' : '') + + '
'; + } + + function followBtn(following) { + return ''; + } + + 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, + }; +})();