YouTube Theater Fill and volume control

Auto-enable Theater mode and resize the YouTube player to fill the viewport height. While in Fullscreen, temporarily "cover" the header to prevent stray bars; revert on exit. Excludes embeds and YouTube Music. Global Arrow keys control volume with a clean OSD.

// ==UserScript==
// @name         YouTube Theater Fill and volume control
// @namespace    https://greasyfork.org/users/1533208
// @version      1.4.1
// @description  Auto-enable Theater mode and resize the YouTube player to fill the viewport height. While in Fullscreen, temporarily "cover" the header to prevent stray bars; revert on exit. Excludes embeds and YouTube Music. Global Arrow keys control volume with a clean OSD.
// @author       Martin (Left234) & Lina
// @license      MIT
// @match        https://*.youtube.com/*
// @exclude      https://*.youtube.com/embed/*
// @exclude      https://music.youtube.com/*
// @homepageURL  https://greasyfork.org/en/scripts/554496-youtube-theater-fill
// @supportURL   https://greasyfork.org/en/scripts/554496-youtube-theater-fill/feedback
// @icon         https://www.youtube.com/s/desktop/6c8d3e3a/img/favicon_144x144.png
// @run-at       document-start
// @grant        none
// ==/UserScript==
/*
MIT License
Copyright (c) 2025 Martin (Left234) & Lina
*/

