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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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