您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性,也达到了类似B站的网页全屏功能。同时新增可选的、自制风格的浮动聊天室功能(仅限全fullscreen模式),融入了 YouTube 原有的设计语言。
// ==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(); })();