Merge YouTube channels into the TV-show detail page
Per request: a channel now opens the SAME detail page as a TV show instead of a
separate page. A channel renders through the show pipeline — currentKind='show'
(so the show container resolves) with d.kind='channel' + d.source='youtube'
driving content. Every change is an additive 'youtube' branch; the show/movie
path is byte-for-byte unchanged.
- Data transform (ytToShow): upload YEAR = season, video = episode, channel
banner=backdrop, avatar=poster (proxied), tags=genres, subs/videos/views=meta.
- Source-aware seams: bbBackdrop/bbPoster/seasonArt, billboard meta + actions
(Follow + Open-on-YouTube), episodeRow (ytEpisodeRow: Wish toggle), episode
expand (loadEpisodeExtra → /youtube/video full description+stats).
- Channel-only: playlists as a section below episodes (collapsible, lazy-load
their videos with wish toggles); per-video wish syncs across all cards; Follow.
- Routing: kind=channel → navigate('video-show-detail'); deep-link
/video-detail/youtube/channel/<id> still parses (string id). All TMDB-only
sections (cast/ratings/providers/etc.) auto-hide on empty channel data.
Retired the standalone video-channel.js + its subpage. JS/CSS balanced; music
untouched.
This commit is contained in:
parent
9084f1b7bb
commit
284acc8591
6 changed files with 290 additions and 346 deletions
BIN
Issue/1.png
Normal file
BIN
Issue/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
|
|
@ -1172,6 +1172,11 @@
|
|||
<span>Fetching the full episode list from TMDB… missing episodes will fill in shortly.</span>
|
||||
</div>
|
||||
<div class="vd-episodes" data-vd-episodes></div>
|
||||
<!-- YouTube channel playlists (channel source only) — video-detail.js. -->
|
||||
<div class="vd-yt-pl-section" data-vd-yt-pl-section hidden>
|
||||
<h2 class="vd-section-h">Playlists</h2>
|
||||
<div class="vc-playlists" data-vd-yt-playlists></div>
|
||||
</div>
|
||||
<!-- Cast & crew (TMDB) — populated by video-detail.js. -->
|
||||
<div class="vd-cast-section" data-vd-cast-section hidden>
|
||||
<div class="vd-section-head">
|
||||
|
|
@ -1346,58 +1351,6 @@
|
|||
<!-- Person detail (drill-in from a cast/crew member; not a nav page).
|
||||
Bio + filmography, every credit links back into SoulSync. Built
|
||||
by video/video-person.js, styled by .vp-* in video-side.css. -->
|
||||
<!-- YouTube channel detail (isolated; opens like a show/movie via
|
||||
soulsync:video-open-detail {kind:'channel'}). Built by
|
||||
video/video-channel.js, styled by .vc-* in video-side.css. -->
|
||||
<section class="video-subpage" data-video-subpage="video-channel-detail" hidden>
|
||||
<div class="vc-page" data-video-channel>
|
||||
<div class="vc-banner" data-vc-banner aria-hidden="true"></div>
|
||||
<button class="vd-back" type="button" data-video-detail-back>
|
||||
<span aria-hidden="true">←</span> <span data-vd-back-label>Back</span>
|
||||
</button>
|
||||
<div class="vd-loading" data-vc-loading hidden>
|
||||
<div class="loading-spinner"></div><div class="loading-text">Loading channel…</div>
|
||||
</div>
|
||||
<div class="vc-hero">
|
||||
<div class="vc-avatar-wrap">
|
||||
<img class="vc-avatar" data-vc-avatar alt="" hidden>
|
||||
<span class="vc-avatar-ph" data-vc-avatar-ph aria-hidden="true">▶</span>
|
||||
</div>
|
||||
<div class="vc-hero-info">
|
||||
<div class="vc-eyebrow">YouTube channel</div>
|
||||
<h1 class="vc-name" data-vc-name></h1>
|
||||
<div class="vc-meta" data-vc-meta></div>
|
||||
<p class="vc-desc" data-vc-desc></p>
|
||||
<div class="vc-tags" data-vc-tags></div>
|
||||
<div class="vc-actions">
|
||||
<button class="vc-follow" type="button" data-vc-follow>+ Follow</button>
|
||||
<a class="vc-yt-link" data-vc-yt target="_blank" rel="noopener">Open on YouTube ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vc-body">
|
||||
<div class="vc-pl-section" data-vc-pl-section hidden>
|
||||
<h2 class="vc-section-title">Playlists</h2>
|
||||
<div class="vc-playlists" data-vc-playlists></div>
|
||||
</div>
|
||||
<div class="vc-videos-head">
|
||||
<h2 class="vc-videos-title">Videos</h2>
|
||||
<span class="vc-videos-count" data-vc-count></span>
|
||||
<div class="vc-tools">
|
||||
<button class="vc-tool-btn" type="button" data-vc-wished-toggle>Wished only</button>
|
||||
<select class="vc-sort" data-vc-sort aria-label="Sort videos">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="views">Most viewed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vc-videos" data-vc-videos></div>
|
||||
<button class="vc-more" type="button" data-vc-more hidden>Load more</button>
|
||||
<div class="vc-empty" data-vc-empty hidden>No videos found for this channel.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="video-subpage" data-video-subpage="video-person-detail" hidden>
|
||||
<div class="vp-page" data-video-person data-has-bg="0">
|
||||
|
|
@ -9787,8 +9740,6 @@
|
|||
<script src="{{ url_for('static', filename='video/video-calendar.js', v=static_v) }}"></script>
|
||||
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
|
||||
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>
|
||||
<!-- Video YouTube channel page (isolated; opens like a show via open-detail) -->
|
||||
<script src="{{ url_for('static', filename='video/video-channel.js', v=static_v) }}"></script>
|
||||
<!-- Video watchlist page (isolated; tabbed shows / people you follow) -->
|
||||
<script src="{{ url_for('static', filename='video/video-watchlist.js', v=static_v) }}"></script>
|
||||
<!-- Video wishlist page (isolated; tabbed movies / TV, grouped show tree) -->
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
/*
|
||||
* SoulSync — Video YouTube Channel detail page (isolated).
|
||||
*
|
||||
* Opens in-app like a show/movie via soulsync:video-open-detail {kind:'channel',
|
||||
* source:'youtube', id:<channelId>}. Banner hero (avatar, stats, tags, Follow),
|
||||
* a sortable/filterable upload grid with Load-more + per-video inline detail, and
|
||||
* the channel's playlists as collapsible "seasons". Sibling of video-person.js;
|
||||
* listens only for kind==='channel'. Styled by .vc-* in video-side.css.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PAGE_ID = 'video-channel-detail';
|
||||
var YT = function () { return window.VideoYoutube; };
|
||||
var PAGE_SIZE = 60;
|
||||
var state = { id: null, channel: null, videos: {}, all: [], sort: 'newest',
|
||||
wishedOnly: false, limit: PAGE_SIZE, plLoaded: {} };
|
||||
|
||||
function $(s, r) { return (r || document).querySelector(s); }
|
||||
function esc(s) { return YT() ? YT().esc(s) : String(s == null ? '' : s); }
|
||||
function show(el, on) { if (el) el.hidden = !on; }
|
||||
function watchUrl(v) { return 'https://www.youtube.com/watch?v=' + encodeURIComponent(v); }
|
||||
function channelUrl(id) { return 'https://www.youtube.com/channel/' + encodeURIComponent(id); }
|
||||
|
||||
// ── a video tile (used in the main grid AND inside playlist sections) ──────
|
||||
function videoCard(v) {
|
||||
var dur = YT().fmtDuration(v.duration_seconds);
|
||||
var thumb = v.thumbnail_url
|
||||
? '<img class="vc-vid-img" src="' + esc(YT().img(v.thumbnail_url)) + '" alt="" loading="lazy" ' +
|
||||
'onerror="this.parentNode.classList.add(\'vc-vid-thumb--none\')">'
|
||||
: '';
|
||||
var bits = [];
|
||||
var d = YT().fmtDate(v.published_at); if (d) bits.push(esc(d));
|
||||
var vc = YT().compactCount(v.view_count); if (vc) bits.push(esc(vc) + ' views');
|
||||
return '<div class="vc-vid" data-vc-vid="' + esc(v.youtube_id) + '">' +
|
||||
'<a class="vc-vid-thumb' + (v.thumbnail_url ? '' : ' vc-vid-thumb--none') + '" href="' + watchUrl(v.youtube_id) +
|
||||
'" target="_blank" rel="noopener" data-vc-ext>' + thumb +
|
||||
(dur ? '<span class="vc-vid-dur">' + esc(dur) + '</span>' : '') +
|
||||
'<span class="vc-vid-play" aria-hidden="true">▶</span></a>' +
|
||||
'<div class="vc-vid-body" data-vc-expand="' + esc(v.youtube_id) + '" title="Show details">' +
|
||||
'<div class="vc-vid-title">' + esc(v.title || 'Untitled') + '</div>' +
|
||||
(bits.length ? '<div class="vc-vid-meta">' + bits.join(' · ') + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="vc-vid-detail" data-vc-detail="' + esc(v.youtube_id) + '" hidden></div>' +
|
||||
'<button class="vc-wish' + (v.wished ? ' vc-wish--on' : '') + '" type="button" data-vc-wish="' +
|
||||
esc(v.youtube_id) + '">' + (v.wished ? '✓ Wished' : '+ Wish') + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function applyGrid() {
|
||||
var list = state.all.slice();
|
||||
if (state.wishedOnly) list = list.filter(function (v) { return v.wished; });
|
||||
if (state.sort === 'oldest') list.reverse(); // state.all is newest-first
|
||||
else if (state.sort === 'views') list.sort(function (a, b) { return (b.view_count || 0) - (a.view_count || 0); });
|
||||
var grid = $('[data-vc-videos]'); if (grid) grid.innerHTML = list.map(videoCard).join('');
|
||||
var count = $('[data-vc-count]');
|
||||
if (count) count.textContent = list.length + (state.wishedOnly ? ' wished' : ' shown');
|
||||
show($('[data-vc-empty]'), !list.length);
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
var ch = d.channel || {};
|
||||
state.channel = ch; state.videos = {}; state.all = (ch.videos || []).slice();
|
||||
state.all.forEach(function (v) { state.videos[v.youtube_id] = v; });
|
||||
|
||||
var banner = $('[data-vc-banner]');
|
||||
if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + YT().img(ch.banner_url) + "')" : '';
|
||||
var page = $('[data-video-channel]'); if (page) page.setAttribute('data-has-banner', ch.banner_url ? '1' : '0');
|
||||
|
||||
var av = $('[data-vc-avatar]'), avph = $('[data-vc-avatar-ph]');
|
||||
if (av) {
|
||||
if (ch.avatar_url) { av.src = YT().img(ch.avatar_url); show(av, true); if (avph) avph.hidden = true; }
|
||||
else { show(av, false); if (avph) avph.hidden = false; }
|
||||
}
|
||||
var name = $('[data-vc-name]'); if (name) name.textContent = ch.title || 'Channel';
|
||||
var meta = $('[data-vc-meta]');
|
||||
if (meta) {
|
||||
var m = [];
|
||||
if (ch.handle) m.push(esc(ch.handle));
|
||||
var subs = YT().compactCount(ch.subscriber_count); if (subs) m.push(subs + ' subscribers');
|
||||
if (ch.video_count != null) m.push(esc(ch.video_count) + ' videos');
|
||||
var views = YT().compactCount(ch.view_count); if (views) m.push(views + ' views');
|
||||
meta.innerHTML = m.join('<span class="vc-dot">·</span>');
|
||||
}
|
||||
var tags = $('[data-vc-tags]');
|
||||
if (tags) {
|
||||
tags.innerHTML = (ch.tags || []).map(function (t) { return '<span class="vc-tag">' + esc(t) + '</span>'; }).join('');
|
||||
tags.hidden = !(ch.tags && ch.tags.length);
|
||||
}
|
||||
var desc = $('[data-vc-desc]');
|
||||
if (desc) { desc.textContent = ch.description || ''; desc.hidden = !ch.description; }
|
||||
var yt = $('[data-vc-yt]'); if (yt) yt.href = channelUrl(ch.youtube_id);
|
||||
setFollow(!!d.following);
|
||||
|
||||
applyGrid();
|
||||
// More uploads likely exist if we got a full page back.
|
||||
show($('[data-vc-more]'), state.all.length >= state.limit);
|
||||
loadPlaylists(ch.youtube_id);
|
||||
}
|
||||
|
||||
function setFollow(on) {
|
||||
var b = $('[data-vc-follow]'); if (!b) return;
|
||||
b.classList.toggle('vc-follow--on', on);
|
||||
b.textContent = on ? '✓ Following' : '+ Follow';
|
||||
}
|
||||
|
||||
// ── playlists ("seasons") ──────────────────────────────────────────────────
|
||||
function playlistRow(p) {
|
||||
var thumb = p.thumbnail_url
|
||||
? '<img class="vc-pl-thumb" src="' + esc(YT().img(p.thumbnail_url)) + '" alt="" loading="lazy">'
|
||||
: '<span class="vc-pl-thumb vc-pl-thumb--none">▶</span>';
|
||||
var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : '';
|
||||
return '<div class="vc-pl vc-pl--collapsed" data-vc-pl="' + esc(p.playlist_id) + '">' +
|
||||
'<div class="vc-pl-hd" data-vc-pl-toggle="' + esc(p.playlist_id) + '">' + thumb +
|
||||
'<div class="vc-pl-meta"><span class="vc-pl-title">' + esc(p.title) + '</span>' +
|
||||
(n ? '<span class="vc-pl-count">' + n + '</span>' : '') + '</div>' +
|
||||
'<span class="vc-pl-chev" aria-hidden="true">▾</span>' +
|
||||
'</div>' +
|
||||
'<div class="vc-pl-vids" data-vc-pl-vids="' + esc(p.playlist_id) + '"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function loadPlaylists(cid) {
|
||||
var sec = $('[data-vc-pl-section]'), host = $('[data-vc-playlists]');
|
||||
if (host) host.innerHTML = '';
|
||||
if (sec) sec.hidden = true;
|
||||
if (!cid) return;
|
||||
fetch('/api/video/youtube/playlists/' + encodeURIComponent(cid), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var pls = (d && d.playlists) || [];
|
||||
if (!pls.length || !host) return;
|
||||
host.innerHTML = pls.map(playlistRow).join('');
|
||||
if (sec) sec.hidden = false;
|
||||
})
|
||||
.catch(function () { /* best-effort */ });
|
||||
}
|
||||
|
||||
function loadPlaylistVideos(pid) {
|
||||
if (state.plLoaded[pid]) return;
|
||||
state.plLoaded[pid] = true;
|
||||
var host = $('[data-vc-pl-vids="' + pid + '"]'); if (!host) return;
|
||||
host.innerHTML = '<div class="vc-pl-loading">Loading…</div>';
|
||||
fetch('/api/video/youtube/playlist/' + encodeURIComponent(pid), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var vids = (d && d.videos) || [];
|
||||
vids.forEach(function (v) { state.videos[v.youtube_id] = v; });
|
||||
host.innerHTML = vids.length ? vids.map(videoCard).join('') : '<div class="vc-pl-loading">No videos.</div>';
|
||||
})
|
||||
.catch(function () { state.plLoaded[pid] = false; host.innerHTML = ''; });
|
||||
}
|
||||
|
||||
// ── load ───────────────────────────────────────────────────────────────────
|
||||
function load(id, keepLimit) {
|
||||
state.id = id;
|
||||
if (!keepLimit) { state.limit = PAGE_SIZE; state.plLoaded = {}; }
|
||||
var ld = $('[data-vc-loading]'); show(ld, true);
|
||||
if (!keepLimit) { var g = $('[data-vc-videos]'); if (g) g.innerHTML = ''; }
|
||||
fetch('/api/video/youtube/channel/' + encodeURIComponent(id) + '?limit=' + state.limit,
|
||||
{ headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
show(ld, false);
|
||||
if (!d || !d.success) {
|
||||
var nm = $('[data-vc-name]'); if (nm) nm.textContent = 'Channel unavailable';
|
||||
show($('[data-vc-empty]'), true);
|
||||
return;
|
||||
}
|
||||
render(d);
|
||||
})
|
||||
.catch(function () { show(ld, false); });
|
||||
}
|
||||
|
||||
// ── interactions ──────────────────────────────────────────────────────────
|
||||
function channelStub() {
|
||||
var c = state.channel || {};
|
||||
return { youtube_id: c.youtube_id, title: c.title, avatar_url: c.avatar_url };
|
||||
}
|
||||
|
||||
function toggleWish(btn) {
|
||||
if (!YT()) return;
|
||||
var id = btn.getAttribute('data-vc-wish');
|
||||
var v = state.videos[id]; if (!v) return;
|
||||
var on = btn.classList.contains('vc-wish--on');
|
||||
btn.disabled = true;
|
||||
var setOn = function (val) {
|
||||
v.wished = val; btn.disabled = false;
|
||||
// reflect on every card with this id (grid + playlists)
|
||||
var btns = document.querySelectorAll('[data-vc-wish="' + id + '"]');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.toggle('vc-wish--on', val);
|
||||
btns[i].textContent = val ? '✓ Wished' : '+ Wish';
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||||
};
|
||||
if (on) YT().removeWish('video', id).then(function (d) { setOn(!(d && d.success)); }).catch(function () { btn.disabled = false; });
|
||||
else YT().addVideos(channelStub(), [v]).then(function (d) {
|
||||
setOn(!!(d && d.success));
|
||||
if (d && d.success && typeof showToast === 'function') showToast('Added to wishlist', 'success');
|
||||
}).catch(function () { btn.disabled = false; });
|
||||
}
|
||||
|
||||
function toggleFollow(btn) {
|
||||
if (!YT()) return;
|
||||
var on = btn.classList.contains('vc-follow--on');
|
||||
btn.disabled = true;
|
||||
var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); load(state.id, true); };
|
||||
if (on) YT().unfollow(state.id).then(done).catch(function () { btn.disabled = false; });
|
||||
else YT().follow(channelStub()).then(function (d) {
|
||||
if (d && d.success && typeof showToast === 'function') showToast('Following · ' + (d.added_videos || 0) + ' videos added', 'success');
|
||||
done();
|
||||
}).catch(function () { btn.disabled = false; });
|
||||
}
|
||||
|
||||
// expand a video's full description/stats inline (lazy, cached on the object)
|
||||
function toggleExpand(body) {
|
||||
var id = body.getAttribute('data-vc-expand');
|
||||
var panel = $('[data-vc-detail="' + id + '"]', body.parentNode); if (!panel) return;
|
||||
if (!panel.hidden) { panel.hidden = true; return; }
|
||||
panel.hidden = false;
|
||||
var v = state.videos[id] || {};
|
||||
if (v._full) { panel.innerHTML = detailHTML(v._full); return; }
|
||||
if (v._loading) return;
|
||||
v._loading = true;
|
||||
panel.innerHTML = '<div class="vc-vid-detail-load">Loading details…</div>';
|
||||
fetch('/api/video/youtube/video/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
v._loading = false; v._full = (d && d.video) || {};
|
||||
if (!panel.hidden) panel.innerHTML = detailHTML(v._full);
|
||||
})
|
||||
.catch(function () { v._loading = false; panel.innerHTML = '<div class="vc-vid-detail-load">No details.</div>'; });
|
||||
}
|
||||
function detailHTML(f) {
|
||||
var stats = [];
|
||||
var likes = YT().compactCount(f.like_count); if (likes) stats.push(likes + ' likes');
|
||||
var vc = YT().compactCount(f.view_count); if (vc) stats.push(vc + ' views');
|
||||
return (stats.length ? '<div class="vc-vid-stats">' + esc(stats.join(' · ')) + '</div>' : '') +
|
||||
(f.description ? '<div class="vc-vid-desc">' + esc(f.description) + '</div>' : '<div class="vc-vid-desc">No description.</div>');
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var t = e.target;
|
||||
var wish = t.closest('[data-vc-wish]'); if (wish) { e.preventDefault(); toggleWish(wish); return; }
|
||||
var fb = t.closest('[data-vc-follow]'); if (fb) { e.preventDefault(); toggleFollow(fb); return; }
|
||||
if (t.closest('[data-vc-ext]')) return; // thumbnail / YouTube link
|
||||
var more = t.closest('[data-vc-more]'); if (more) { e.preventDefault(); state.limit += PAGE_SIZE; load(state.id, true); return; }
|
||||
var plt = t.closest('[data-vc-pl-toggle]');
|
||||
if (plt) {
|
||||
var pid = plt.getAttribute('data-vc-pl-toggle');
|
||||
var blk = plt.closest('.vc-pl');
|
||||
if (blk && blk.classList.toggle('vc-pl--collapsed') === false) loadPlaylistVideos(pid);
|
||||
return;
|
||||
}
|
||||
var exp = t.closest('[data-vc-expand]'); if (exp) { e.preventDefault(); toggleExpand(exp); return; }
|
||||
}
|
||||
|
||||
function onOpen(e) {
|
||||
if (!e || !e.detail || e.detail.kind !== 'channel') return;
|
||||
load(e.detail.id);
|
||||
}
|
||||
|
||||
function init() {
|
||||
var page = $('[data-video-channel]');
|
||||
if (page) page.addEventListener('click', onClick);
|
||||
var sort = $('[data-vc-sort]');
|
||||
if (sort) sort.addEventListener('change', function () { state.sort = sort.value; applyGrid(); });
|
||||
var wt = $('[data-vc-wished-toggle]');
|
||||
if (wt) wt.addEventListener('click', function () {
|
||||
state.wishedOnly = !state.wishedOnly;
|
||||
wt.classList.toggle('vc-tool-btn--on', state.wishedOnly);
|
||||
applyGrid();
|
||||
});
|
||||
document.addEventListener('soulsync:video-open-detail', onOpen);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
|
|
@ -71,21 +71,23 @@
|
|||
return null;
|
||||
}
|
||||
function seasonArt(s) {
|
||||
if (data && data.source === 'tmdb') return s.poster_url || data.poster_url || '';
|
||||
// tmdb + youtube carry direct (already-proxied for yt) art urls on the payload.
|
||||
if (data && (data.source === 'tmdb' || data.source === 'youtube')) return s.poster_url || data.poster_url || '';
|
||||
return (s.has_poster && s.id != null) ? '/api/video/poster/season/' + s.id
|
||||
: (data && data.has_poster ? '/api/video/poster/show/' + data.id : '');
|
||||
}
|
||||
// Source-aware billboard art: library items proxy through /api/video; tmdb
|
||||
// (preview) items use the direct image URLs in the payload.
|
||||
// (preview) + youtube items use the (proxied) image URLs in the payload.
|
||||
function bbBackdrop(d) {
|
||||
if (d.source === 'tmdb') return d.backdrop_url || d.poster_url || '';
|
||||
if (d.source === 'tmdb' || d.source === 'youtube') return d.backdrop_url || d.poster_url || '';
|
||||
var art = '/' + d.kind + '/' + d.id;
|
||||
return d.has_backdrop ? '/api/video/backdrop' + art : (d.has_poster ? '/api/video/poster' + art : '');
|
||||
}
|
||||
function bbPoster(d) {
|
||||
// The offscreen poster is canvas-sampled for the accent — must be
|
||||
// same-origin, so tmdb (preview) posters go through our image proxy.
|
||||
// same-origin, so tmdb posters proxy; youtube urls are already proxied.
|
||||
if (d.source === 'tmdb') return d.poster_url ? proxied(d.poster_url) : '';
|
||||
if (d.source === 'youtube') return d.poster_url || '';
|
||||
return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : '';
|
||||
}
|
||||
function proxied(url) {
|
||||
|
|
@ -161,6 +163,21 @@
|
|||
if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; }
|
||||
|
||||
var meta = [];
|
||||
if (d.source === 'youtube') {
|
||||
meta.push('<span class="vd-status vd-status--yt">YouTube</span>');
|
||||
var yc = window.VideoYoutube;
|
||||
var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('<span>' + subs + ' subscribers</span>');
|
||||
if (d.video_count != null) meta.push('<span>' + esc(d.video_count) + ' videos</span>');
|
||||
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 = '';
|
||||
var gg = q('[data-vd-genres]');
|
||||
if (gg) gg.innerHTML = (d.genres || []).slice(0, 8).map(function (gn) { return '<span class="vd-genre">' + esc(gn) + '</span>'; }).join('');
|
||||
renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d);
|
||||
return;
|
||||
}
|
||||
if (d.source === 'tmdb') {
|
||||
meta.push('<span class="vd-status vd-status--preview">Preview</span>');
|
||||
} else if (d.kind === 'show') {
|
||||
|
|
@ -317,6 +334,15 @@
|
|||
function renderActions(d) {
|
||||
var a = q('[data-vd-actions]');
|
||||
if (!a) return;
|
||||
if (d.source === 'youtube') {
|
||||
var on = !!d.following;
|
||||
a.innerHTML =
|
||||
'<button class="vd-yt-follow' + (on ? ' vd-yt-follow--on' : '') + '" type="button" data-vd-act="yt-follow">' +
|
||||
(on ? '✓ Following' : '+ Follow') + '</button>' +
|
||||
'<a class="vd-yt-link" href="https://www.youtube.com/channel/' + esc(d.id) +
|
||||
'" target="_blank" rel="noopener">Open on YouTube ↗</a>';
|
||||
return;
|
||||
}
|
||||
// The watchlist eye applies only to airing shows (movies + ended shows are
|
||||
// terminal — they get acquisition, not a "watch for new" follow).
|
||||
var isAiringShow = d.kind === 'show' && d.tmdb_id && (!window.VideoGet || VideoGet.isAiring(d.status));
|
||||
|
|
@ -905,7 +931,30 @@
|
|||
}
|
||||
|
||||
// ── episodes ──────────────────────────────────────────────────────────────
|
||||
// A YouTube video as an "episode": still + title + date, a Wish toggle instead
|
||||
// of owned/missing, expand → full description (lazy).
|
||||
function ytEpisodeRow(ep) {
|
||||
var key = selectedSeason + '_' + ep.episode_number;
|
||||
var still = ep.still_url
|
||||
? '<img class="vd-ep-still" src="' + esc(ep.still_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
|
||||
: '';
|
||||
var meta = [];
|
||||
if (ep.air_date) meta.push(fmtDate(ep.air_date));
|
||||
var wished = !!ep.owned;
|
||||
return '<div class="vd-ep vd-ep--yt" data-vd-ep-key="' + key + '" data-vd-yt-vid="' + esc(ep.youtube_id) + '">' +
|
||||
'<div class="vd-ep-thumb">' + still + '<span class="vd-ep-thumb-ic">▶</span></div>' +
|
||||
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
|
||||
esc(ep.title || 'Untitled') + '</span>' +
|
||||
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +
|
||||
(ep.overview ? '<p class="vd-ep-desc">' + esc(ep.overview) + '</p>' : '') + '</div>' +
|
||||
'<button class="vd-yt-wish' + (wished ? ' vd-yt-wish--on' : '') + '" type="button" data-vd-yt-wish="' +
|
||||
esc(ep.youtube_id) + '">' + (wished ? '✓ Wished' : '+ Wish') + '</button>' +
|
||||
'<span class="vd-ep-chev" aria-hidden="true">⌄</span></div>' +
|
||||
'<div class="vd-ep-extra" data-vd-ep-panel="' + key + '" hidden></div>';
|
||||
}
|
||||
|
||||
function episodeRow(ep) {
|
||||
if (data && data.source === 'youtube') return ytEpisodeRow(ep);
|
||||
var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
|
||||
var meta = [];
|
||||
var rt = runtimeLabel(ep.runtime_minutes); if (rt) meta.push(rt);
|
||||
|
|
@ -943,6 +992,26 @@
|
|||
}
|
||||
}
|
||||
function loadEpisodeExtra(key, panel) {
|
||||
// YouTube: the row carries the video id → fetch its full metadata.
|
||||
if (data && data.source === 'youtube') {
|
||||
var row = q('[data-vd-ep-key="' + key + '"]');
|
||||
var vid = row && row.getAttribute('data-vd-yt-vid');
|
||||
if (!vid) { panel.innerHTML = '<div class="vd-ep-extra-empty">No details.</div>'; return; }
|
||||
panel.innerHTML = '<div class="vd-ep-extra-empty">Loading…</div>';
|
||||
fetch('/api/video/youtube/video/' + encodeURIComponent(vid), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var v = (d && d.video) || {};
|
||||
var yc = window.VideoYoutube, stats = [];
|
||||
var lk = yc && yc.compactCount(v.like_count); if (lk) stats.push(lk + ' likes');
|
||||
var vw = yc && yc.compactCount(v.view_count); if (vw) stats.push(vw + ' views');
|
||||
panel.innerHTML = '<div class="vd-ep-extra-body">' +
|
||||
(stats.length ? '<div class="vd-ep-extra-gh">' + esc(stats.join(' · ')) + '</div>' : '') +
|
||||
'<p class="vd-ep-extra-ov">' + esc(v.description || 'No description.') + '</p></div>';
|
||||
})
|
||||
.catch(function () { panel.innerHTML = '<div class="vd-ep-extra-empty">No details.</div>'; });
|
||||
return;
|
||||
}
|
||||
var tmdb = data && data.tmdb_id;
|
||||
var parts = key.split('_');
|
||||
if (!tmdb) { panel.innerHTML = '<div class="vd-ep-extra-empty">No extra info.</div>'; return; }
|
||||
|
|
@ -1227,12 +1296,188 @@
|
|||
.catch(function () { showEpSyncing(false); });
|
||||
}
|
||||
|
||||
// ── YouTube channel (rendered through the show pipeline) ──────────────────
|
||||
// A channel = a "show": upload YEAR is a season, video an episode. Renders
|
||||
// into the SHOW container (currentKind='show') with d.kind='channel' +
|
||||
// d.source='youtube' driving every branch above. All TMDB-only sections
|
||||
// auto-hide on empty channel data.
|
||||
var ytVideoMap = {}; // youtube_id -> raw video (for wish add, main grid + playlists)
|
||||
|
||||
function ytProx(u) { return (window.VideoYoutube && u) ? VideoYoutube.img(u) : (u || ''); }
|
||||
|
||||
function ytToShow(resp) {
|
||||
var ch = resp.channel || {};
|
||||
var byYear = {};
|
||||
ytVideoMap = {};
|
||||
(ch.videos || []).forEach(function (v) {
|
||||
ytVideoMap[v.youtube_id] = v;
|
||||
var yr = (v.published_at && /^\d{4}/.test(v.published_at)) ? parseInt(v.published_at.slice(0, 4), 10) : 0;
|
||||
(byYear[yr] = byYear[yr] || []).push(v);
|
||||
});
|
||||
var years = Object.keys(byYear).map(Number).sort(function (a, b) { return b - a; });
|
||||
var seasons = years.map(function (yr) {
|
||||
var vids = byYear[yr], poster = '';
|
||||
for (var k = 0; k < vids.length; k++) { if (vids[k].thumbnail_url) { poster = ytProx(vids[k].thumbnail_url); break; } }
|
||||
var eps = vids.map(function (v, i) {
|
||||
return { episode_number: i + 1, title: v.title, overview: v.description || '',
|
||||
air_date: v.published_at, owned: !!v.wished, has_still: false,
|
||||
still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id };
|
||||
});
|
||||
var wishedN = eps.filter(function (e) { return e.owned; }).length;
|
||||
return { season_number: yr, title: yr ? String(yr) : 'Undated', poster_url: poster || ytProx(ch.avatar_url),
|
||||
episode_owned: wishedN, episode_total: eps.length, episodes: eps };
|
||||
});
|
||||
return { kind: 'channel', source: 'youtube', id: ch.youtube_id, title: ch.title || 'Channel',
|
||||
overview: ch.description || '', backdrop_url: ytProx(ch.banner_url), has_backdrop: !!ch.banner_url,
|
||||
poster_url: ytProx(ch.avatar_url), has_poster: !!ch.avatar_url, genres: ch.tags || [], handle: ch.handle,
|
||||
subscriber_count: ch.subscriber_count, video_count: ch.video_count, view_count: ch.view_count,
|
||||
following: !!resp.following, _channel: ch, seasons: seasons, season_count: seasons.length,
|
||||
episode_total: (ch.videos || []).length, episode_owned: 0 };
|
||||
}
|
||||
|
||||
function loadChannel(id) {
|
||||
currentKind = 'show'; currentSource = 'youtube'; // render into the show container
|
||||
if (currentId !== id) artAttemptedFor = null;
|
||||
currentId = id;
|
||||
if (!root()) return;
|
||||
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/channel/' + encodeURIComponent(id) + '?limit=90', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (resp) {
|
||||
showLoading(false);
|
||||
if (!resp || !resp.success) { setText('[data-vd-title]', 'Channel unavailable'); return; }
|
||||
if (currentId !== id) return;
|
||||
data = ytToShow(resp); menuOpen = false; missingOnly = false;
|
||||
selectedSeason = data.seasons.length ? data.seasons[0].season_number : null;
|
||||
var mt = q('[data-vd-missing-toggle]');
|
||||
if (mt) { mt.hidden = !data.seasons.length; mt.classList.remove('vd-missing-toggle--on'); }
|
||||
renderBillboard(data); renderViewToggle(); renderSeasonNav(); ensureSeasonEpisodes();
|
||||
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
|
||||
if (sub) sub.scrollTop = 0;
|
||||
ytLoadPlaylists(id);
|
||||
})
|
||||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load channel'); });
|
||||
}
|
||||
|
||||
function ytFindEp(id) {
|
||||
if (!data || !data.seasons) return null;
|
||||
for (var i = 0; i < data.seasons.length; i++) {
|
||||
var es = data.seasons[i].episodes;
|
||||
for (var j = 0; j < es.length; j++) if (es[j].youtube_id === id) return es[j];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleYtWish(btn) {
|
||||
var yc = window.VideoYoutube; if (!yc) return;
|
||||
var id = btn.getAttribute('data-vd-yt-wish');
|
||||
var on = btn.classList.contains('vd-yt-wish--on');
|
||||
btn.disabled = true;
|
||||
var setOn = function (val) {
|
||||
btn.disabled = false;
|
||||
if (ytVideoMap[id]) ytVideoMap[id].wished = val;
|
||||
var r0 = root(), btns = r0 ? r0.querySelectorAll('[data-vd-yt-wish="' + id + '"]') : [];
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.toggle('vd-yt-wish--on', val);
|
||||
btns[i].textContent = val ? '✓ Wished' : '+ Wish';
|
||||
}
|
||||
var ep = ytFindEp(id); if (ep) { ep.owned = val; renderSeasonNav(); }
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||||
};
|
||||
if (on) yc.removeWish('video', id).then(function (d) { setOn(!(d && d.success)); }).catch(function () { btn.disabled = false; });
|
||||
else {
|
||||
var ch = (data && data._channel) || {};
|
||||
yc.addVideos({ youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url },
|
||||
[ytVideoMap[id] || { youtube_id: id, title: '' }])
|
||||
.then(function (d) { setOn(!!(d && d.success)); if (d && d.success && typeof showToast === 'function') showToast('Added to wishlist', 'success'); })
|
||||
.catch(function () { btn.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleYtFollow() {
|
||||
var yc = window.VideoYoutube; if (!yc || !data) return;
|
||||
var ch = data._channel || {}, on = data.following;
|
||||
if (on) yc.unfollow(data.id).then(function () { data.following = false; renderActions(data);
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); }).catch(function () { /* ignore */ });
|
||||
else yc.follow({ youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url }).then(function (d) {
|
||||
if (d && d.success) {
|
||||
if (typeof showToast === 'function') showToast('Following · ' + (d.added_videos || 0) + ' videos added', 'success');
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||||
loadChannel(data.id); // reload so the now-wished videos reflect
|
||||
}
|
||||
}).catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
// playlists as collapsible rows below the episodes (channel-only section)
|
||||
function ytResetPlaylists() {
|
||||
var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]');
|
||||
if (host) host.innerHTML = '';
|
||||
if (sec) sec.hidden = true;
|
||||
}
|
||||
function ytPlaylistRow(p) {
|
||||
var thumb = p.thumbnail_url
|
||||
? '<img class="vc-pl-thumb" src="' + esc(ytProx(p.thumbnail_url)) + '" alt="" loading="lazy">'
|
||||
: '<span class="vc-pl-thumb vc-pl-thumb--none">▶</span>';
|
||||
var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : '';
|
||||
return '<div class="vc-pl vc-pl--collapsed" data-vc-pl="' + esc(p.playlist_id) + '">' +
|
||||
'<div class="vc-pl-hd" data-vd-yt-pl-toggle="' + esc(p.playlist_id) + '">' + thumb +
|
||||
'<div class="vc-pl-meta"><span class="vc-pl-title">' + esc(p.title) + '</span>' +
|
||||
(n ? '<span class="vc-pl-count">' + n + '</span>' : '') + '</div>' +
|
||||
'<span class="vc-pl-chev" aria-hidden="true">▾</span></div>' +
|
||||
'<div class="vc-pl-vids" data-vd-yt-pl-vids="' + esc(p.playlist_id) + '"></div></div>';
|
||||
}
|
||||
function ytLoadPlaylists(cid) {
|
||||
var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]');
|
||||
if (!host) return;
|
||||
fetch('/api/video/youtube/playlists/' + encodeURIComponent(cid), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var pls = (d && d.playlists) || [];
|
||||
if (!pls.length || currentId !== cid) return;
|
||||
host.innerHTML = pls.map(ytPlaylistRow).join('');
|
||||
if (sec) sec.hidden = false;
|
||||
})
|
||||
.catch(function () { /* best-effort */ });
|
||||
}
|
||||
function ytPlVideoCard(v) {
|
||||
ytVideoMap[v.youtube_id] = v;
|
||||
var thumb = v.thumbnail_url
|
||||
? '<img src="' + esc(ytProx(v.thumbnail_url)) + '" alt="" loading="lazy">' : '';
|
||||
return '<div class="vd-yt-plvid">' +
|
||||
'<a class="vd-yt-plvid-thumb" href="https://www.youtube.com/watch?v=' + esc(v.youtube_id) +
|
||||
'" target="_blank" rel="noopener" data-vd-ext>' + thumb + '<span class="vd-yt-plvid-play">▶</span></a>' +
|
||||
'<div class="vd-yt-plvid-title" title="' + esc(v.title) + '">' + esc(v.title || 'Untitled') + '</div>' +
|
||||
'<button class="vd-yt-wish' + (v.wished ? ' vd-yt-wish--on' : '') + '" type="button" data-vd-yt-wish="' +
|
||||
esc(v.youtube_id) + '">' + (v.wished ? '✓' : '+') + '</button></div>';
|
||||
}
|
||||
function toggleYtPlaylist(el) {
|
||||
var pid = el.getAttribute('data-vd-yt-pl-toggle');
|
||||
var blk = el.closest('.vc-pl'); if (!blk) return;
|
||||
var opened = blk.classList.toggle('vc-pl--collapsed') === false;
|
||||
if (!opened) return;
|
||||
var host = q('[data-vd-yt-pl-vids="' + pid + '"]');
|
||||
if (!host || host.getAttribute('data-loaded')) return;
|
||||
host.setAttribute('data-loaded', '1');
|
||||
host.innerHTML = '<div class="vc-pl-loading">Loading…</div>';
|
||||
fetch('/api/video/youtube/playlist/' + encodeURIComponent(pid), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var vids = (d && d.videos) || [];
|
||||
host.innerHTML = vids.length ? vids.map(ytPlVideoCard).join('') : '<div class="vc-pl-loading">No videos.</div>';
|
||||
})
|
||||
.catch(function () { host.removeAttribute('data-loaded'); host.innerHTML = ''; });
|
||||
}
|
||||
|
||||
// ── events ────────────────────────────────────────────────────────────────
|
||||
function onOpen(e) {
|
||||
if (!e || !e.detail) return;
|
||||
var src = e.detail.source || 'library';
|
||||
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);
|
||||
// 'person' is handled by video-person.js (same event).
|
||||
}
|
||||
|
||||
|
|
@ -1277,6 +1522,12 @@
|
|||
if (body) { var open = body.classList.toggle('vd-review-body--open'); revMore.textContent = open ? 'Read less' : 'Read more'; }
|
||||
return;
|
||||
}
|
||||
// YouTube channel interactions (rendered in the show container)
|
||||
if (e.target.closest('[data-vd-ext]')) return; // let watch links open
|
||||
var ytWish = e.target.closest('[data-vd-yt-wish]');
|
||||
if (ytWish && r.contains(ytWish)) { e.preventDefault(); toggleYtWish(ytWish); return; }
|
||||
var ytPl = e.target.closest('[data-vd-yt-pl-toggle]');
|
||||
if (ytPl && r.contains(ytPl)) { toggleYtPlaylist(ytPl); return; }
|
||||
var epRow = e.target.closest('[data-vd-ep-key]');
|
||||
if (epRow && r.contains(epRow)) { toggleEpisode(epRow); return; }
|
||||
var seasonBtn = e.target.closest('[data-vd-season]');
|
||||
|
|
@ -1290,6 +1541,7 @@
|
|||
var which = act.getAttribute('data-vd-act');
|
||||
if (which === 'watchlist') toggleWatchlist();
|
||||
else if (which === 'missing') toggleMissing();
|
||||
else if (which === 'yt-follow') toggleYtFollow();
|
||||
else if (which === 'trailer' && data && data.trailer) openTrailer(data.trailer.key);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2933,3 +2933,30 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vyt-result-title { font-size: 13px; font-weight: 800; color: #fff; line-height: 1.2; max-width: 100%;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vyt-result-sub { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.42); }
|
||||
|
||||
/* ── YouTube channel rendered through the SHOW detail page (.vd-yt-*) ───────── */
|
||||
.vd-status--yt { background: rgba(255,59,59,0.18); color: #ff8a8a; }
|
||||
.vd-yt-follow { border: 0; cursor: pointer; border-radius: 999px; padding: 11px 24px; font-size: 13.5px; font-weight: 800;
|
||||
background: #ff3b3b; color: #fff; box-shadow: 0 8px 22px rgba(255,59,59,0.4); transition: all 0.15s ease; }
|
||||
.vd-yt-follow:hover { transform: translateY(-1px); background: #ff5252; }
|
||||
.vd-yt-follow--on { background: rgba(255,255,255,0.12); color: #6cd391; box-shadow: none; }
|
||||
.vd-yt-link { display: inline-flex; align-items: center; font-size: 13px; font-weight: 800; color: #ff6b6b;
|
||||
text-decoration: none; padding: 10px 18px; border-radius: 999px; border: 1px solid rgba(255,59,59,0.35); }
|
||||
.vd-yt-link:hover { background: rgba(255,59,59,0.12); }
|
||||
.vd-section-h { font-size: 20px; font-weight: 800; color: #fff; margin: 0 0 16px; }
|
||||
.vd-yt-pl-section { margin-top: 34px; }
|
||||
/* per-video Wish button inside an episode row */
|
||||
.vd-yt-wish { flex-shrink: 0; align-self: center; border: 1px solid rgba(255,255,255,0.14); cursor: pointer;
|
||||
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.78); font-size: 11.5px; font-weight: 800;
|
||||
padding: 6px 13px; border-radius: 999px; transition: all 0.15s ease; white-space: nowrap; }
|
||||
.vd-yt-wish:hover { background: rgba(255,59,59,0.18); border-color: rgba(255,59,59,0.5); color: #fff; }
|
||||
.vd-yt-wish--on { background: rgba(108,211,145,0.16); border-color: rgba(108,211,145,0.4); color: #6cd391; }
|
||||
/* playlist video chips */
|
||||
.vd-yt-plvid { display: flex; flex-direction: column; }
|
||||
.vd-yt-plvid-thumb { position: relative; display: block; aspect-ratio: 16/9; border-radius: 9px; overflow: hidden; background: #16161d; }
|
||||
.vd-yt-plvid-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.vd-yt-plvid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 22px; color: #fff; opacity: 0; transition: opacity 0.2s ease; }
|
||||
.vd-yt-plvid-thumb:hover .vd-yt-plvid-play { opacity: 0.9; }
|
||||
.vd-yt-plvid-title { font-size: 12px; font-weight: 700; color: #fff; line-height: 1.3; margin-top: 7px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.vd-yt-plvid .vd-yt-wish { align-self: flex-start; margin-top: 7px; }
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@
|
|||
// reload / Back / Forward / open-in-new-tab all work. ``source`` is 'library'
|
||||
// (a video.db id) today; 'tmdb' (a search result not yet in the library) later.
|
||||
var DETAIL_BASE = '/video-detail/';
|
||||
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1,
|
||||
'video-channel-detail': 1 };
|
||||
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1 };
|
||||
|
||||
function buildDetailPath(source, kind, id) {
|
||||
return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + encodeURIComponent(id);
|
||||
|
|
@ -70,10 +69,6 @@
|
|||
var n = document.querySelector('[data-video-person] [data-vp-name]');
|
||||
return n ? (n.textContent || '').trim() : '';
|
||||
}
|
||||
if (pageId === 'video-channel-detail') {
|
||||
var cn = document.querySelector('[data-video-channel] [data-vc-name]');
|
||||
return cn ? (cn.textContent || '').trim() : '';
|
||||
}
|
||||
var host = pageId === 'video-movie-detail' ? '[data-video-detail="movie"]' : '[data-video-detail="show"]';
|
||||
var t = document.querySelector(host + ' [data-vd-title]');
|
||||
return t ? (t.textContent || '').trim() : '';
|
||||
|
|
@ -145,7 +140,6 @@
|
|||
{ id: 'video-show-detail', label: 'Show' },
|
||||
{ id: 'video-movie-detail', label: 'Movie' },
|
||||
{ id: 'video-person-detail', label: 'Person' },
|
||||
{ id: 'video-channel-detail', label: 'Channel' },
|
||||
];
|
||||
|
||||
// "Shared" video pages reuse the REAL music page (shown identically on the
|
||||
|
|
@ -313,7 +307,7 @@
|
|||
if (d.kind === 'movie') navigate('video-movie-detail');
|
||||
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-channel-detail');
|
||||
else if (d.kind === 'channel') navigate('video-show-detail'); // channels reuse the show detail page
|
||||
else return;
|
||||
if (d._replace) {
|
||||
// A redirect (e.g. a tmdb link that's actually owned) → replace the
|
||||
|
|
|
|||
Loading…
Reference in a new issue