Channel page: Add-to-watchlist on each playlist section

Channel playlists (the collapsible sections on a channel page) now each carry the
standard watchlist button — Add to Watchlist / In Watchlist — so you can follow a
channel's playlist without pasting its link. The /youtube/playlists/<id> endpoint
flags each playlist with its follow state to hydrate the button; the button click
is handled before the row's expand toggle (stopPropagation), and adds via
followPlaylist — the same plumbing the search chip + watchlist use. Followed ones
then appear in the watchlist Channels tab alongside channels.
This commit is contained in:
BoulderBadgeDad 2026-06-17 20:48:31 -07:00
parent e141e3dcbd
commit 62b815a3fe
3 changed files with 40 additions and 3 deletions

View file

@ -345,10 +345,16 @@ def register_routes(bp):
@bp.route("/youtube/playlists/<channel_id>", methods=["GET"])
def video_youtube_playlists(channel_id):
"""The channel's playlists (rendered as 'seasons' on the channel page)."""
"""The channel's playlists (collapsible sections on the channel page), each
flagged ``following`` so its Add-to-watchlist button hydrates."""
from . import get_video_db
from core.video import youtube as yt
try:
return jsonify({"success": True, "playlists": yt.channel_playlists(channel_id)})
pls = yt.channel_playlists(channel_id)
followed = get_video_db().playlist_watch_state([p.get("playlist_id") for p in pls])
for p in pls:
p["following"] = p.get("playlist_id") in followed
return jsonify({"success": True, "playlists": pls})
except Exception:
logger.exception("youtube playlists failed for %r", channel_id)
return jsonify({"success": False, "error": "Failed"}), 500

View file

@ -1652,13 +1652,39 @@
? '<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') : '';
var on = !!p.following;
var watch = '<button class="library-artist-watchlist-btn vc-pl-watch' + (on ? ' watching' : '') + '" type="button" ' +
'data-vd-yt-pl-watch="' + esc(p.playlist_id) + '" data-pl-title="' + esc(p.title) + '" data-pl-thumb="' + esc(p.thumbnail_url || '') + '">' +
'<span class="watchlist-icon">' + (on ? '✓' : '') + '</span>' +
'<span class="watchlist-text">' + (on ? 'In Watchlist' : 'Add to Watchlist') + '</span></button>';
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>' +
(n ? '<span class="vc-pl-count">' + n + '</span>' : '') + '</div>' + watch +
'<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 toggleYtPlaylistWatch(btn) {
var yc = window.VideoYoutube; if (!yc) return;
var pid = btn.getAttribute('data-vd-yt-pl-watch'), on = btn.classList.contains('watching');
btn.disabled = true;
var setBtn = function (s) {
btn.classList.toggle('watching', s);
var ic = btn.querySelector('.watchlist-icon'); if (ic) ic.textContent = s ? '✓' : '';
var tx = btn.querySelector('.watchlist-text'); if (tx) tx.textContent = s ? 'In Watchlist' : 'Add to Watchlist';
btn.disabled = false;
};
var bump = function () { document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
if (on) {
yc.unfollowPlaylist(pid).then(function () { setBtn(false); bump(); }).catch(function () { btn.disabled = false; });
} else {
yc.followPlaylist({ playlist_id: pid, title: btn.getAttribute('data-pl-title'),
thumbnail_url: btn.getAttribute('data-pl-thumb') }).then(function (d) {
if (d && d.success) { setBtn(true); bump(); if (typeof showToast === 'function') showToast('Added to watchlist', 'success'); }
else btn.disabled = false;
}).catch(function () { btn.disabled = false; });
}
}
function ytLoadPlaylists(cid) {
var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]');
if (!host) return;
@ -1757,6 +1783,8 @@
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 ytPlW = e.target.closest('[data-vd-yt-pl-watch]');
if (ytPlW && r.contains(ytPlW)) { e.preventDefault(); e.stopPropagation(); toggleYtPlaylistWatch(ytPlW); 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]');

View file

@ -2849,6 +2849,9 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.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; }
/* Compact watchlist button on each playlist row (the standard button, shrunk) */
.vc-pl-watch { flex: 0 0 auto; padding: 6px 12px; font-size: 12px; }
.vc-pl-watch .watchlist-icon { font-size: 13px; }
.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; }