Greasy Fork is available in English.
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.
// ==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.8.1 // @match *://www.youtube.com/* // @match *://www.youtube-nocookie.com/* // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM.notification // @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を修正します。 // ==/UserScript== /*jshint esversion: 11 */ (function () { "use strict"; // ------------------------------- // Default settings for storage under the "settings" key. // Blacklist is now stored separately. // ------------------------------- const DEFAULT_SETTINGS = { isScriptActive: true, isSimpleMode: true, enableOnlyForLiveStreams: false, modifyVideoPlayer: true, modifyChat: true, setLowHeadmast: false }; const DEFAULT_BLACKLIST = []; // default blacklist (empty) // ------------------------------- // Other constants and translations // ------------------------------- const BROWSER_LANGUAGE = navigator.language || navigator.userLanguage; const GET_PREFERRED_LANGUAGE = () => { if (BROWSER_LANGUAGE.startsWith('zh') && BROWSER_LANGUAGE !== 'zh-TW') { return 'zh-CN'; } else { return BROWSER_LANGUAGE; } }; const TRANSLATIONS = { 'en-US': { tampermonkeyOutdatedAlertMessage: "It looks like you're using an older version of Tampermonkey that might cause menu issues. For the best experience, please update to version 5.4.6224 or later.", turnOn: 'Turn On', turnOff: 'Turn Off', livestreamOnlyMode: 'Livestream Only Mode', applyChatStyles: 'Apply Chat Styles', applyVideoPlayerStyles: 'Apply Video Player Styles', moveHeadmastBelowVideoPlayer: 'Move Headmast Below Video Player', blacklistVideo: 'Blacklist Video', unblacklistVideo: 'Unblacklist Video', simpleMode: 'Simple Mode', advancedMode: 'Advanced Mode', debug: 'DEBUG' }, 'zh-TW': { tampermonkeyOutdatedAlertMessage: "看起來您正在使用較舊版本的篡改猴,可能會導致選單問題。為了獲得最佳體驗,請更新至 5.4.6224 或更高版本。", turnOn: '開啟', turnOff: '關閉', livestreamOnlyMode: '僅限直播模式', applyChatStyles: '套用聊天樣式', applyVideoPlayerStyles: '套用影片播放器樣式', moveHeadmastBelowVideoPlayer: '將頁首橫幅移到影片播放器下方', blacklistVideo: '將影片加入黑名單', unblacklistVideo: '從黑名單中移除影片', simpleMode: '簡易模式', advancedMode: '進階模式', debug: '偵錯' }, 'zh-CN': { tampermonkeyOutdatedAlertMessage: "看起来您正在使用旧版本的篡改猴,这可能会导致菜单问题。为了获得最佳体验,请更新到 5.4.6224 或更高版本。", turnOn: '开启', turnOff: '关闭', livestreamOnlyMode: '仅限直播模式', applyChatStyles: '应用聊天样式', applyVideoPlayerStyles: '应用视频播放器样式', moveHeadmastBelowVideoPlayer: '将页首横幅移动到视频播放器下方', blacklistVideo: '将视频加入黑名单', unblacklistVideo: '从黑名单中移除视频', simpleMode: '简易模式', advancedMode: '高级模式', debug: '调试' }, 'ja': { tampermonkeyOutdatedAlertMessage: "ご利用のTampermonkeyのバージョンが古いため、メニューに問題が発生する可能性があります。より良い体験のため、バージョン5.4.6224以上に更新してください。", turnOn: "オンにする", turnOff: "オフにする", livestreamOnlyMode: "ライブ配信専用モード", applyChatStyles: "チャットスタイルを適用", applyVideoPlayerStyles: "ビデオプレイヤースタイルを適用", moveHeadmastBelowVideoPlayer: "ヘッドマストをビデオプレイヤーの下に移動", blacklistVideo: "動画をブラックリストに追加", unblacklistVideo: "ブラックリストから動画を解除", simpleMode: "シンプルモード", advancedMode: "高度モード", debug: "デバッグ" } }; const GET_LOCALIZED_TEXT = () => { const language = GET_PREFERRED_LANGUAGE(); return TRANSLATIONS[language] || TRANSLATIONS['en-US']; }; // ------------------------------- // Global variables for dynamic state // ------------------------------- let userSettings = { ...DEFAULT_SETTINGS }; let blacklist = new Set(); let userSettingsBackup = { ...DEFAULT_SETTINGS }; let useCompatibilityMode = false; let menuItems = new Set(); let activeStyles = new Map(); let resizeObserver; let moviePlayer; let videoId; let chatFrame; let currentPageType = ''; let isFullscreen = false; let isTheaterMode = false; let chatCollapsed = true; let isLiveStream = false; let chatWidth = 0; let moviePlayerHeight = 0; let isOldTampermonkey = false; const updatedVersions = { Tampermonkey: '5.4.624' }; let isScriptRecentlyUpdated = false; // ------------------------------- // Greasemonkey API Compatibility Layer // ------------------------------- // (Note: We no longer use GM for adding styles; style insertion is done by our custom function.) 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; const GMCustomNotification = useCompatibilityMode ? GM_notification : GM.notification; // ------------------------------- // (Existing) Style Rules & Functions // ------------------------------- 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; } #skip-navigation.ytd-masthead { left: -500px; } `, }, chatFrameFixStyle: { id: "staticChatFrameFixStyle", getRule: () => { const chatInputContainer = document.querySelector("tp-yt-iron-pages#panel-pages.style-scope.yt-live-chat-renderer"); const shouldHideChatInputContainerTopBorder = chatInputContainer?.clientHeight === 0; 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; } `, }, }; 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 }); } }); } // Use our custom style insertion helper. function applyStyle(style, setPersistent = false) { if (typeof style.getRule !== 'function') return; if (activeStyles.has(style.id)) removeStyle(style); const styleElement = addStyleHelper(style.getRule()); activeStyles.set(style.id, { element: styleElement, persistent: setPersistent }); function addStyleHelper(css) { const head = document.head || document.documentElement; const styleElem = document.createElement('style'); styleElem.type = 'text/css'; styleElem.textContent = css; head.appendChild(styleElem); return styleElem; } } function setStyleState(style, on = true) { on ? applyStyle(style) : removeStyle(style); } function updateLowHeadmastStyle() { if (!moviePlayer) return; const shouldApplyLowHeadmast = userSettings.setLowHeadmast && isTheaterMode && !isFullscreen && currentPageType === 'watch'; 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 || (blacklist && blacklist.has(videoId)) || (userSettings.enableOnlyForLiveStreams && !isLiveStream); if (shouldNotActivate) { removeAllStyles(); if (moviePlayer) moviePlayer.setCenterCrop(); // trigger update for html5 video element return; } setStyleState(styleRules.chatStyle, userSettings.modifyChat); setStyleState(styleRules.videoPlayerStyle, userSettings.modifyVideoPlayer); updateHeadmastStyle(); if (moviePlayer) moviePlayer.setCenterCrop(); } catch (error) { console.log(`Error when trying to update styles: ${error}.`); } } 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 { currentPageType = event.detail.pageData.page; 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); } } // ------------------------------- // New Storage Helper Functions // ------------------------------- async function loadUserSettings() { try { userSettings = await GMCustomGetValue('settings', DEFAULT_SETTINGS); console.log(`Loaded user settings: ${JSON.stringify(userSettings)}`); } catch (error) { throw `Error loading user settings: ${error}. Aborting script.`; } } async function updateSetting(key, value) { try { let currentSettings = await GMCustomGetValue('settings', DEFAULT_SETTINGS); currentSettings[key] = value; await GMCustomSetValue('settings', currentSettings); } catch (error) { console.log("Error updating setting: " + error); } } async function loadBlacklist() { try { let storedBlacklist = await GMCustomGetValue('blacklist', DEFAULT_BLACKLIST); if (Array.isArray(storedBlacklist)) { blacklist = new Set(storedBlacklist); } else { blacklist = new Set(); } console.log(`Loaded blacklist: ${JSON.stringify(Array.from(blacklist))}`); } catch (error) { throw `Error loading blacklist: ${error}. Aborting script.`; } } async function updateBlacklist() { try { await GMCustomSetValue('blacklist', Array.from(blacklist)); } catch (error) { console.log("Error updating blacklist: " + error); } } async function updateScriptInfo() { try { const oldScriptInfo = await GMCustomGetValue('scriptInfo', null); console.log(`Previous script info: ${JSON.stringify(oldScriptInfo)}`); const newScriptInfo = { version: getScriptVersionFromMeta(), }; await GMCustomSetValue('scriptInfo', newScriptInfo); console.log(`Updated script info: ${JSON.stringify(newScriptInfo)}`); if (!oldScriptInfo || compareVersions(newScriptInfo.version, oldScriptInfo?.version) !== 0) { isScriptRecentlyUpdated = true; } } catch (error) { console.log("Error updating script info: " + error); } } async function cleanupOldStorage() { try { const allowedKeys = ['settings', 'scriptInfo', 'blacklist']; const keys = await GMCustomListValues(); for (const key of keys) { if (!allowedKeys.includes(key)) { await GMCustomDeleteValue(key); console.log(`Deleted leftover key: ${key}`); } } } catch (error) { console.log("Error cleaning up old storage keys: " + error); } } function getScriptVersionFromMeta() { const meta = GM_info.scriptMetaStr; const versionMatch = meta.match(/@version\s+([^\r\n]+)/); return versionMatch ? versionMatch[1].trim() : null; } // ------------------------------- // Updated Menu Management using new storage functions // ------------------------------- 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(); } async function showMenuOptions() { const shouldAutoClose = isOldTampermonkey; removeMenuOptions(); const advancedMenuOptions = userSettings.isSimpleMode ? {} : { toggleOnlyLiveStreamMode: { alwaysShow: true, label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().livestreamOnlyMode, menuId: "toggleOnlyLiveStreamMode", handleClick: async function () { userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams; await updateSetting('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams); updateStyles(); showMenuOptions(); }, }, toggleChatStyle: { alwaysShow: true, label: () => `${userSettings.modifyChat ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().applyChatStyles, menuId: "toggleChatStyle", handleClick: async function () { userSettings.modifyChat = !userSettings.modifyChat; await updateSetting('modifyChat', userSettings.modifyChat); updateStyles(); showMenuOptions(); }, }, toggleVideoPlayerStyle: { alwaysShow: true, label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().applyVideoPlayerStyles, menuId: "toggleVideoPlayerStyle", handleClick: async function () { userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer; await updateSetting('modifyVideoPlayer', userSettings.modifyVideoPlayer); updateStyles(); showMenuOptions(); }, }, toggleLowHeadmast: { alwaysShow: true, label: () => `${userSettings.setLowHeadmast ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().moveHeadmastBelowVideoPlayer, menuId: "toggleLowHeadmast", handleClick: async function () { userSettings.setLowHeadmast = !userSettings.setLowHeadmast; await updateSetting('setLowHeadmast', userSettings.setLowHeadmast); updateStyles(); showMenuOptions(); }, }, }; const menuOptions = { toggleScript: { alwaysShow: true, label: () => `🔄 ${userSettings.isScriptActive ? GET_LOCALIZED_TEXT().turnOff : GET_LOCALIZED_TEXT().turnOn}`, menuId: "toggleScript", handleClick: async function () { userSettings.isScriptActive = !userSettings.isScriptActive; await updateSetting('isScriptActive', userSettings.isScriptActive); updateStyles(); showMenuOptions(); }, }, ...advancedMenuOptions, addVideoToBlacklist: { alwaysShow: true, label: () => `🚫 ${blacklist.has(videoId) ? GET_LOCALIZED_TEXT().unblacklistVideo : GET_LOCALIZED_TEXT().blacklistVideo} [id: ${videoId}]`, menuId: "addVideoToBlacklist", handleClick: async function () { if (blacklist.has(videoId)) { blacklist.delete(videoId); } else { blacklist.add(videoId); } await updateBlacklist(); updateStyles(); showMenuOptions(); }, }, toggleSimpleMode: { alwaysShow: true, label: () => `${userSettings.isSimpleMode ? "🚀 " + GET_LOCALIZED_TEXT().simpleMode : "🔧 " + GET_LOCALIZED_TEXT().advancedMode}`, menuId: "toggleSimpleMode", handleClick: async function () { const isNewModeSimple = !userSettings.isSimpleMode; if (isNewModeSimple) userSettingsBackup = { ...userSettings }; await updateSetting('isSimpleMode', isNewModeSimple); userSettings = isNewModeSimple ? { ...DEFAULT_SETTINGS } : userSettingsBackup; userSettings.isSimpleMode = isNewModeSimple; updateStyles(); showMenuOptions(); }, }, }; processMenuOptions(menuOptions, (item) => { GMCustomRegisterMenuCommand(item.label(), item.handleClick, { id: item.menuId, autoClose: shouldAutoClose, }); menuItems.add(item.menuId); }); } function compareVersions(v1, v2) { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); const len = Math.max(parts1.length, parts2.length); for (let i = 0; i < len; i++) { const num1 = parts1[i] || 0; const num2 = parts2[i] || 0; if (num1 > num2) return 1; if (num1 < num2) return -1; } return 0; } 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; } function CheckTampermonkeyUpdated() { if (GM_info.scriptHandler === "Tampermonkey" && compareVersions(GM_info.version, updatedVersions.Tampermonkey) !== 1) { isOldTampermonkey = true; if (isScriptRecentlyUpdated) { GMCustomNotification({ text: GET_LOCALIZED_TEXT().tampermonkeyOutdatedAlertMessage, timeout: 15000 }); } } } 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); } function isLiveChatIFrame() { const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/; return liveChatIFramePattern.test(window.location.href); } // ------------------------------- // Initialize the script // ------------------------------- async function initialize() { try { if (!hasGreasyMonkeyAPI()) throw "Did not detect valid Grease Monkey API"; await cleanupOldStorage(); await loadUserSettings(); await loadBlacklist(); await updateScriptInfo(); CheckTampermonkeyUpdated(); if (isLiveChatIFrame()) { applyStyle(styleRules.chatFrameFixStyle, true); return; } applyStyle(styleRules.chatRendererFixStyle, true); applyStyle(styleRules.videoPlayerFixStyle, true); updateStyles(); attachEventListeners(); showMenuOptions(); } catch (error) { console.error(`Error when initializing script: ${error}. Aborting script.`); } } // ------------------------------- // Entry Point // ------------------------------- initialize(); })();