// ==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.11.6
// @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
// @noframes
// @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 adds an optional, customized floating chat for fullscreen mode, seamlessly integrated with YouTube's design.
// @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。另新增可選的、自製風格的浮動聊天室功能(僅限全螢幕模式),與 YouTube 原有的設計語言相融合。
// @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。同时新增可选的、自制风格的浮动聊天室功能(仅限全屏模式),融入了 YouTube 原有的设计语言。
// @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、全画面モード専用のオプションとして、カスタマイズ済みフローティングチャット機能を、YouTubeのデザイン言語に沿って統合しています。
// ==/UserScript==
/*jshint esversion: 11 */
(function () {
"use strict";
// UI Constants
const MIN_CHAT_SIZE = { width: '300px', height: '355px' };
const DRAG_BAR_HEIGHT = '35px';
// CONFIG
const CONFIG = {
DEFAULT_SETTINGS: {
isSimpleMode: true,
enableOnlyForLiveStreams: false,
modifyVideoPlayer: true,
modifyChat: true,
setLowHeadmast: false,
useCustomPlayerHeight: false,
playerHeightPx: 600,
floatingChat: false,
chatStyle: {
left: '0px',
top: '-500px',
width: MIN_CHAT_SIZE.width,
height: MIN_CHAT_SIZE.height,
opacity: '0.95',
},
debug: false
},
DEFAULT_BLACKLIST: [],
REQUIRED_VERSIONS: {
Tampermonkey: '5.4.624'
}
};
// TRANSLATIONS
const BROWSER_LANGUAGE = navigator.language || navigator.userLanguage;
function getPreferredLanguage() {
if (BROWSER_LANGUAGE.startsWith('zh') && BROWSER_LANGUAGE !== 'zh-TW') {
return 'zh-CN';
}
// Check if language is supported, otherwise fall back to English
return ['en-US', 'zh-TW', 'zh-CN', 'ja'].includes(BROWSER_LANGUAGE)
? BROWSER_LANGUAGE
: 'en-US';
}
const TRANSLATIONS = {
'en-US': {
tampermonkeyOutdatedAlert: "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',
useCustomPlayerHeight: 'Use Custom Player Height',
playerHeightText: 'Player Height',
floatingChat: 'Floating Chat',
blacklistVideo: 'Blacklist Video',
unblacklistVideo: 'Unblacklist Video',
simpleMode: 'Simple Mode',
advancedMode: 'Advanced Mode',
debug: 'DEBUG'
},
'zh-TW': {
tampermonkeyOutdatedAlert: "看起來您正在使用較舊版本的篡改猴,可能會導致選單問題。為了獲得最佳體驗,請更新至 5.4.6224 或更高版本。",
turnOn: '開啟',
turnOff: '關閉',
livestreamOnlyMode: '僅限直播模式',
applyChatStyles: '套用聊天樣式',
applyVideoPlayerStyles: '套用影片播放器樣式',
moveHeadmastBelowVideoPlayer: '將頁首橫幅移到影片播放器下方',
useCustomPlayerHeight: '使用自訂播放器高度',
playerHeightText: '播放器高度',
floatingChat: '浮動聊天室',
blacklistVideo: '將影片加入黑名單',
unblacklistVideo: '從黑名單中移除影片',
simpleMode: '簡易模式',
advancedMode: '進階模式',
debug: '偵錯'
},
'zh-CN': {
tampermonkeyOutdatedAlert: "看起来您正在使用旧版本的篡改猴,这可能会导致菜单问题。为了获得最佳体验,请更新到 5.4.6224 或更高版本。",
turnOn: '开启',
turnOff: '关闭',
livestreamOnlyMode: '仅限直播模式',
applyChatStyles: '应用聊天样式',
applyVideoPlayerStyles: '应用视频播放器样式',
moveHeadmastBelowVideoPlayer: '将页首横幅移动到视频播放器下方',
useCustomPlayerHeight: '使用自定义播放器高度',
playerHeightText: '播放器高度',
floatingChat: '浮动聊天室',
blacklistVideo: '将视频加入黑名单',
unblacklistVideo: '从黑名单中移除视频',
simpleMode: '简易模式',
advancedMode: '高级模式',
debug: '调试'
},
'ja': {
tampermonkeyOutdatedAlert: "ご利用のTampermonkeyのバージョンが古いため、メニューに問題が発生する可能性があります。より良い体験のため、バージョン5.4.6224以上に更新してください。",
turnOn: "オンにする",
turnOff: "オフにする",
livestreamOnlyMode: "ライブ配信専用モード",
applyChatStyles: "チャットスタイルを適用",
applyVideoPlayerStyles: "ビデオプレイヤースタイルを適用",
moveHeadmastBelowVideoPlayer: "ヘッドマストをビデオプレイヤーの下に移動",
useCustomPlayerHeight: "カスタムプレイヤーの高さを使用",
playerHeightText: "プレイヤーの高さ",
floatingChat: "フローティングチャット",
blacklistVideo: "動画をブラックリストに追加",
unblacklistVideo: "ブラックリストから動画を解除",
simpleMode: "シンプルモード",
advancedMode: "高度モード",
debug: "デバッグ"
}
};
function getLocalizedText() {
return TRANSLATIONS[getPreferredLanguage()] || TRANSLATIONS['en-US'];
}
// STATE VARIABLES
const state = {
userSettings: { ...CONFIG.DEFAULT_SETTINGS },
advancedSettingsBackup: null,
blacklist: new Set(),
useCompatibilityMode: false,
menuItems: new Set(),
activeStyles: new Map(),
resizeObserver: null,
moviePlayer: null,
videoId: null,
chatFrame: null,
currentPageType: '',
isFullscreen: false,
isTheaterMode: false,
chatCollapsed: true,
isLiveStream: false,
chatWidth: 0,
moviePlayerHeight: 0,
isOldTampermonkey: false,
isScriptRecentlyUpdated: false
};
// GM API COMPATIBILITY
const GM = {
registerMenuCommand: state.useCompatibilityMode ? GM_registerMenuCommand : window.GM?.registerMenuCommand,
unregisterMenuCommand: state.useCompatibilityMode ? GM_unregisterMenuCommand : window.GM?.unregisterMenuCommand,
getValue: state.useCompatibilityMode ? GM_getValue : window.GM?.getValue,
setValue: state.useCompatibilityMode ? GM_setValue : window.GM?.setValue,
listValues: state.useCompatibilityMode ? GM_listValues : window.GM?.listValues,
deleteValue: state.useCompatibilityMode ? GM_deleteValue : window.GM?.deleteValue,
notification: state.useCompatibilityMode ? GM_notification : window.GM?.notification
};
// STYLE DEFINITIONS
const styleRules = {
chatStyle: {
id: "betterTheater-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: "betterTheater-videoPlayerStyle",
getRule: () => {
if (state.userSettings.useCustomPlayerHeight) {
return `
ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
min-height: 0px !important;
height: ${state.userSettings.playerHeightPx}px !important;
}
`;
} else {
return `
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: "betterTheater-headmastStyle",
getRule: () => `
#masthead-container.ytd-app {
max-width: calc(100% - ${state.chatWidth}px) !important;
}
`,
},
lowHeadmastStyle: {
id: "betterTheater-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;
}
${state.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: ${state.moviePlayerHeight}px !important;
position: relative !important;
}
`,
},
videoPlayerFixStyle: {
id: "betterTheater-videoPlayerFixStyle",
getRule: () => `
.html5-video-container {
top: -1px !important;
}
#skip-navigation.ytd-masthead {
left: -500px;
}
`,
},
chatFrameFixStyle: {
id: "betterTheater-chatFrameFixStyle",
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: "betterTheater-chatRendererFixStyle",
getRule: () => `
ytd-live-chat-frame[theater-watch-while][rounded-container] {
border-bottom: 0 !important;
}
`,
},
floatingChatStyle: {
id: "betterTheater-floatingChatStyle",
getRule: () => `
#chat-container {
min-width: ${MIN_CHAT_SIZE.width} !important;
max-width: 100vw !important;
max-height: 100vh !important;
position: absolute;
border-radius: 0 0 12px 12px !important;
}
#chat {
top: ${DRAG_BAR_HEIGHT} !important;
height: calc(100% - ${DRAG_BAR_HEIGHT}) !important;
width: inherit !important;
min-width: inherit !important;
max-width: inherit !important;
min-height: ${parseInt(MIN_CHAT_SIZE.height) - parseInt(DRAG_BAR_HEIGHT)}px !important;
max-height: 100vh !important;
}
`,
},
floatingChatStyleExpanded: {
id: "betterTheater-floatingChatStyleExpanded",
getRule: () => `
#chat-container {
min-height: ${MIN_CHAT_SIZE.height} !important;
}
ytd-live-chat-frame:not([theater-watch-while])[rounded-container] {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-top: 0 !important;
}
ytd-live-chat-frame:not([theater-watch-while])[rounded-container] iframe.ytd-live-chat-frame {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
`,
},
floatingChatStyleCollapsed: {
id: "betterTheater-floatingChatStyleCollapsed",
getRule: () => `
ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame>ytd-toggle-button-renderer.ytd-live-chat-frame,
ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame>ytd-button-renderer.ytd-live-chat-frame {
margin: 0 !important;
border-radius: 0 0 12px 12px !important;
border-left: 1px solid var(--yt-spec-10-percent-layer) !important;
border-right: 1px solid var(--yt-spec-10-percent-layer) !important;
border-bottom: 1px solid var(--yt-spec-10-percent-layer) !important;
background-clip: padding-box !important;
}
ytd-live-chat-frame[modern-buttons][collapsed] {
border-radius: 0 0 12px 12px !important;
}
button.yt-spec-button-shape-next.yt-spec-button-shape-next--outline.yt-spec-button-shape-next--mono.yt-spec-button-shape-next--size-m {
border-radius: 0 0 12px 12px !important;
border: none !important;
}
.chat-resize-handle {
visibility: hidden !important;
}
`,
},
debugResizeHandleStyle: {
id: "betterTheater-debugResizeHandleStyle",
getRule: () => `
/* Default state for resize handles */
#chat-container .chat-resize-handle {
background: transparent;
opacity: 0;
}
/* Debug state for resize handles when #chat-container has [debug] attribute */
#chat-container[debug] .chat-resize-handle {
opacity: 0.5;
}
#chat-container[debug] .chat-resize-handle.rs-right {
background: rgba(255, 0, 0, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-left {
background: rgba(0, 255, 0, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-bottom {
background: rgba(0, 0, 255, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-top {
background: rgba(255, 255, 0, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-bottom-left {
background: rgba(0, 255, 255, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-top-left {
background: rgba(255, 255, 0, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-top-right {
background: rgba(255, 0, 0, 0.5);
}
#chat-container[debug] .chat-resize-handle.rs-bottom-right {
background: rgba(255, 0, 255, 0.5);
}
`,
},
chatSliderStyle: {
id: "betterTheater-chatSliderStyle",
getRule: () => `
.chat-drag-bar input[type=range] {
-webkit-appearance: none;
width: 100px;
height: 4px;
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
border-radius: 2px;
outline: none;
}
.chat-drag-bar input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
cursor: pointer;
}
.chat-drag-bar input[type=range]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
cursor: pointer;
}
.chat-drag-bar input[type=range]::-moz-range-track {
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
height: 4px;
border-radius: 2px;
}
.chat-drag-bar input[type=range]::-ms-thumb {
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
}
.chat-drag-bar input[type=range]::-ms-track {
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
height: 4px;
border-radius: 2px;
}
`
}
};
// STYLE MANAGEMENT
function applyStyle(style, setPersistent = false) {
if (typeof style.getRule !== 'function') return;
if (state.activeStyles.has(style.id)) {
removeStyle(style);
}
const styleElement = document.createElement('style');
styleElement.id = style.id;
styleElement.type = 'text/css';
styleElement.textContent = style.getRule();
(document.head || document.documentElement).appendChild(styleElement);
state.activeStyles.set(style.id, {
element: styleElement,
persistent: setPersistent
});
}
function removeStyle(style) {
if (!state.activeStyles.has(style.id)) return;
const { element: styleElement } = state.activeStyles.get(style.id);
if (styleElement && styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
state.activeStyles.delete(style.id);
}
function removeAllStyles() {
state.activeStyles.forEach((styleData, styleId) => {
if (!styleData.persistent) {
removeStyle({ id: styleId });
}
});
}
function setStyleState(style, on = true) {
on ? applyStyle(style) : removeStyle(style);
}
function addResizeHandles(chatContainer) {
const handleConfigs = {
right: {
width: "6px", top: "0", right: "0", bottom: "0",
cursor: "ew-resize", horizontal: true, vertical: false
},
left: {
width: "6px", top: "0", left: "0", bottom: "0",
cursor: "ew-resize", horizontal: true, vertical: false
},
bottom: {
height: "6px", left: "0", bottom: "0", right: "0",
cursor: "ns-resize", horizontal: false, vertical: true
},
top: {
height: "6px", left: "0", top: "0", right: "0",
cursor: "ns-resize", horizontal: false, vertical: true
},
bottomLeft: {
width: "12px", height: "12px", left: "0", bottom: "0",
cursor: "nesw-resize", horizontal: true, vertical: true
},
topLeft: {
width: "12px", height: "12px", left: "0", top: "0",
cursor: "nwse-resize", horizontal: true, vertical: true
},
topRight: {
width: "12px", height: "12px", right: "0", top: "0",
cursor: "nesw-resize", horizontal: true, vertical: true
},
bottomRight: {
width: "12px", height: "12px", right: "0", bottom: "0",
cursor: "nwse-resize", horizontal: true, vertical: true
}
};
const handles = {};
for (const [position, config] of Object.entries(handleConfigs)) {
const handle = document.createElement("div");
handle.className = `chat-resize-handle rs-${position}`;
handle.style.position = "absolute";
handle.style.zIndex = "10001";
Object.assign(handle.style, config);
chatContainer.appendChild(handle);
handles[position] = handle;
initResizeHandler(handle, config);
}
return handles;
function initResizeHandler(handle, config) {
let startX, startY, startWidth, startHeight, startLeft, startTop;
async function saveChatStyle() {
Object.assign(state.userSettings.chatStyle ??= {}, {
width: chatContainer.style.width,
height: chatContainer.style.height,
left: chatContainer.style.left,
top: chatContainer.style.top
});
await updateSetting('chatStyle', state.userSettings.chatStyle);
}
handle.addEventListener("pointerdown", function (e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
startWidth = chatContainer.offsetWidth;
startHeight = chatContainer.offsetHeight;
startLeft = parseFloat(getComputedStyle(chatContainer).left) || 0;
startTop = parseFloat(getComputedStyle(chatContainer).top) || 0;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener("pointermove", function (e) {
if (!handle.hasPointerCapture(e.pointerId)) return;
e.preventDefault();
const movieRect = state.moviePlayer.getBoundingClientRect();
const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
const chatRect = chatContainer.getBoundingClientRect();
const minWidth = parseInt(MIN_CHAT_SIZE.width);
const minHeight = parseInt(MIN_CHAT_SIZE.height);
let dx = e.clientX - startX;
let dy = e.clientY - startY;
let newLeft = startLeft;
let newTop = startTop;
let newWidth = startWidth;
let newHeight = startHeight;
dx = Math.max(-startX, Math.min(dx, movieRect.right - startX));
dy = Math.max(-startY, Math.min(dy, movieRect.bottom - startY));
if (config.horizontal) {
const isRightSide = handle.className.toLowerCase().includes('right');
const isLeftSide = handle.className.toLowerCase().includes('left');
if (isRightSide) {
newWidth += dx;
} else if (isLeftSide) {
newWidth -= dx;
newLeft += Math.min(dx, startWidth - minWidth);
}
}
if (config.vertical) {
const isBottomSide = handle.className.toLowerCase().includes('bottom');
const isTopSide = handle.className.toLowerCase().includes('top');
if (isBottomSide) {
newHeight += dy;
} else if (isTopSide) {
newHeight -= dy;
newTop += Math.min(dy, startHeight - minHeight);
}
}
// constrain to movie rect
const correctedTopBound = movieRect.top - chatParentRect.top;
const correctedLeftBound = movieRect.left - chatParentRect.left;
newWidth = Math.min(Math.max(minWidth, newWidth), movieRect.right - chatRect.left);
newHeight = Math.min(Math.max(minHeight, newHeight), movieRect.bottom - chatRect.top);
newTop = Math.max(newTop, correctedTopBound);
newLeft = Math.max(newLeft, correctedLeftBound);
Object.assign(chatContainer.style, {
left: newLeft + "px",
top: newTop + "px",
width: newWidth + "px",
height: newHeight + "px"
});
});
handle.addEventListener("pointerup", function (e) {
handle.releasePointerCapture(e.pointerId);
saveChatStyle();
});
}
}
// Removes all resize handles from a container
function removeResizeHandles(chatContainer) {
if (!chatContainer) return;
const handles = chatContainer.querySelectorAll(".chat-resize-handle");
handles.forEach(handle => handle.remove());
}
// DRAG BAR & CHAT CONTROLS
function addDragBarWithOpacitySlider(chatContainer) {
let existingBar = chatContainer.querySelector('.chat-drag-bar');
if (existingBar) return existingBar;
applyStyle(styleRules.chatSliderStyle, true);
const dragBar = document.createElement("div");
dragBar.className = "chat-drag-bar";
dragBar.style.position = "absolute";
dragBar.style.top = "0";
dragBar.style.left = "0";
dragBar.style.right = "0";
dragBar.style.height = "15px";
dragBar.style.background = "var(--yt-live-chat-background-color)";
dragBar.style.color = "var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color))";
dragBar.style.border = "1px solid var(--yt-spec-10-percent-layer)";
dragBar.style.backgroundClip = "padding-box";
dragBar.style.display = "flex";
dragBar.style.alignItems = "center";
dragBar.style.justifyContent = "space-between";
dragBar.style.padding = (parseInt(DRAG_BAR_HEIGHT) - 15) / 2 + "px";
dragBar.style.zIndex = "10000";
dragBar.style.borderRadius = "12px 12px 0 0";
const dragLabel = document.createElement("div");
dragLabel.innerText = "⋮⋮";
dragLabel.style.fontSize = "var(--yt-live-chat-header-font-size, 18px)";
dragLabel.style.userSelect = "none";
const opacitySlider = document.createElement("input");
opacitySlider.type = "range";
opacitySlider.min = "20";
opacitySlider.max = "100";
opacitySlider.value = Math.round(parseFloat(state.userSettings.chatStyle.opacity) * 100).toString();
opacitySlider.style.marginLeft = "10px";
opacitySlider.addEventListener("input", () => {
const newOpacity = opacitySlider.value / 100;
chatContainer.style.opacity = newOpacity;
});
opacitySlider.addEventListener("mouseup", () => {
Object.assign(state.userSettings.chatStyle ??= {}, {
opacity: chatContainer.style.opacity
});
updateSetting('chatStyle', state.userSettings.chatStyle);
});
["pointerdown", "pointermove", "pointerup"].forEach(eventType => {
opacitySlider.addEventListener(eventType, (e) => {
e.stopPropagation();
});
});
dragBar.appendChild(dragLabel);
dragBar.appendChild(opacitySlider);
chatContainer.insertBefore(dragBar, chatContainer.firstChild);
setupDragBehavior(dragBar, chatContainer);
return dragBar;
}
function setupDragBehavior(dragBar, chatContainer) {
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
async function saveChatPosition() {
Object.assign(state.userSettings.chatStyle ??= {}, {
left: chatContainer.style.left,
top: chatContainer.style.top
});
await updateSetting('chatStyle', state.userSettings.chatStyle);
}
dragBar.addEventListener("pointerdown", function (e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
startLeft = parseFloat(getComputedStyle(chatContainer).left) || 0;
startTop = parseFloat(getComputedStyle(chatContainer).top) || 0;
dragBar.setPointerCapture(e.pointerId);
e.preventDefault();
});
// Handle pointer move event
dragBar.addEventListener("pointermove", function (e) {
if (!dragBar.hasPointerCapture(e.pointerId)) return;
let dx = e.clientX - startX;
let dy = e.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
const movieRect = state.moviePlayer.getBoundingClientRect();
const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
const correctedTopBound = movieRect.top - chatParentRect.top;
const correctedLeftBound = movieRect.left - chatParentRect.left;
const correctedLowerBound = movieRect.bottom - chatParentRect.top - (
state.chatCollapsed
? parseInt(DRAG_BAR_HEIGHT) + chatContainer.querySelector('#show-hide-button').offsetHeight
: chatContainer.offsetHeight
);
const correctedRightBound = movieRect.right - chatParentRect.left - chatContainer.offsetWidth;
newTop = Math.min(Math.max(newTop, correctedTopBound), correctedLowerBound);
newLeft = Math.min(Math.max(newLeft, correctedLeftBound), correctedRightBound);
Object.assign(chatContainer.style, {
left: newLeft + "px",
top: newTop + "px",
});
e.preventDefault();
});
dragBar.addEventListener("pointerup", function (e) {
dragBar.releasePointerCapture(e.pointerId);
saveChatPosition();
});
}
function removeDragBarWithOpacitySlider(chatContainer) {
if (!chatContainer) return;
const dragBar = chatContainer.querySelector('.chat-drag-bar');
if (dragBar) dragBar.remove();
}
function removeAllChatStyles(chatContainer) {
removeStyle(styleRules.floatingChatStyleCollapsed);
removeStyle(styleRules.floatingChatStyleExpanded);
removeStyle(styleRules.floatingChatStyle);
if (chatContainer) chatContainer.style = '';
}
function applySavedChatStyle(chatContainer, shouldSave = false) {
if (!chatContainer) return;
const movieRect = state.moviePlayer.getBoundingClientRect();
const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
const chatRect = chatContainer.getBoundingClientRect();
const minWidth = parseInt(MIN_CHAT_SIZE.width);
const minHeight = parseInt(MIN_CHAT_SIZE.height);
let width = parseFloat(state.userSettings.chatStyle.width);
let height = parseFloat(state.userSettings.chatStyle.height);
let top = parseFloat(state.userSettings.chatStyle.top);
let left = parseFloat(state.userSettings.chatStyle.left);
const correctedTopBound = movieRect.top - chatParentRect.top;
const correctedLeftBound = movieRect.left - chatParentRect.left;
width = Math.min(Math.max(minWidth, width), movieRect.width);
height = Math.min(Math.max(minHeight, height), movieRect.height);
top = Math.max(top, correctedTopBound) - (
state.chatCollapsed ?
0 :
Math.max(0, chatRect.bottom - movieRect.bottom)
);
left = Math.max(left, correctedLeftBound) - (
state.chatCollapsed ?
0 :
Math.max(0, chatRect.right - movieRect.right)
);
Object.assign(chatContainer.style, {
left: left + "px",
top: top + "px",
width: width + "px",
height: height + "px",
opacity: parseFloat(state.userSettings.chatStyle.opacity)
});
if (shouldSave && state.isFullscreen) {
Object.assign(state.userSettings.chatStyle ??= {}, {
width: chatContainer.style.width,
height: chatContainer.style.height,
left: chatContainer.style.left,
top: chatContainer.style.top
});
updateSetting('chatStyle', state.userSettings.chatStyle);
}
}
// STYLE UPDATE FUNCTIONS
function updateStyles(shouldSave = false) {
try {
if (state.userSettings.useCustomPlayerHeight) {
state.userSettings.modifyVideoPlayer = true;
}
const shouldNotActivate =
(state.blacklist && state.blacklist.has(state.videoId)) ||
(state.userSettings.enableOnlyForLiveStreams && !state.isLiveStream);
if (shouldNotActivate) {
removeAllStyles();
if (state.moviePlayer && state.moviePlayer.setCenterCrop) {
state.moviePlayer.setCenterCrop();
}
return;
}
setStyleState(styleRules.chatStyle, state.userSettings.modifyChat);
setStyleState(styleRules.videoPlayerStyle, state.userSettings.modifyVideoPlayer);
updateHeadmastStyle();
updateFullscreenFloatingChatStyle(shouldSave);
if (state.moviePlayer && state.moviePlayer.setCenterCrop) {
state.moviePlayer.setCenterCrop();
}
} catch (error) {
logDebug(`Error when updating styles: ${error}`, 'error');
}
}
function updateHeadmastStyle() {
updateLowHeadmastStyle();
const shouldShrinkHeadmast =
state.isTheaterMode &&
state.chatFrame?.getAttribute('theater-watch-while') === '' &&
(state.userSettings.setLowHeadmast || state.userSettings.modifyChat);
// Update chat width for style calculation
state.chatWidth = state.chatFrame?.offsetWidth || 0;
// Apply or remove headmast style
setStyleState(styleRules.headmastStyle, shouldShrinkHeadmast);
}
function updateLowHeadmastStyle() {
if (!state.moviePlayer) return;
const shouldApplyLowHeadmast =
state.userSettings.setLowHeadmast &&
state.isTheaterMode &&
!state.isFullscreen &&
state.currentPageType === 'watch';
setStyleState(styleRules.lowHeadmastStyle, shouldApplyLowHeadmast);
}
function updateFullscreenFloatingChatStyle(shouldSave = false) {
try {
const chatContainer = document.querySelector('#chat-container');
setStyleState(styleRules.floatingChatStyleCollapsed,
state.chatCollapsed && state.isFullscreen);
setStyleState(styleRules.floatingChatStyleExpanded,
!state.chatCollapsed && state.isFullscreen);
setStyleState(styleRules.floatingChatStyle, state.isFullscreen);
if (state.userSettings.floatingChat &&
chatContainer.querySelector('#chat') &&
chatContainer &&
state.isFullscreen) {
applySavedChatStyle(chatContainer, shouldSave);
removeDragBarWithOpacitySlider(chatContainer);
addDragBarWithOpacitySlider(chatContainer);
addResizeHandles(chatContainer);
} else if (chatContainer) {
removeAllChatStyles(chatContainer);
removeDragBarWithOpacitySlider(chatContainer);
removeResizeHandles(chatContainer);
}
} catch (error) {
logDebug(`Error when updating fullscreen chat styles: ${error}`, 'error');
}
}
function updateDebugStyles() {
const chatContainer = document.querySelector('#chat-container');
if (chatContainer) {
if (state.userSettings.debug) {
chatContainer.setAttribute("debug", "");
} else {
chatContainer.removeAttribute("debug");
}
}
}
// EVENT HANDLERS
function updateFullscreenStatus() {
state.isFullscreen = !!document.fullscreenElement;
updateStyles();
}
function updateTheaterStatus(event) {
state.isTheaterMode = !!event?.detail?.enabled;
updateStyles();
}
function updateChatStatus(event) {
state.chatFrame = event.target;
state.chatCollapsed = event.detail !== false;
window.addEventListener('player-api-ready', () => {
updateStyles(true);
}, { once: true });
}
function updateMoviePlayer() {
const newMoviePlayer = document.querySelector('#movie_player');
// Create resize observer if needed
if (!state.resizeObserver) {
state.resizeObserver = new ResizeObserver(() => {
state.moviePlayerHeight = state.moviePlayer?.offsetHeight || 0;
updateStyles();
});
}
// Stop observing old player
if (state.moviePlayer) {
state.resizeObserver.unobserve(state.moviePlayer);
}
// Start observing new player
state.moviePlayer = newMoviePlayer;
if (state.moviePlayer) {
state.resizeObserver.observe(state.moviePlayer);
}
}
function updateVideoStatus(event) {
try {
state.currentPageType = event.detail.pageData.page;
state.videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
state.isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent;
updateMoviePlayer();
refreshMenuOptions();
} catch (error) {
logDebug(`Failed to update video status: ${error}`, 'error');
}
}
// SETTINGS MANAGEMENT
async function updateSetting(key, value) {
try {
let currentSettings = await GM.getValue('settings', CONFIG.DEFAULT_SETTINGS);
currentSettings[key] = value;
await GM.setValue('settings', currentSettings);
state.userSettings[key] = value;
} catch (error) {
logDebug(`Error updating setting: ${error}`, 'error');
}
}
async function loadUserSettings() {
try {
const storedSettings = await GM.getValue('settings', CONFIG.DEFAULT_SETTINGS);
const newSettings = {};
let needsSave = false;
// Use stored settings or defaults
for (const key in CONFIG.DEFAULT_SETTINGS) {
if (key in storedSettings) {
newSettings[key] = storedSettings[key];
} else {
newSettings[key] = CONFIG.DEFAULT_SETTINGS[key];
needsSave = true;
}
}
// Check for obsolete settings
for (const key in storedSettings) {
if (!(key in CONFIG.DEFAULT_SETTINGS)) {
needsSave = true;
}
}
// Save settings if needed
state.userSettings = newSettings;
if (needsSave) {
await GM.setValue('settings', state.userSettings);
}
updateMode();
} catch (error) {
logDebug(`Error loading user settings: ${error}`, 'error');
throw new Error(`Error loading user settings: ${error}. Aborting script.`);
}
}
function updateMode() {
if (state.userSettings.isSimpleMode === true) {
// Backup advanced settings before switching to simple mode
state.advancedSettingsBackup = {
...state.userSettings,
isSimpleMode: false
};
// Apply simple mode settings
state.userSettings = {
...CONFIG.DEFAULT_SETTINGS,
isSimpleMode: true
};
logDebug('Using simple mode');
} else if (state.advancedSettingsBackup) {
// Restore advanced settings
state.userSettings = {
...state.advancedSettingsBackup,
isSimpleMode: false
};
logDebug('Using advanced mode');
logDebug('Advanced settings backup:', state.advancedSettingsBackup);
}
logDebug(`Loaded settings: ${JSON.stringify(state.userSettings)}`);
}
async function loadBlacklist() {
try {
let storedBlacklist = await GM.getValue('blacklist', CONFIG.DEFAULT_BLACKLIST);
state.blacklist = new Set(
Array.isArray(storedBlacklist) ? storedBlacklist : []
);
logDebug(`Loaded blacklist: ${JSON.stringify(Array.from(state.blacklist))}`);
} catch (error) {
logDebug(`Error loading blacklist: ${error}`, 'error');
throw new Error(`Error loading blacklist: ${error}. Aborting script.`);
}
}
async function updateBlacklist() {
try {
await GM.setValue('blacklist', Array.from(state.blacklist));
} catch (error) {
logDebug(`Error updating blacklist: ${error}`, 'error');
}
}
async function updateScriptInfo() {
try {
const oldScriptInfo = await GM.getValue('scriptInfo', null);
const newScriptInfo = {
version: getScriptVersionFromMeta(),
};
await GM.setValue('scriptInfo', newScriptInfo);
// Check if script was updated
if (!oldScriptInfo || compareVersions(newScriptInfo.version, oldScriptInfo?.version) !== 0) {
state.isScriptRecentlyUpdated = true;
}
logDebug(`Previous script info: ${JSON.stringify(oldScriptInfo)}`);
logDebug(`Updated script info: ${JSON.stringify(newScriptInfo)}`);
} catch (error) {
logDebug(`Error updating script info: ${error}`, 'error');
}
}
async function cleanupOldStorage() {
try {
const allowedKeys = ['settings', 'scriptInfo', 'blacklist'];
const keys = await GM.listValues();
for (const key of keys) {
if (!allowedKeys.includes(key)) {
await GM.deleteValue(key);
logDebug(`Deleted leftover key: ${key}`);
}
}
} catch (error) {
logDebug(`Error cleaning up old storage keys: ${error}`, 'error');
}
}
// MENU MANAGEMENT
function removeMenuOptions() {
state.menuItems.forEach((menuItem) => {
GM.unregisterMenuCommand(menuItem);
});
state.menuItems.clear();
}
async function refreshMenuOptions() {
const shouldAutoClose = state.isOldTampermonkey;
removeMenuOptions();
// Advanced mode menu options
const advancedMenuOptions = state.userSettings.isSimpleMode ? {} : {
toggleOnlyLiveStreamMode: {
alwaysShow: true,
label: () => `${state.userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} ${getLocalizedText().livestreamOnlyMode}`,
menuId: "toggleOnlyLiveStreamMode",
handleClick: async function () {
state.userSettings.enableOnlyForLiveStreams = !state.userSettings.enableOnlyForLiveStreams;
await updateSetting('enableOnlyForLiveStreams', state.userSettings.enableOnlyForLiveStreams);
updateStyles();
refreshMenuOptions();
},
},
toggleChatStyle: {
alwaysShow: true,
label: () => `${state.userSettings.modifyChat ? "✅" : "❌"} ${getLocalizedText().applyChatStyles}`,
menuId: "toggleChatStyle",
handleClick: async function () {
state.userSettings.modifyChat = !state.userSettings.modifyChat;
await updateSetting('modifyChat', state.userSettings.modifyChat);
updateStyles();
refreshMenuOptions();
},
},
...(!state.userSettings.useCustomPlayerHeight ? {
toggleVideoPlayerStyle: {
alwaysShow: true,
label: () => `${state.userSettings.modifyVideoPlayer ? "✅" : "❌"} ${getLocalizedText().applyVideoPlayerStyles}`,
menuId: "toggleVideoPlayerStyle",
handleClick: async function () {
state.userSettings.modifyVideoPlayer = !state.userSettings.modifyVideoPlayer;
await updateSetting('modifyVideoPlayer', state.userSettings.modifyVideoPlayer);
updateStyles();
refreshMenuOptions();
},
},
} : {}),
toggleLowHeadmast: {
alwaysShow: true,
label: () => `${state.userSettings.setLowHeadmast ? "✅" : "❌"} ${getLocalizedText().moveHeadmastBelowVideoPlayer}`,
menuId: "toggleLowHeadmast",
handleClick: async function () {
state.userSettings.setLowHeadmast = !state.userSettings.setLowHeadmast;
await updateSetting('setLowHeadmast', state.userSettings.setLowHeadmast);
updateStyles();
refreshMenuOptions();
},
},
toggleCustomPlayerHeight: {
alwaysShow: true,
label: () => `${state.userSettings.useCustomPlayerHeight ? "✅" : "❌"} ${getLocalizedText().useCustomPlayerHeight}`,
menuId: "toggleCustomPlayerHeight",
handleClick: async function () {
state.userSettings.useCustomPlayerHeight = !state.userSettings.useCustomPlayerHeight;
await updateSetting('useCustomPlayerHeight', state.userSettings.useCustomPlayerHeight);
updateStyles();
refreshMenuOptions();
},
},
...(state.userSettings.useCustomPlayerHeight ? {
customHeightInputSelector: {
alwaysShow: true,
label: () => `🔢 ${getLocalizedText().playerHeightText} (${state.userSettings.playerHeightPx}px)`,
menuId: "customHeightInputSelector",
handleClick: async function () {
const playerHeightInputValue = await promptForNumber();
if (playerHeightInputValue === null) return;
state.userSettings.playerHeightPx = playerHeightInputValue;
await updateSetting('playerHeightPx', playerHeightInputValue);
updateStyles();
refreshMenuOptions();
},
},
} : {}),
toggleFloatingChat: {
alwaysShow: true,
label: () => `${state.userSettings.floatingChat ? "✅" : "❌"} ${getLocalizedText().floatingChat}`,
menuId: "toggleFloatingChat",
handleClick: async function () {
state.userSettings.floatingChat = !state.userSettings.floatingChat;
await updateSetting('floatingChat', state.userSettings.floatingChat);
refreshMenuOptions();
},
},
toggleDebug: {
alwaysShow: true,
label: () => `${state.userSettings.debug ? "✅" : "❌"} ${getLocalizedText().debug}`,
menuId: "toggleDebug",
handleClick: async function () {
state.userSettings.debug = !state.userSettings.debug;
await updateSetting('debug', state.userSettings.debug);
updateDebugStyles();
refreshMenuOptions();
}
}
};
// Common menu options for both simple and advanced modes
const commonMenuOptions = {
addVideoToBlacklist: {
alwaysShow: true,
label: () => `🚫 ${state.blacklist.has(state.videoId) ? getLocalizedText().unblacklistVideo : getLocalizedText().blacklistVideo} [id: ${state.videoId}]`,
menuId: "addVideoToBlacklist",
handleClick: async function () {
if (state.blacklist.has(state.videoId)) {
state.blacklist.delete(state.videoId);
} else {
state.blacklist.add(state.videoId);
}
await updateBlacklist();
updateStyles();
refreshMenuOptions();
},
},
toggleSimpleMode: {
alwaysShow: true,
label: () => `${state.userSettings.isSimpleMode ? "🚀 " + getLocalizedText().simpleMode : "🔧 " + getLocalizedText().advancedMode}`,
menuId: "toggleSimpleMode",
handleClick: async function () {
state.userSettings.isSimpleMode = !state.userSettings.isSimpleMode;
await updateSetting('isSimpleMode', state.userSettings.isSimpleMode);
updateMode();
updateStyles();
refreshMenuOptions();
},
},
};
// Combine menu options
const menuOptions = {
...commonMenuOptions,
...advancedMenuOptions
};
// Process and register all menu options
for (const [_, item] of Object.entries(menuOptions)) {
if (!item.alwaysShow && !state.userSettings.expandMenu) continue;
const menuId = GM.registerMenuCommand(item.label(), item.handleClick, {
id: item.menuId,
autoClose: shouldAutoClose,
});
state.menuItems.add(item.menuId);
}
}
async function promptForNumber(message = "Enter a number:", validator = null) {
while (true) {
const input = prompt(message);
if (input === null) return null;
const value = Number(input.trim());
const isValidNumber = input.trim() !== "" && !isNaN(value);
const passesCustomValidator = typeof validator === "function" ? validator(value) : true;
if (isValidNumber && passesCustomValidator) {
return value;
} else {
alert("⚠️ Please enter a valid number.");
}
}
}
// UTILITY FUNCTIONS
function logDebug(message, level = 'log', data) {
if (!state.userSettings.debug) return;
const consoleMethod = console[level] || console.log;
if (data !== undefined) {
consoleMethod('[Better Theater] ' + message, data);
} else {
consoleMethod('[Better Theater] ' + message);
}
}
function compareVersions(v1, v2) {
if (!v1 || !v2) return 0;
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 getScriptVersionFromMeta() {
const versionMatch = GM_info.scriptMetaStr.match(/@version\s+([^\r\n]+)/);
return versionMatch ? versionMatch[1].trim() : null;
}
function detectGreasemonkeyAPI() {
if (typeof GM !== 'undefined') return true;
if (typeof GM_info !== 'undefined') {
state.useCompatibilityMode = true;
logDebug("Running in compatibility mode", 'warn');
return true;
}
return false;
}
function checkTampermonkeyVersion() {
if (GM_info.scriptHandler === "Tampermonkey" &&
compareVersions(GM_info.version, CONFIG.REQUIRED_VERSIONS.Tampermonkey) !== 1) {
state.isOldTampermonkey = true;
if (state.isScriptRecentlyUpdated) {
GM.notification({
text: getLocalizedText().tampermonkeyOutdatedAlert,
timeout: 15000
});
}
}
}
function isLiveChatIFrame() {
return /^https?:\/\/.*youtube\.com\/live_chat.*$/.test(window.location.href);
}
// EVENT LISTENERS
function attachEventListeners() {
window.addEventListener('yt-set-theater-mode-enabled', updateTheaterStatus, true);
window.addEventListener('yt-chat-collapsed-changed', updateChatStatus, true);
window.addEventListener('yt-page-data-fetched', updateVideoStatus, true);
window.addEventListener('yt-page-data-updated', updateStyles, true);
window.addEventListener('fullscreenchange', updateFullscreenStatus, true);
window.addEventListener('yt-navigate-finish', updateDebugStyles, { once: true });
}
// INITIALIZATION
async function initialize() {
try {
if (!detectGreasemonkeyAPI()) {
throw new Error("Did not detect valid Greasemonkey API");
}
applyStyle(styleRules.debugResizeHandleStyle, true);
await cleanupOldStorage();
await loadUserSettings();
await loadBlacklist();
await updateScriptInfo();
checkTampermonkeyVersion();
if (isLiveChatIFrame()) {
applyStyle(styleRules.chatFrameFixStyle, true);
return;
}
applyStyle(styleRules.chatRendererFixStyle, true);
applyStyle(styleRules.videoPlayerFixStyle, true);
updateStyles();
attachEventListeners();
refreshMenuOptions();
} catch (error) {
logDebug(`Error when initializing script: ${error}. Aborting script.`, 'error');
}
}
initialize();
})();