diff --git a/webui/index.html b/webui/index.html index 20878af9..21ae5662 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1368,6 +1368,7 @@

+
Open on YouTube ↗ @@ -1375,11 +1376,24 @@
+

Videos

+
+ + +
+
diff --git a/webui/static/video/video-channel.js b/webui/static/video/video-channel.js index db04280e..7c40c1de 100644 --- a/webui/static/video/video-channel.js +++ b/webui/static/video/video-channel.js @@ -2,25 +2,27 @@ * SoulSync — Video YouTube Channel detail page (isolated). * * Opens in-app like a show/movie via soulsync:video-open-detail {kind:'channel', - * source:'youtube', id:}. Renders a banner hero (avatar, stats, - * description, Follow), and the channel's uploads as a grid where each video can - * be wished individually. Separate module — mirrors video-person.js — listening - * only for kind==='channel'. Styled by .vc-* in video-side.css. + * source:'youtube', id:}. 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 state = { id: null, channel: null, videos: {} }; + 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(vid) { return 'https://www.youtube.com/watch?v=' + encodeURIComponent(vid); } + 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 @@ -30,26 +32,36 @@ 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'); - var wished = !!v.wished; return '
' + '' + thumb + (dur ? '' + esc(dur) + '' : '') + '' + - '
' + - '
' + esc(v.title || 'Untitled') + '
' + + '
' + + '
' + esc(v.title || 'Untitled') + '
' + (bits.length ? '
' + bits.join(' · ') + '
' : '') + '
' + - '' + + '' + + '' + '
'; } + 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 = {}; - (ch.videos || []).forEach(function (v) { state.videos[v.youtube_id] = v; }); + 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) + "')" : ''; @@ -67,17 +79,23 @@ 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('·'); } + var tags = $('[data-vc-tags]'); + if (tags) { + tags.innerHTML = (ch.tags || []).map(function (t) { return '' + esc(t) + ''; }).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); - var count = $('[data-vc-count]'); if (count) count.textContent = (ch.videos || []).length + ' shown'; - var grid = $('[data-vc-videos]'); - if (grid) grid.innerHTML = (ch.videos || []).map(videoCard).join(''); - show($('[data-vc-empty]'), !(ch.videos || []).length); + 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) { @@ -86,14 +104,60 @@ b.textContent = on ? '✓ Following' : '+ Follow'; } - function load(id) { + // ── playlists ("seasons") ────────────────────────────────────────────────── + function playlistRow(p) { + var thumb = p.thumbnail_url + ? '' + : ''; + var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : ''; + return '
' + + '
' + thumb + + '
' + esc(p.title) + '' + + (n ? '' + n + '' : '') + '
' + + '' + + '
' + + '
' + + '
'; + } + + 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 = '
Loading…
'; + 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('') : '
No videos.
'; + }) + .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); - var grid = $('[data-vc-videos]'); if (grid) grid.innerHTML = ''; - var name = $('[data-vc-name]'); if (name) name.textContent = ''; - var meta = $('[data-vc-meta]'); if (meta) meta.innerHTML = ''; - var desc = $('[data-vc-desc]'); if (desc) { desc.textContent = ''; desc.hidden = true; } - fetch('/api/video/youtube/channel/' + encodeURIComponent(id) + '?limit=60', + 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) { @@ -121,40 +185,75 @@ var on = btn.classList.contains('vc-wish--on'); btn.disabled = true; var setOn = function (val) { - btn.classList.toggle('vc-wish--on', val); btn.textContent = val ? '✓ Wished' : '+ Wish'; - btn.disabled = false; v.wished = 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)); if (d && d.success && typeof showToast === 'function') showToast('Removed from wishlist', 'info'); }) - .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; }); - } + 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); }; + 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'); + 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 = '
Loading details…
'; + 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 = '
No details.
'; }); + } + 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 ? '
' + esc(stats.join(' · ')) + '
' : '') + + (f.description ? '
' + esc(f.description) + '
' : '
No description.
'); + } + function onClick(e) { - var wish = e.target.closest('[data-vc-wish]'); - if (wish) { e.preventDefault(); toggleWish(wish); return; } - var fb = e.target.closest('[data-vc-follow]'); - if (fb) { e.preventDefault(); toggleFollow(fb); return; } - // [data-vc-ext] anchors (thumbnail / Open on YouTube) fall through to navigate. + 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) { @@ -165,6 +264,14 @@ 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); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 88a729a6..9408d9e8 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2874,3 +2874,48 @@ body[data-side="video"] #soulsync-toggle { display: none; } color: #ff6b6b; text-decoration: none; padding: 6px 13px; border-radius: 999px; border: 1px solid rgba(255, 59, 59, 0.35); transition: all 0.15s ease; } .vwsh-nebula .vwsh-yt-watch:hover { background: rgba(255, 59, 59, 0.14); color: #fff; } + +/* ── channel page: tags ribbon, toolbar, load-more, inline detail, playlists ── */ +.vc-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; } +.vc-tag { font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 999px; } + +.vc-section-title { font-size: 20px; font-weight: 800; color: #fff; margin: 0 0 16px; } +.vc-pl-section { margin-bottom: 40px; } +.vc-videos-head { display: flex; align-items: baseline; gap: 12px; } +.vc-tools { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.vc-tool-btn { border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.05); + color: rgba(255,255,255,0.72); font-size: 12px; font-weight: 700; padding: 7px 13px; border-radius: 999px; cursor: pointer; transition: all 0.15s ease; } +.vc-tool-btn:hover { color: #fff; background: rgba(255,255,255,0.1); } +.vc-tool-btn--on { background: rgba(108,211,145,0.16); border-color: rgba(108,211,145,0.4); color: #6cd391; } +.vc-sort { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); color: #fff; + font-size: 12px; font-weight: 700; padding: 7px 11px; border-radius: 9px; cursor: pointer; } +.vc-more { display: block; margin: 22px auto 0; border: 1px solid rgba(255,59,59,0.35); background: rgba(255,59,59,0.08); + color: #ff6b6b; font-size: 13px; font-weight: 800; padding: 11px 28px; border-radius: 999px; cursor: pointer; transition: all 0.15s ease; } +.vc-more:hover { background: rgba(255,59,59,0.16); color: #fff; } + +/* clickable card body → inline detail panel */ +.vc-vid-body { margin-top: 9px; flex: 1; cursor: pointer; } +.vc-vid-body:hover .vc-vid-title { color: #ff6b6b; } +.vc-vid-detail { margin-top: 8px; padding: 11px 12px; border-radius: 10px; background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.07); } +.vc-vid-detail-load { font-size: 12px; color: rgba(255,255,255,0.5); } +.vc-vid-stats { font-size: 11.5px; font-weight: 700; color: #ff8a8a; margin-bottom: 6px; } +.vc-vid-desc { font-size: 12px; line-height: 1.55; color: rgba(255,255,255,0.62); white-space: pre-line; + max-height: 220px; overflow-y: auto; scrollbar-width: thin; } + +/* playlists as collapsible "seasons" */ +.vc-playlists { display: flex; flex-direction: column; gap: 10px; } +.vc-pl { border-radius: 12px; border: 1px solid rgba(255,255,255,0.07); background: rgba(255,255,255,0.02); overflow: hidden; } +.vc-pl-hd { display: flex; align-items: center; gap: 13px; padding: 10px 14px; cursor: pointer; transition: background 0.15s ease; } +.vc-pl-hd:hover { background: rgba(255,59,59,0.06); } +.vc-pl-thumb { flex-shrink: 0; width: 64px; height: 36px; border-radius: 6px; object-fit: cover; background: #16161d; + display: flex; align-items: center; justify-content: center; color: rgba(255,59,59,0.5); font-size: 14px; } +.vc-pl-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.vc-pl-title { font-size: 14px; font-weight: 800; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vc-pl-count { font-size: 11.5px; font-weight: 600; color: rgba(255,255,255,0.42); } +.vc-pl-chev { flex-shrink: 0; color: rgba(255,255,255,0.35); transition: transform 0.2s ease; } +.vc-pl--collapsed .vc-pl-chev { transform: rotate(-90deg); } +.vc-pl-vids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 6px 14px 16px; } +.vc-pl--collapsed .vc-pl-vids { display: none; } +.vc-pl-loading { padding: 14px; color: rgba(255,255,255,0.5); font-size: 13px; }