(() => {
  "use strict";

  // ---------------- Config ----------------
  // OSD composition placement: 'golden' (0.382 from top) or 'thirds' (1/3 from top)
  const OSD_COMPOSITION = 'golden';
  const OSD_FRAC = OSD_COMPOSITION === 'golden' ? 0.382 : (1 / 3);

  // Volume steps
  const VOL_STEP = 0.05; // 5% normal
  const SHIFT_MULT = 2;  // Shift = 10% total (2 * 5%)

  // -------------- Theater Fill --------------
  const VERSION = "1.4.1";
  const CLASS = "ytf-fill";
  const COVER = "ytf-cover-header";
  const STYLE_ID = "ytf-fill-style";

  const LS_THEATER_PREF_KEYS = [
    "yt-player-theater-mode-preference",
    "ytd-player-theater-mode-preference",
  ];

  let flexyAttrObserver = null;
  let mastheadRO = null;

  // --- Utilities ---
  function onReady(fn) {
    if (document.readyState === "complete" || document.readyState === "interactive") fn();
    else document.addEventListener("DOMContentLoaded", fn, { once: true });
  }

  function initViewportUnit() {
    let unit = "100vh";
    try {
      if (CSS.supports("height: 100dvh")) unit = "100dvh";
      else if (CSS.supports("height: 100svh")) unit = "100svh";
    } catch {}
    document.documentElement.style.setProperty("--ytf-viewport", unit);
  }

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const css = `
/* Base sizing */
body.${CLASS} { overflow-x: hidden !important; }
body.${CLASS} ytd-app { position: static !important; }

body.${CLASS} ytd-watch-flexy[theater] #player-theater-container.ytd-watch-flexy,
body.${CLASS} ytd-watch-flexy[theater] #player-full-bleed-container.ytd-watch-flexy,
body.${CLASS} ytd-watch-flexy[theater] #full-bleed-container.ytd-watch-flexy,
body.${CLASS} ytd-watch-grid[theater]   #player-theater-container.ytd-watch-grid,
body.${CLASS} ytd-watch-grid[theater]   #player-full-bleed-container.ytd-watch-grid,
body.${CLASS} ytd-watch-grid[theater]   #full-bleed-container.ytd-watch-grid {
  height: calc(var(--ytf-viewport, 100vh) - var(--ytf-offset, 56px)) !important;
  max-height: calc(var(--ytf-viewport, 100vh) - var(--ytf-offset, 56px)) !important;
  width: 100vw !important;
  max-width: 100vw !important;
  background: #000 !important;
  position: relative !important;
  inset: auto !important;
  left: 0 !important; right: 0 !important;
  transform: none !important;
  margin: 0 auto !important;
  padding: 0 !important;
}

body.${CLASS} ytd-watch-flexy[theater] #player-container-outer.ytd-watch-flexy,
body.${CLASS} ytd-watch-flexy[theater] #player-container-inner.ytd-watch-flexy,
body.${CLASS} ytd-watch-flexy[theater] #player-container.ytd-watch-flexy,
body.${CLASS} ytd-watch-grid[theater]   #player-container-outer.ytd-watch-grid,
body.${CLASS} ytd-watch-grid[theater]   #player-container-inner.ytd-watch-grid,
body.${CLASS} ytd-watch-grid[theater]   #player-container.ytd-watch-grid {
  height: 100% !important;
  width: 100% !important;
  display: flex !important;
  justify-content: center !important;
  align-items: stretch !important;
  left: 0 !important; right: 0 !important;
  transform: none !important;
  margin: 0 !important; padding: 0 !important;
}

body.${CLASS} ytd-watch-flexy[theater] ytd-player#ytd-player,
body.${CLASS} ytd-watch-grid[theater]   ytd-player#ytd-player {
  height: 100% !important; width: 100% !important;
}

body.${CLASS} ytd-watch-flexy[theater] .html5-video-player,
body.${CLASS} ytd-watch-flexy[theater] .html5-video-container,
body.${CLASS} ytd-watch-flexy[theater] video.html5-main-video,
body.${CLASS} ytd-watch-grid[theater]   .html5-video-player,
body.${CLASS} ytd-watch-grid[theater]   .html5-video-container,
body.${CLASS} ytd-watch-grid[theater]   video.html5-main-video {
  width: 100% !important; height: 100% !important;
  left: 0 !important; right: 0 !important;
  transform: none !important;
  object-fit: contain !important;
  object-position: center center !important;
}

/* Remove ambient cinematics that fight our sizing */
body.${CLASS} ytd-watch-flexy #cinematics,
body.${CLASS} ytd-watch-grid  #cinematics,
body.${CLASS} #cinematics-container { display: none !important; }

/* Chips and below content placement */
body.${CLASS} ytd-watch-flexy #chips,
body.${CLASS} ytd-watch-grid  #chips,
body.${CLASS} #chips-wrapper { position: static !important; }
body.${CLASS} ytd-watch-flexy #below,
body.${CLASS} ytd-watch-grid  #below { margin-top: 8px !important; }

/* Header cover mode */
body.${CLASS}.${COVER} { --ytf-offset: 0px !important; }
body.${CLASS}.${COVER} ytd-app #masthead-container.ytd-app,
body.${CLASS}.${COVER} ytd-masthead {
  position: absolute !important;
  top: 0 !important; left: 0 !important; right: 0 !important;
  z-index: 2020 !important;
  width: 100% !important;
}
`;
    const s = document.createElement("style");
    s.id = STYLE_ID;
    s.textContent = css;
    (document.head || document.documentElement).appendChild(s);
  }

  function masthead() {
    return document.getElementById("masthead-container") || document.querySelector("ytd-masthead");
  }

  function updateOffset() {
    if (!document.body) return;
    if (document.body.classList.contains(COVER)) {
      document.documentElement.style.setProperty("--ytf-offset", "0px");
    } else {
      const h = masthead()?.offsetHeight || 56;
      document.documentElement.style.setProperty("--ytf-offset", `${h}px`);
    }
  }

  function isWatchUrl() {
    return location.pathname === "/watch" || /[?&]v=/.test(location.search);
  }
  const onWatch = () => isWatchUrl() || !!document.querySelector("ytd-watch-flexy, ytd-watch-grid");

  function setTheaterPref() {
    try {
      LS_THEATER_PREF_KEYS.forEach(k => localStorage.setItem(k, "DEFAULT_ON"));
    } catch {}
  }

  function ensureTheater(flexy) {
    if (!flexy) return;
    setTheaterPref();
    if (!flexy.hasAttribute("theater")) {
      try { flexy.setAttribute("theater", ""); } catch {}
      try { flexy.theater = true; } catch {}
      queueMicrotask(() => {
        const stillNot = !document.querySelector("ytd-watch-flexy[theater], ytd-watch-grid[theater]");
        if (stillNot) {
          const btn = document.querySelector(".ytp-size-button");
          if (btn) btn.click();
        }
      });
    }
  }

  function whenFlexy(cb) {
    const tryNow = () => {
      const el = document.querySelector("ytd-watch-flexy, ytd-watch-grid");
      if (!el) return false;
      cb(el);
      return true;
    };
    if (tryNow()) return;
    const mo = new MutationObserver(() => { if (tryNow()) mo.disconnect(); });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }

  function watchFlexyAttrs(flexy) {
    if (!flexy) return;
    if (flexyAttrObserver) flexyAttrObserver.disconnect();
    flexyAttrObserver = new MutationObserver(updateOffset);
    flexyAttrObserver.observe(flexy, { attributes: true });
  }

  function watchMasthead() {
    const mh = masthead();
    if (!mh || typeof ResizeObserver === "undefined") return;
    if (mastheadRO) mastheadRO.disconnect();
    mastheadRO = new ResizeObserver(updateOffset);
    mastheadRO.observe(mh);
  }

  function setCover(on) {
    if (!document.body) return;
    document.body.classList.toggle(COVER, !!on);
    updateOffset();
  }

  function isFullscreen() {
    return !!(document.fullscreenElement ||
              document.webkitFullscreenElement ||
              document.mozFullScreenElement ||
              document.msFullscreenElement);
  }

  function onFullscreenChange() {
    setCover(isFullscreen());
  }

  function apply() {
    if (!document.body) return;
    if (!onWatch()) {
      document.body.classList.remove(CLASS, COVER);
      return;
    }

    document.body.classList.add(CLASS);

    whenFlexy(flexy => {
      ensureTheater(flexy);
      watchFlexyAttrs(flexy);
      updateOffset();
      // Scroll to top so player is fully visible
      window.scrollTo(0, 0);
      console.info(`[YouTube Theater Fill v${VERSION}] Theater + fill applied.`);
    });

    watchMasthead();
  }

  // -------------- Global Volume --------------
  let ytfOsd, ytfOsdTimer;
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
  const isEditable = (el) => {
    if (!el) return false;
    if (el.isContentEditable) return true;
    const tag = el.tagName;
    if (!tag) return false;
    return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" ||
           !!el.closest('input, textarea, select, [contenteditable="true"]');
  };
  const getVideo = () =>
    document.querySelector("video.html5-main-video") ||
    document.querySelector("#movie_player video") ||
    document.querySelector("video");

  function positionOSDToVideo() {
    if (!ytfOsd) return;
    const vid = getVideo();
    if (!vid) return;
    const r = vid.getBoundingClientRect();
    const x = r.left + r.width / 2;
    const y = r.top + r.height * OSD_FRAC; // golden/thirds placement
    ytfOsd.style.left = `${x}px`;
    ytfOsd.style.top  = `${y}px`;
  }

  // Transparent, thinner OSD (white text), positioned by composition rule
  function showVolumeOSD(value) {
    if (!ytfOsd) {
      ytfOsd = document.createElement("div");
      ytfOsd.style.cssText = [
        "position:fixed",
        "left:50%",                 // initial (will be repositioned to video on first show)
        "top:30%",
        "transform:translate(-50%,-50%)",
        "padding:0",
        "background:transparent",
        "color:#fff",
        // Slightly thinner, clean stack
        "font:600 40px/1.08 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, Apple Color Emoji, Segoe UI Emoji",
        "letter-spacing:.2px",
        "-webkit-font-smoothing:antialiased",
        "text-rendering:optimizeLegibility",
        // Very light edge for legibility on bright frames
        "-webkit-text-stroke:.35px rgba(0,0,0,.30)",
        "text-shadow:0 0 8px rgba(0,0,0,.28)",
        "z-index:2147483647",
        "pointer-events:none",
        "opacity:0",
        "transition:opacity .15s ease"
      ].join(";");
      document.documentElement.appendChild(ytfOsd);

      // Keep OSD aligned on resizes and fullscreen toggles
      window.addEventListener("resize", positionOSDToVideo);
      document.addEventListener("fullscreenchange", positionOSDToVideo);
      document.addEventListener("webkitfullscreenchange", positionOSDToVideo);
      document.addEventListener("mozfullscreenchange", positionOSDToVideo);
      document.addEventListener("MSFullscreenChange", positionOSDToVideo);
    }

    // Set text & place using composition geometry
    ytfOsd.textContent = typeof value === "number" ? `${value}%` : String(value);
    positionOSDToVideo();

    // Fade in/out
    ytfOsd.style.opacity = "1";
    clearTimeout(ytfOsdTimer);
    ytfOsdTimer = setTimeout(() => (ytfOsd.style.opacity = "0"), 900);
  }

  function onKeydown(e) {
    // Only on watch pages; ignore when typing; ignore with Ctrl/Cmd/Alt
    if (!onWatch()) return;
    if (isEditable(e.target)) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;

    // Volume Up/Down
    if (e.key === "ArrowUp" || e.key === "ArrowDown") {
      const vid = getVideo();
      if (!vid) return;
      const mult = e.shiftKey ? SHIFT_MULT : 1;
      const delta = (e.key === "ArrowUp" ? 1 : -1) * VOL_STEP * mult;
      const newVol = clamp(Math.round((vid.volume + delta) * 100) / 100, 0, 1);

      if (newVol !== vid.volume) {
        if (vid.muted && newVol > 0) vid.muted = false;
        vid.volume = newVol;
        showVolumeOSD(Math.round(newVol * 100));
      }

      e.preventDefault();
      e.stopImmediatePropagation();
      return;
    }

    // Mute toggle
    if (e.key === "m" || e.key === "M") {
      const vid = getVideo();
      if (!vid) return;
      vid.muted = !vid.muted;
      showVolumeOSD(vid.muted ? "Muted" : Math.round(vid.volume * 100));
      e.preventDefault();
      e.stopImmediatePropagation();
      return;
    }
  }

  // --- Init + SPA awareness ---
  initViewportUnit();
  injectStyle();

  onReady(() => {
    apply();
    updateOffset();

    // Fallback retry in case of race conditions
    setTimeout(apply, 1500);

    // YouTube SPA events
    document.addEventListener("yt-navigate-start", apply);
    document.addEventListener("yt-navigate-finish", apply);
    document.addEventListener("yt-page-data-fetched", apply);

    // Resize updates header offset
    window.addEventListener("resize", updateOffset);

    // Fullscreen listeners (cross-browser)
    document.addEventListener("fullscreenchange", onFullscreenChange);
    document.addEventListener("webkitfullscreenchange", onFullscreenChange);
    document.addEventListener("mozfullscreenchange", onFullscreenChange);
    document.addEventListener("MSFullscreenChange", onFullscreenChange);

    // Global volume keys (capture phase to beat page scroll)
    window.addEventListener("keydown", onKeydown, true);
  });
})();