YouTube Theater Fill and volume control (Brave v2)

Simple, robust baseline for Brave: auto-enable Theater and fill viewport height. Minimal SPA hooks. No volume OSD or extras.

// ==UserScript==
// @name         YouTube Theater Fill and volume control (Brave v2)
// @namespace    https://greasyfork.org/users/1533208
// @version      2.2.1-brave
// @description  Simple, robust baseline for Brave: auto-enable Theater and fill viewport height. Minimal SPA hooks. No volume OSD or extras.
// @author       Martin (Left234) & Lina
// @license      MIT
// @match        https://*.youtube.com/*
// @exclude      https://*.youtube.com/embed/*
// @exclude      https://music.youtube.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==
(() => {
  "use strict";

  const VERSION = "2.2.1-brave";
  const CLASS   = "ytf-fill";
  const COVER   = "ytf-cover-header";
  const STYLE_ID = "ytf-fill-style";

  // --- OSD/Volume config ---
  const OSD_FRAC = 0.382; // golden-ish position
  const VOL_STEP = 0.05;
  const SHIFT_MULT = 2;

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

  // ---------- utils ----------
  function onReady(fn){
    if (document.readyState === "complete" || document.readyState === "interactive") fn();
    else document.addEventListener("DOMContentLoaded", fn, { once:true });
  }
  function $(sel, root=document){ return root.querySelector(sel); }

  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 = `
body.${CLASS} { overflow-x: hidden !important; }
body.${CLASS} ytd-app { position: static !important; }

/* host containers sized to the usable viewport */
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;
}

/* reduce ambient fights */
body.${CLASS} #cinematics, body.${CLASS} #cinematics-container { display: none !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;
}
/* --- Contain/Cover guard & absolute-fill chain --- */
.ytp-fit-cover-video .html5-main-video { object-fit: contain !important; }

#movie_player,
#movie_player .html5-video-player,
#movie_player .html5-video-container,
#movie_player video.html5-main-video {
  position: absolute !important;
  inset: 0 !important;
  width: 100% !important;
  height: 100% !important;
}

#movie_player video.html5-main-video {
  object-fit: contain !important;
  object-position: center center !important;
  display: block !important;
}

/* Ensure clean bars on extra-wide videos (avoid page peeking) */
ytd-watch-flexy[theater] #player-theater-container.ytd-watch-flexy,
ytd-watch-grid[theater]   #player-theater-container.ytd-watch-grid {
  background: #000 !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") || $("ytd-masthead"); }

  function updateOffset(){
    if (!document.body) return;
    if (document.body.classList.contains(COVER)) {
      document.documentElement.style.setProperty("--ytf-offset", "0px"); return;
    }
    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() || !!$("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 = !$("ytd-watch-flexy[theater], ytd-watch-grid[theater]");
        if (stillNot) { const btn = $(".ytp-size-button"); if (btn) btn.click(); }
      });
    }
  }

  function whenFlexy(cb){
    const tryNow = () => { const el = $("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 isFullscreen(){
    return !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
  }
  function onFullscreenChange(){ document.body && document.body.classList.toggle(COVER, isFullscreen()); updateOffset(); }

  
  // --- normalize player sizing to prevent "tiny corner" and overscale ---
  function normalizePlayerSizing(){
    const player = document.getElementById('movie_player');
    const container = player?.querySelector('.html5-video-container');
    const video = player?.querySelector('video.html5-main-video');
    if (!player || !container || !video) return;

    // Remove inline offsets/scales that can persist across SPA transitions
    [player, container, video].forEach(el => {
      ['width','height','left','top','right','bottom','transform'].forEach(p => {
        try { el.style.removeProperty(p); } catch {}
      });
    });

    // Re-assert sane sizing
    try {
      container.style.position = 'absolute';
      container.style.inset = '0';
      video.style.width = '100%';
      video.style.height = '100%';
      video.style.objectFit = 'contain';
      video.style.objectPosition = 'center center';
    } catch {}
  }

  
  // -------------- Global Volume + OSD --------------
  let ytfOsd, ytfOsdTimer;
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
  const isEditable = (el) => el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/i.test(el.tagName) ||
                       !!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;
    ytfOsd.style.left = `${x}px`;
    ytfOsd.style.top  = `${y}px`;
  }

  function showVolumeOSD(value) {
    if (!ytfOsd) {
      ytfOsd = document.createElement("div");
      ytfOsd.style.cssText = [
        "position:fixed","left:50%","top:30%","transform:translate(-50%,-50%)",
        "padding:0","background:transparent","color:#fff",
        "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",
        "-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-in-out"
      ].join(";");
      document.documentElement.appendChild(ytfOsd);
      window.addEventListener("resize", positionOSDToVideo);
      document.addEventListener("fullscreenchange", positionOSDToVideo);
      document.addEventListener("webkitfullscreenchange", positionOSDToVideo);
      document.addEventListener("mozfullscreenchange", positionOSDToVideo);
      document.addEventListener("MSFullscreenChange", positionOSDToVideo);
    }
    ytfOsd.textContent = typeof value === "number" ? `${value}%` : String(value);
    positionOSDToVideo();
    ytfOsd.style.opacity = "1";
    clearTimeout(ytfOsdTimer);
    ytfOsdTimer = setTimeout(() => (ytfOsd.style.opacity = "0"), 900);
  }

  function onKeydown(e) {
    if (!onWatch()) return;
    if (isEditable(e.target)) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;

    if (e.key === "ArrowUp" || e.key === "ArrowDown") {
      const v = getVideo(); if (!v) return;
      const mult = e.shiftKey ? SHIFT_MULT : 1;
      const delta = (e.key === "ArrowUp" ? 1 : -1) * VOL_STEP * mult;
      const newVol = clamp(Math.round((v.volume + delta) * 100) / 100, 0, 1);
      setVolumeUIAware(newVol);
      showVolumeOSD(Math.round(newVol * 100));
      e.preventDefault(); e.stopImmediatePropagation(); return;
    }

    if (e.key === "m" || e.key === "M") {
      const muted = toggleMuteUIAware();
      const mp = document.getElementById('movie_player');
      const volPct = (muted ? 0 : Math.round(((mp?.getVolume?.() ?? getVideo()?.volume ?? 0) * (mp?.getVolume ? 1 : 100)) ));
      showVolumeOSD(muted ? "Muted" : volPct);
      e.preventDefault(); e.stopImmediatePropagation(); return;
    }
  }

  // --- v2.1 beta: UI-aware volume so YT slider stays in sync ---
  function setVolumeUIAware(v01) {
    const mp = document.getElementById('movie_player');
    const pct = Math.round(v01 * 100);
    if (mp && typeof mp.setVolume === 'function') {
      try { pct === 0 ? mp.mute?.() : mp.unMute?.(); } catch {}
      try { mp.setVolume(pct); } catch {}
    } else {
      const v = getVideo(); if (v) { if (pct > 0) v.muted = false; v.volume = v01; }
    }
    try {
      const v = getVideo(); v?.dispatchEvent(new Event('volumechange', { bubbles: true }));
    } catch {}
  }

  function toggleMuteUIAware() {
    const mp = document.getElementById('movie_player');
    if (mp && typeof mp.isMuted === 'function') {
      try { mp.isMuted() ? mp.unMute?.() : mp.mute?.(); } catch {}
      return !!mp.isMuted?.();
    } else {
      const v = getVideo(); if (v) v.muted = !v.muted; return !!v?.muted;
    }
  }

  // v2.2.1 beta: Guarded normalize when tab becomes visible / pageshow (race-safe)
  function normalizeOnShow() {
    if (!onWatch()) return;
    const attempt = (tries = 0) => {
      const v = getVideo();
      const ready = v && v.readyState >= 1 && v.videoWidth > 0;
      if (ready) {
        normalizePlayerSizing();
      } else if (tries < 5) {
        setTimeout(() => attempt(tries + 1), 120);
      }
    };
    setTimeout(() => attempt(0), 60);
  }

  function apply(){
    if (!document.body) return;
    if (!onWatch()) { document.body.classList.remove(CLASS, COVER); return; }
    document.body.classList.add(CLASS);
    whenFlexy(flexy => { ensureTheater(flexy); updateOffset(); window.scrollTo(0, 0); });
  }

  // ---------- init ----------
  initViewportUnit();
  injectStyle();

  onReady(() => {
    apply(); updateOffset(); setTimeout(normalizePlayerSizing, 50);
    // light SPA hooks
    document.addEventListener("yt-navigate-start", apply);
    document.addEventListener("yt-navigate-finish", () => { apply(); normalizePlayerSizing(); });
    window.addEventListener("load", () => { apply(); normalizePlayerSizing(); });
    window.addEventListener("resize", () => { updateOffset(); normalizePlayerSizing(); });
    document.addEventListener("fullscreenchange", onFullscreenChange);
    document.addEventListener("webkitfullscreenchange", onFullscreenChange);
    document.addEventListener("mozfullscreenchange", onFullscreenChange);
    document.addEventListener("MSFullscreenChange", onFullscreenChange);

    // capture phase to beat YT handlers
    window.addEventListener("keydown", onKeydown, true);

    // v2.2.1 beta guarded show hooks
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible") normalizeOnShow();
    }, { once: true });
    window.addEventListener("pageshow", () => { normalizeOnShow(); }, { once: true });
  });
})();