YouTube Theater Mode Fixer (new UI)

This fixes the new horrible YouTube Theater mode (2025 UI) to be like before, just a full-width video with scrolling and above the sidebar.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Theater Mode Fixer (new UI)
// @namespace    yt-theater-scroll-horizontal-fix
// @version      1.1.0
// @description  This fixes the new horrible YouTube Theater mode (2025 UI) to be like before, just a full-width video with scrolling and above the sidebar.
// @match        https://www.youtube.com/*
// @license      MIT
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(() => {
  'use strict';

  const STORE_KEY = 'ytTheaterFix:config';

  const DEFAULT_CONFIG = {
    enableScrollFix: true,

    // old theater behavior (expand width, height follows width; no forced "fill viewport height")
    theaterHorizontalOnly: true,

    // Cap so the player never becomes taller than the viewport (prevents giant player on very wide screens)
    theaterMaxHeightVh: 82, // 0-100

    hideBottomGridOverlay: true,

    // NEW: delete the bottom gradient overlay element inside the player
    removeYtpGradientBottom: true,

    // Off by default. Enable ONLY if scrolling still fails when cursor is over the player.
    wheelForwardingFallback: false,
  };

  function loadConfig() {
    try {
      const saved = (typeof GM_getValue === 'function') ? GM_getValue(STORE_KEY, {}) : {};
      return { ...DEFAULT_CONFIG, ...(saved || {}) };
    } catch {
      return { ...DEFAULT_CONFIG };
    }
  }

  function saveConfig(cfg) {
    try {
      if (typeof GM_setValue === 'function') GM_setValue(STORE_KEY, cfg);
    } catch {}
  }

  let config = loadConfig();
  let styleEl = null;

  // Gradient remover state
  let playerObserver = null;
  let playerObserverAttachedTo = null;

  function isWatchPage() {
    return location.pathname === '/watch' || location.pathname.startsWith('/watch');
  }

  function buildCss() {
    const maxVh = Math.max(0, Math.min(100, Number(config.theaterMaxHeightVh) || 82));

    return `
      :root { --yt-theater-h: min(56.25vw, ${maxVh}vh); }

      /* ============================
         1) Scroll restore
         ============================ */
      ${config.enableScrollFix ? `
      ytd-app { overflow: auto !important; }

      ytd-app[scrolling] {
        position: absolute !important;
        top: 0 !important;
        left: 0 !important;
        right: calc((var(--ytd-app-fullerscreen-scrollbar-width) + 1px) * -1) !important;
        bottom: 0 !important;
        overflow-x: auto !important;
      }

      ytd-watch-flexy[full-bleed-player] #single-column-container.ytd-watch-flexy,
      ytd-watch-flexy[full-bleed-player] #columns.ytd-watch-flexy {
        display: flex !important;
      }
      ` : ''}

      /* ============================================
         2) Theater "horizontal-only" sizing (KEY FIX)
         ============================================ */
      ${config.theaterHorizontalOnly ? `
      ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container.ytd-watch-flexy,
      ytd-watch-flexy[theater]:not([fullscreen]) #player-container-outer.ytd-watch-flexy,
      ytd-watch-flexy[theater]:not([fullscreen]) #player-container-inner.ytd-watch-flexy,
      ytd-watch-flexy[theater]:not([fullscreen]) #player.ytd-watch-flexy,
      ytd-watch-flexy[theater]:not([fullscreen]) ytd-player {
        height: var(--yt-theater-h) !important;
        min-height: 0 !important;
        max-height: none !important;
      }

      ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container.ytd-watch-flexy,
      ytd-watch-flexy[theater]:not([fullscreen]) #player-container-outer.ytd-watch-flexy {
        align-items: stretch !important;
        justify-content: flex-start !important;
      }

      ytd-watch-flexy[theater]:not([fullscreen]) #movie_player {
        height: 100% !important;
        max-height: none !important;
      }
      ` : ''}

      /* ============================================
         3) Optional: hide the annoying bottom grid overlay in player
         ============================================ */
      ${config.hideBottomGridOverlay ? `
      .ytp-fullscreen-grid-peeking.ytp-full-bleed-player.ytp-delhi-modern:not(.ytp-autohide) .ytp-chrome-bottom {
        bottom: 0 !important;
        opacity: 1 !important;
      }

      #movie_player:not(.ytp-grid-ended-state) .ytp-fullscreen-grid {
        display: none !important;
        top: 100% !important;
        opacity: 0 !important;
      }
      ` : ''}

      /* ============================================
         4) NEW: CSS fallback to hide ytp-gradient-bottom
         (JS below will actually delete it, but this helps if it flashes in briefly)
         ============================================ */
      ${config.removeYtpGradientBottom ? `
      #movie_player .ytp-gradient-bottom { display: none !important; }
      ` : ''}
    `;
  }

  function injectCssIfNeeded() {
    if (styleEl) return;
    styleEl = document.createElement('style');
    styleEl.id = 'yt-theater-fix-style';
    styleEl.textContent = buildCss();
    (document.head || document.documentElement).appendChild(styleEl);
  }

  function refreshCss() {
    if (!styleEl) return;
    styleEl.textContent = buildCss();
  }

  function removeCssIfPresent() {
    if (!styleEl) return;
    styleEl.remove();
    styleEl = null;
  }

  /* ============================
     Gradient deletion logic
     ============================ */
  function removeGradientBottomNow() {
    if (!config.removeYtpGradientBottom) return;

    // Try within movie_player first (less expensive)
    const mp = document.querySelector('#movie_player');
    if (mp) {
      mp.querySelectorAll('.ytp-gradient-bottom').forEach(el => el.remove());
      return;
    }

    // Fallback if player isn't mounted yet
    document.querySelectorAll('.ytp-gradient-bottom').forEach(el => el.remove());
  }

  function detachPlayerObserver() {
    if (playerObserver) {
      playerObserver.disconnect();
      playerObserver = null;
      playerObserverAttachedTo = null;
    }
  }

  function attachPlayerObserverIfPossible() {
    if (!config.removeYtpGradientBottom) {
      detachPlayerObserver();
      return;
    }

    const mp = document.querySelector('#movie_player');
    if (!mp) return;

    // Already attached to this exact node
    if (playerObserverAttachedTo === mp && playerObserver) return;

    detachPlayerObserver();
    playerObserverAttachedTo = mp;

    // Remove any existing gradient immediately
    removeGradientBottomNow();

    // Watch for YouTube recreating it
    playerObserver = new MutationObserver(() => {
      removeGradientBottomNow();
    });

    playerObserver.observe(mp, { childList: true, subtree: true });
  }

  // A lightweight “keep trying until the player exists” loop
  function ensureGradientRemovalWired() {
    if (!config.removeYtpGradientBottom) return;
    if (!isWatchPage()) return;

    // Try now
    attachPlayerObserverIfPossible();
    removeGradientBottomNow();

    // If player isn't there yet, retry a few times
    let tries = 0;
    const maxTries = 120; // ~120 * 250ms = 30s worst case, stops early once attached
    const t = setInterval(() => {
      tries++;
      attachPlayerObserverIfPossible();
      removeGradientBottomNow();

      if (playerObserverAttachedTo || tries >= maxTries || !isWatchPage()) {
        clearInterval(t);
      }
    }, 250);
  }

  function applyForCurrentUrl() {
    detachPlayerObserver();
    removeCssIfPresent();

    if (isWatchPage()) {
      injectCssIfNeeded();
      ensureGradientRemovalWired();
    }
  }

  // --- SPA navigation detector (YouTube is a single-page app) ---
  let lastHref = location.href;
  const navObserver = new MutationObserver(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      applyForCurrentUrl();
    }
  });
  navObserver.observe(document, { subtree: true, childList: true });

  // YouTube also fires navigation events; use them too
  document.addEventListener('yt-navigate-finish', () => {
    applyForCurrentUrl();
  }, true);

  // Initial apply
  applyForCurrentUrl();

  /* ============================
     Optional: wheel forwarding fallback
     ============================ */
  function theaterActive() {
    const flexy = document.querySelector('ytd-watch-flexy');
    return !!(flexy && flexy.hasAttribute('theater') && !flexy.hasAttribute('fullscreen'));
  }

  function isInPlayer(target) {
    return !!(target && (target.closest('#movie_player') || target.closest('ytd-player') || target.closest('#player')));
  }

  function getScrollTarget() {
    const candidates = [
      document.querySelector('ytd-app'),
      document.scrollingElement,
      document.documentElement,
      document.body,
    ].filter(Boolean);

    for (const el of candidates) {
      try {
        if (el.scrollHeight > el.clientHeight + 5) return el;
      } catch {}
    }
    return document.scrollingElement || document.documentElement;
  }

  function onWheelCapture(e) {
    if (!config.wheelForwardingFallback) return;
    if (!isWatchPage()) return;
    if (!theaterActive()) return;
    if (!isInPlayer(e.target)) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;

    const delta = e.deltaY;
    if (!delta) return;

    const scroller = getScrollTarget();
    if (!scroller) return;

    const prev = scroller.scrollTop;
    scroller.scrollTop += delta;

    if (scroller.scrollTop !== prev) {
      if (e.cancelable) e.preventDefault();
      e.stopImmediatePropagation();
    }
  }

  document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });

  /* ============================
     Menu toggles
     ============================ */
  function toggle(key) {
    config = { ...config, [key]: !config[key] };
    saveConfig(config);

    // If we toggled gradient removal, update observer state immediately
    if (key === 'removeYtpGradientBottom') {
      applyForCurrentUrl();
      return;
    }

    if (styleEl) refreshCss();
    else applyForCurrentUrl();
  }

  function setMaxVh(delta) {
    const cur = Number(config.theaterMaxHeightVh) || 82;
    const next = Math.max(0, Math.min(100, cur + delta));
    config = { ...config, theaterMaxHeightVh: next };
    saveConfig(config);
    refreshCss();
  }

  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand(`YT: Scroll fix (${config.enableScrollFix ? 'ON' : 'OFF'})`, () => toggle('enableScrollFix'));
    GM_registerMenuCommand(`YT: Theater horizontal-only (${config.theaterHorizontalOnly ? 'ON' : 'OFF'})`, () => toggle('theaterHorizontalOnly'));
    GM_registerMenuCommand(`YT: Hide bottom grid overlay (${config.hideBottomGridOverlay ? 'ON' : 'OFF'})`, () => toggle('hideBottomGridOverlay'));
    GM_registerMenuCommand(`YT: Remove ytp-gradient-bottom (${config.removeYtpGradientBottom ? 'ON' : 'OFF'})`, () => toggle('removeYtpGradientBottom'));
    GM_registerMenuCommand(`YT: Wheel-forwarding fallback (${config.wheelForwardingFallback ? 'ON' : 'OFF'})`, () => toggle('wheelForwardingFallback'));
    GM_registerMenuCommand(`YT: Theater max height +2vh (now ${config.theaterMaxHeightVh}vh)`, () => setMaxVh(+2));
    GM_registerMenuCommand(`YT: Theater max height -2vh (now ${config.theaterMaxHeightVh}vh)`, () => setMaxVh(-2));
  }
})();