YouTube playlists on the watchlist — frontend (search, detail, watchlist)
- search: paste a playlist link → it resolves to a 'YouTube playlist' chip (cover, owner, count) with Add-to-watchlist; clicking the chip opens it. (isPlaylistRef + playlistCard + followPlaylist/unfollowPlaylist helpers.) - routing: /video-detail/youtube/playlist/<PL…> deep-links like a channel (string id); the open-detail event handles kind='playlist'. - detail view: a FLAT list in the curator's order (no year-seasons, no season nav / view toggle, no catalog streaming — it's a partial set). Billboard shows 'Playlist · N videos · owner'. Reuses the show-detail shell + the new duration/views cards. - watchlist: followed playlists sit beside channels in the Channels tab (rounded square + a list badge), open on click, ✕ to unfollow. Live-validated (Lex Fridman playlist resolves + renders in order). The 2 shell test 'failures' are the pre-existing window.-assertion (no new globals added).
This commit is contained in:
parent
28035f0d70
commit
ef3d4cfdb4
6 changed files with 193 additions and 26 deletions
|
|
@ -164,13 +164,19 @@
|
||||||
|
|
||||||
var meta = [];
|
var meta = [];
|
||||||
if (d.source === 'youtube') {
|
if (d.source === 'youtube') {
|
||||||
meta.push('<span class="vd-status vd-status--yt">YouTube</span>');
|
var isPl = d.kind === 'playlist';
|
||||||
|
meta.push('<span class="vd-status vd-status--yt">' + (isPl ? 'Playlist' : 'YouTube') + '</span>');
|
||||||
var yc = window.VideoYoutube;
|
var yc = window.VideoYoutube;
|
||||||
var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('<span>' + subs + ' subscribers</span>');
|
if (isPl) {
|
||||||
// NB: no "N videos" — YouTube doesn't expose a reliable total, and what we
|
// A playlist IS a known, fixed list, so its count is real (unlike a
|
||||||
// fetch is just the recent page (the cap), so showing it would mislead.
|
// channel's total). Owner shows as a genre tag below.
|
||||||
var views = yc && yc.compactCount(d.view_count); if (views) meta.push('<span>' + views + ' views</span>');
|
if (d.video_count != null) meta.push('<span>' + esc(d.video_count) + ' videos</span>');
|
||||||
if (d.handle) meta.push('<span>' + esc(d.handle) + '</span>');
|
} else {
|
||||||
|
var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('<span>' + subs + ' subscribers</span>');
|
||||||
|
// NB: no "N videos" for a CHANNEL — YouTube doesn't expose a reliable total.
|
||||||
|
var views = yc && yc.compactCount(d.view_count); if (views) meta.push('<span>' + views + ' views</span>');
|
||||||
|
if (d.handle) meta.push('<span>' + esc(d.handle) + '</span>');
|
||||||
|
}
|
||||||
var mm = q('[data-vd-meta]'); if (mm) mm.innerHTML = meta.join('');
|
var mm = q('[data-vd-meta]'); if (mm) mm.innerHTML = meta.join('');
|
||||||
renderActions(d);
|
renderActions(d);
|
||||||
var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = '';
|
var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = '';
|
||||||
|
|
@ -869,6 +875,7 @@
|
||||||
function renderViewToggle() {
|
function renderViewToggle() {
|
||||||
var host = q('[data-vd-view-toggle]');
|
var host = q('[data-vd-view-toggle]');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
if (data && data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no view toggle
|
||||||
host.innerHTML = VIEWS.map(function (v) {
|
host.innerHTML = VIEWS.map(function (v) {
|
||||||
return '<button class="vd-vt-btn' + (v.id === seasonView ? ' vd-vt-btn--active' : '') +
|
return '<button class="vd-vt-btn' + (v.id === seasonView ? ' vd-vt-btn--active' : '') +
|
||||||
'" type="button" data-vd-view="' + v.id + '" title="' + v.label + '">' +
|
'" type="button" data-vd-view="' + v.id + '" title="' + v.label + '">' +
|
||||||
|
|
@ -879,6 +886,7 @@
|
||||||
function renderSeasonNav() {
|
function renderSeasonNav() {
|
||||||
var host = q('[data-vd-season-nav]');
|
var host = q('[data-vd-season-nav]');
|
||||||
if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; }
|
if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; }
|
||||||
|
if (data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no season nav
|
||||||
if (data.source === 'youtube') { // channels: search + sort controls above the
|
if (data.source === 'youtube') { // channels: search + sort controls above the
|
||||||
// year nav — which still honours the view toggle (rail posters, timeline,
|
// year nav — which still honours the view toggle (rail posters, timeline,
|
||||||
// tabs, list). Flat mode (search / most-viewed / longest) hides the nav.
|
// tabs, list). Flat mode (search / most-viewed / longest) hides the nav.
|
||||||
|
|
@ -1539,6 +1547,51 @@
|
||||||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load channel'); });
|
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load channel'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A playlist → a single FLAT season in the curator's order (a partial set, so
|
||||||
|
// no year-grouping, no season nav, no catalog streaming).
|
||||||
|
function ytPlaylistToShow(resp) {
|
||||||
|
var pl = resp.playlist || {};
|
||||||
|
var vids = pl.videos || [];
|
||||||
|
ytVideoMap = {};
|
||||||
|
vids.forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
|
||||||
|
var season = { season_number: 1, title: 'Videos', poster_url: ytProx(pl.thumbnail_url),
|
||||||
|
episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
|
||||||
|
return { kind: 'playlist', source: 'youtube', id: pl.playlist_id, title: pl.title || 'Playlist',
|
||||||
|
overview: '', poster_url: ytProx(pl.thumbnail_url), has_poster: !!pl.thumbnail_url,
|
||||||
|
backdrop_url: ytProx(pl.thumbnail_url), has_backdrop: !!pl.thumbnail_url,
|
||||||
|
genres: pl.channel_title ? [pl.channel_title] : [], handle: null,
|
||||||
|
subscriber_count: null, view_count: null, video_count: pl.video_count,
|
||||||
|
following: !!resp.following, _playlist: pl, seasons: [season], season_count: 1,
|
||||||
|
episode_total: vids.length, episode_owned: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPlaylist(id) {
|
||||||
|
currentKind = 'show'; currentSource = 'youtube';
|
||||||
|
if (currentId !== id) artAttemptedFor = null;
|
||||||
|
currentId = id;
|
||||||
|
if (!root()) return;
|
||||||
|
ytFilter = { q: '', sort: 'newest' };
|
||||||
|
ytCancelLoad();
|
||||||
|
showLoading(true); resetExtras(); showEpSyncing(false);
|
||||||
|
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
|
||||||
|
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
|
||||||
|
ytResetPlaylists();
|
||||||
|
fetch('/api/video/youtube/playlist/' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (resp) {
|
||||||
|
showLoading(false);
|
||||||
|
if (!resp || !resp.success || !resp.playlist) { setText('[data-vd-title]', 'Playlist unavailable'); return; }
|
||||||
|
if (currentId !== id) return;
|
||||||
|
data = ytPlaylistToShow(resp); menuOpen = false; missingOnly = false;
|
||||||
|
selectedSeason = 1;
|
||||||
|
var mt = q('[data-vd-missing-toggle]'); if (mt) { mt.hidden = true; mt.classList.remove('vd-missing-toggle--on'); }
|
||||||
|
renderBillboard(data); renderViewToggle(); renderSeasonNav(); renderEpisodes();
|
||||||
|
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
||||||
|
if (sub) sub.scrollTop = 0;
|
||||||
|
})
|
||||||
|
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load playlist'); });
|
||||||
|
}
|
||||||
|
|
||||||
function ytFindEp(id) {
|
function ytFindEp(id) {
|
||||||
if (!data || !data.seasons) return null;
|
if (!data || !data.seasons) return null;
|
||||||
for (var i = 0; i < data.seasons.length; i++) {
|
for (var i = 0; i < data.seasons.length; i++) {
|
||||||
|
|
@ -1655,6 +1708,7 @@
|
||||||
if (e.detail.kind === 'movie') loadMovie(e.detail.id, src);
|
if (e.detail.kind === 'movie') loadMovie(e.detail.id, src);
|
||||||
else if (e.detail.kind === 'show') loadShow(e.detail.id, src);
|
else if (e.detail.kind === 'show') loadShow(e.detail.id, src);
|
||||||
else if (e.detail.kind === 'channel') loadChannel(e.detail.id);
|
else if (e.detail.kind === 'channel') loadChannel(e.detail.id);
|
||||||
|
else if (e.detail.kind === 'playlist') loadPlaylist(e.detail.id);
|
||||||
// 'person' is handled by video-person.js (same event).
|
// 'person' is handled by video-person.js (same event).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
var wired = false;
|
var wired = false;
|
||||||
var trendingCache = null; // null = not fetched; [] = fetched/empty
|
var trendingCache = null; // null = not fetched; [] = fetched/empty
|
||||||
var lastChannel = null; // resolved YouTube channel awaiting a Follow
|
var lastChannel = null; // resolved YouTube channel awaiting a Follow
|
||||||
|
var lastPlaylist = null; // resolved YouTube playlist awaiting Add-to-watchlist
|
||||||
|
|
||||||
function $(sel) { return document.querySelector(sel); }
|
function $(sel) { return document.querySelector(sel); }
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
|
|
@ -188,8 +189,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pasted YouTube channel link → resolve + render a Follow chip instead of
|
// A pasted YouTube channel OR playlist link → resolve + render a Follow chip
|
||||||
// a normal title search (the obscure-channel entry point).
|
// instead of a normal title search (the obscure-channel / playlist entry point).
|
||||||
function runChannel(ref) {
|
function runChannel(ref) {
|
||||||
var seq = ++reqSeq;
|
var seq = ++reqSeq;
|
||||||
show('[data-video-search-loading]', true);
|
show('[data-video-search-loading]', true);
|
||||||
|
|
@ -197,16 +198,22 @@
|
||||||
if (seq !== reqSeq) return;
|
if (seq !== reqSeq) return;
|
||||||
show('[data-video-search-loading]', false);
|
show('[data-video-search-loading]', false);
|
||||||
show('[data-video-search-hint]', false);
|
show('[data-video-search-hint]', false);
|
||||||
|
show('[data-video-search-empty]', false);
|
||||||
var host = $('[data-video-search-results]'); if (!host) return;
|
var host = $('[data-video-search-results]'); if (!host) return;
|
||||||
if (!d || !d.success || !d.channel) {
|
if (d && d.success && d.playlist) {
|
||||||
show('[data-video-search-empty]', false);
|
lastPlaylist = d.playlist; lastChannel = null;
|
||||||
host.innerHTML = '<div class="vsr-group"><div class="vyt-miss">' +
|
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
|
||||||
'Couldn’t read that channel. Paste a channel link like ' +
|
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube playlist</h2>' +
|
||||||
'<code>youtube.com/@handle</code>.</div></div>';
|
'<div class="vyt-search">' + VideoYoutube.playlistCard(d.playlist, d.following) + '</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastChannel = d.channel;
|
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 link. Paste a channel link like ' +
|
||||||
|
'<code>youtube.com/@handle</code> or a playlist link.</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastChannel = d.channel; lastPlaylist = null;
|
||||||
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
|
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
|
||||||
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channel</h2>' +
|
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channel</h2>' +
|
||||||
'<div class="vyt-search">' + VideoYoutube.searchCard(d.channel, d.following) + '</div></div>';
|
'<div class="vyt-search">' + VideoYoutube.searchCard(d.channel, d.following) + '</div></div>';
|
||||||
|
|
@ -226,7 +233,7 @@
|
||||||
showIdle(); // back to the trending rail
|
showIdle(); // back to the trending rail
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (window.VideoYoutube && VideoYoutube.isChannelRef(q)) {
|
if (window.VideoYoutube && (VideoYoutube.isChannelRef(q) || VideoYoutube.isPlaylistRef(q))) {
|
||||||
timer = setTimeout(function () { runChannel(q); }, 360);
|
timer = setTimeout(function () { runChannel(q); }, 360);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +262,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add / remove the resolved playlist chip to the watchlist.
|
||||||
|
function togglePlaylistFollow(btn) {
|
||||||
|
if (!lastPlaylist) 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.unfollowPlaylist(lastPlaylist.playlist_id).then(function () {
|
||||||
|
btn.classList.remove('vyt-follow--on'); btn.innerHTML = '+ Follow'; done();
|
||||||
|
}).catch(function () { btn.disabled = false; });
|
||||||
|
} else {
|
||||||
|
VideoYoutube.followPlaylist(lastPlaylist).then(function (d) {
|
||||||
|
if (d && d.success) {
|
||||||
|
btn.classList.add('vyt-follow--on'); btn.innerHTML = '✓ Following';
|
||||||
|
if (typeof showToast === 'function')
|
||||||
|
showToast('Added ' + lastPlaylist.title + ' to watchlist', 'success');
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
}).catch(function () { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCard(card) {
|
function openCard(card) {
|
||||||
var kind = card.getAttribute('data-vsr-open');
|
var kind = card.getAttribute('data-vsr-open');
|
||||||
var id = parseInt(card.getAttribute('data-vsr-id'), 10);
|
var id = parseInt(card.getAttribute('data-vsr-id'), 10);
|
||||||
|
|
@ -280,6 +309,8 @@
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
var fb = e.target.closest('[data-vyt-follow]');
|
var fb = e.target.closest('[data-vyt-follow]');
|
||||||
if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; }
|
if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; }
|
||||||
|
var pfb = e.target.closest('[data-vyt-follow-playlist]');
|
||||||
|
if (pfb && results.contains(pfb)) { e.preventDefault(); togglePlaylistFollow(pfb); return; }
|
||||||
var ytc = e.target.closest('[data-vyt-open-channel]');
|
var ytc = e.target.closest('[data-vyt-open-channel]');
|
||||||
if (ytc && results.contains(ytc)) {
|
if (ytc && results.contains(ytc)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -287,6 +318,13 @@
|
||||||
{ detail: { kind: 'channel', source: 'youtube', id: ytc.getAttribute('data-vyt-open-channel') } }));
|
{ detail: { kind: 'channel', source: 'youtube', id: ytc.getAttribute('data-vyt-open-channel') } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var ytp = e.target.closest('[data-vyt-playlist]'); // the chip (not its button) → open detail
|
||||||
|
if (ytp && results.contains(ytp)) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
|
||||||
|
{ detail: { kind: 'playlist', source: 'youtube', id: ytp.getAttribute('data-vyt-playlist') } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
var card = e.target.closest('[data-vsr-open]');
|
var card = e.target.closest('[data-vsr-open]');
|
||||||
if (!card || !results.contains(card)) return;
|
if (!card || !results.contains(card)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -2718,6 +2718,11 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
||||||
.vyt-wcard-art { display: block; width: 118px; height: 118px; border-radius: 50%; overflow: hidden;
|
.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; }
|
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-art:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(255, 59, 59, 0.35); }
|
||||||
|
/* Followed-playlist cards: a rounded square (not a circle) + a stacked-list badge */
|
||||||
|
.vyt-wcard--pl .vyt-wcard-art { position: relative; border-radius: 16px; }
|
||||||
|
.vyt-wcard-pl-ic { position: absolute; right: 7px; bottom: 7px; z-index: 2;
|
||||||
|
width: 26px; height: 26px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.74); color: #fff; font-size: 13px; }
|
||||||
.vyt-wcard-avatar { width: 100%; height: 100%; object-fit: cover; font-size: 34px; }
|
.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;
|
.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);
|
border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.6); color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
|
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
|
||||||
if (p.length < 3) return null;
|
if (p.length < 3) return null;
|
||||||
var kind = p[1];
|
var kind = p[1];
|
||||||
if (kind === 'channel') { // YouTube channel ids are strings (UC…), not numeric
|
if (kind === 'channel' || kind === 'playlist') { // YouTube ids are strings (UC… / PL…), not numeric
|
||||||
return { source: decodeURIComponent(p[0]), kind: kind, id: decodeURIComponent(p[2]) };
|
return { source: decodeURIComponent(p[0]), kind: kind, id: decodeURIComponent(p[2]) };
|
||||||
}
|
}
|
||||||
var id = parseInt(p[2], 10);
|
var id = parseInt(p[2], 10);
|
||||||
|
|
@ -354,6 +354,7 @@
|
||||||
else if (d.kind === 'show') navigate('video-show-detail');
|
else if (d.kind === 'show') navigate('video-show-detail');
|
||||||
else if (d.kind === 'person') navigate('video-person-detail');
|
else if (d.kind === 'person') navigate('video-person-detail');
|
||||||
else if (d.kind === 'channel') navigate('video-show-detail'); // channels reuse the show detail page
|
else if (d.kind === 'channel') navigate('video-show-detail'); // channels reuse the show detail page
|
||||||
|
else if (d.kind === 'playlist') navigate('video-show-detail'); // playlists too (flat list)
|
||||||
else return;
|
else return;
|
||||||
if (d._replace) {
|
if (d._replace) {
|
||||||
// A redirect (e.g. a tmdb link that's actually owned) → replace the
|
// A redirect (e.g. a tmdb link that's actually owned) → replace the
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@
|
||||||
'</span></div></div>';
|
'</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Followed playlists sit beside channels in the same grid; the ✕ unfollows.
|
||||||
|
function playlistCard(pl) {
|
||||||
|
var av = window.VideoYoutube
|
||||||
|
? VideoYoutube.avatar({ poster_url: pl.poster_url, title: pl.title }, 'vyt-wcard-avatar') : '';
|
||||||
|
return '<div class="vyt-wcard vyt-wcard--pl" data-vyt-open-playlist="' + esc(pl.playlist_id) + '" title="Open playlist">' +
|
||||||
|
'<div class="vyt-wcard-art">' + av + '<span class="vyt-wcard-pl-ic" aria-hidden="true">▤</span></div>' +
|
||||||
|
'<button class="vyt-wcard-unfollow" type="button" data-vyt-wunfollow-playlist="' + esc(pl.playlist_id) +
|
||||||
|
'" title="Unfollow">✕</button>' +
|
||||||
|
'<div class="vyt-wcard-info"><span class="vyt-wcard-title" title="' + esc(pl.title) + '">' +
|
||||||
|
esc(pl.title) + '</span><span class="vyt-wcard-meta">Playlist</span></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
function $(s, r) { return (r || document).querySelector(s); }
|
function $(s, r) { return (r || document).querySelector(s); }
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s)
|
return String(s == null ? '' : s)
|
||||||
|
|
@ -122,7 +134,7 @@
|
||||||
var grid = $('[data-vwlp-grid]');
|
var grid = $('[data-vwlp-grid]');
|
||||||
if (state.tab === 'channel') {
|
if (state.tab === 'channel') {
|
||||||
grid.classList.add('vyt-wgrid');
|
grid.classList.add('vyt-wgrid');
|
||||||
grid.innerHTML = items.map(channelCard).join('');
|
grid.innerHTML = items.map(function (it) { return it.playlist_id ? playlistCard(it) : channelCard(it); }).join('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.classList.remove('vyt-wgrid');
|
grid.classList.remove('vyt-wgrid');
|
||||||
|
|
@ -144,12 +156,12 @@
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (ld) ld.classList.add('hidden');
|
if (ld) ld.classList.add('hidden');
|
||||||
var chans = (d && d.channels) || [];
|
var all = ((d && d.channels) || []).concat((d && d.playlists) || []); // channels + playlists
|
||||||
state.channelCount = chans.length;
|
state.channelCount = all.length;
|
||||||
var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = chans.length;
|
var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = all.length;
|
||||||
render(chans);
|
render(all);
|
||||||
updatePagination(null);
|
updatePagination(null);
|
||||||
updateEmpty(chans.length);
|
updateEmpty(all.length);
|
||||||
})
|
})
|
||||||
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); });
|
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); });
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +265,16 @@
|
||||||
}).catch(function () { unf.disabled = false; });
|
}).catch(function () { unf.disabled = false; });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var punf = e.target.closest('[data-vyt-wunfollow-playlist]');
|
||||||
|
if (punf && window.VideoYoutube) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
punf.disabled = true;
|
||||||
|
VideoYoutube.unfollowPlaylist(punf.getAttribute('data-vyt-wunfollow-playlist')).then(function () {
|
||||||
|
if (typeof showToast === 'function') showToast('Unfollowed', 'info');
|
||||||
|
load();
|
||||||
|
}).catch(function () { punf.disabled = false; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
var ch = e.target.closest('[data-vyt-open-channel]');
|
var ch = e.target.closest('[data-vyt-open-channel]');
|
||||||
if (ch) {
|
if (ch) {
|
||||||
|
|
@ -261,6 +283,13 @@
|
||||||
detail: { kind: 'channel', source: 'youtube', id: ch.getAttribute('data-vyt-open-channel') } }));
|
detail: { kind: 'channel', source: 'youtube', id: ch.getAttribute('data-vyt-open-channel') } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var pl = e.target.closest('[data-vyt-open-playlist]');
|
||||||
|
if (pl) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
|
||||||
|
detail: { kind: 'playlist', source: 'youtube', id: pl.getAttribute('data-vyt-open-playlist') } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
var card = e.target.closest('[data-vwlp-open]');
|
var card = e.target.closest('[data-vwlp-open]');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@
|
||||||
return /youtube\.com\/(@[\w.\-]+|channel\/UC[\w\-]+|c\/[\w.\-]+|user\/[\w.\-]+)/i.test(q);
|
return /youtube\.com\/(@[\w.\-]+|channel\/UC[\w\-]+|c\/[\w.\-]+|user\/[\w.\-]+)/i.test(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pasted YouTube *playlist* link (or bare PL/OL/FL/UU id). Rejects mixes (RD…)
|
||||||
|
// and personal lists (WL/LL) — same rule as the backend parse_playlist_id.
|
||||||
|
function isPlaylistRef(q) {
|
||||||
|
q = (q || '').trim();
|
||||||
|
var m = /[?&]list=([A-Za-z0-9_-]+)/.exec(q);
|
||||||
|
var id = m ? m[1] : (/^(PL|OL|FL|UU)[A-Za-z0-9_-]{8,}$/.test(q) ? q : null);
|
||||||
|
return !!(id && !/^(RD|UL|LL|WL)/.test(id));
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
|
var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
|
||||||
|
|
@ -101,6 +110,28 @@
|
||||||
'data-vyt-follow>' + (following ? '✓ Following' : '+ Follow') + '</button>';
|
'data-vyt-follow>' + (following ? '✓ Following' : '+ Follow') + '</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pasted-playlist result chip (mirrors searchCard): cover, title, owner +
|
||||||
|
// count, and an Add-to-watchlist toggle (data-vyt-follow-playlist).
|
||||||
|
function playlistCard(pl, following) {
|
||||||
|
var cover = pl.thumbnail_url
|
||||||
|
? '<img class="vyt-chip-avatar" src="' + esc(img(pl.thumbnail_url)) + '" alt="" loading="lazy" ' +
|
||||||
|
'onerror="this.outerHTML=\'<span class="vyt-chip-avatar vyt-avatar--ph">▤</span>\'">'
|
||||||
|
: '<span class="vyt-chip-avatar vyt-avatar--ph">▤</span>';
|
||||||
|
var sub = [];
|
||||||
|
if (pl.channel_title) sub.push(esc(pl.channel_title));
|
||||||
|
if (pl.video_count != null) sub.push(esc(pl.video_count) + ' videos');
|
||||||
|
return '<div class="vyt-chip" data-vyt-playlist="' + esc(pl.playlist_id) + '">' +
|
||||||
|
'<div class="vyt-chip-head">' + cover +
|
||||||
|
'<div class="vyt-chip-meta">' +
|
||||||
|
'<span class="vyt-chip-badge">YouTube playlist</span>' +
|
||||||
|
'<span class="vyt-chip-title">' + esc(pl.title) + '</span>' +
|
||||||
|
(sub.length ? '<span class="vyt-chip-sub">' + sub.join(' · ') + '</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="vyt-follow' + (following ? ' vyt-follow--on' : '') + '" type="button" ' +
|
||||||
|
'data-vyt-follow-playlist>' + (following ? '✓ Following' : '+ Follow') + '</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
function post(url, body) {
|
function post(url, body) {
|
||||||
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body || {}) }).then(function (r) { return r.ok ? r.json() : null; });
|
body: JSON.stringify(body || {}) }).then(function (r) { return r.ok ? r.json() : null; });
|
||||||
|
|
@ -114,6 +145,14 @@
|
||||||
{ channel: { youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url } });
|
{ channel: { youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url } });
|
||||||
}
|
}
|
||||||
function unfollow(youtubeId) { return post('/api/video/youtube/unfollow', { youtube_id: youtubeId }); }
|
function unfollow(youtubeId) { return post('/api/video/youtube/unfollow', { youtube_id: youtubeId }); }
|
||||||
|
function followPlaylist(pl) {
|
||||||
|
var p = pl || {};
|
||||||
|
return post('/api/video/youtube/playlist/follow', { playlist: {
|
||||||
|
playlist_id: p.playlist_id, title: p.title, thumbnail_url: p.thumbnail_url } });
|
||||||
|
}
|
||||||
|
function unfollowPlaylist(playlistId) {
|
||||||
|
return post('/api/video/youtube/playlist/unfollow', { playlist_id: playlistId });
|
||||||
|
}
|
||||||
function removeWish(scope, sourceId) {
|
function removeWish(scope, sourceId) {
|
||||||
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
|
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
|
||||||
}
|
}
|
||||||
|
|
@ -161,10 +200,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.VideoYoutube = {
|
window.VideoYoutube = {
|
||||||
esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar, img: img,
|
esc: esc, isChannelRef: isChannelRef, isPlaylistRef: isPlaylistRef, fmtDate: fmtDate, avatar: avatar, img: img,
|
||||||
fmtDuration: fmtDuration, compactCount: compactCount,
|
fmtDuration: fmtDuration, compactCount: compactCount,
|
||||||
videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
|
videoCard: videoCard, searchCard: searchCard, playlistCard: playlistCard, followBtn: followBtn,
|
||||||
follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve,
|
follow: follow, unfollow: unfollow, followPlaylist: followPlaylist, unfollowPlaylist: unfollowPlaylist,
|
||||||
|
removeWish: removeWish, addVideos: addVideos, resolve: resolve,
|
||||||
searchChannels: searchChannels, channelResultCard: channelResultCard,
|
searchChannels: searchChannels, channelResultCard: channelResultCard,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue