// ==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.9 // @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'; // Default settings for the script const DEFAULT_SETTINGS = { isScriptActive: true, enableOnlyForLiveStreams: false, modifyVideoPlayer: true, modifyChat: true, blacklist: new Set() }; let userSettings = { ...DEFAULT_SETTINGS }; let useCompatibilityMode = false; let menuItems = new Set(); let activeStyles = new Map(); let hidChatTemporarily = false; let moviePlayer; let videoId; let chatFrame; let isFullscreen = false; let isTheaterMode = false; let chatCollapsed = true; let isLiveStream = false; let chatWidth = 0; // Greasemonkey API Compatibility Layer 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; // Style Rules const styleRules = { chatStyle: { id: "chatStyle", getRule: () => ` ytd-live-chat-frame[theater-watch-while][rounded-container] { border-radius: 0 !important; border-top: 0 !important; } ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy { top: 0 !important; border-top: 0 !important; border-bottom: 0 !important; } `, }, videoPlayerStyle: { id: "videoPlayerStyle", getRule: () => ` ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy { max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important; } `, }, headmastStyle: { id: "headmastStyle", getRule: () => ` #masthead-container.ytd-app { max-width: calc(100% - ${chatWidth}px) !important; } `, }, videoPlayerFixStyle: { id: "staticVideoPlayerFixStyle", getRule: () => ` .html5-video-container { top: -1px !important; } `, }, chatFrameFixStyle: { id: "staticChatFrameFixStyle", getRule: () => { const panelPage = document.querySelector('iron-pages#panel-pages'); const shouldHideChatInputContainerTopBorder = panelPage?.offsetHeight <= 3; const borderTopStyle = shouldHideChatInputContainerTopBorder ? 'border-top: 0 !important;' : ''; return ` #panel-pages.yt-live-chat-renderer { ${borderTopStyle} border-bottom: 0 !important; } `; }, }, chatRendererFixStyle: { id: "staticChatRendererFixStyle", getRule: () => ` ytd-live-chat-frame[theater-watch-while][rounded-container] { border-bottom: 0 !important; } `, }, }; // Fix fullscreen chat issues by toggling chat renderer function toggleChatRendererToTemporarilyFixFullscreenIssues() { if (isFullscreen) { if (chat && !chat.collapsed) { chat.getElementsByTagName('button')[0].click(); hidChatTemporarily = true; } } else if (hidChatTemporarily) { chat.getElementsByTagName('button')[0].click(); hidChatTemporarily = false; } } // Apply and remove styles dynamically based on settings function removeStyle(style) { if (!activeStyles.has(style.id)) return; const { element: styleElement } = activeStyles.get(style.id); if (styleElement && styleElement.parentNode) { styleElement.parentNode.removeChild(styleElement); } activeStyles.delete(style.id); } function removeAllStyles() { activeStyles.forEach((styleData, styleId) => { if (!styleData.persistent) { removeStyle({ id: styleId }); } }); } function applyStyle(style, setPersistent = false) { if (activeStyles.has(style.id)) return; if (typeof style.getRule !== 'function') return; const styleElement = GMCustomAddStyle(style.getRule()); activeStyles.set(style.id, { element: styleElement, persistent: setPersistent }); } // Update styles dynamically based on settings and current state 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) { applyStyle(styleRules.chatStyle); const mastHeadContainer = document.querySelector('#masthead-container'); let chatFramePositionValid = mastHeadContainer.getBoundingClientRect().bottom < 0 || chatFrame?.getBoundingClientRect().top <= mastHeadContainer.getBoundingClientRect().bottom; let shouldShrinkHeadmast = isTheaterMode && !chatCollapsed && chatFramePositionValid; if (shouldShrinkHeadmast) { chatWidth = chatFrame?.offsetWidth || 0; applyStyle(styleRules.headmastStyle); } else { removeStyle(styleRules.headmastStyle); } } else { [styleRules.chatStyle, styleRules.headmastStyle].forEach(removeStyle); } if (userSettings.modifyVideoPlayer) { applyStyle(styleRules.videoPlayerStyle); } else { removeStyle(styleRules.videoPlayerStyle); } 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 updateFullscreenStatus() { isFullscreen = document.fullscreenElement; toggleChatRendererToTemporarilyFixFullscreenIssues(); // To fix fullscreen issues this needs to alway run } 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); } } // Menu management for user interaction 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); }); } // User Setting Handling 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) { throw `Error loading user settings: ${error}. Aborting script.`; } } // Check compatibility with Greasemonkey API function hasGreasyMonkeyAPI() { if (typeof GM != 'undefined') return true; if (typeof GM_info != 'undefined') { useCompatibilityMode = true; console.warn("Running in compatibility mode."); return true; } return false; } // Attach necessary event listeners 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); window.addEventListener("fullscreenchange", updateFullscreenStatus, true); } // Check if the script is running inside a live chat iframe function isLiveChatIFrame() { const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/; const currentUrl = window.location.href; return liveChatIFramePattern.test(currentUrl); } // Initialize the script async function initialize() { try { if (!hasGreasyMonkeyAPI()) throw "Did not detect valid Grease Monkey API"; if (isLiveChatIFrame()) return (applyStyle(styleRules.chatFrameFixStyle, true)); // Fixes the terrible css of the live chat iframe. applyStyle(styleRules.chatRendererFixStyle, true); // Removes the unnecessary extra bottom border from the chat renderer. applyStyle(styleRules.videoPlayerFixStyle, true); // Fixes video player end screen style rounding issues during certain zoom levels. await loadUserSettings(); updateStyles(); attachEventListeners(); showMenuOptions(); } catch (error) { return console.error(`Error when initializing script: ${error}. Aborting script.`); } } // Entry Point initialize(); })();