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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();