// ==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.2 // @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のデザイン言語に沿って統合しています。 // @downloadURL none // ==/UserScript== /*jshint esversion: 11 */ (function () { "use strict"; // CONFIG AND CONSTANTS const CONFIG = { // UI Constants MIN_CHAT_SIZE: { width: '300px', height: '355px' }, DRAG_BAR_HEIGHT: '35px', // Default settings DEFAULT_SETTINGS: { isScriptActive: true, isSimpleMode: true, enableOnlyForLiveStreams: false, modifyVideoPlayer: true, modifyChat: true, setLowHeadmast: false, useCustomPlayerHeight: false, floatingChat: false, chatOpacity: '0.95', chatOffset: { left: '0px', top: '-500px' }, chatSize: { width: '300px', height: '355px' }, debug: false }, // Default empty blacklist DEFAULT_BLACKLIST: [], // Version requirements 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: ${CONFIG.MIN_CHAT_SIZE.width} !important; max-width: 100vw !important; max-height: 100vh !important; position: absolute; border-radius: 0 0 10px 10px !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: ${parseInt(CONFIG.MIN_CHAT_SIZE.height) - parseInt(CONFIG.DRAG_BAR_HEIGHT)}px !important; max-height: 100vh !important; } `, }, floatingChatStyleExpanded: { id: "betterTheater-floatingChatStyleExpanded", getRule: () => ` #chat-container { min-height: ${CONFIG.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); } // RESIZE HANDLES function isAtMinSize(element) { const style = window.getComputedStyle(element); const width = parseFloat(style.width); const height = parseFloat(style.height); const minWidth = parseFloat(style.minWidth); const minHeight = parseFloat(style.minHeight); return { isAtMinWidth: width <= minWidth, isAtMinHeight: height <= minHeight }; } 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 saveChatSize() { state.userSettings.chatSize = { width: chatContainer.style.width, height: chatContainer.style.height }; state.userSettings.chatOffset = { left: chatContainer.style.left, top: chatContainer.style.top }; await updateSetting('chatSize', state.userSettings.chatSize); await updateSetting('chatOffset', state.userSettings.chatOffset); } 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(CONFIG.MIN_CHAT_SIZE.width); const minHeight = parseInt(CONFIG.MIN_CHAT_SIZE.height); let dx = e.clientX - startX; let dy = e.clientY - startY; if (dx === 0 && dy === 0) return; dx = Math.max(-startX, Math.min(dx, movieRect.right - startX)); dy = Math.max(-startY, Math.min(dy, movieRect.bottom - startY)); const tooSmall = isAtMinSize(chatContainer); if (config.horizontal) { console.log('horizontal'); const isRightSide = handle.className.toLowerCase().includes('right'); const isLeftSide = handle.className.toLowerCase().includes('left'); if (!isRightSide && !isLeftSide) { console.log(handle.className); } if (isRightSide) { if (!tooSmall.isAtMinWidth || dx > 0) { if (startWidth + dx < minWidth) dx = minWidth - startWidth; if (chatRect.left + startWidth + dx > movieRect.right) { dx = movieRect.right - chatRect.left - startWidth; } chatContainer.style.width = Math.max(minWidth, startWidth + dx) + "px"; } } else if (isLeftSide) { if (!tooSmall.isAtMinWidth || dx < 0) { if (startWidth - dx < minWidth) dx = startWidth - minWidth; if (startLeft + chatParentRect.left + dx < movieRect.left) { dx = movieRect.left - startLeft - chatParentRect.left; } chatContainer.style.width = Math.max(minWidth, startWidth - dx) + "px"; chatContainer.style.left = (startLeft + dx) + "px"; } } } if (config.vertical) { console.log('vertical'); const isBottomSide = handle.className.toLowerCase().includes('bottom'); const isTopSide = handle.className.toLowerCase().includes('top'); if (isBottomSide) { if (!tooSmall.isAtMinHeight || dy > 0) { if (startHeight + dy < minHeight) dy = minHeight - startHeight; if (chatRect.top + startHeight + dy > movieRect.bottom) { dy = movieRect.bottom - chatRect.top - startHeight; } chatContainer.style.height = Math.max(minHeight, startHeight + dy) + "px"; } } else if (isTopSide) { if (!tooSmall.isAtMinHeight || dy < 0) { if (startHeight - dy < minHeight) dy = startHeight - minHeight; if (startTop + chatParentRect.top + dy < movieRect.top) { dy = movieRect.top - startTop - chatParentRect.top; } chatContainer.style.height = Math.max(minHeight, startHeight - dy) + "px"; chatContainer.style.top = (startTop + dy) + "px"; } } } }); handle.addEventListener("pointerup", function (e) { handle.releasePointerCapture(e.pointerId); saveChatSize(); }); } } // 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; // Add chat slider style if not present 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(CONFIG.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.chatOpacity) * 100).toString(); opacitySlider.style.marginLeft = "10px"; opacitySlider.addEventListener("input", () => { const newOpacity = opacitySlider.value / 100; chatContainer.style.opacity = newOpacity; }); opacitySlider.addEventListener("mouseup", () => { updateSetting('chatOpacity', chatContainer.style.opacity); }); ["pointerdown", "pointermove", "pointerup"].forEach(eventType => { opacitySlider.addEventListener(eventType, (e) => { e.stopPropagation(); }); }); dragBar.appendChild(dragLabel); dragBar.appendChild(opacitySlider); chatContainer.insertBefore(dragBar, chatContainer.firstChild); // Add drag functionality setupDragBehavior(dragBar, chatContainer); return dragBar; } function setupDragBehavior(dragBar, chatContainer) { let dragging = false; let startX = 0, startY = 0; let containerStartLeft = 0; let containerStartTop = 0; const dragThreshold = 5; async function saveChatPosition() { state.userSettings.chatOffset = { left: chatContainer.style.left, top: chatContainer.style.top }; await updateSetting('chatOffset', state.userSettings.chatOffset); } // Handle pointer down event dragBar.addEventListener("pointerdown", function (e) { if (e.pointerType === "mouse" && e.button !== 0) return; dragging = false; startX = e.clientX; startY = e.clientY; containerStartLeft = parseFloat(getComputedStyle(chatContainer).left) || 0; containerStartTop = 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; if (dx === 0 && dy === 0) return; // Start dragging after threshold is crossed if (!dragging && Math.hypot(dx, dy) > dragThreshold) { dragging = true; } if (dragging) { // Calculate new position let newLeft = containerStartLeft + dx; let newTop = containerStartTop + dy; // Get boundaries const movieRect = state.moviePlayer.getBoundingClientRect(); const chatParentRect = chatContainer.parentElement.getBoundingClientRect(); // Constrain to left edge if (newLeft + chatParentRect.left < 0) { newLeft = -chatParentRect.left; } // Constrain to top edge if (newTop + chatParentRect.top < 0) { newTop = -chatParentRect.top; } // Constrain to right edge if (newLeft > movieRect.right - (chatParentRect.left + chatContainer.offsetWidth)) { newLeft = movieRect.right - (chatParentRect.left + chatContainer.offsetWidth); } // Constrain to bottom edge, accounting for chat collapsed state if (state.chatCollapsed) { const showHideButton = chatContainer.querySelector('#show-hide-button'); if (showHideButton && newTop > movieRect.bottom - (chatParentRect.top + showHideButton.offsetHeight + dragBar.offsetHeight)) { newTop = movieRect.bottom - (chatParentRect.top + showHideButton.offsetHeight + dragBar.offsetHeight); } } else { if (newTop > movieRect.bottom - (chatParentRect.top + chatContainer.offsetHeight)) { newTop = movieRect.bottom - (chatParentRect.top + chatContainer.offsetHeight); } } // Apply new position chatContainer.style.left = newLeft + "px"; chatContainer.style.top = newTop + "px"; e.preventDefault(); } }); // Handle pointer up event dragBar.addEventListener("pointerup", function (e) { dragBar.releasePointerCapture(e.pointerId); dragging = false; saveChatPosition(); }); } // Removes drag bar from chat container function removeDragBarWithOpacitySlider(chatContainer) { if (!chatContainer) return; const dragBar = chatContainer.querySelector('.chat-drag-bar'); if (dragBar) dragBar.remove(); } // Removes all chat-related styles from container function removeAllChatStyles(chatContainer) { removeStyle(styleRules.floatingChatStyleCollapsed); removeStyle(styleRules.floatingChatStyleExpanded); removeStyle(styleRules.floatingChatStyle); if (chatContainer) chatContainer.style = ''; } /** * Applies saved chat style to container * @param {HTMLElement} chatContainer - The chat container */ function applySavedChatStyle(chatContainer) { if (!chatContainer) return; const chatPrison = chatContainer.parentElement.getBoundingClientRect(); // Apply width if (state.userSettings.chatSize?.width) { chatContainer.style.width = Math.min( window.innerWidth, parseFloat(state.userSettings.chatSize.width) ) + 'px'; } // Apply height if (state.userSettings.chatSize?.height) { chatContainer.style.height = Math.min( window.innerHeight, parseFloat(state.userSettings.chatSize.height) ) + 'px'; } // Apply left position if (state.userSettings.chatOffset?.left !== undefined) { const leftPos = parseFloat(state.userSettings.chatOffset.left); chatContainer.style.left = Math.min( Math.max(leftPos, chatPrison.left), window.innerWidth - chatPrison.width ) + 'px'; } // Apply top position if (state.userSettings.chatOffset?.top !== undefined) { const topPos = parseFloat(state.userSettings.chatOffset.top); chatContainer.style.top = Math.min( Math.max(topPos, chatPrison.top), window.innerHeight - chatPrison.height ) + 'px'; } // Apply opacity chatContainer.style.opacity = parseFloat(state.userSettings.chatOpacity); } // STYLE UPDATE FUNCTIONS /** * Updates styles based on current settings and state */ function updateStyles() { try { // Enforce dependency: custom player height requires modifying player if (state.userSettings.useCustomPlayerHeight) { state.userSettings.modifyVideoPlayer = true; } // Check if script should be disabled const shouldNotActivate = !state.userSettings.isScriptActive || (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; } // Apply main styles based on settings setStyleState(styleRules.chatStyle, state.userSettings.modifyChat); setStyleState(styleRules.videoPlayerStyle, state.userSettings.modifyVideoPlayer); // Update header styles updateHeadmastStyle(); // Update floating chat styles updateFullscreenFloatingChatStyle(); // Reset player crop if needed if (state.moviePlayer && state.moviePlayer.setCenterCrop) { state.moviePlayer.setCenterCrop(); } } catch (error) { logDebug(`Error when updating styles: ${error}`, 'error'); } } /** * Updates the headmast styles based on current state */ function updateHeadmastStyle() { updateLowHeadmastStyle(); // Determine if headmast should be shrunk to account for chat 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); } /** * Updates low headmast style based on current state */ function updateLowHeadmastStyle() { if (!state.moviePlayer) return; const shouldApplyLowHeadmast = state.userSettings.setLowHeadmast && state.isTheaterMode && !state.isFullscreen && state.currentPageType === 'watch'; setStyleState(styleRules.lowHeadmastStyle, shouldApplyLowHeadmast); } /** * Updates floating chat styles based on fullscreen state */ function updateFullscreenFloatingChatStyle() { try { const chatContainer = document.querySelector('#chat-container'); // Update collapsed/expanded styles based on chat state setStyleState(styleRules.floatingChatStyleCollapsed, state.chatCollapsed && state.isFullscreen); setStyleState(styleRules.floatingChatStyleExpanded, !state.chatCollapsed && state.isFullscreen); setStyleState(styleRules.floatingChatStyle, state.isFullscreen); // Configure floating chat if needed if (state.userSettings.isScriptActive && state.userSettings.floatingChat && chatContainer.querySelector('#chat') && chatContainer && state.isFullscreen) { applySavedChatStyle(chatContainer); removeDragBarWithOpacitySlider(chatContainer); addDragBarWithOpacitySlider(chatContainer); addResizeHandles(chatContainer); // Apply saved settings chatContainer.style.opacity = parseFloat(state.userSettings.chatOpacity); chatContainer.style.width = Math.min( window.innerWidth, parseFloat(state.userSettings.chatSize.width) ) + 'px'; chatContainer.style.height = Math.min( window.innerHeight, parseFloat(state.userSettings.chatSize.height) ) + 'px'; chatContainer.style.left = parseFloat(state.userSettings.chatOffset.left) + 'px'; chatContainer.style.top = parseFloat(state.userSettings.chatOffset.top) + 'px'; } else if (chatContainer) { // Remove chat modifications if not needed removeAllChatStyles(chatContainer); removeDragBarWithOpacitySlider(chatContainer); removeResizeHandles(chatContainer); } } catch (error) { logDebug(`Error when updating fullscreen chat styles: ${error}`, 'error'); } } /** * Updates debug visual indicators */ function updateDebugStyles() { const chatContainer = document.querySelector('#chat-container'); if (chatContainer) { if (state.userSettings.debug) { chatContainer.setAttribute("debug", ""); } else { chatContainer.removeAttribute("debug"); } } } // EVENT HANDLERS /** * Updates fullscreen status and related styles */ function updateFullscreenStatus() { state.isFullscreen = !!document.fullscreenElement; updateStyles(); } /** * Handles theater mode toggle events * @param {Event} event - The theater mode event */ function updateTheaterStatus(event) { state.isTheaterMode = !!event?.detail?.enabled; updateStyles(); } /** * Handles chat status change events * @param {Event} event - The chat status event */ function updateChatStatus(event) { state.chatFrame = event.target; state.chatCollapsed = event.detail !== false; // Wait for player API to be ready before updating styles window.addEventListener('player-api-ready', updateStyles, { once: true }); } /** * Updates movie player reference and sets up resize observer */ 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); } } /** * Handles video status update events * @param {Event} event - The video status event */ 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 /** * Updates a setting in storage * @param {string} key - The setting key * @param {any} value - The setting value */ 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'); } } /** * Loads user settings from storage */ 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.`); } } /** * Updates the mode (simple/advanced) based on user settings */ 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, isScriptActive: state.userSettings.isScriptActive, 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)}`); } /** * Loads video blacklist from storage */ 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.`); } } /** * Updates the blacklist in storage */ async function updateBlacklist() { try { await GM.setValue('blacklist', Array.from(state.blacklist)); } catch (error) { logDebug(`Error updating blacklist: ${error}`, 'error'); } } /** * Updates script info in storage and checks for updates */ 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'); } } /** * Cleans up old storage keys */ 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 /** * Removes all menu options */ function removeMenuOptions() { state.menuItems.forEach((menuItem) => { GM.unregisterMenuCommand(menuItem); }); state.menuItems.clear(); } /** * Updates and shows menu options based on current state */ 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 = { toggleScript: { alwaysShow: true, label: () => `🔄 ${state.userSettings.isScriptActive ? getLocalizedText().turnOff : getLocalizedText().turnOn}`, menuId: "toggleScript", handleClick: async function () { state.userSettings.isScriptActive = !state.userSettings.isScriptActive; await updateSetting('isScriptActive', state.userSettings.isScriptActive); updateStyles(); refreshMenuOptions(); }, }, 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); } } /** * Shows a number input prompt with validation * @param {string} message - The prompt message * @param {Function} validator - Optional validation function * @returns {number|null} The entered number or null if cancelled */ 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 /** * Logs debug information if debug mode is enabled * @param {string} message - The message to log * @param {string} [level='log'] - The log level ('log', 'warn', 'error') * @param {*} [data] - Optional data to log */ 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); } } /** * Compares two version strings * @param {string} v1 - First version string * @param {string} v2 - Second version string * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal */ 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; } /** * Gets the script version from metadata * @returns {string} The script version */ function getScriptVersionFromMeta() { const versionMatch = GM_info.scriptMetaStr.match(/@version\s+([^\r\n]+)/); return versionMatch ? versionMatch[1].trim() : null; } /** * Checks if we have the required Greasemonkey API * @returns {boolean} Whether the required API is available */ 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; } /** * Checks if Tampermonkey is up to date */ 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 }); } } } /** * Checks if the current page is a live chat iframe * @returns {boolean} Whether this is a live chat iframe */ function isLiveChatIFrame() { return /^https?:\/\/.*youtube\.com\/live_chat.*$/.test(window.location.href); } // EVENT LISTENERS /** * Attaches all necessary event listeners */ function attachEventListeners() { // YouTube-specific events 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); // Standard events window.addEventListener('fullscreenchange', updateFullscreenStatus, true); window.addEventListener('yt-navigate-finish', updateDebugStyles, { once: true }); } // INITIALIZATION /** * Initializes the script */ async function initialize() { try { // Check for Greasemonkey API if (!detectGreasemonkeyAPI()) { throw new Error("Did not detect valid Greasemonkey API"); } // Initialize static styles applyStyle(styleRules.debugResizeHandleStyle, true); // Clean up and load settings await cleanupOldStorage(); await loadUserSettings(); await loadBlacklist(); await updateScriptInfo(); // Check Tampermonkey version checkTampermonkeyVersion(); // Handle iframe case if (isLiveChatIFrame()) { applyStyle(styleRules.chatFrameFixStyle, true); return; } // Apply fixes and initialize applyStyle(styleRules.chatRendererFixStyle, true); applyStyle(styleRules.videoPlayerFixStyle, true); updateStyles(); attachEventListeners(); refreshMenuOptions(); } catch (error) { logDebug(`Error when initializing script: ${error}. Aborting script.`, 'error'); } } // Start the script initialize(); })();