// ==UserScript== // @name Better YouTube Theater Mode // @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 // @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 This script adjusts YouTube's player to extend to the bottom of the screen, creating a Twitch.tv-like viewing experience with fewer distractions. // @description:zh-TW 此腳本會將 YouTube 播放器調整為延伸至螢幕底部,提供類似 Twitch.tv 的沉浸式觀看體驗,減少干擾。 // @description:zh-CN 此脚本将 YouTube 播放器调整为延伸至屏幕底部,提供类似 Twitch.tv 的沉浸式观看体验,减少干扰。 // @description:ja このスクリプトは、YouTubeのプレーヤーを画面の下部まで拡張し、Twitch.tvのようなより没入感のある視聴体験を提供します。 // @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 useCompatilibtyMode = false; let isBrokenOrMissingGMAPI = false; const GMCustomAddStyle = useCompatilibtyMode ? GM_addStyle : GM.addStyle; const GMCustomRegisterMenuCommand = useCompatilibtyMode ? GM_registerMenuCommand : GM.registerMenuCommand; const GMCustomUnregisterMenuCommand = useCompatilibtyMode ? GM_unregisterMenuCommand : GM.unregisterMenuCommand; const GMCustomGetValue = useCompatilibtyMode ? GM_getValue : GM.getValue; const GMCustomSetValue = useCompatilibtyMode ? GM_setValue : GM.setValue; const GMCustomListValues = useCompatilibtyMode ? GM_listValues : GM.listValues; const GMCustomDeleteValue = useCompatilibtyMode ? GM_deleteValue : GM.deleteValue; let menuItems = new Set(); let activeStyles = new Set(); let chatStyles; let videoPlayerStyles; let headmastStyles; let moviePlayer; let videoId; let chatFrame; let isTheaterMode = false; let chatCollapsed = true; let isLiveStream = false; const RETRY_COUNT = 5; const RETRY_DELAY = 100; // Helper Functions //------------------------------------------------------------------------------- const retryOperation = (operation, retries, delay) => { return new Promise((resolve, reject) => { const attempt = (remainingRetries) => { try { const result = operation(); if (result) { console.log(`Operation succeeded: ${operation}`); resolve(result); } else if (remainingRetries > 0) { console.log(`Retrying operation: ${operation}, retries left: ${remainingRetries}`); setTimeout(() => attempt(remainingRetries - 1), delay); } else { reject(new Error('Operation failed after retries')); } } catch (error) { console.error(`Error in retryOperation: ${error}`); reject(error); } }; attempt(retries); }); }; function findElement(selector, retries = RETRY_COUNT, delay = RETRY_DELAY) { return retryOperation(() => document.querySelector(selector), retries, delay) .catch(() => console.warn(`Element not found: ${selector}`)); } function removeStyle(style) { if (style && style.parentNode) { style.parentNode.removeChild(style); } activeStyles.delete(style); } function removeAllStyles() { activeStyles.forEach((style) => { removeStyle(style); }) activeStyles.clear(); } function addStyle(styleRule, styleObject) { removeStyle(styleObject); styleObject = GMCustomAddStyle(styleRule); activeStyles.add(styleObject); return styleObject; } // Apply Styles //---------------------------------- function applyChatStyles() { chatStyles = addStyle(` ytd-live-chat-frame[theater-watch-while][rounded-container], #panel-pages.yt-live-chat-renderer { border-radius: 0 !important; border-top: 0px !important; border-bottom: 0px !important; } ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy { top: 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() { let shouldNotActivate = !userSettings.isScriptActive || (userSettings.enableOnlyForLiveStreams && !isLiveStream) || userSettings.blacklist.has(videoId); if (shouldNotActivate) { removeAllStyles(); if (moviePlayer) moviePlayer.setSizeStyle(); //triger 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.setSizeStyle(); //triger size update for the html5 video element } function updateTheaterStatus(event) { isTheaterMode = !!event?.detail?.enabled; updateStyles(); } async function updateChatStatus(event) { chatFrame = event.target; chatCollapsed = event.detail !== false; window.addEventListener('player-api-ready', () => { updateStyles(); }, { once: true} ); } function updateVideoStatus() { moviePlayer = document.querySelector('#movie_player'); const infoText = document.querySelector('.ytd-watch-info-text'); isLiveStream = moviePlayer?.getVideoData().isLive || infoText?.textContent.toLowerCase().includes('streamed'); } // 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); // Unregister each menu command }); 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') { useCompatilibtyMode = true; console.warn("Running in compatiblity mode."); return; } isBrokenOrMissingGMAPI = true; } // Preperatin 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-navigate-finish', (event) => { updateVideoStatus(); if (event.detail.pageType === 'watch'){ window.addEventListener('yt-page-data-updated', () => { updateStyles(); }, { once: true } ); videoId = event.detail.endpoint.watchEndpoint.videoId; } else { updateStyles(); } showMenuOptions(); }, true); } async function initialize() { checkGMAPI(); try { if (isBrokenOrMissingGMAPI) throw "Did not detect valid Grease Monkey API"; await loadUserSettings(); updateStyles() attachEventListeners(); showMenuOptions(); } catch (error) { console.error(`Error loading user settings: ${error}. Aborting script.`); } } // Entry Point //------------------------------------------- initialize(); })();