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:
parent
d30149db14
commit
b440f2bcdc
6 changed files with 441 additions and 16 deletions
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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">' +
|
||||
'Couldn’t 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();
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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">✕</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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
112
webui/static/video/video-youtube.js
Normal file
112
webui/static/video/video-youtube.js
Normal 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">✕</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,
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue