From 62b815a3fe99fe1b8afe3e691b5cfcbb24028f36 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 20:48:31 -0700 Subject: [PATCH] Channel page: Add-to-watchlist on each playlist section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- api/video/youtube.py | 10 ++++++++-- webui/static/video/video-detail.js | 30 +++++++++++++++++++++++++++++- webui/static/video/video-side.css | 3 +++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/api/video/youtube.py b/api/video/youtube.py index 4c5f6687..2a26c15a 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -345,10 +345,16 @@ def register_routes(bp): @bp.route("/youtube/playlists/", 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 diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 30c614c8..e47b399f 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -1652,13 +1652,39 @@ ? '' : ''; var n = p.video_count != null ? p.video_count + ' video' + (p.video_count === 1 ? '' : 's') : ''; + var on = !!p.following; + var watch = ''; return '
' + '
' + thumb + '
' + esc(p.title) + '' + - (n ? '' + n + '' : '') + '
' + + (n ? '' + n + '' : '') + '
' + watch + '
' + '
'; } + 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]'); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index f7f2e8e9..51709ebe 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }