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, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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));
  }
})();