// ==UserScript== // @name Better Theater Mode for YouTube // @name:zh-TW 更佳 YouTube 劇場模式 // @name:zh-CN 更佳 YouTube 剧场模式 // @name:ja より良いYouTubeシアターモード // @icon https://www.youtube.com/img/favicon_48.png // @author ElectroKnight22 // @namespace electroknight22_youtube_better_theater_mode_namespace // @version 1.5.3 // @match *://www.youtube.com/* // @match *://www.youtube-nocookie.com/* // @grant GM.addStyle // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license MIT // @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. // @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。並修復近期 YouTube 更新中損壞的全螢幕介面。 // @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。并修复近期 YouTube 更新中损坏的全屏界面。 // @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、最近のYouTubeアップデートによる壊れたフルスクリーンUIを修正します。 // @downloadURL none // ==/UserScript== /*jshint esversion: 11 */ (function () { 'use strict'; const DEFAULT_SETTINGS = { isScriptActive: true, enableOnlyForLiveStreams: false, modifyVideoPlayer: true, modifyChat: true, blacklist: new Set() }; let userSettings = { ...DEFAULT_SETTINGS }; let useCompatibilityMode = false; let isBrokenOrMissingGMAPI = false; const GMCustomAddStyle = useCompatibilityMode ? GM_addStyle : GM.addStyle; const GMCustomRegisterMenuCommand = useCompatibilityMode ? GM_registerMenuCommand : GM.registerMenuCommand; const GMCustomUnregisterMenuCommand = useCompatibilityMode ? GM_unregisterMenuCommand : GM.unregisterMenuCommand; const GMCustomGetValue = useCompatibilityMode ? GM_getValue : GM.getValue; const GMCustomSetValue = useCompatibilityMode ? GM_setValue : GM.setValue; const GMCustomListValues = useCompatibilityMode ? GM_listValues : GM.listValues; const GMCustomDeleteValue = useCompatibilityMode ? GM_deleteValue : GM.deleteValue; let menuItems = new Set(); let activeStyles = new Set(); let chatStyles; let videoPlayerStyles; let headmastStyles; let temporaryFixRuleStyles; let staticChatFrameFixStyles; let moviePlayer; let videoId; let chatFrame; let isTheaterMode = false; let chatCollapsed = true; let isLiveStream = false; // Helper Functions //------------------------------------------------------------------------------- function removeStyle(style) { if (!activeStyles.has(style)) return; if (style && style.parentNode) { style.parentNode.removeChild(style); } activeStyles.delete(style); } function removeAllStyles() { activeStyles.forEach((style) => { removeStyle(style); }); activeStyles.clear(); } function addStyle(styleRule, styleObject) { if (activeStyles.has(styleObject)) return styleObject; styleObject = GMCustomAddStyle(styleRule); activeStyles.add(styleObject); return styleObject; } // Apply Styles //---------------------------------- function applyCssRulesToTemporarilyFixYouTubeFullscreen() { GMCustomAddStyle(` .html5-video-container { top: -1px !important; display: flex !important; justify-content: center !important; } .ytp-fit-cover-video .html5-main-video { left: 0 !important; object-fit: contain !important; width: 100% !important; } `); } function applyStaticChatFrameFixStyles() { GMCustomAddStyle(` ytd-live-chat-frame[theater-watch-while][rounded-container] { border-top: 0 !important; border-bottom: 0 !important; } #panel-pages.yt-live-chat-renderer { border-bottom: 0 !important; } `); const panelPages = document.querySelector('iron-pages#panel-pages'); if (panelPages.offsetHeight <= 3) { GMCustomAddStyle(` #panel-pages.yt-live-chat-renderer{ border-top: 0 !important; } `); } } function applyChatStyles() { chatStyles = addStyle(` ytd-live-chat-frame[theater-watch-while][rounded-container] { border-radius: 0 !important; } ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy { top: 0 !important; border-top: 0 !important; border-bottom: 0 !important; } `, chatStyles); } function applyVideoPlayerStyles() { videoPlayerStyles = addStyle(` ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy { max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important; } .html5-video-container { top: -1px !important; } `, videoPlayerStyles); } function applyHeadmastStyles() { headmastStyles = addStyle(` #masthead-container.ytd-app { max-width: calc(100% - ${chatFrame.offsetWidth}px) !important; } `, headmastStyles); } // Update Stuff //------------------------------------------------------ function updateStyles() { const shouldNotActivate = !userSettings.isScriptActive || userSettings.blacklist.has(videoId) || (userSettings.enableOnlyForLiveStreams && !isLiveStream); if (shouldNotActivate) { removeAllStyles(); if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element return; } if (userSettings.modifyChat) { applyChatStyles(); const mastHeadContainer = document.querySelector('#masthead-container'); let shouldShrinkHeadmast = isTheaterMode && !chatCollapsed && chatFrame?.getBoundingClientRect().top <= mastHeadContainer.getBoundingClientRect().bottom; if (shouldShrinkHeadmast) { applyHeadmastStyles(); } else { removeStyle(headmastStyles); } } else { [chatStyles, headmastStyles].forEach(removeStyle); } if (userSettings.modifyVideoPlayer) { applyVideoPlayerStyles(); } else { removeStyle(videoPlayerStyles); } if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element } function updateTheaterStatus(event) { isTheaterMode = !!event?.detail?.enabled; updateStyles(); } function updateChatStatus(event) { chatFrame = event.target; chatCollapsed = event.detail !== false; window.addEventListener('player-api-ready', () => { updateStyles(); }, { once: true }); } function updateVideoStatus(event) { try { videoId = event.detail.pageData.playerResponse.videoDetails.videoId; moviePlayer = document.querySelector('#movie_player'); isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent; showMenuOptions(); } catch (error) { throw ("Failed to update video status due to this error. Error: " + error); } } // Functions for the GUI //----------------------------------------------------- function processMenuOptions(options, callback) { Object.values(options).forEach(option => { if (!option.alwaysShow && !userSettings.expandMenu) return; if (option.items) { option.items.forEach(item => callback(item)); } else { callback(option); } }); } function removeMenuOptions() { menuItems.forEach((menuItem) => { GMCustomUnregisterMenuCommand(menuItem); }); menuItems.clear(); } function showMenuOptions() { removeMenuOptions(); const menuOptions = { toggleScript: { alwaysShow: true, label: () => `🔄 ${userSettings.isScriptActive ? "Turn Off" : "Turn On"}`, menuId: "toggleScript", handleClick: function () { userSettings.isScriptActive = !userSettings.isScriptActive; GMCustomSetValue('isScriptActive', userSettings.isScriptActive); updateStyles(); showMenuOptions(); }, }, toggleOnlyLiveStreamMode: { alwaysShow: true, label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} Livestream Only Mode`, menuId: "toggleOnlyLiveStreamMode", handleClick: function () { userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams; GMCustomSetValue('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams); updateStyles(); showMenuOptions(); }, }, toggleChatStyle: { alwaysShow: true, label: () => `${userSettings.modifyChat ? "✅" : "❌"} Apply Chat Styles`, menuId: "toggleChatStyle", handleClick: function () { userSettings.modifyChat = !userSettings.modifyChat; GMCustomSetValue('modifyChat', userSettings.modifyChat); updateStyles(); showMenuOptions(); }, }, toggleVideoPlayerStyle: { alwaysShow: true, label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} Apply Video Player Styles`, menuId: "toggleVideoPlayerStyle", handleClick: function () { userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer; GMCustomSetValue('modifyVideoPlayer', userSettings.modifyVideoPlayer); updateStyles(); showMenuOptions(); }, }, addVideoToBlacklist: { alwaysShow: true, label: () => `${userSettings.blacklist.has(videoId) ? "Unblacklist Video " : "Blacklist Video"} [id: ${videoId}]`, menuId: "addVideoToBlacklist", handleClick: function () { if (userSettings.blacklist.has(videoId)) { userSettings.blacklist.delete(videoId); } else { userSettings.blacklist.add(videoId); } GMCustomSetValue('blacklist', [...userSettings.blacklist]); updateStyles(); showMenuOptions(); }, }, }; processMenuOptions(menuOptions, (item) => { GMCustomRegisterMenuCommand(item.label(), item.handleClick, { id: item.menuId, autoClose: false, }); menuItems.add(item.menuId); }); } // Handle User Preferences //------------------------------------------------ async function loadUserSettings() { try { const storedValues = await GMCustomListValues(); for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { if (!storedValues.includes(key)) { await GMCustomSetValue(key, value instanceof Set ? Array.from(value) : value); } } for (const key of storedValues) { if (!(key in DEFAULT_SETTINGS)) { await GMCustomDeleteValue(key); } } const keyValuePairs = await Promise.all( storedValues.map(async key => [key, await GMCustomGetValue(key)]) ); keyValuePairs.forEach(([newKey, newValue]) => { userSettings[newKey] = newValue; }); // Convert blacklist to Set if it exists if (userSettings.blacklist) { userSettings.blacklist = new Set(userSettings.blacklist); } console.log(`Loaded user settings: ${JSON.stringify(userSettings)}`); } catch (error) { console.error(error); } } // Verify Grease Monkey API //----------------------------------------------- function checkGMAPI() { if (typeof GM != 'undefined') return; if (typeof GM_info != 'undefined') { useCompatibilityMode = true; console.warn("Running in compatibility mode."); return; } isBrokenOrMissingGMAPI = true; } // Preparation Stuff //------------------------------------------------- function attachEventListeners() { window.addEventListener('yt-set-theater-mode-enabled', (event) => { updateTheaterStatus(event); }, true); window.addEventListener('yt-chat-collapsed-changed', (event) => { updateChatStatus(event); }, true); window.addEventListener('yt-page-data-fetched', (event) => { updateVideoStatus(event); }, true); window.addEventListener('yt-page-data-updated', updateStyles, true); } function isLiveChatIFrame() { const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/; const currentUrl = window.location.href; return liveChatIFramePattern.test(currentUrl); } async function initialize() { checkGMAPI(); try { if (isBrokenOrMissingGMAPI) throw "Did not detect valid Grease Monkey API"; if (isLiveChatIFrame()) return applyStaticChatFrameFixStyles(); // Fixes the terrible css of the live chat iframe. 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. await loadUserSettings(); updateStyles(); attachEventListeners(); showMenuOptions(); } catch (error) { console.error(`Error loading user settings: ${error}. Aborting script.`); } } // Entry Point //------------------------------------------- initialize(); })();