Better YouTube Theater Mode

This script adjusts YouTube's player to extend to the bottom of the screen, creating a Twitch.tv-like viewing experience with fewer distractions.

As of 2025-01-09. See the latest version.

  1. // ==UserScript==
  2. // @name Better YouTube Theater Mode
  3. // @name:zh-TW 更佳的 YouTube 劇場模式
  4. // @name:zh-CN 更佳的 YouTube 剧场模式
  5. // @name:ja 改良されたYouTubeシアターモード
  6. // @icon https://www.youtube.com/img/favicon_48.png
  7. // @author ElectroKnight22
  8. // @namespace electroknight22_youtube_better_theater_mode_namespace
  9. // @version 1.5.1
  10. // @match *://www.youtube.com/*
  11. // @match *://www.youtube-nocookie.com/*
  12. // @grant GM.addStyle
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @grant GM.deleteValue
  16. // @grant GM.listValues
  17. // @grant GM.registerMenuCommand
  18. // @grant GM.unregisterMenuCommand
  19. // @grant GM_addStyle
  20. // @grant GM_getValue
  21. // @grant GM_setValue
  22. // @grant GM_deleteValue
  23. // @grant GM_listValues
  24. // @grant GM_registerMenuCommand
  25. // @grant GM_unregisterMenuCommand
  26. // @license MIT
  27. // @description This script adjusts YouTube's player to extend to the bottom of the screen, creating a Twitch.tv-like viewing experience with fewer distractions.
  28. // @description:zh-TW 此腳本會將 YouTube 播放器調整為延伸至螢幕底部,提供類似 Twitch.tv 的沉浸式觀看體驗,減少干擾。
  29. // @description:zh-CN 此脚本将 YouTube 播放器调整为延伸至屏幕底部,提供类似 Twitch.tv 的沉浸式观看体验,减少干扰。
  30. // @description:ja このスクリプトは、YouTubeのプレーヤーを画面の下部まで拡張し、Twitch.tvのようなより没入感のある視聴体験を提供します。
  31. // ==/UserScript==
  32.  
  33. /*jshint esversion: 11 */
  34.  
  35. (function () {
  36. 'use strict';
  37.  
  38. const DEFAULT_SETTINGS = {
  39. isScriptActive: true,
  40. enableOnlyForLiveStreams: false,
  41. modifyVideoPlayer: true,
  42. modifyChat: true,
  43. blacklist: new Set()
  44. };
  45.  
  46. let userSettings = { ...DEFAULT_SETTINGS };
  47. let useCompatibilityMode = false;
  48. let isBrokenOrMissingGMAPI = false;
  49.  
  50. const GMCustomAddStyle = useCompatibilityMode ? GM_addStyle : GM.addStyle;
  51. const GMCustomRegisterMenuCommand = useCompatibilityMode ? GM_registerMenuCommand : GM.registerMenuCommand;
  52. const GMCustomUnregisterMenuCommand = useCompatibilityMode ? GM_unregisterMenuCommand : GM.unregisterMenuCommand;
  53. const GMCustomGetValue = useCompatibilityMode ? GM_getValue : GM.getValue;
  54. const GMCustomSetValue = useCompatibilityMode ? GM_setValue : GM.setValue;
  55. const GMCustomListValues = useCompatibilityMode ? GM_listValues : GM.listValues;
  56. const GMCustomDeleteValue = useCompatibilityMode ? GM_deleteValue : GM.deleteValue;
  57.  
  58. let menuItems = new Set();
  59. let activeStyles = new Set();
  60. let chatStyles;
  61. let videoPlayerStyles;
  62. let headmastStyles;
  63.  
  64. let moviePlayer;
  65. let videoId;
  66. let chatFrame;
  67. let isTheaterMode = false;
  68. let chatCollapsed = true;
  69. let isLiveStream = false;
  70.  
  71.  
  72. // Helper Functions
  73. //-------------------------------------------------------------------------------
  74. function removeStyle(style) {
  75. if (style && style.parentNode) {
  76. style.parentNode.removeChild(style);
  77. }
  78. activeStyles.delete(style);
  79. }
  80.  
  81. function removeAllStyles() {
  82. activeStyles.forEach((style) => {
  83. removeStyle(style);
  84. })
  85. activeStyles.clear();
  86. }
  87.  
  88. function addStyle(styleRule, styleObject) {
  89. removeStyle(styleObject);
  90. styleObject = GMCustomAddStyle(styleRule);
  91. activeStyles.add(styleObject);
  92. return styleObject;
  93. }
  94.  
  95. // Apply Styles
  96. //----------------------------------
  97. function applyChatStyles() {
  98. chatStyles = addStyle(`
  99. ytd-live-chat-frame[theater-watch-while][rounded-container],
  100. #panel-pages.yt-live-chat-renderer {
  101. border-radius: 0 !important;
  102. border-top: 0px !important;
  103. border-bottom: 0px !important;
  104. }
  105.  
  106. ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
  107. top: 0 !important;
  108. }
  109. `, chatStyles);
  110. }
  111.  
  112. function applyVideoPlayerStyles() {
  113. videoPlayerStyles = addStyle(`
  114. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  115. max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
  116. }
  117.  
  118. .html5-video-container {
  119. top: -1px !important;
  120. }
  121. `, videoPlayerStyles);
  122. }
  123.  
  124. function applyHeadmastStyles() {
  125. headmastStyles = addStyle(`
  126. #masthead-container.ytd-app {
  127. max-width: calc(100% - ${chatFrame.offsetWidth}px) !important;
  128. }
  129. `, headmastStyles);
  130. }
  131.  
  132. // Update Stuff
  133. //------------------------------------------------------
  134. function updateStyles() {
  135. console.log('Updating Styles');
  136. let shouldNotActivate =
  137. !userSettings.isScriptActive ||
  138. (userSettings.enableOnlyForLiveStreams && !isLiveStream) ||
  139. userSettings.blacklist.has(videoId);
  140.  
  141. if (shouldNotActivate) {
  142. removeAllStyles();
  143. if (moviePlayer) moviePlayer.setSizeStyle(); //trigger size update for the html5 video element
  144. return;
  145. }
  146.  
  147. if (userSettings.modifyChat) {
  148. applyChatStyles();
  149.  
  150. const mastHeadContainer = document.querySelector('#masthead-container');;
  151. let shouldShrinkHeadmast = isTheaterMode && !chatCollapsed
  152. && chatFrame?.getBoundingClientRect().top <= mastHeadContainer.getBoundingClientRect().bottom;
  153.  
  154. if (shouldShrinkHeadmast) {
  155. applyHeadmastStyles();
  156. } else {
  157. removeStyle(headmastStyles);
  158. }
  159. } else {
  160. [chatStyles, headmastStyles].forEach(removeStyle);
  161. }
  162.  
  163. if (userSettings.modifyVideoPlayer) {
  164. applyVideoPlayerStyles();
  165. } else {
  166. removeStyle(videoPlayerStyles);
  167. }
  168.  
  169. if (moviePlayer) moviePlayer.setSizeStyle(); //trigger size update for the html5 video element
  170. }
  171.  
  172. function updateTheaterStatus(event) {
  173. isTheaterMode = !!event?.detail?.enabled;
  174. updateStyles();
  175. }
  176.  
  177. async function updateChatStatus(event) {
  178. chatFrame = event.target;
  179. chatCollapsed = event.detail !== false;
  180. window.addEventListener('player-api-ready', () => { updateStyles(); }, { once: true });
  181. }
  182.  
  183. function updateVideoStatus(event) {
  184. videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
  185. moviePlayer = document.querySelector('#movie_player');
  186. isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent;
  187. }
  188.  
  189. // Functions for the GUI
  190. //-----------------------------------------------------
  191. function processMenuOptions(options, callback) {
  192. Object.values(options).forEach(option => {
  193. if (!option.alwaysShow && !userSettings.expandMenu) return;
  194. if (option.items) {
  195. option.items.forEach(item => callback(item));
  196. } else {
  197. callback(option);
  198. }
  199. });
  200. }
  201.  
  202. function removeMenuOptions() {
  203. menuItems.forEach((menuItem) => {
  204. GMCustomUnregisterMenuCommand(menuItem);
  205. });
  206. menuItems.clear();
  207. }
  208.  
  209. function showMenuOptions() {
  210. removeMenuOptions();
  211. const menuOptions = {
  212. toggleScript: {
  213. alwaysShow: true,
  214. label: () => `🔄 ${userSettings.isScriptActive ? "Turn Off" : "Turn On"}`,
  215. menuId: "toggleScript",
  216. handleClick: function () {
  217. userSettings.isScriptActive = !userSettings.isScriptActive;
  218. GMCustomSetValue('isScriptActive', userSettings.isScriptActive);
  219. updateStyles();
  220. showMenuOptions();
  221. },
  222. },
  223. toggleOnlyLiveStreamMode: {
  224. alwaysShow: true,
  225. label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} Livestream Only Mode`,
  226. menuId: "toggleOnlyLiveStreamMode",
  227. handleClick: function () {
  228. userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams;
  229. GMCustomSetValue('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams);
  230. updateStyles();
  231. showMenuOptions();
  232. },
  233. },
  234. toggleChatStyle: {
  235. alwaysShow: true,
  236. label: () => `${userSettings.modifyChat ? "✅" : "❌"} Apply Chat Styles`,
  237. menuId: "toggleChatStyle",
  238. handleClick: function () {
  239. userSettings.modifyChat = !userSettings.modifyChat;
  240. GMCustomSetValue('modifyChat', userSettings.modifyChat);
  241. updateStyles();
  242. showMenuOptions();
  243. },
  244. },
  245. toggleVideoPlayerStyle: {
  246. alwaysShow: true,
  247. label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} Apply Video Player Styles`,
  248. menuId: "toggleVideoPlayerStyle",
  249. handleClick: function () {
  250. userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer;
  251. GMCustomSetValue('modifyVideoPlayer', userSettings.modifyVideoPlayer);
  252. updateStyles();
  253. showMenuOptions();
  254. },
  255. },
  256. addVideoToBlacklist: {
  257. alwaysShow: true,
  258. label: () => `${userSettings.blacklist.has(videoId) ? "Unblacklist Video " : "Blacklist Video"} [id: ${videoId}]`,
  259. menuId: "addVideoToBlacklist",
  260. handleClick: function () {
  261. if (userSettings.blacklist.has(videoId)) {
  262. userSettings.blacklist.delete(videoId)
  263. } else {
  264. userSettings.blacklist.add(videoId);
  265. }
  266. GMCustomSetValue('blacklist', [...userSettings.blacklist]);
  267. updateStyles();
  268. showMenuOptions();
  269. },
  270. },
  271. };
  272.  
  273. processMenuOptions(menuOptions, (item) => {
  274. GMCustomRegisterMenuCommand(item.label(), item.handleClick, {
  275. id: item.menuId,
  276. autoClose: false,
  277. });
  278. menuItems.add(item.menuId);
  279. });
  280. }
  281.  
  282. // Handle User Preferences
  283. //------------------------------------------------
  284. async function loadUserSettings() {
  285. try {
  286. const storedValues = await GMCustomListValues();
  287.  
  288. for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
  289. if (!storedValues.includes(key)) {
  290. await GMCustomSetValue(key, value instanceof Set ? Array.from(value) : value);
  291. }
  292. }
  293.  
  294. for (const key of storedValues) {
  295. if (!(key in DEFAULT_SETTINGS)) {
  296. await GMCustomDeleteValue(key);
  297. }
  298. }
  299.  
  300. const keyValuePairs = await Promise.all(
  301. storedValues.map(async key => [key, await GMCustomGetValue(key)])
  302. );
  303.  
  304. keyValuePairs.forEach(([newKey, newValue]) => {
  305. userSettings[newKey] = newValue;
  306. });
  307.  
  308. // Convert blacklist to Set if it exists
  309. if (userSettings.blacklist) {
  310. userSettings.blacklist = new Set(userSettings.blacklist);
  311. }
  312.  
  313. console.log(`Loaded user settings: ${JSON.stringify(userSettings)}`);
  314. } catch (error) {
  315. console.error(error);
  316. }
  317. }
  318. // Verify Grease Monkey API
  319. //-----------------------------------------------
  320. function checkGMAPI() {
  321. if (typeof GM != 'undefined') return;
  322. if (typeof GM_info != 'undefined') {
  323. useCompatibilityMode = true;
  324. console.warn("Running in compatibility mode.");
  325. return;
  326. }
  327. isBrokenOrMissingGMAPI = true;
  328. }
  329.  
  330. // Preparation Stuff
  331. //-------------------------------------------------
  332. function attachEventListeners() {
  333. window.addEventListener('yt-set-theater-mode-enabled', (event) => { updateTheaterStatus(event); }, true);
  334. window.addEventListener('yt-chat-collapsed-changed', (event) => { updateChatStatus(event); }, true);
  335. window.addEventListener('yt-page-data-fetched', (event) => {
  336. updateVideoStatus(event);
  337. }, true);
  338. window.addEventListener('yt-page-data-updated', updateStyles, true);
  339. }
  340.  
  341. async function initialize() {
  342. checkGMAPI();
  343. try {
  344. if (isBrokenOrMissingGMAPI) throw "Did not detect valid Grease Monkey API";
  345. await loadUserSettings();
  346. updateStyles()
  347. attachEventListeners();
  348. showMenuOptions();
  349. } catch (error) {
  350. console.error(`Error loading user settings: ${error}. Aborting script.`);
  351. }
  352. }
  353.  
  354. // Entry Point
  355. //-------------------------------------------
  356. initialize();
  357. })();