Created
November 2, 2025 19:25
-
-
Save RodriMora/280c3edde75d0fd99a27f5e4c42622bf to your computer and use it in GitHub Desktop.
Tampermonkey script to automatically switch to theater mode when the browser window is half size
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name YouTube Auto-Theater by Window Width (robust) | |
| // @namespace rodrigo.mora.autotheater | |
| // @version 0.2 | |
| // @description Toggle Theater mode automatically based on browser window width; works on reloads + SPA navigations. | |
| // @match https://www.youtube.com/* | |
| // @exclude https://www.youtube.com/embed/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (() => { | |
| // --- Tweakables --- | |
| const WIDTH_FRACTION = 0.62; // enter Theater when window.innerWidth <= 62% of screen width | |
| const MIN_THRESHOLD_PX = 1000; // never go below this cutoff | |
| const RESIZE_DEBOUNCE_MS = 120; // how often we react to window resizes | |
| const BURST_INTERVAL_MS = 250; // how often we "burst" enforce after nav/load | |
| const BURST_DURATION_MS = 5000; // how long we burst enforce after nav/load | |
| let lastDesired = undefined; | |
| let burstTimer = null; | |
| let flexyObserver = null; | |
| // --- Helpers --- | |
| const isWatchPage = () => location.pathname === '/watch'; | |
| const flexy = () => document.querySelector('ytd-watch-flexy'); | |
| const isTheater = () => !!document.querySelector('ytd-watch-flexy[theater]'); | |
| const isFullscreen = () => | |
| !!document.querySelector('ytd-watch-flexy[fullscreen]') || !!document.fullscreenElement; | |
| const thresholdPx = () => { | |
| const base = window.screen?.availWidth || window.screen?.width || window.innerWidth; | |
| return Math.max(MIN_THRESHOLD_PX, Math.round(base * WIDTH_FRACTION)); | |
| }; | |
| const desireTheater = () => window.innerWidth <= thresholdPx(); | |
| const clickTheaterButton = () => { | |
| const btn = | |
| document.querySelector('.ytp-size-button') || | |
| document.querySelector('button[aria-label*="Theater"]'); | |
| if (btn) { | |
| btn.click(); | |
| return true; | |
| } | |
| return false; | |
| }; | |
| const sendKeyT = () => { | |
| const opts = { key: 't', code: 'KeyT', keyCode: 84, which: 84, bubbles: true }; | |
| document.dispatchEvent(new KeyboardEvent('keydown', opts)); | |
| document.dispatchEvent(new KeyboardEvent('keyup', opts)); | |
| }; | |
| const toggleTheaterViaUI = () => clickTheaterButton() || sendKeyT(); | |
| const enforce = () => { | |
| if (!isWatchPage() || isFullscreen()) return; | |
| const f = flexy(); | |
| if (!f) return; // wait until the player container exists | |
| const want = desireTheater(); | |
| if (want === lastDesired && isTheater() === want) return; // nothing to do | |
| // Update lastDesired before toggling so burst calls won’t loop | |
| lastDesired = want; | |
| if (isTheater() !== want) toggleTheaterViaUI(); | |
| }; | |
| const debounce = (fn, ms) => { | |
| let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; | |
| }; | |
| const startBurst = () => { | |
| if (burstTimer) clearInterval(burstTimer); | |
| const t0 = Date.now(); | |
| burstTimer = setInterval(() => { | |
| enforce(); | |
| if (Date.now() - t0 > BURST_DURATION_MS) { | |
| clearInterval(burstTimer); | |
| burstTimer = null; | |
| } | |
| }, BURST_INTERVAL_MS); | |
| }; | |
| const observeFlexy = () => { | |
| const f = flexy(); | |
| if (!f) return; | |
| if (flexyObserver) { try { flexyObserver.disconnect(); } catch {} | |
| } | |
| flexyObserver = new MutationObserver(debounce(enforce, 50)); | |
| flexyObserver.observe(f, { | |
| attributes: true, | |
| attributeFilter: ['theater', 'fullscreen', 'style'], | |
| childList: true, | |
| subtree: true, | |
| }); | |
| }; | |
| const onNavOrLoad = () => { | |
| lastDesired = undefined; // force re-evaluation | |
| enforce(); | |
| observeFlexy(); | |
| startBurst(); // catch late-mounting controls/layout | |
| }; | |
| const init = () => { | |
| onNavOrLoad(); | |
| // SPA navigations on youtube.com | |
| window.addEventListener('yt-navigate-finish', onNavOrLoad, { passive: true }); | |
| // Another SPA signal YouTube emits when content changes | |
| document.addEventListener('yt-page-data-updated', onNavOrLoad, { passive: true }); | |
| // Handle normal loads/refresh + BFCache restores | |
| window.addEventListener('load', onNavOrLoad, { passive: true }); | |
| window.addEventListener('pageshow', onNavOrLoad, { passive: true }); | |
| // When tab regains focus after being resized elsewhere | |
| document.addEventListener('visibilitychange', () => { | |
| if (!document.hidden) onNavOrLoad(); | |
| }, { passive: true }); | |
| // Resizes and fullscreen changes | |
| window.addEventListener('resize', debounce(enforce, RESIZE_DEBOUNCE_MS)); | |
| document.addEventListener('fullscreenchange', enforce); | |
| // Whole-document observer: if YouTube shuffles big chunks, re-check | |
| const mo = new MutationObserver(debounce(() => { | |
| observeFlexy(); | |
| enforce(); | |
| }, 100)); | |
| mo.observe(document.documentElement, { childList: true, subtree: true }); | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init, { once: true }); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment