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 = [];
|
||||
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 subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('<span>' + subs + ' subscribers</span>');
|
||||
// NB: no "N videos" — YouTube doesn't expose a reliable total, and what we
|
||||
// fetch is just the recent page (the cap), so showing it would mislead.
|
||||
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>');
|
||||
if (isPl) {
|
||||
// A playlist IS a known, fixed list, so its count is real (unlike a
|
||||
// channel's total). Owner shows as a genre tag below.
|
||||
if (d.video_count != null) meta.push('<span>' + esc(d.video_count) + ' videos</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('');
|
||||
renderActions(d);
|
||||
var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = '';
|
||||
|
|
@ -869,6 +875,7 @@
|
|||
function renderViewToggle() {
|
||||
var host = q('[data-vd-view-toggle]');
|
||||
if (!host) return;
|
||||
if (data && data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no view toggle
|
||||
host.innerHTML = VIEWS.map(function (v) {
|
||||
return '<button class="vd-vt-btn' + (v.id === seasonView ? ' vd-vt-btn--active' : '') +
|
||||
'" type="button" data-vd-view="' + v.id + '" title="' + v.label + '">' +
|
||||
|
|
@ -879,6 +886,7 @@
|
|||
function renderSeasonNav() {
|
||||
var host = q('[data-vd-season-nav]');
|
||||
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
|
||||
// year nav — which still honours the view toggle (rail posters, timeline,
|
||||
// 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'); });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (!data || !data.seasons) return null;
|
||||
for (var i = 0; i < data.seasons.length; i++) {
|
||||
|
|
@ -1655,6 +1708,7 @@
|
|||
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 === 'channel') loadChannel(e.detail.id);
|
||||
else if (e.detail.kind === 'playlist') loadPlaylist(e.detail.id);
|
||||
// 'person' is handled by video-person.js (same event).
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
var wired = false;
|
||||
var trendingCache = null; // null = not fetched; [] = fetched/empty
|
||||
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 esc(s) {
|
||||
|
|
@ -188,8 +189,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
// A pasted YouTube channel link → resolve + render a Follow chip instead of
|
||||
// a normal title search (the obscure-channel entry point).
|
||||
// A pasted YouTube channel OR playlist link → resolve + render a Follow chip
|
||||
// instead of a normal title search (the obscure-channel / playlist entry point).
|
||||
function runChannel(ref) {
|
||||
var seq = ++reqSeq;
|
||||
show('[data-video-search-loading]', true);
|
||||
|
|
@ -197,16 +198,22 @@
|
|||
if (seq !== reqSeq) return;
|
||||
show('[data-video-search-loading]', false);
|
||||
show('[data-video-search-hint]', false);
|
||||
show('[data-video-search-empty]', 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>';
|
||||
if (d && d.success && d.playlist) {
|
||||
lastPlaylist = d.playlist; lastChannel = null;
|
||||
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
|
||||
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube playlist</h2>' +
|
||||
'<div class="vyt-search">' + VideoYoutube.playlistCard(d.playlist, d.following) + '</div></div>';
|
||||
return;
|
||||
}
|
||||
lastChannel = d.channel;
|
||||
show('[data-video-search-empty]', false);
|
||||
if (!d || !d.success || !d.channel) {
|
||||
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">' +
|
||||
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channel</h2>' +
|
||||
'<div class="vyt-search">' + VideoYoutube.searchCard(d.channel, d.following) + '</div></div>';
|
||||
|
|
@ -226,7 +233,7 @@
|
|||
showIdle(); // back to the trending rail
|
||||
return;
|
||||
}
|
||||
if (window.VideoYoutube && VideoYoutube.isChannelRef(q)) {
|
||||
if (window.VideoYoutube && (VideoYoutube.isChannelRef(q) || VideoYoutube.isPlaylistRef(q))) {
|
||||
timer = setTimeout(function () { runChannel(q); }, 360);
|
||||
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) {
|
||||
var kind = card.getAttribute('data-vsr-open');
|
||||
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;
|
||||
var fb = e.target.closest('[data-vyt-follow]');
|
||||
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]');
|
||||
if (ytc && results.contains(ytc)) {
|
||||
e.preventDefault();
|
||||
|
|
@ -287,6 +318,13 @@
|
|||
{ detail: { kind: 'channel', source: 'youtube', id: ytc.getAttribute('data-vyt-open-channel') } }));
|
||||
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]');
|
||||
if (!card || !results.contains(card)) return;
|
||||
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;
|
||||
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); }
|
||||
/* 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-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);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
|
||||
if (p.length < 3) return null;
|
||||
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]) };
|
||||
}
|
||||
var id = parseInt(p[2], 10);
|
||||
|
|
@ -354,6 +354,7 @@
|
|||
else if (d.kind === 'show') navigate('video-show-detail');
|
||||
else if (d.kind === 'person') navigate('video-person-detail');
|
||||
else if (d.kind === 'channel') navigate('video-show-detail'); // channels reuse the show detail page
|
||||
else if (d.kind === 'playlist') navigate('video-show-detail'); // playlists too (flat list)
|
||||
else return;
|
||||
if (d._replace) {
|
||||
// A redirect (e.g. a tmdb link that's actually owned) → replace the
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@
|
|||
'</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 esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
|
|
@ -122,7 +134,7 @@
|
|||
var grid = $('[data-vwlp-grid]');
|
||||
if (state.tab === 'channel') {
|
||||
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;
|
||||
}
|
||||
grid.classList.remove('vyt-wgrid');
|
||||
|
|
@ -144,12 +156,12 @@
|
|||
.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);
|
||||
var all = ((d && d.channels) || []).concat((d && d.playlists) || []); // channels + playlists
|
||||
state.channelCount = all.length;
|
||||
var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = all.length;
|
||||
render(all);
|
||||
updatePagination(null);
|
||||
updateEmpty(chans.length);
|
||||
updateEmpty(all.length);
|
||||
})
|
||||
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); });
|
||||
}
|
||||
|
|
@ -253,6 +265,16 @@
|
|||
}).catch(function () { unf.disabled = false; });
|
||||
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;
|
||||
var ch = e.target.closest('[data-vyt-open-channel]');
|
||||
if (ch) {
|
||||
|
|
@ -261,6 +283,13 @@
|
|||
detail: { kind: 'channel', source: 'youtube', id: ch.getAttribute('data-vyt-open-channel') } }));
|
||||
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]');
|
||||
if (!card) return;
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@
|
|||
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) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
|
||||
|
|
@ -101,6 +110,28 @@
|
|||
'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) {
|
||||
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
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 } });
|
||||
}
|
||||
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) {
|
||||
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
|
||||
}
|
||||
|
|
@ -161,10 +200,11 @@
|
|||
}
|
||||
|
||||
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,
|
||||
videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
|
||||
follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve,
|
||||
videoCard: videoCard, searchCard: searchCard, playlistCard: playlistCard, followBtn: followBtn,
|
||||
follow: follow, unfollow: unfollow, followPlaylist: followPlaylist, unfollowPlaylist: unfollowPlaylist,
|
||||
removeWish: removeWish, addVideos: addVideos, resolve: resolve,
|
||||
searchChannels: searchChannels, channelResultCard: channelResultCard,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue