// ==UserScript==
// @name Better YouTube Theater Mode
// @name:zh-TW 更佳 YouTube 劇場模式
// @name:zh-CN 更佳 YouTube 剧场模式
// @name:ja より良いYouTubeシアターモード
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @author ElectroKnight22
// @namespace electroknight22_youtube_better_theater_mode_namespace
// @version 2.0.0
// @match *://www.youtube.com/*
// @match *://www.youtube-nocookie.com/*
// @noframes
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.listValues
// @grant GM.registerMenuCommand
// @grant GM.unregisterMenuCommand
// @grant GM.notification
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_notification
// @run-at document-start
// @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 的设计,增强视频与聊天室布局,同时保持性能与兼容性,也达到了类似B站的网页全屏功能。同时新增可选的、自制风格的浮动聊天室功能(仅限全fullscreen模式),融入了 YouTube 原有的设计语言。
// @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、全画面モード専用のオプションとして、カスタマイズ済みフローティングチャット機能を、YouTubeのデザイン言語に沿って統合しています。
// ==/UserScript==
// Note: Both GM.* and GM_.* are granted for compatibility with older script managers.
/*jshint esversion: 11 */
(function () {
'use strict';
const CONFIG = {
DRAG_BAR_HEIGHT: '35px',
MIN_CHAT_SIZE: { // YouTube chat minimum size is 300px by 320px, going smaller would require a lot of CSS overrides.
width: 300, // px
height: 355, // px (320 + DRAG_BAR_HEIGHT)
},
DEFAULT_SETTINGS: {
isSimpleMode: true,
enableOnlyForLiveStreams: false,
modifyVideoPlayer: true,
modifyChat: true,
setLowHeadmast: false,
useCustomPlayerHeight: false,
playerHeightPx: 600,
floatingChat: false,
get theaterChatWidth() { return `${CONFIG.MIN_CHAT_SIZE.width}px`; },
chatStyle: {
left: '0px',
top: '-500px',
get width() { return `${CONFIG.MIN_CHAT_SIZE.width}px`; },
get height() { return `${CONFIG.MIN_CHAT_SIZE.height}px`; },
opacity: '0.95',
},
debug: false,
},
DEFAULT_BLACKLIST: [],
REQUIRED_VERSIONS: {
Tampermonkey: '5.4.624',
},
};
const BROWSER_LANGUAGE = navigator.language ?? navigator.userLanguage;
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 getPreferredLanguage() {
if (TRANSLATIONS[BROWSER_LANGUAGE]) {
return BROWSER_LANGUAGE;
}
if (BROWSER_LANGUAGE.startsWith('zh')) {
return 'zh-CN'; // Default to Simplified Mainland Chinese if Chinese variant is not available or not specified.
}
return 'en-US'; // Default to US English if all else fails.
}
function getLocalizedText() {
return TRANSLATIONS[getPreferredLanguage()] ?? TRANSLATIONS['en-US'];
}
const state = {
userSettings: { ...CONFIG.DEFAULT_SETTINGS },
advancedSettingsBackup: null,
blacklist: new Set(),
gmFallback: false,
menuItems: new Set(),
activeStyles: new Map(),
resizeObserver: null,
videoId: null,
currentPageType: '',
isFullscreen: false,
isTheaterMode: false,
chatCollapsed: true,
isLiveStream: false,
chatWidth: 0,
moviePlayerHeight: 0,
isOldTampermonkey: false,
versionWarningShown: false,
};
const DOM = {
moviePlayer: null,
chatContainer: null,
chatFrame: null,
ytdWatchFlexy: null,
};
const createGmApi = () => {
const isGmFallback = typeof GM === 'undefined' && typeof GM_info !== 'undefined';
state.gmFallback = isGmFallback;
if (isGmFallback) {
return {
registerMenuCommand: GM_registerMenuCommand,
unregisterMenuCommand: GM_unregisterMenuCommand,
getValue: GM_getValue,
setValue: GM_setValue,
listValues: GM_listValues,
deleteValue: GM_deleteValue,
notification: GM_notification,
info: () => GM_info,
};
}
return {
registerMenuCommand: (...args) => window.GM?.registerMenuCommand?.(...args),
unregisterMenuCommand: (...args) => window.GM?.unregisterMenuCommand?.(...args),
getValue: (...args) => window.GM?.getValue?.(...args),
setValue: (...args) => window.GM?.setValue?.(...args),
listValues: (...args) => window.GM?.listValues?.(...args),
deleteValue: (...args) => window.GM?.deleteValue?.(...args),
notification: (...args) => window.GM?.notification?.(...args),
info: () => window.GM?.info,
};
};
const GM_API = createGmApi();
const Utils = {
log(message, level = 'log', data) {
if (!state.userSettings.debug) return;
const consoleMethod = console[level] || console.log;
const prefix = '[Better Theater]';
data !== undefined ? consoleMethod(prefix, message, data) : consoleMethod(prefix, message);
},
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;
},
async 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 passesValidator = typeof validator === 'function' ? validator(value) : true;
if (isValidNumber && passesValidator) return value;
alert('⚠️ Please enter a valid number.');
}
},
};
const StyleManager = {
styleDefinitions: {
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;
}
#chat-container { z-index: 2021 !important; }
`,
},
videoPlayerStyle: {
id: 'betterTheater-videoPlayerStyle',
getRule: () =>
state.userSettings.useCustomPlayerHeight
? `ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
min-height: 0px !important;
height: ${state.userSettings.playerHeightPx}px !important;
}`
: `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; }
`,
},
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: ${CONFIG.MIN_CHAT_SIZE.width}px !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100vh !important;
position: absolute;
border-radius: 0 0 12px 12px !important;
}
#chat {
top: ${CONFIG.DRAG_BAR_HEIGHT} !important;
height: calc(100% - ${CONFIG.DRAG_BAR_HEIGHT}) !important;
width: inherit !important;
min-width: inherit !important;
max-width: inherit !important;
min-height: ${CONFIG.MIN_CHAT_SIZE.height - parseInt(CONFIG.DRAG_BAR_HEIGHT)}px !important;
max-height: 100vh !important;
pointer-events: auto !important;
}
#chat[collapsed] {
height: ${CONFIG.DRAG_BAR_HEIGHT} !important;
min-height: ${CONFIG.DRAG_BAR_HEIGHT} !important;
}
.chat-drag-bar {
cursor: move !important;
pointer-events: auto !important;
}
`,
},
floatingChatStyleExpanded: {
id: 'betterTheater-floatingChatStyleExpanded',
getRule: () => `
#chat-container { min-height: ${CONFIG.MIN_CHAT_SIZE.height}px !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: 1px solid var(--yt-spec-10-percent-layer) !important;
background-clip: padding-box !important;
border-top: none !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; }
#chat-container { pointer-events: none !important; }
`,
},
debugResizeHandleStyle: {
id: 'betterTheater-debugResizeHandleStyle',
getRule: () => `
#chat-container .chat-resize-handle { background: transparent; opacity: 0; }
#chat-container[debug] .chat-resize-handle { opacity: 0.5; }
#chat-container[debug] .rs-right { background: rgba(255, 0, 0, 0.5); }
#chat-container[debug] .rs-left { background: rgba(0, 255, 0, 0.5); }
#chat-container[debug] .rs-bottom { background: rgba(0, 0, 255, 0.5); }
#chat-container[debug] .rs-top { background: rgba(255, 255, 0, 0.5); }
#chat-container[debug] .rs-bottom-left { background: rgba(0, 255, 255, 0.5); }
#chat-container[debug] .rs-top-left { background: rgba(255, 255, 0, 0.5); }
#chat-container[debug] .rs-top-right { background: rgba(255, 0, 0, 0.5); }
#chat-container[debug] .rs-bottom-right { background: rgba(255, 0, 255, 0.5); }
`,
},
chatSliderStyle: {
id: 'betterTheater-chatSliderStyle',
getRule: () => `
.chat-drag-bar input[type=range] {
-webkit-appearance: none; appearance: none;
width: 100px; height: 4px; border-radius: 2px;
background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
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;
}
`,
},
chatClampLimits: {
id: 'betterTheater-chatClampLimits',
getRule: () => {
const hostEl = DOM.ytdWatchFlexy;
const originalWidth = '402px';
const originalMinWidth = '402px';
if (hostEl) {
const style = window.getComputedStyle(hostEl);
const fetchedWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-width')?.trim();
const fetchedMinWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-min-width')?.trim();
return `
ytd-live-chat-frame[theater-watch-while] {
min-width: ${CONFIG.MIN_CHAT_SIZE.width}px !important;
max-width: 33.33vw !important;
}
.ytd-watch-flexy {
--ytd-watch-flexy-sidebar-width: clamp(${
CONFIG.MIN_CHAT_SIZE.width
}px, var(--bt-chat-width), 33.33vw) !important;
--ytd-watch-flexy-sidebar-min-width: clamp(${
CONFIG.MIN_CHAT_SIZE.width
}px, var(--bt-chat-width), 33.33vw) !important;
}
ytd-watch-flexy[flexy] #secondary.ytd-watch-flexy {
--ytd-watch-flexy-sidebar-width: ${fetchedWidth ?? originalWidth} !important;
--ytd-watch-flexy-sidebar-min-width: ${fetchedMinWidth ?? originalMinWidth} !important;
}
ytd-watch-next-secondary-results-renderer {
--ytd-reel-item-compact-layout-width: calc((${
fetchedWidth ?? originalWidth
} - 8px) / 3) !important;
--ytd-reel-item-thumbnail-height: calc((${
fetchedWidth ?? originalWidth
} / 3 / 9 * 16)) !important;
}
ytd-live-chat-frame[theater-watch-while] yt-live-chat-renderer {
width: 100% !important; max-width: 100% !important;
}
`;
}
return ''; // Return empty if host element not found
},
},
},
apply(styleDef, isPersistent = false) {
if (typeof styleDef.getRule !== 'function') return;
this.remove(styleDef); // Ensure no duplicates
const styleElement = document.createElement('style');
styleElement.id = styleDef.id;
styleElement.textContent = styleDef.getRule();
document.head.appendChild(styleElement);
state.activeStyles.set(styleDef.id, {
element: styleElement,
persistent: isPersistent,
});
},
remove(styleDef) {
const styleData = state.activeStyles.get(styleDef.id);
if (styleData) {
styleData.element?.remove();
state.activeStyles.delete(styleDef.id);
}
},
removeAll() {
const styleIdsToRemove = [...state.activeStyles.keys()];
styleIdsToRemove.forEach((styleId) => {
const styleData = state.activeStyles.get(styleId);
if (styleData && !styleData.persistent) {
this.remove({ id: styleId });
}
});
},
toggle(styleDef, condition) {
condition ? this.apply(styleDef) : this.remove(styleDef);
},
};
const SettingsManager = {
async update(key, value) {
try {
const settings = await GM_API.getValue('settings', CONFIG.DEFAULT_SETTINGS);
settings[key] = value;
await GM_API.setValue('settings', settings);
state.userSettings[key] = value;
} catch (error) {
Utils.log(`Error updating setting: ${key}`, 'error', error);
}
},
async load() {
try {
state.versionWarningShown = await GM_API.getValue('versionWarningShown', false);
const storedSettings = await GM_API.getValue('settings', CONFIG.DEFAULT_SETTINGS);
const newSettings = {
...CONFIG.DEFAULT_SETTINGS,
...storedSettings,
};
state.userSettings = newSettings;
if (Object.keys(storedSettings).length !== Object.keys(newSettings).length) {
await GM_API.setValue('settings', state.userSettings);
}
this.updateMode();
} catch (error) {
Utils.log('Error loading settings', 'error', error);
throw error;
}
},
updateMode() {
if (state.userSettings.isSimpleMode) {
if (!state.advancedSettingsBackup) {
state.advancedSettingsBackup = {
...state.userSettings,
isSimpleMode: false,
};
}
state.userSettings = {
...CONFIG.DEFAULT_SETTINGS,
isSimpleMode: true,
};
Utils.log('Switched to Simple Mode');
} else if (state.advancedSettingsBackup) {
state.userSettings = {
...state.advancedSettingsBackup,
isSimpleMode: false,
};
state.advancedSettingsBackup = null;
App.warnIfOldTampermonkey();
Utils.log('Switched to Advanced Mode', 'log', state.userSettings);
}
Utils.log('Loaded settings:', 'log', state.userSettings);
},
async loadBlacklist() {
try {
const stored = await GM_API.getValue('blacklist', CONFIG.DEFAULT_BLACKLIST);
state.blacklist = new Set(Array.isArray(stored) ? stored : []);
Utils.log('Loaded blacklist:', 'log', Array.from(state.blacklist));
} catch (error) {
Utils.log('Error loading blacklist', 'error', error);
throw error;
}
},
async updateBlacklist() {
try {
await GM_API.setValue('blacklist', Array.from(state.blacklist));
} catch (error) {
Utils.log('Error updating blacklist', 'error', error);
}
},
async cleanupStorage() {
try {
const allowedKeys = ['settings', 'blacklist', 'versionWarningShown'];
const keys = await GM_API.listValues();
for (const key of keys) {
if (!allowedKeys.includes(key)) {
await GM_API.deleteValue(key);
Utils.log(`Deleted leftover key: ${key}`);
}
}
} catch (error) {
Utils.log('Error cleaning up old storage', 'error', error);
}
},
};
const MenuManager = {
clear() {
state.menuItems.forEach((menuId) => GM_API.unregisterMenuCommand(menuId));
state.menuItems.clear();
},
refresh() {
this.clear();
const LABEL = getLocalizedText();
const shouldAutoClose = state.isOldTampermonkey;
const menuConfig = [
// Always visible
{
label: () =>
`🚫 ${
state.blacklist.has(state.videoId) ? LABEL.unblacklistVideo : LABEL.blacklistVideo
} [id: ${state.videoId}]`,
id: 'toggleBlacklist',
action: async () => {
state.blacklist.has(state.videoId)
? state.blacklist.delete(state.videoId)
: state.blacklist.add(state.videoId);
await SettingsManager.updateBlacklist();
App.updateAllStyles();
},
},
{
label: () =>
`${state.userSettings.isSimpleMode ? '🚀 ' + LABEL.simpleMode : '🔧 ' + LABEL.advancedMode}`,
id: 'toggleMode',
action: async () => {
await SettingsManager.update('isSimpleMode', !state.userSettings.isSimpleMode);
SettingsManager.updateMode();
App.updateAllStyles();
},
},
// Advanced only
{
label: () =>
`${state.userSettings.enableOnlyForLiveStreams ? '✅' : '❌'} ${LABEL.livestreamOnlyMode}`,
id: 'toggleLiveOnly',
action: () =>
SettingsManager.update(
'enableOnlyForLiveStreams',
!state.userSettings.enableOnlyForLiveStreams
).then(App.updateAllStyles),
advanced: true,
},
{
label: () => `${state.userSettings.modifyChat ? '✅' : '❌'} ${LABEL.applyChatStyles}`,
id: 'toggleChatStyle',
action: () =>
SettingsManager.update('modifyChat', !state.userSettings.modifyChat).then(App.updateAllStyles),
advanced: true,
},
{
label: () =>
`${state.userSettings.modifyVideoPlayer ? '✅' : '❌'} ${LABEL.applyVideoPlayerStyles}`,
id: 'toggleVideoStyle',
action: () =>
SettingsManager.update('modifyVideoPlayer', !state.userSettings.modifyVideoPlayer).then(
App.updateAllStyles
),
advanced: true,
condition: () => !state.userSettings.useCustomPlayerHeight,
},
{
label: () => `${state.userSettings.setLowHeadmast ? '✅' : '❌'} ${LABEL.moveHeadmastBelowVideoPlayer}`,
id: 'toggleLowHeadmast',
action: () =>
SettingsManager.update('setLowHeadmast', !state.userSettings.setLowHeadmast).then(
App.updateAllStyles
),
advanced: true,
},
{
label: () =>
`${state.userSettings.useCustomPlayerHeight ? '✅' : '❌'} ${LABEL.useCustomPlayerHeight}`,
id: 'toggleCustomHeight',
action: () =>
SettingsManager.update('useCustomPlayerHeight', !state.userSettings.useCustomPlayerHeight).then(
App.updateAllStyles
),
advanced: true,
},
{
label: () => `🔢 ${LABEL.playerHeightText} (${state.userSettings.playerHeightPx}px)`,
id: 'setCustomHeight',
action: async () => {
const newHeight = await Utils.promptForNumber();
if (newHeight !== null) {
await SettingsManager.update('playerHeightPx', newHeight);
App.updateAllStyles();
}
},
advanced: true,
condition: () => state.userSettings.useCustomPlayerHeight,
},
{
label: () => `${state.userSettings.floatingChat ? '✅' : '❌'} ${LABEL.floatingChat}`,
id: 'toggleFloatingChat',
action: () =>
SettingsManager.update('floatingChat', !state.userSettings.floatingChat).then(
App.updateAllStyles
),
advanced: true,
},
{
label: () => `${state.userSettings.debug ? '✅' : '❌'} ${LABEL.debug}`,
id: 'toggleDebug',
action: async () => {
await SettingsManager.update('debug', !state.userSettings.debug);
App.updateDebugStyles();
},
advanced: true,
},
];
menuConfig.forEach((item) => {
const isAdvancedItem = item.advanced;
const inAdvancedMode = !state.userSettings.isSimpleMode;
const conditionMet = item.condition ? item.condition() : true;
if (conditionMet && (!isAdvancedItem || inAdvancedMode)) {
const commandId = GM_API.registerMenuCommand(
item.label(),
async () => {
await item.action();
this.refresh();
},
{ id: item.id, autoClose: shouldAutoClose }
);
state.menuItems.add(commandId ?? item.id);
}
});
},
};
const ChatInteractionManager = {
addTheaterResizeHandle() {
if (window.innerWidth / 3 <= CONFIG.MIN_CHAT_SIZE.width) return;
const chat = DOM.chatFrame;
if (!chat || chat.querySelector('#chat-width-resize-handle')) return;
const ytdWatchFlexy = DOM.ytdWatchFlexy;
const storedWidth = state.userSettings.theaterChatWidth ?? `${CONFIG.MIN_CHAT_SIZE.width}px`;
this._applyTheaterWidth(ytdWatchFlexy, chat, storedWidth);
const handle = document.createElement('div');
handle.id = 'chat-width-resize-handle';
handle.className = 'style-scope ytd-live-chat-frame';
Object.assign(handle.style, {
position: 'absolute',
top: '0',
left: '0',
width: '6px',
height: '100%',
cursor: 'ew-resize',
zIndex: '10001',
});
chat.appendChild(handle);
let startX = 0,
startWidth = 0,
animationFrame;
const onPointerMove = (e) => {
if (!handle.hasPointerCapture(e.pointerId)) return;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const dx = startX - e.clientX;
const newWidth = Math.max(CONFIG.MIN_CHAT_SIZE.width, startWidth + dx);
this._applyTheaterWidth(ytdWatchFlexy, chat, `${newWidth}px`);
});
};
const onPointerUp = (e) => {
handle.releasePointerCapture(e.pointerId);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
SettingsManager.update('theaterChatWidth', ytdWatchFlexy.style.getPropertyValue('--bt-chat-width'));
};
handle.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
document.body.click(); // Deselect any text
startX = e.clientX;
startWidth = chat.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
});
},
_applyTheaterWidth(flexy, chat, widthCss) {
if (flexy) flexy.style.setProperty('--bt-chat-width', widthCss);
if (chat) {
chat.style.width = widthCss;
chat.style.zIndex = '1999';
}
},
removeTheaterResizeHandle() {
DOM.chatFrame?.querySelector('#chat-width-resize-handle')?.remove();
const flexy = DOM.ytdWatchFlexy;
const chat = DOM.chatFrame;
if (flexy) flexy.style.removeProperty('--bt-chat-width');
if (chat) {
chat.style.width = '';
chat.style.zIndex = '';
}
},
initFullscreenChat(chatContainer) {
this.applySavedChatStyle(chatContainer, true);
this.addDragBar(chatContainer);
this.addResizeHandles(chatContainer);
},
cleanupFullscreenChat(chatContainer) {
this.removeDragBar(chatContainer);
this.removeResizeHandles(chatContainer);
chatContainer.style.cssText = '';
},
applySavedChatStyle(chatContainer, shouldSave = false) {
if (!chatContainer || !DOM.moviePlayer) return;
const movieRect = DOM.moviePlayer.getBoundingClientRect();
if (movieRect.width === 0 || movieRect.height === 0) return;
const parentRect = chatContainer.parentElement.getBoundingClientRect();
const { width, height, top, left, opacity } = state.userSettings.chatStyle;
const parsedWidth = parseFloat(width) ?? CONFIG.MIN_CHAT_SIZE.width;
const parsedHeight = parseFloat(height) ?? CONFIG.MIN_CHAT_SIZE.height;
let newWidth = Math.min(Math.max(CONFIG.MIN_CHAT_SIZE.width, parsedWidth), movieRect.width);
let newHeight = Math.min(Math.max(CONFIG.MIN_CHAT_SIZE.height, parsedHeight), movieRect.height);
const parsedTop = parseFloat(top) ?? 0;
const parsedLeft = parseFloat(left) ?? 0;
const minTop = movieRect.top - parentRect.top;
const maxTop = movieRect.bottom - parentRect.top - newHeight;
const minLeft = movieRect.left - parentRect.left;
const maxLeft = movieRect.right - parentRect.left - newWidth;
let newTop = Math.max(minTop, Math.min(parsedTop, maxTop));
let newLeft = Math.max(minLeft, Math.min(parsedLeft, maxLeft));
Object.assign(chatContainer.style, {
width: `${newWidth}px`,
height: `${newHeight}px`,
top: `${newTop}px`,
left: `${newLeft}px`,
opacity: parseFloat(opacity) ?? 0.95,
});
if (shouldSave && (newTop !== parsedTop || newLeft !== parsedLeft)) {
this.saveChatStyle(chatContainer);
}
},
saveChatStyle(chatContainer) {
const style = {
width: chatContainer.style.width,
height: chatContainer.style.height,
left: chatContainer.style.left,
top: chatContainer.style.top,
opacity: chatContainer.style.opacity,
};
return SettingsManager.update('chatStyle', style);
},
addDragBar(chatContainer) {
if (chatContainer.querySelector('.chat-drag-bar')) return;
StyleManager.apply(StyleManager.styleDefinitions.chatSliderStyle, true);
const dragBar = document.createElement('div');
dragBar.className = 'chat-drag-bar';
Object.assign(dragBar.style, {
position: 'absolute',
top: '0',
left: '0',
right: '0',
height: '15px',
background: 'var(--yt-live-chat-background-color)',
color: 'var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color))',
border: '1px solid var(--yt-spec-10-percent-layer)',
backgroundClip: 'padding-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: `${(parseInt(CONFIG.DRAG_BAR_HEIGHT) - 15) / 2}px`,
zIndex: '10000',
borderRadius: '12px 12px 0 0',
});
const dragHandleIcon = document.createElement('div');
dragHandleIcon.textContent = '⋮⋮';
Object.assign(dragHandleIcon.style, {
fontSize: '18px',
userSelect: 'none',
});
const opacitySlider = document.createElement('input');
opacitySlider.type = 'range';
opacitySlider.min = '20';
opacitySlider.max = '100';
opacitySlider.value = String(Math.round((parseFloat(state.userSettings.chatStyle.opacity) ?? 0.95) * 100));
opacitySlider.addEventListener(
'input',
() => (chatContainer.style.opacity = String(opacitySlider.value / 100))
);
opacitySlider.addEventListener('mouseup', () => this.saveChatStyle(chatContainer));
opacitySlider.addEventListener('pointerdown', (e) => e.stopPropagation());
dragBar.appendChild(dragHandleIcon);
dragBar.appendChild(opacitySlider);
chatContainer.insertBefore(dragBar, chatContainer.firstChild);
this._initDrag(dragBar, chatContainer);
},
_initDrag(dragBar, chatContainer) {
let start = {},
parentRect,
movieRect,
animationFrame,
isDragging = false;
const onPointerMove = (e) => {
if (!isDragging) return;
const isOutside =
e.clientX < movieRect.left ||
e.clientX > movieRect.right ||
e.clientY < movieRect.top ||
e.clientY > movieRect.bottom;
if (isOutside) return;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const newLeft = e.clientX - start.offsetX;
const newTop = e.clientY - start.offsetY;
const isChatCollapsed = chatContainer.querySelector('#chat[collapsed]');
const showHideButtonHeight = chatContainer.querySelector('#show-hide-button')?.offsetHeight ?? 0;
const chatHeight = isChatCollapsed
? parseInt(CONFIG.DRAG_BAR_HEIGHT) + showHideButtonHeight
: chatContainer.offsetHeight;
const clampedLeft = Math.max(
movieRect.left,
Math.min(newLeft, movieRect.right - chatContainer.offsetWidth)
);
const clampedTop = Math.max(movieRect.top, Math.min(newTop, movieRect.bottom - chatHeight));
chatContainer.style.left = `${clampedLeft - parentRect.left}px`;
chatContainer.style.top = `${clampedTop - parentRect.top}px`;
});
};
const onPointerUp = (e) => {
isDragging = false;
dragBar.releasePointerCapture(e.pointerId);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
this.saveChatStyle(chatContainer);
};
dragBar.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
isDragging = true;
dragBar.setPointerCapture(e.pointerId);
parentRect = chatContainer.parentElement.getBoundingClientRect();
movieRect = DOM.moviePlayer.getBoundingClientRect();
const chatRect = chatContainer.getBoundingClientRect();
start = {
offsetX: e.clientX - chatRect.left,
offsetY: e.clientY - chatRect.top,
};
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
});
},
removeDragBar(chatContainer) {
chatContainer?.querySelector('.chat-drag-bar')?.remove();
StyleManager.remove(StyleManager.styleDefinitions.chatSliderStyle);
},
addResizeHandles(chatContainer) {
const handleConfigs = {
right: {
cursor: 'ew-resize',
right: '0',
width: '6px',
top: '0',
bottom: '0',
},
left: {
cursor: 'ew-resize',
left: '0',
width: '6px',
top: '0',
bottom: '0',
},
bottom: {
cursor: 'ns-resize',
bottom: '0',
height: '6px',
left: '0',
right: '0',
},
top: {
cursor: 'ns-resize',
top: '0',
height: '6px',
left: '0',
right: '0',
},
bottomLeft: {
cursor: 'nesw-resize',
left: '0',
bottom: '0',
width: '12px',
height: '12px',
},
topLeft: {
cursor: 'nwse-resize',
left: '0',
top: '0',
width: '12px',
height: '12px',
},
topRight: {
cursor: 'nesw-resize',
right: '0',
top: '0',
width: '12px',
height: '12px',
},
bottomRight: {
cursor: 'nwse-resize',
right: '0',
bottom: '0',
width: '12px',
height: '12px',
},
};
for (const [pos, config] of Object.entries(handleConfigs)) {
if (chatContainer.querySelector(`.rs-${pos}`)) continue;
const handle = document.createElement('div');
handle.className = `chat-resize-handle rs-${pos}`;
Object.assign(handle.style, {
position: 'absolute',
zIndex: '10001',
...config,
});
chatContainer.appendChild(handle);
this._initResize(handle, chatContainer);
}
},
_initResize(handle, chatContainer) {
let start, parentRect, movieRect, animationFrame;
const onPointerMove = (e) => {
if (!handle.hasPointerCapture(e.pointerId)) return;
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
let newWidth = start.width;
let newHeight = start.height;
let newLeft = start.left;
let newTop = start.top;
const className = handle.className.toLowerCase();
const isLeft = className.includes('left');
const isRight = className.includes('right');
const isTop = className.includes('top');
const isBottom = className.includes('bottom');
if (isRight) newWidth += dx;
if (isLeft) {
newWidth -= dx;
newLeft += dx;
}
if (isBottom) newHeight += dy;
if (isTop) {
newHeight -= dy;
newTop += dy;
}
newWidth = Math.max(CONFIG.MIN_CHAT_SIZE.width, newWidth);
newHeight = Math.max(CONFIG.MIN_CHAT_SIZE.height, newHeight);
if (isLeft)
newLeft = Math.max(
movieRect.left,
Math.min(newLeft, start.left + start.width - CONFIG.MIN_CHAT_SIZE.width)
);
if (isTop)
newTop = Math.max(
movieRect.top,
Math.min(newTop, start.top + start.height - CONFIG.MIN_CHAT_SIZE.height)
);
newWidth = Math.min(newWidth, movieRect.right - newLeft);
newHeight = Math.min(newHeight, movieRect.bottom - newTop);
chatContainer.style.width = `${newWidth}px`;
chatContainer.style.height = `${newHeight}px`;
chatContainer.style.left = `${newLeft - parentRect.left}px`;
chatContainer.style.top = `${newTop - parentRect.top}px`;
});
};
const onPointerUp = (e) => {
handle.releasePointerCapture(e.pointerId);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
this.saveChatStyle(chatContainer);
};
handle.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
handle.setPointerCapture(e.pointerId);
parentRect = chatContainer.parentElement.getBoundingClientRect();
movieRect = DOM.moviePlayer.getBoundingClientRect();
const chatRect = chatContainer.getBoundingClientRect();
start = {
x: e.clientX,
y: e.clientY,
width: chatRect.width,
height: chatRect.height,
left: chatRect.left,
top: chatRect.top,
};
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
});
},
removeResizeHandles(chatContainer) {
chatContainer?.querySelectorAll('.chat-resize-handle').forEach((h) => h.remove());
},
};
const App = {
init() {
try {
if (!this.detectGreasemonkey()) throw new Error('Greasemonkey API not detected');
Utils.log(state.gmFallback ? 'Running in compatibility mode' : 'Running in normal mode', 'warn');
StyleManager.apply(StyleManager.styleDefinitions.debugResizeHandleStyle, true);
Promise.all([
SettingsManager.cleanupStorage(),
SettingsManager.load(),
SettingsManager.loadBlacklist(),
]).then(() => {
this.checkTampermonkeyVersion();
StyleManager.apply(StyleManager.styleDefinitions.chatRendererFixStyle, true);
StyleManager.apply(StyleManager.styleDefinitions.videoPlayerFixStyle, true);
this.onPageChange();
this.attachEventListeners();
MenuManager.refresh();
});
} catch (error) {
Utils.log('Initialization failed', 'error', error);
}
},
detectGreasemonkey() {
return typeof window.GM?.info !== 'undefined' || typeof GM_info !== 'undefined';
},
checkTampermonkeyVersion() {
const info = GM_API.info();
if (info?.scriptHandler === 'Tampermonkey') {
if (Utils.compareVersions(info.version, CONFIG.REQUIRED_VERSIONS.Tampermonkey) < 0) {
state.isOldTampermonkey = true;
this.warnIfOldTampermonkey();
}
}
},
async warnIfOldTampermonkey() {
if (state.versionWarningShown || state.userSettings.isSimpleMode || !state.isOldTampermonkey) return;
GM_API.notification({
text: getLocalizedText().tampermonkeyOutdatedAlert,
timeout: 15000,
});
state.versionWarningShown = true;
await GM_API.setValue('versionWarningShown', true);
},
updateAllStyles(shouldSaveChatPos = false) {
try {
if (state.userSettings.useCustomPlayerHeight) {
state.userSettings.modifyVideoPlayer = true;
}
const isBlacklisted = state.blacklist.has(state.videoId);
const isLiveOnly = state.userSettings.enableOnlyForLiveStreams && !state.isLiveStream;
if (isBlacklisted || isLiveOnly) {
StyleManager.removeAll();
ChatInteractionManager.removeTheaterResizeHandle();
DOM.moviePlayer?.setCenterCrop?.();
return;
}
StyleManager.toggle(
StyleManager.styleDefinitions.videoPlayerStyle,
state.userSettings.modifyVideoPlayer
);
this.updateChatStyles();
this.updateFullscreenChatStyles(shouldSaveChatPos);
DOM.moviePlayer?.setCenterCrop?.();
} catch (error) {
Utils.log('Error updating styles', 'error', error);
}
},
updateChatStyles() {
const chatBox = DOM.chatFrame?.getBoundingClientRect();
const isSecondaryVisible = DOM.ytdWatchFlexy?.querySelector('#secondary')?.style.display !== 'none';
const shouldApplyChatStyle =
state.userSettings.modifyChat &&
state.isTheaterMode &&
!state.isFullscreen &&
!state.chatCollapsed &&
chatBox?.width > 0 &&
isSecondaryVisible;
StyleManager.toggle(StyleManager.styleDefinitions.chatStyle, shouldApplyChatStyle);
StyleManager.toggle(StyleManager.styleDefinitions.chatClampLimits, shouldApplyChatStyle);
shouldApplyChatStyle
? ChatInteractionManager.addTheaterResizeHandle()
: ChatInteractionManager.removeTheaterResizeHandle();
this.updateHeadmastStyle(shouldApplyChatStyle);
},
updateHeadmastStyle(isChatStyled) {
this.updateLowHeadmastStyle();
const shouldShrinkHeadmast =
isChatStyled &&
DOM.chatFrame?.getAttribute('theater-watch-while') === '' &&
(state.userSettings.setLowHeadmast || state.userSettings.modifyChat);
state.chatWidth = DOM.chatFrame?.offsetWidth ?? 0;
StyleManager.toggle(StyleManager.styleDefinitions.headmastStyle, shouldShrinkHeadmast);
},
updateLowHeadmastStyle() {
if (!DOM.moviePlayer) return;
const shouldApply =
state.userSettings.setLowHeadmast &&
state.isTheaterMode &&
!state.isFullscreen &&
state.currentPageType === 'watch';
StyleManager.toggle(StyleManager.styleDefinitions.lowHeadmastStyle, shouldApply);
},
updateFullscreenChatStyles(shouldSave) {
const chatContainer = DOM.chatContainer;
const shouldEnableFloatingChat = state.userSettings.floatingChat && state.isFullscreen;
if (!chatContainer || !shouldEnableFloatingChat) {
if (chatContainer) ChatInteractionManager.cleanupFullscreenChat(chatContainer);
StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyleCollapsed);
StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyleExpanded);
StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyle);
return;
}
const isChatAvailable = chatContainer.querySelector('#chat');
if (isChatAvailable) {
StyleManager.apply(StyleManager.styleDefinitions.floatingChatStyle);
StyleManager.toggle(StyleManager.styleDefinitions.floatingChatStyleCollapsed, state.chatCollapsed);
StyleManager.toggle(StyleManager.styleDefinitions.floatingChatStyleExpanded, !state.chatCollapsed);
ChatInteractionManager.initFullscreenChat(chatContainer);
if (shouldSave) {
ChatInteractionManager.applySavedChatStyle(chatContainer, true);
}
} else {
ChatInteractionManager.cleanupFullscreenChat(chatContainer);
}
},
updateDebugStyles() {
if (DOM.chatContainer) {
DOM.chatContainer.toggleAttribute('debug', state.userSettings.debug);
}
},
updateDOMCache() {
DOM.ytdWatchFlexy = document.querySelector('ytd-watch-flexy');
DOM.chatContainer = document.querySelector('#chat-container');
DOM.chatFrame = document.querySelector('ytd-live-chat-frame#chat');
},
updateMoviePlayerObserver() {
const newMoviePlayer = document.querySelector('#movie_player');
if (DOM.moviePlayer === newMoviePlayer) return;
if (state.resizeObserver) {
if (DOM.moviePlayer) {
state.resizeObserver.unobserve(DOM.moviePlayer);
}
} else {
state.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
state.moviePlayerHeight = entry.contentRect.height;
this.updateAllStyles();
}
});
}
DOM.moviePlayer = newMoviePlayer;
if (DOM.moviePlayer) {
state.resizeObserver.observe(DOM.moviePlayer);
}
},
onPageChange() {
this.updateDOMCache();
this.updateMoviePlayerObserver();
this.updateAllStyles();
this.updateDebugStyles();
MenuManager.refresh();
},
handleFullscreenChange() {
state.isFullscreen = !!document.fullscreenElement;
this.updateAllStyles(true);
},
handleTheaterChange(event) {
state.isTheaterMode = !!event?.detail?.enabled;
this.updateAllStyles();
},
handleChatCollapse(event) {
DOM.chatFrame = event.target;
state.chatCollapsed = event.detail !== false;
this.updateAllStyles(true);
},
handlePageData(event) {
try {
const pageData = event.detail.pageData;
state.currentPageType = pageData.page;
state.videoId = pageData.playerResponse?.videoDetails?.videoId;
state.isLiveStream = pageData.playerResponse?.videoDetails?.isLiveContent;
state.isFullscreen = !!document.fullscreenElement;
this.onPageChange();
} catch (error) {
Utils.log('Failed to process page data', 'error', error);
}
},
attachEventListeners() {
const events = {
'yt-set-theater-mode-enabled': (e) => this.handleTheaterChange(e),
'yt-chat-collapsed-changed': (e) => this.handleChatCollapse(e),
'yt-page-data-fetched': (e) => this.handlePageData(e),
'yt-page-data-updated': () => this.onPageChange(),
fullscreenchange: () => this.handleFullscreenChange(),
'yt-navigate-finish': () => this.onPageChange(),
};
for (const [event, handler] of Object.entries(events)) {
window.addEventListener(event, handler.bind(this), {
capture: true,
passive: true,
});
}
let isResizeScheduled = false;
window.addEventListener('resize', () => {
if (isResizeScheduled) return;
isResizeScheduled = true;
requestAnimationFrame(() => {
this.updateAllStyles(true);
isResizeScheduled = false;
});
});
},
};
App.init();
})();