// ==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.6 // @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, setLowHeadmast: false, blacklist: new Set() }; let userSettings = { ...DEFAULT_SETTINGS }; let useCompatibilityMode = false; let menuItems = new Set(); let activeStyles = new Map(); let hidChatTemporarily = false; let resizeObserver; let moviePlayer; let videoId; let chatFrame; let isFullscreen = false; let isTheaterMode = false; let chatCollapsed = true; let isLiveStream = false; let chatWidth = 0; let moviePlayerHeight = 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; } `, }, lowHeadmastStyle: { id: "lowHeadmastStyle", getRule: () => ` #page-manager.ytd-app { margin-top: 0 !important; top: calc(-1 * var(--ytd-toolbar-offset)) !important; position: relative !important; } ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy { margin-top: var(--ytd-toolbar-offset) !important; } ${userSettings.modifyVideoPlayer ? ` ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy { max-height: 100vh !important; } ` : ''} #masthead-container.ytd-app { z-index: 599 !important; top: ${moviePlayerHeight}px !important; position: relative !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; } `, }, }; // 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 (typeof style.getRule !== 'function') return; if (activeStyles.has(style.id)) removeStyle(style); const styleElement = GMCustomAddStyle(style.getRule()); activeStyles.set(style.id, { element: styleElement, persistent: setPersistent }); } function setStyleState(style, on = true) { on ? applyStyle(style) : removeStyle(style); } // Update styles dynamically based on settings and current state function updateLowHeadmastStyle() { if (!moviePlayer) return; const shouldApplyLowHeadmast = userSettings.setLowHeadmast && isTheaterMode && !isFullscreen; setStyleState(styleRules.lowHeadmastStyle, shouldApplyLowHeadmast); } function updateHeadmastStyle() { updateLowHeadmastStyle(); let shouldShrinkHeadmast = isTheaterMode && chatFrame?.getAttribute('theater-watch-while') === '' && (userSettings.setLowHeadmast || userSettings.modifyChat); chatWidth = chatFrame?.offsetWidth || 0; setStyleState(styleRules.headmastStyle, shouldShrinkHeadmast); } function updateStyles() { try { 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; } setStyleState(styleRules.chatStyle, userSettings.modifyChat); setStyleState(styleRules.videoPlayerStyle, userSettings.modifyVideoPlayer); updateHeadmastStyle(); if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element } catch (error) { console.log(`Error when trying to update styles: ${error}.`); } } // Updates things function updateFullscreenStatus() { isFullscreen = document.fullscreenElement; } 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 updateMoviePlayer() { const newMoviePlayer = document.querySelector('#movie_player'); if (!resizeObserver) { resizeObserver = new ResizeObserver(entries => { moviePlayerHeight = moviePlayer.offsetHeight; updateStyles(); }); } if (moviePlayer) resizeObserver.unobserve(moviePlayer); moviePlayer = newMoviePlayer; if (moviePlayer) resizeObserver.observe(moviePlayer); } function updateVideoStatus(event) { try { videoId = event.detail.pageData.playerResponse.videoDetails.videoId; updateMoviePlayer(); 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(); }, }, toggleLowHeadmast: { alwaysShow: true, label: () => `${userSettings.setLowHeadmast ? "✅" : "❌"} Move Headmast Below Video Player`, menuId: "toggleLowHeadmast", handleClick: function () { userSettings.setLowHeadmast = !userSettings.setLowHeadmast; GMCustomSetValue('setLowHeadmast', userSettings.setLowHeadmast); 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(); })();