您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a simple GUI to the Watch Later playlist to help manage, including filters (channel/progress) and bulk removal.
// ==UserScript== // @name Watch Later Manager — checkboxes, filters, bulk delete (v1.3.1) // @namespace WLManager // @version 1.3.1 // @description Add a simple GUI to the Watch Later playlist to help manage, including filters (channel/progress) and bulk removal. // @match https://www.youtube.com/* // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const onWL = () => new URL(location.href).searchParams.get("list") === "WL"; // ---------- UI bar ---------- let uiInjected = false; function createControlBar() { if (uiInjected) return; const header = document.querySelector("ytd-playlist-header-renderer"); const contents = document.querySelector("#contents"); const anchor = header || contents; if (!anchor || !anchor.parentElement) return; const bar = document.createElement("div"); bar.id = "wl-manager-bar"; bar.style.cssText = [ "position:sticky","top:0","z-index:9000", "background:var(--yt-spec-general-background-a)", "padding:12px","margin:8px 0", "border:1px solid var(--yt-spec-10-percent-layer)","border-radius:12px", "display:flex","flex-wrap:wrap","gap:8px","align-items:center" ].join(";"); bar.innerHTML = ` <strong style="margin-right:8px;">Watch Later Manager</strong> <label>Channel: <input id="wl-filter-channel" type="text" placeholder="contains…" style="padding:6px;border-radius:8px;border:1px solid var(--yt-spec-10-percent-layer)"> </label> <button id="wl-select-channel" class="wl-btn">Select channel</button> <label>Progress: <select id="wl-filter-progress" style="padding:6px;border-radius:8px;border:1px solid var(--yt-spec-10-percent-layer)"> <option value="any">Any</option> <option value="unwatched">Unwatched</option> <option value="partial">Partially watched</option> <option value="watched">Watched</option> </select> </label> <button id="wl-select-filtered" class="wl-btn">Select filtered</button> <button id="wl-clear-selection" class="wl-btn">Clear selection</button> <button id="wl-remove-selected" class="wl-btn" style="background:#bb1a1a;color:white;">Remove selected</button> <span id="wl-status" style="margin-left:auto; opacity:.8;"></span> <style> .wl-btn { padding:8px 10px; border-radius:10px; border:1px solid var(--yt-spec-10-percent-layer); background: var(--yt-spec-10-percent-layer); cursor:pointer; } .wl-btn:hover { filter: brightness(0.95); } .wl-checked { outline:2px solid #3ea6ff; border-radius:8px; } .wl-pill { font-size:12px; padding:2px 6px; border-radius:9999px; border:1px solid var(--yt-spec-10-percent-layer); margin-left:6px; } </style> `; anchor.parentElement.insertBefore(bar, anchor.nextSibling); uiInjected = true; byId("wl-select-channel").addEventListener("click", () => { const needle = (byId("wl-filter-channel").value || "").toLowerCase(); getAllItems().forEach((it) => { const match = (getChannelName(it) || "").toLowerCase().includes(needle); setChecked(it, !!needle && match); }); updateStatus(); }); byId("wl-select-filtered").addEventListener("click", () => { getItemsByFilter().forEach((it) => setChecked(it, true)); updateStatus(); }); byId("wl-clear-selection").addEventListener("click", () => { getAllItems().forEach((it) => setChecked(it, false)); updateStatus(); }); byId("wl-remove-selected").addEventListener("click", removeSelected); setInterval(() => { if (!document.contains(bar)) uiInjected = false; // watchdog will re-inject updateStatus(); }, 800); } function byId(id){ return document.getElementById(id); } function statusEl(){ return byId("wl-status"); } function updateStatus() { const all = getAllItems().length; const sel = getAllItems().filter(isChecked).length; const s = statusEl(); if (s) s.textContent = `${sel} selected • ${all} total`; } // ---------- Items & checkboxes ---------- function getAllItems() { return Array.from(document.querySelectorAll("ytd-playlist-video-renderer")).filter(n => n.isConnected); } function ensureCheckbox(item) { if (item.querySelector(".wl-checkbox")) return; const wrap = document.createElement("div"); wrap.className = "wl-checkbox-wrap"; wrap.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:6px;"; const box = document.createElement("input"); box.type = "checkbox"; box.className = "wl-checkbox"; box.style.cssText = "transform:scale(1.2); cursor:pointer;"; // Don’t navigate; do allow default checkbox toggle ["click","mousedown","mouseup","pointerdown","pointerup","touchstart","touchend"] .forEach(ev => box.addEventListener(ev, e => e.stopPropagation(), { capture:true })); const pillChannel = document.createElement("span"); pillChannel.className = "wl-pill wl-pill-channel"; const pillProg = document.createElement("span"); pillProg.className = "wl-pill wl-pill-progress"; wrap.appendChild(box); wrap.appendChild(pillChannel); wrap.appendChild(pillProg); (item.querySelector("#meta") || item).prepend(wrap); box.addEventListener("change", () => { item.classList.toggle("wl-checked", box.checked); updateStatus(); }); pillChannel.textContent = getChannelName(item) || "—"; pillProg.textContent = progressLabel(getProgress(item)); } function setChecked(item, val) { const ch = item.querySelector(".wl-checkbox"); if (!ch) return; ch.checked = val; item.classList.toggle("wl-checked", val); } function isChecked(item) { const ch = item.querySelector(".wl-checkbox"); return !!(ch && ch.checked); } function getChannelName(item) { const n = item.querySelector("ytd-channel-name a, #channel-name a, a.yt-simple-endpoint.style-scope.yt-formatted-string"); return n ? n.textContent.trim() : ""; } function getVideoId(item) { const a = item.querySelector("a#thumbnail[href*='watch'], a#video-title[href*='watch']"); if (a) { try { return new URL(a.href, location.origin).searchParams.get("v") || ""; } catch{} } return item.getAttribute("data-video-id") || ""; } // ---------- Progress detection (robust) ---------- function getProgress(item) { // 1) Explicit "Watched" overlay wins if (isWatchedOverlayVisible(item)) return "watched"; // 2) Percent from the resume bar (several fallbacks) const pct = getResumePercent(item); if (pct != null) { if (pct >= 98) return "watched"; if (pct >= 2) return "partial"; return "unwatched"; } // 3) Badge that explicitly says "Unwatched" if (hasUnwatchedBadge(item)) return "unwatched"; // 4) Fallback return "unwatched"; } function isWatchedOverlayVisible(item) { const el = item.querySelector("ytd-thumbnail-overlay-playback-status-renderer"); if (!el) return false; const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && el.offsetParent !== null; } function hasUnwatchedBadge(item) { const b = item.querySelector("#badges ytd-badge-supported-renderer, ytd-badge-supported-renderer"); return !!(b && /unwatched/i.test(b.textContent || "")); } function getResumePercent(item) { // Typical progress element(s) const bar = item.querySelector( "ytd-thumbnail-overlay-resume-playback-renderer #progress, " + "ytd-playlist-thumbnail #progress, " + "ytd-thumbnail #progress" ); if (!bar) return null; // A) If inline style uses %, read it const w = (bar.style && bar.style.width) || ""; const m = w.match(/([\d.]+)\s*%/); if (m) { const pct = parseFloat(m[1]); return isFinite(pct) ? pct : null; } // B) aria-valuenow on a progressbar ancestor const pb = bar.closest('[role="progressbar"]'); if (pb && pb.hasAttribute("aria-valuenow")) { const pct = parseFloat(pb.getAttribute("aria-valuenow")); if (isFinite(pct)) return pct; } // C) Compute from pixels const rect = bar.getBoundingClientRect(); const parent = bar.parentElement || bar.closest("#progress") || bar.parentElement; const parentRect = parent ? parent.getBoundingClientRect() : null; if (rect && parentRect && parentRect.width > 0) { const pct = (rect.width / parentRect.width) * 100; return Math.max(0, Math.min(100, pct)); } // D) Last resort: non-trivial width -> treat as partial if (rect && rect.width > 2) return 50; return 0; } function progressLabel(k){ return k === "watched" ? "Watched" : k === "partial" ? "Partial" : "Unwatched"; } function getItemsByFilter() { const channelNeedle = (byId("wl-filter-channel")?.value || "").toLowerCase(); const prog = byId("wl-filter-progress")?.value || "any"; return getAllItems().filter((item) => { const ch = (getChannelName(item) || "").toLowerCase(); const p = getProgress(item); return (!channelNeedle || ch.includes(channelNeedle)) && (prog === "any" || p === prog); }); } // ---------- Popup helpers ---------- function allPopups(){ return Array.from(document.querySelectorAll("ytd-menu-popup-renderer")); } function latestPopup(){ const pops = allPopups().filter(visible); return pops.length ? pops[pops.length - 1] : null; } function visible(el){ if (!el) return false; const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && el.offsetHeight > 0 && el.offsetWidth > 0; } function closeAnyPopup() { document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true})); document.body.click(); } // ---------- Bulk removal ---------- async function removeSelected() { const selected = getAllItems().filter(isChecked); if (!selected.length) return alert("No videos selected."); const jobs = selected.map((el) => ({ el, vid: getVideoId(el) })); for (let i = 0; i < jobs.length; i++) { await removeOne(jobs[i], i + 1, jobs.length); } } async function removeOne(job, index, total) { const { el: itemNode, vid } = job; for (let attempt = 1; attempt <= 3; attempt++) { try { closeAnyPopup(); await sleep(120); itemNode.scrollIntoView({ block: "center", inline: "nearest" }); await sleep(80); const menuBtn = itemNode.querySelector("#menu #button, button[aria-label*='Action']"); if (!menuBtn) throw new Error("Menu button not found"); menuBtn.click(); const popup = await waitFor(() => { const p = latestPopup(); return p && visible(p) ? p : null; }, 3000); if (!popup) throw new Error("Popup not found"); const options = Array.from(popup.querySelectorAll("ytd-menu-service-item-renderer tp-yt-paper-item, tp-yt-paper-item")); let target = options.find((el) => /remove/i.test(el.textContent || "") && /watch\s*later/i.test(el.textContent || "")); if (!target) target = options.find((el) => /remove/i.test(el.textContent || "")); if (!target) throw new Error("Remove item not found in popup"); target.click(); const ok = await waitFor(() => { if (!document.body.contains(itemNode)) return true; if (vid) return !findItemByVideoId(vid); return false; }, 7000); if (!ok) throw new Error("Item still present after removal click"); closeAnyPopup(); status(`${index}/${total} removed`); await sleep(120); return; } catch (e) { console.warn(`Remove attempt ${attempt} failed:`, e); closeAnyPopup(); await sleep(250); } } status(`Skipped 1 after 3 attempts`); } function findItemByVideoId(vid) { return Array.from(document.querySelectorAll("ytd-playlist-video-renderer a#thumbnail[href*='watch'], ytd-playlist-video-renderer a#video-title[href*='watch']")) .find(a => { try { return new URL(a.href, location.origin).searchParams.get("v") === vid; } catch { return false; } }) ?.closest("ytd-playlist-video-renderer") || null; } async function waitFor(predicate, timeoutMs = 3000, interval = 50) { const t0 = performance.now(); while (performance.now() - t0 < timeoutMs) { const v = predicate(); if (v) return v; await sleep(interval); } return null; } function status(msg) { const s = statusEl(); if (s) s.textContent = `${msg} • ${s.textContent.replace(/^[^•]+•\s*/,"")}`; } // ---------- Boot / watchdog ---------- function processAllItems() { if (!onWL()) return; if (!uiInjected) createControlBar(); getAllItems().forEach(ensureCheckbox); } new MutationObserver(() => { if (onWL()) processAllItems(); }) .observe(document.documentElement, { childList:true, subtree:true }); setInterval(() => { if (onWL() && !document.getElementById("wl-manager-bar")) uiInjected = false; }, 1500); window.addEventListener("yt-navigate-finish", processAllItems); processAllItems(); })();