Created
May 22, 2026 20:37
-
-
Save vdeemann/74cf325e6f7d29f5a51989bbf99cb90a to your computer and use it in GitHub Desktop.
In theater mode: title, Subscribe row, and description are centered as a group at the top with empty space on either side. Recommendations sidebar appears on the right starting at the comments section. No flicker on page load.
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 Theater Mode - Centered Top + Sidebar Beside Comments | |
| // @namespace https://github.com/vdeemann | |
| // @version 9.0.0 | |
| // @description In theater mode: title, Subscribe row, and description are centered as a group at the top with empty space on either side. Recommendations sidebar appears on the right starting at the comments section. No flicker on page load. | |
| // @author Dee | |
| // @match https://www.youtube.com/* | |
| // @match https://youtube.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const STYLE_ID = 'yt-theater-centered-v9'; | |
| const READY_CLASS = 'yt-theater-sidebar-ready'; | |
| // Width of the top centered content group (title, subscribe row, description). | |
| const CENTERED_WIDTH = 1280; | |
| // Width of the right-side recommendations column. | |
| const SIDEBAR_WIDTH = 402; | |
| // Gap between comments column and sidebar. | |
| const COLUMN_GAP = 24; | |
| // Tweak: + lowers the sidebar, - raises it. | |
| const ALIGN_FUDGE = 0; | |
| const css = ` | |
| :root { | |
| --yt-theater-sidebar-top: 0px; | |
| } | |
| /* ============================================================ | |
| HIDE-UNTIL-READY: kills flicker on initial load and on SPA nav. | |
| Selectors target #secondary and related containers by plain ID | |
| so they match before custom elements upgrade. | |
| ============================================================ */ | |
| html:not(.${READY_CLASS}) #secondary, | |
| html:not(.${READY_CLASS}) #secondary-inner, | |
| html:not(.${READY_CLASS}) ytd-watch-next-secondary-results-renderer, | |
| html:not(.${READY_CLASS}) #related { | |
| display: none !important; | |
| } | |
| /* ============================================================ | |
| THEATER MODE ONLY: the rules below scope themselves to | |
| ytd-watch-flexy[theater] so default mode is untouched. | |
| ============================================================ */ | |
| /* The columns container must be a positioning context so we can | |
| absolutely position #secondary relative to it. */ | |
| ytd-watch-flexy[theater] #columns.ytd-watch-flexy { | |
| position: relative !important; | |
| } | |
| /* Constrain and center the content ABOVE comments. These are the | |
| direct children of #primary that hold title, owner/subscribe row, | |
| description, and the chip bar. */ | |
| ytd-watch-flexy[theater] #primary.ytd-watch-flexy > #primary-inner > ytd-watch-metadata, | |
| ytd-watch-flexy[theater] #primary.ytd-watch-flexy > #primary-inner > #below, | |
| ytd-watch-flexy[theater] #primary.ytd-watch-flexy > #primary-inner > #info, | |
| ytd-watch-flexy[theater] #primary.ytd-watch-flexy ytd-watch-metadata { | |
| max-width: ${CENTERED_WIDTH}px !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* Sidebar: absolutely positioned to the right of #columns, top set | |
| by JS-measured CSS variable. */ | |
| html.${READY_CLASS} ytd-watch-flexy[theater] #secondary.ytd-watch-flexy { | |
| display: block !important; | |
| position: absolute !important; | |
| top: var(--yt-theater-sidebar-top) !important; | |
| right: 24px !important; | |
| width: ${SIDEBAR_WIDTH}px !important; | |
| max-width: ${SIDEBAR_WIDTH}px !important; | |
| min-width: ${SIDEBAR_WIDTH}px !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| opacity: 1 !important; | |
| z-index: 1 !important; | |
| } | |
| ytd-watch-flexy[theater] #secondary-inner.ytd-watch-flexy { | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| position: static !important; | |
| } | |
| /* Make sure related items render normally inside the sidebar. */ | |
| html.${READY_CLASS} ytd-watch-flexy[theater] #related.ytd-watch-flexy, | |
| html.${READY_CLASS} ytd-watch-flexy[theater] ytd-watch-next-secondary-results-renderer { | |
| display: block !important; | |
| visibility: visible !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| } | |
| ytd-watch-flexy[theater] #related.ytd-watch-flexy #items { | |
| display: block !important; | |
| } | |
| ytd-watch-flexy[theater] #related.ytd-watch-flexy ytd-compact-video-renderer, | |
| ytd-watch-flexy[theater] #related.ytd-watch-flexy ytd-compact-radio-renderer, | |
| ytd-watch-flexy[theater] #related.ytd-watch-flexy ytd-compact-playlist-renderer { | |
| display: flex !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| margin: 0 0 8px 0 !important; | |
| } | |
| /* Reserve right-side space for the absolutely positioned sidebar | |
| in the comments area, so comments don't extend under it. */ | |
| ytd-watch-flexy[theater] ytd-comments#comments, | |
| ytd-watch-flexy[theater] #primary > #primary-inner > ytd-comments, | |
| ytd-watch-flexy[theater] #primary ytd-comments { | |
| padding-right: ${SIDEBAR_WIDTH + COLUMN_GAP}px !important; | |
| box-sizing: border-box !important; | |
| } | |
| `; | |
| function injectStyle() { | |
| // Remove styles from any prior versions of this script. | |
| [ | |
| 'yt-theater-relocate-recommendations', | |
| 'yt-theater-keep-sidebar', | |
| 'yt-theater-sidebar-below', | |
| 'yt-theater-sidebar-aligned', | |
| 'yt-theater-centered-top' | |
| ].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.remove(); | |
| }); | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = css; | |
| (document.head || document.documentElement).appendChild(style); | |
| } | |
| function markReady() { | |
| document.documentElement.classList.add(READY_CLASS); | |
| } | |
| function markNotReady() { | |
| document.documentElement.classList.remove(READY_CLASS); | |
| } | |
| function findCommentsAnchor() { | |
| return ( | |
| document.querySelector('ytd-comments#comments') || | |
| document.querySelector('#comments') || | |
| document.querySelector('ytd-comments') | |
| ); | |
| } | |
| function isOnWatchPage() { | |
| return /\/watch\b/.test(location.pathname); | |
| } | |
| /** | |
| * Measure where comments sit relative to #columns and set the sidebar's | |
| * top position so its top edge aligns with the comments start. | |
| */ | |
| function updateSidebarOffset() { | |
| if (!isOnWatchPage()) { | |
| markReady(); | |
| return true; | |
| } | |
| const flexy = document.querySelector('ytd-watch-flexy'); | |
| if (!flexy) return false; | |
| if (!flexy.hasAttribute('theater')) { | |
| markReady(); | |
| return true; | |
| } | |
| const columns = flexy.querySelector('#columns'); | |
| const comments = findCommentsAnchor(); | |
| if (!columns || !comments) return false; | |
| const colRect = columns.getBoundingClientRect(); | |
| const cmtRect = comments.getBoundingClientRect(); | |
| if (cmtRect.height === 0 && cmtRect.top === 0) { | |
| return false; | |
| } | |
| const top = Math.max(0, Math.round(cmtRect.top - colRect.top + ALIGN_FUDGE)); | |
| document.documentElement.style.setProperty('--yt-theater-sidebar-top', `${top}px`); | |
| markReady(); | |
| return true; | |
| } | |
| let scheduled = false; | |
| function scheduleUpdate() { | |
| if (scheduled) return; | |
| scheduled = true; | |
| requestAnimationFrame(() => { | |
| scheduled = false; | |
| updateSidebarOffset(); | |
| }); | |
| } | |
| function onNavigateStart() { | |
| markNotReady(); | |
| } | |
| function onNavigateFinish() { | |
| injectStyle(); | |
| startMeasurementLoop(); | |
| } | |
| let loopActive = false; | |
| function startMeasurementLoop() { | |
| if (loopActive) return; | |
| loopActive = true; | |
| let tries = 0; | |
| const maxTries = 900; // ~15s at 60fps | |
| const tick = () => { | |
| if (updateSidebarOffset()) { | |
| loopActive = false; | |
| return; | |
| } | |
| if (++tries < maxTries) { | |
| requestAnimationFrame(tick); | |
| } else { | |
| loopActive = false; | |
| // Safety reveal so user isn't stuck with an invisible sidebar. | |
| markReady(); | |
| } | |
| }; | |
| requestAnimationFrame(tick); | |
| } | |
| function setupObservers() { | |
| window.addEventListener('resize', scheduleUpdate); | |
| window.addEventListener('yt-navigate-start', onNavigateStart); | |
| window.addEventListener('yt-navigate-finish', onNavigateFinish); | |
| window.addEventListener('yt-page-data-updated', scheduleUpdate); | |
| const observeFlexy = () => { | |
| const flexy = document.querySelector('ytd-watch-flexy'); | |
| if (!flexy || flexy.__ytTheaterObserved) return; | |
| flexy.__ytTheaterObserved = true; | |
| new MutationObserver(scheduleUpdate).observe(flexy, { | |
| attributes: true, | |
| attributeFilter: ['theater', 'fullscreen', 'hidden'] | |
| }); | |
| const primary = flexy.querySelector('#primary'); | |
| if (primary && typeof ResizeObserver !== 'undefined') { | |
| new ResizeObserver(scheduleUpdate).observe(primary); | |
| } | |
| }; | |
| observeFlexy(); | |
| if (!document.querySelector('ytd-watch-flexy') && document.body) { | |
| const bodyObs = new MutationObserver(() => { | |
| if (document.querySelector('ytd-watch-flexy')) { | |
| observeFlexy(); | |
| bodyObs.disconnect(); | |
| } | |
| }); | |
| bodyObs.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| } | |
| // ---- Boot ---- | |
| injectStyle(); | |
| if (!document.head) { | |
| const headWatch = new MutationObserver(() => { | |
| if (document.head) { | |
| injectStyle(); | |
| headWatch.disconnect(); | |
| } | |
| }); | |
| headWatch.observe(document.documentElement, { childList: true }); | |
| } | |
| function boot() { | |
| injectStyle(); | |
| setupObservers(); | |
| startMeasurementLoop(); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', boot, { once: true }); | |
| } else { | |
| boot(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment