Watch Later Manager — checkboxes, filters, bulk delete (v1.3.1)

Add a simple GUI to the Watch Later playlist to help manage, including filters (channel/progress) and bulk removal.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();