更佳 YouTube 剧场模式

改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。并修复近期 YouTube 更新中损坏的全屏界面。

目前为 2025-01-10 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Better Theater Mode for YouTube
  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.3
  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 Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts, while maintaining performance and compatibility. Also fixes the broken fullscreen UI from the recent YouTube update.
  28. // @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。並修復近期 YouTube 更新中損壞的全螢幕介面。
  29. // @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。并修复近期 YouTube 更新中损坏的全屏界面。
  30. // @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、最近のYouTubeアップデートによる壊れたフルスクリーンUIを修正します。
  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 temporaryFixRuleStyles;
  65. let staticChatFrameFixStyles;
  66.  
  67. let moviePlayer;
  68. let videoId;
  69. let chatFrame;
  70. let isTheaterMode = false;
  71. let chatCollapsed = true;
  72. let isLiveStream = false;
  73.  
  74. // Helper Functions
  75. //-------------------------------------------------------------------------------
  76. function removeStyle(style) {
  77. if (!activeStyles.has(style)) return;
  78. if (style && style.parentNode) {
  79. style.parentNode.removeChild(style);
  80. }
  81. activeStyles.delete(style);
  82. }
  83.  
  84. function removeAllStyles() {
  85. activeStyles.forEach((style) => {
  86. removeStyle(style);
  87. });
  88. activeStyles.clear();
  89. }
  90.  
  91. function addStyle(styleRule, styleObject) {
  92. if (activeStyles.has(styleObject)) return styleObject;
  93. styleObject = GMCustomAddStyle(styleRule);
  94. activeStyles.add(styleObject);
  95. return styleObject;
  96. }
  97.  
  98. // Apply Styles
  99. //----------------------------------
  100. function applyCssRulesToTemporarilyFixYouTubeFullscreen() {
  101. GMCustomAddStyle(`
  102. .html5-video-container {
  103. top: -1px !important;
  104. display: flex !important;
  105. justify-content: center !important;
  106. }
  107. .ytp-fit-cover-video .html5-main-video {
  108. left: 0 !important;
  109. object-fit: contain !important;
  110. width: 100% !important;
  111. }
  112. `);
  113. }
  114.  
  115. function applyStaticChatFrameFixStyles() {
  116. GMCustomAddStyle(`
  117. ytd-live-chat-frame[theater-watch-while][rounded-container] {
  118. border-top: 0 !important;
  119. border-bottom: 0 !important;
  120. }
  121. #panel-pages.yt-live-chat-renderer {
  122. border-bottom: 0 !important;
  123. }
  124. `);
  125.  
  126. const panelPages = document.querySelector('iron-pages#panel-pages');
  127. if (panelPages.offsetHeight <= 3) {
  128. GMCustomAddStyle(`
  129. #panel-pages.yt-live-chat-renderer{
  130. border-top: 0 !important;
  131. }
  132. `);
  133. }
  134. }
  135.  
  136. function applyChatStyles() {
  137. chatStyles = addStyle(`
  138. ytd-live-chat-frame[theater-watch-while][rounded-container] {
  139. border-radius: 0 !important;
  140. }
  141. ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
  142. top: 0 !important;
  143. border-top: 0 !important;
  144. border-bottom: 0 !important;
  145. }
  146. `, chatStyles);
  147. }
  148.  
  149. function applyVideoPlayerStyles() {
  150. videoPlayerStyles = addStyle(`
  151. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  152. max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
  153. }
  154. .html5-video-container {
  155. top: -1px !important;
  156. }
  157. `, videoPlayerStyles);
  158. }
  159.  
  160. function applyHeadmastStyles() {
  161. headmastStyles = addStyle(`
  162. #masthead-container.ytd-app {
  163. max-width: calc(100% - ${chatFrame.offsetWidth}px) !important;
  164. }
  165. `, headmastStyles);
  166. }
  167.  
  168. // Update Stuff
  169. //------------------------------------------------------
  170. function updateStyles() {
  171. const shouldNotActivate =
  172. !userSettings.isScriptActive ||
  173. userSettings.blacklist.has(videoId) ||
  174. (userSettings.enableOnlyForLiveStreams && !isLiveStream);
  175.  
  176. if (shouldNotActivate) {
  177. removeAllStyles();
  178. if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element
  179. return;
  180. }
  181.  
  182. if (userSettings.modifyChat) {
  183. applyChatStyles();
  184.  
  185. const mastHeadContainer = document.querySelector('#masthead-container');
  186. let shouldShrinkHeadmast = isTheaterMode && !chatCollapsed
  187. && chatFrame?.getBoundingClientRect().top <= mastHeadContainer.getBoundingClientRect().bottom;
  188.  
  189. if (shouldShrinkHeadmast) {
  190. applyHeadmastStyles();
  191. } else {
  192. removeStyle(headmastStyles);
  193. }
  194. } else {
  195. [chatStyles, headmastStyles].forEach(removeStyle);
  196. }
  197.  
  198. if (userSettings.modifyVideoPlayer) {
  199. applyVideoPlayerStyles();
  200. } else {
  201. removeStyle(videoPlayerStyles);
  202. }
  203. if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element
  204. }
  205.  
  206. function updateTheaterStatus(event) {
  207. isTheaterMode = !!event?.detail?.enabled;
  208. updateStyles();
  209. }
  210.  
  211. function updateChatStatus(event) {
  212. chatFrame = event.target;
  213. chatCollapsed = event.detail !== false;
  214. window.addEventListener('player-api-ready', () => { updateStyles(); }, { once: true });
  215. }
  216.  
  217. function updateVideoStatus(event) {
  218. try {
  219. videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
  220. moviePlayer = document.querySelector('#movie_player');
  221. isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent;
  222. showMenuOptions();
  223. } catch (error) {
  224. throw ("Failed to update video status due to this error. Error: " + error);
  225. }
  226. }
  227.  
  228. // Functions for the GUI
  229. //-----------------------------------------------------
  230. function processMenuOptions(options, callback) {
  231. Object.values(options).forEach(option => {
  232. if (!option.alwaysShow && !userSettings.expandMenu) return;
  233. if (option.items) {
  234. option.items.forEach(item => callback(item));
  235. } else {
  236. callback(option);
  237. }
  238. });
  239. }
  240. function removeMenuOptions() {
  241. menuItems.forEach((menuItem) => {
  242. GMCustomUnregisterMenuCommand(menuItem);
  243. });
  244. menuItems.clear();
  245. }
  246. function showMenuOptions() {
  247. removeMenuOptions();
  248. const menuOptions = {
  249. toggleScript: {
  250. alwaysShow: true,
  251. label: () => `🔄 ${userSettings.isScriptActive ? "Turn Off" : "Turn On"}`,
  252. menuId: "toggleScript",
  253. handleClick: function () {
  254. userSettings.isScriptActive = !userSettings.isScriptActive;
  255. GMCustomSetValue('isScriptActive', userSettings.isScriptActive);
  256. updateStyles();
  257. showMenuOptions();
  258. },
  259. },
  260. toggleOnlyLiveStreamMode: {
  261. alwaysShow: true,
  262. label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} Livestream Only Mode`,
  263. menuId: "toggleOnlyLiveStreamMode",
  264. handleClick: function () {
  265. userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams;
  266. GMCustomSetValue('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams);
  267. updateStyles();
  268. showMenuOptions();
  269. },
  270. },
  271. toggleChatStyle: {
  272. alwaysShow: true,
  273. label: () => `${userSettings.modifyChat ? "✅" : "❌"} Apply Chat Styles`,
  274. menuId: "toggleChatStyle",
  275. handleClick: function () {
  276. userSettings.modifyChat = !userSettings.modifyChat;
  277. GMCustomSetValue('modifyChat', userSettings.modifyChat);
  278. updateStyles();
  279. showMenuOptions();
  280. },
  281. },
  282. toggleVideoPlayerStyle: {
  283. alwaysShow: true,
  284. label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} Apply Video Player Styles`,
  285. menuId: "toggleVideoPlayerStyle",
  286. handleClick: function () {
  287. userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer;
  288. GMCustomSetValue('modifyVideoPlayer', userSettings.modifyVideoPlayer);
  289. updateStyles();
  290. showMenuOptions();
  291. },
  292. },
  293. addVideoToBlacklist: {
  294. alwaysShow: true,
  295. label: () => `${userSettings.blacklist.has(videoId) ? "Unblacklist Video " : "Blacklist Video"} [id: ${videoId}]`,
  296. menuId: "addVideoToBlacklist",
  297. handleClick: function () {
  298. if (userSettings.blacklist.has(videoId)) {
  299. userSettings.blacklist.delete(videoId);
  300. } else {
  301. userSettings.blacklist.add(videoId);
  302. }
  303. GMCustomSetValue('blacklist', [...userSettings.blacklist]);
  304. updateStyles();
  305. showMenuOptions();
  306. },
  307. },
  308. };
  309.  
  310. processMenuOptions(menuOptions, (item) => {
  311. GMCustomRegisterMenuCommand(item.label(), item.handleClick, {
  312. id: item.menuId,
  313. autoClose: false,
  314. });
  315. menuItems.add(item.menuId);
  316. });
  317. }
  318.  
  319. // Handle User Preferences
  320. //------------------------------------------------
  321. async function loadUserSettings() {
  322. try {
  323. const storedValues = await GMCustomListValues();
  324.  
  325. for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
  326. if (!storedValues.includes(key)) {
  327. await GMCustomSetValue(key, value instanceof Set ? Array.from(value) : value);
  328. }
  329. }
  330.  
  331. for (const key of storedValues) {
  332. if (!(key in DEFAULT_SETTINGS)) {
  333. await GMCustomDeleteValue(key);
  334. }
  335. }
  336.  
  337. const keyValuePairs = await Promise.all(
  338. storedValues.map(async key => [key, await GMCustomGetValue(key)])
  339. );
  340.  
  341. keyValuePairs.forEach(([newKey, newValue]) => {
  342. userSettings[newKey] = newValue;
  343. });
  344.  
  345. // Convert blacklist to Set if it exists
  346. if (userSettings.blacklist) {
  347. userSettings.blacklist = new Set(userSettings.blacklist);
  348. }
  349.  
  350. console.log(`Loaded user settings: ${JSON.stringify(userSettings)}`);
  351. } catch (error) {
  352. console.error(error);
  353. }
  354. }
  355. // Verify Grease Monkey API
  356. //-----------------------------------------------
  357. function checkGMAPI() {
  358. if (typeof GM != 'undefined') return;
  359. if (typeof GM_info != 'undefined') {
  360. useCompatibilityMode = true;
  361. console.warn("Running in compatibility mode.");
  362. return;
  363. }
  364. isBrokenOrMissingGMAPI = true;
  365. }
  366.  
  367. // Preparation Stuff
  368. //-------------------------------------------------
  369. function attachEventListeners() {
  370. window.addEventListener('yt-set-theater-mode-enabled', (event) => { updateTheaterStatus(event); }, true);
  371. window.addEventListener('yt-chat-collapsed-changed', (event) => { updateChatStatus(event); }, true);
  372. window.addEventListener('yt-page-data-fetched', (event) => {
  373. updateVideoStatus(event);
  374. }, true);
  375. window.addEventListener('yt-page-data-updated', updateStyles, true);
  376. }
  377.  
  378. function isLiveChatIFrame() {
  379. const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/;
  380. const currentUrl = window.location.href;
  381. return liveChatIFramePattern.test(currentUrl);
  382. }
  383.  
  384. async function initialize() {
  385. checkGMAPI();
  386. try {
  387. if (isBrokenOrMissingGMAPI) throw "Did not detect valid Grease Monkey API";
  388. if (isLiveChatIFrame()) return applyStaticChatFrameFixStyles(); // Fixes the terrible css of the live chat iframe.
  389. applyCssRulesToTemporarilyFixYouTubeFullscreen(); // This fixes the YouTube fullscreen issue where the video element would occasionally be blocked by the chat renderer. Would remove this if the issue is fixed.
  390. await loadUserSettings();
  391. updateStyles();
  392. attachEventListeners();
  393. showMenuOptions();
  394. } catch (error) {
  395. console.error(`Error loading user settings: ${error}. Aborting script.`);
  396. }
  397. }
  398. // Entry Point
  399. //-------------------------------------------
  400. initialize();
  401. })();