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.

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