// ==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を修正します。
// ==/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();
})();