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.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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