// ==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();
})();