YouTube Chapter Readability Overlay (toggle + blur)

Adds a semi-transparent (and optionally blurred) black overlay over the bottom ~20% of the YouTube player when controls are visible. Toggle modes with Ctrl+O.

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Chapter Readability Overlay (toggle + blur)
// @namespace    https://20dots.com
// @license      MIT
// @version      1.1.0
// @description  Adds a semi-transparent (and optionally blurred) black overlay over the bottom ~20% of the YouTube player when controls are visible. Toggle modes with Ctrl+O.
// @match        https://www.youtube.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
  // --- Tweakables ---
  const OVERLAY_HEIGHT = 95;   // bottom area height as % of player height
  const OVERLAY_OPACITY    = 0.40; // 0..1 for the darkening layer
  const BLUR_PX            = 10;    // blur radius when blur mode is active
  const TOGGLE_HOTKEY      = { ctrlKey:true, key: 'o' }; // Alt+O cycles modes
  // -------------------

  // Modes: 0 = Off, 1 = Dim, 2 = Dim + Blur
  const LS_KEY = 'ytChapterOverlayMode';
  let mode = Number(localStorage.getItem(LS_KEY) ?? '2'); // default to Dim+Blur

  let overlay, player, controls, checkTimer, playerObserver, routeHandlerAttached = false;

  function saveMode() { localStorage.setItem(LS_KEY, String(mode)); }
  function showToast(text) {
    try {
      const toast = document.createElement('div');
      Object.assign(toast.style, {
        position: 'fixed', left: '50%', bottom: '96px', transform: 'translateX(-50%)',
        padding: '6px 10px', background: 'rgba(0,0,0,0.8)', color: '#fff',
        font: '500 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial',
        borderRadius: '8px', zIndex: 999999, pointerEvents: 'none', opacity: '0',
        transition: 'opacity 150ms ease'
      });
      toast.textContent = text;
      document.body.appendChild(toast);
      requestAnimationFrame(() => toast.style.opacity = '1');
      setTimeout(() => {
        toast.style.opacity = '0';
        setTimeout(() => toast.remove(), 180);
      }, 900);
    } catch {}
  }

  function findPlayer() {
    return document.querySelector('#movie_player.html5-video-player') ||
           document.querySelector('.html5-video-player');
  }
  function findControls() {
    return player?.querySelector('.ytp-chrome-bottom');
  }

  function ensureOverlay() {
    if (!player || overlay) return;
    overlay = document.createElement('div');
    overlay.className = 'yt-chapter-contrast-overlay';
    Object.assign(overlay.style, {
      position: 'absolute',
      left: 0, right: 0, bottom: 0,
      height: `${OVERLAY_HEIGHT}pt`,
      background: `rgba(0,0,0,${OVERLAY_OPACITY})`,
      pointerEvents: 'none',
      zIndex: '30',                 // below YT controls (~60) but above the video
      opacity: '0',
      transition: 'opacity 120ms ease',
      // blur is toggled dynamically
    });
    const cs = getComputedStyle(player);
    if (cs.position === 'static') player.style.position = 'relative';
    player.appendChild(overlay);
    applyModeStyles();
  }

  function applyModeStyles() {
    if (!overlay) return;
    // Base darkening always present when visible; blur added conditionally
    overlay.style.backdropFilter = (mode === 2) ? `blur(${BLUR_PX}px)` : '';
    overlay.style.webkitBackdropFilter = overlay.style.backdropFilter; // Safari / Chromium
  }

  function isControlsVisible() {
    const autoHidden = player?.classList.contains('ytp-autohide') || player?.classList.contains('ytp-hide-controls');
    if (autoHidden === false) return true;
    if (controls) {
      const style = getComputedStyle(controls);
      const visible = controls.offsetHeight > 0 && style.opacity !== '0' && style.visibility !== 'hidden';
      if (visible) return true;
    }
    return false;
  }

  function updateOverlayVisibility() {
    if (!overlay) return;
    // Show only if controls are visible AND mode != Off
    overlay.style.opacity = (mode !== 0 && isControlsVisible()) ? '1' : '0';
  }

  function startPolling() {
    stopPolling();
    checkTimer = setInterval(updateOverlayVisibility, 250);
  }
  function stopPolling() {
    if (checkTimer) { clearInterval(checkTimer); checkTimer = null; }
  }

  function observePlayer() {
    if (!player) return;
    disconnectObserver();
    playerObserver = new MutationObserver(() => {
      controls = findControls();
      updateOverlayVisibility();
    });
    playerObserver.observe(player, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true });
  }
  function disconnectObserver() {
    if (playerObserver) { playerObserver.disconnect(); playerObserver = null; }
  }

  function teardown() {
    stopPolling();
    disconnectObserver();
    overlay?.remove(); overlay = null;
    player = null; controls = null;
  }

  function initOnceReady(attempts = 0) {
    player = findPlayer();
    if (!player) {
      if (attempts < 80) return void setTimeout(() => initOnceReady(attempts + 1), 100); // ~8s
      return;
    }
    controls = findControls();
    ensureOverlay();
    observePlayer();
    startPolling();
    updateOverlayVisibility();
  }

  function onRouteChange() {
    teardown();
    initOnceReady();
  }

  function attachRouteHandlers() {
    if (routeHandlerAttached) return;
    routeHandlerAttached = true;
    document.addEventListener('yt-navigate-finish', onRouteChange);
    document.addEventListener('yt-player-updated', onRouteChange);
    // Fallback URL watcher
    let lastUrl = location.href;
    new MutationObserver(() => {
      if (location.href !== lastUrl) { lastUrl = location.href; onRouteChange(); }
    }).observe(document.documentElement, { childList: true, subtree: true });
  }

  function keyMatches(e, spec) {
    return !!spec &&
      !!e &&
      (spec.ctrlKey  == null || !!e.ctrlKey  === !!spec.ctrlKey) &&
      (spec.shiftKey == null || !!e.shiftKey === !!spec.shiftKey) &&
      (spec.altKey   == null || !!e.altKey   === !!spec.altKey) &&
      (spec.metaKey  == null || !!e.metaKey  === !!spec.metaKey) &&
      (e.key?.toLowerCase?.() === spec.key?.toLowerCase?.());
  }

  function onKeyDown(e) {
    // Ignore when typing in inputs/textareas/contenteditable
    const t = e.target;
    const typing = t && (
      t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
      t.isContentEditable || t.getAttribute?.('role') === 'textbox'
    );
    if (typing) return;

    if (keyMatches(e, TOGGLE_HOTKEY)) {
      e.preventDefault();
      mode = (mode + 1) % 3; // 0 -> 1 -> 2 -> 0
      saveMode();
      applyModeStyles();
      updateOverlayVisibility();
      showToast(`Chapter Overlay: ${['Off','Dim','Dim + Blur'][mode]}`);
    }
  }

  // Boot
  attachRouteHandlers();
  initOnceReady();
  window.addEventListener('keydown', onKeyDown, true);
})();