// ==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.7
// @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';
// Default settings for the script
const DEFAULT_SETTINGS = {
isScriptActive: true,
isSimpleMode: true,
enableOnlyForLiveStreams: false,
modifyVideoPlayer: true,
modifyChat: true,
setLowHeadmast: false,
blacklist: new Set()
};
let userSettings = { ...DEFAULT_SETTINGS };
let userSettingsBackup = { ...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 currentPageType = '';
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;
}
#skip-navigation.ytd-masthead {
left: -500px;
}
`,
},
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 && 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 ||
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 {
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);
}
}
// 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 advancedMenuOptions = userSettings.isSimpleMode ? {} : {
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();
},
},
};
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();
},
},
...advancedMenuOptions,
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();
},
},
toggleSimpleMode: {
alwaysShow: true,
label: () => `${userSettings.isSimpleMode ? "🚀 Simple Mode" : "🔧 Advanced Mode"}`,
menuId: "toggleSimpleMode",
handleClick: function () {
if (!userSettings.isSimpleMode) userSettingsBackup = { ...userSettings };
userSettings.isSimpleMode = !userSettings.isSimpleMode;
GMCustomSetValue('isSimpleMode', userSettings.isSimpleMode);
userSettings = userSettings.isSimpleMode ? { ...DEFAULT_SETTINGS } : { ...userSettingsBackup };
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);
}
userSettingsBackup = { ...userSettings };
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 various issues with the video player.
await loadUserSettings();
updateStyles();
attachEventListeners();
showMenuOptions();
} catch (error) {
return console.error(`Error when initializing script: ${error}. Aborting script.`);
}
}
// Entry Point
initialize();
})();