// ==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 3.2.10 // @match *://www.youtube.com/* // @match *://www.youtube-nocookie.com/* // @require https://update.greasyfork.icu/scripts/549881/1783571/YouTube%20Helper%20API.js // @noframes // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @run-at document-idle // @inject-into page // @license MIT // @description Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts while maintaining performance and compatibility. // @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。 // @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性,也达到了类似B站的网页全屏功能。 // @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。 // @downloadURL none // ==/UserScript== /*jshint esversion: 11 */ /* global youtubeHelperApi */ (function () { "use strict"; const api = youtubeHelperApi; if (!api) return console.error("Helper API not found."); const CONFIG = { STORAGE_PREFIX: "betterTheater_", MIN_CHAT_SIZE: { width: 300, //px }, DEFAULT_SETTINGS: { setLowMasthead: false, fullHeightVideo: false, tuckRecommendation: false, alwaysShowQuickActions: true, get theaterChatWidth() { return `${CONFIG.MIN_CHAT_SIZE.width}px`; }, }, }; const MENU_LABELS = (() => { const browserLanguage = navigator.language ?? navigator.userLanguage; const translations = { moveMastheadBelowVideoPlayer: { "en-US": "Move Search Bar Below Video", "zh-TW": "將搜尋列移動到影片下方", "zh-CN": "将搜寻列移动到影片下方", ja: "検索バーをビデオプレイヤーの下に移動", }, fullHeightVideo: { "en-US": "Full Height Video", "zh-TW": "延伸影片至視窗高度", "zh-CN": "下移推荐视频", ja: "動画をブラウザの高さに広げる", }, tuckRecommendation: { "en-US": "Shift Recommendations Down", "zh-TW": "下移推薦影片", "zh-CN": "下移推荐视频列表", ja: "おすすめの動画を下にずらす", }, alwaysShowQuickActions: { "en-US": "Always Show Quick Actions", "zh-TW": "常駐顯示快速操作", "zh-CN": "常驻显示快速操作", ja: "クイックアクションを常に表示", }, }; const getPreferredLanguage = () => { if (["zh-TW", "zh-HK"].includes(browserLanguage)) return "zh-TW"; if (browserLanguage.startsWith("zh")) return "zh-CN"; if (browserLanguage.startsWith("ja")) return "ja"; return "en-US"; }; return new Proxy(translations, { get(target, property) { const keyGroup = target[property]; if (!keyGroup) return `[${String(property)}]`; const currentLanguage = getPreferredLanguage(); const fallbackLanguage = "en-US"; return keyGroup[currentLanguage] ?? keyGroup[fallbackLanguage] ?? `[Missing: ${String(property)}]`; }, }); })(); const state = { userSettings: { ...CONFIG.DEFAULT_SETTINGS }, menuItems: [], activeStyles: new Map(), resizeObserver: null, chatWidth: 0, moviePlayerHeight: 0, }; const DOM = { moviePlayer: null }; const GhostManager = { hasInitialized: false, observer: null, _pollingInterval: null, currentSource: null, currentTarget: null, init() { this.hasInitialized = true; this.observer = new MutationObserver((mutations) => { const isRelevant = mutations.some( (_mutation) => _mutation.type === "childList" || _mutation.type === "characterData" || (_mutation.type === "attributes" && _mutation.target === this.currentSource), ); if (isRelevant) this.update(); }); if (!this.update()) { this._pollingInterval = setInterval(() => { if (this.update()) { clearInterval(this._pollingInterval); this._pollingInterval = null; } }, 500); } }, safelyModifyDOM(action) { this.observer?.disconnect(); try { action(); } finally { this.observeElements(this.currentSource, this.currentTarget); } }, isSourceReady(element) { return ( element && element.offsetWidth > 0 && !!element.querySelector("button") && !!element.querySelector("yt-icon, svg, img") ); }, _createBaseButton(referenceButton) { const ghost = document.createElement("button"); ghost.classList.add("bt-ghost-clone"); const defaults = { classes: [ "yt-spec-button-shape-next", "yt-spec-button-shape-next--text", "yt-spec-button-shape-next--overlay", "yt-spec-button-shape-next--size-s", ], styles: { width: "32px", height: "32px" }, }; if (referenceButton) { ghost.className = referenceButton.className + " bt-ghost-clone"; } else { ghost.classList.add(...defaults.classes); } if (referenceButton) { const computed = window.getComputedStyle(referenceButton); Object.assign(ghost.style, { margin: computed.margin, padding: computed.padding, width: computed.width, height: computed.height, minWidth: computed.minWidth, verticalAlign: "top", }); } else { Object.assign(ghost.style, defaults.styles); } Object.assign(ghost.style, { cursor: "pointer", border: "none", outline: "none", display: "inline-flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box", padding: "0 18px", position: "relative", }); return ghost; }, _appendIcon(ghost, original) { const iconSource = original.querySelector("yt-icon, svg, img"); if (!iconSource) { ghost.textContent = "🔔"; return; } const clonedIcon = iconSource.cloneNode(true); clonedIcon.style.cssText = "width: 24px !important; height: 24px !important; display: block; pointer-events: none; fill: currentColor; color: inherit;"; const wrapper = document.createElement("div"); wrapper.className = "yt-spec-button-shape-next__icon"; wrapper.style.pointerEvents = "none"; wrapper.appendChild(clonedIcon); ghost.appendChild(wrapper); }, _appendRipple(ghost) { const shape = document.createElement("yt-touch-feedback-shape"); shape.className = "yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--overlay-touch-response"; shape.setAttribute("aria-hidden", "true"); shape.appendChild(document.createElement("div")).className = "yt-spec-touch-feedback-shape__stroke"; shape.appendChild(document.createElement("div")).className = "yt-spec-touch-feedback-shape__fill"; ghost.appendChild(shape); }, _appendBadge(ghost, original) { const source = original.querySelector(".yt-spec-icon-badge-shape__badge"); const text = source?.textContent?.trim(); if (!text || window.getComputedStyle(source).display === "none") return; const badge = document.createElement("div"); badge.className = "bt-ghost-badge"; badge.textContent = text; const computed = window.getComputedStyle(source); Object.assign(badge.style, { position: "absolute", top: "2px", right: "-2px", backgroundColor: computed.backgroundColor, color: computed.color, fontSize: computed.fontSize, fontWeight: computed.fontWeight, lineHeight: computed.lineHeight, fontFamily: computed.fontFamily, minWidth: computed.minWidth, height: computed.height, padding: computed.padding, borderRadius: computed.borderRadius, border: computed.border, pointerEvents: "none", zIndex: "10", display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box", }); ghost.appendChild(badge); }, syncGhost(original, container, targetIndex = 2) { if (!this.isSourceReady(original)) return false; let ghost = container.querySelector(".bt-ghost-clone"); if (!ghost) { const referenceButton = container.querySelector("button:not(.bt-ghost-clone)"); ghost = this._createBaseButton(referenceButton); this._appendIcon(ghost, original); this._appendRipple(ghost); const clickTarget = original.querySelector("button") || original; ghost.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); clickTarget.click(); }); } const existingBadge = ghost.querySelector(".bt-ghost-badge"); if (existingBadge) existingBadge.remove(); this._appendBadge(ghost, original); this.safelyModifyDOM(() => { const children = Array.from(container.children); const currentIndex = children.indexOf(ghost); if (currentIndex === targetIndex) return; const offset = currentIndex !== -1 && currentIndex < targetIndex ? 1 : 0; const refNode = children[targetIndex + offset] || null; container.insertBefore(ghost, refNode); }); return true; }, observeElements(source, target) { this.currentSource = source || this.currentSource; this.currentTarget = target || this.currentTarget; if (!this.currentSource || !this.currentTarget) return; this.observer.disconnect(); this.observer.observe(this.currentSource, { childList: true, subtree: true, characterData: true, attributes: true, }); this.observer.observe(this.currentTarget, { childList: true }); }, update() { if (!this.hasInitialized) return false; const shouldHaveGhosts = state.userSettings.fullHeightVideo && state.userSettings.setLowMasthead; if (shouldHaveGhosts) { const notifBell = document.querySelector("ytd-notification-topbar-button-renderer"); const quickActions = document.querySelector("yt-player-quick-action-buttons"); if (notifBell && quickActions) { this.observeElements(notifBell, quickActions); return this.syncGhost(notifBell, quickActions, 2); } return false; } else { const ghosts = document.querySelectorAll(".bt-ghost-clone"); if (ghosts.length > 0) ghosts.forEach((el) => el.remove()); this.observer?.disconnect(); if (this._pollingInterval) clearInterval(this._pollingInterval); this.currentSource = null; this.currentTarget = null; return true; } }, }; const StyleManager = { activeStyles: new Map(), styleDefinitions: { staticStyles: { staticVideoPlayerFixStyle: { id: "betterTheater-staticVideoPlayerFixStyle", getRule: () => ` .html5-video-container { top: -1px !important; } #skip-navigation.ytd-masthead { left: -500px; } `, }, chatRendererFixStyle: { id: "betterTheater-staticChatRendererFixStyle", getRule: () => `ytd-live-chat-frame[theater-watch-while][rounded-container] { border-bottom: 0 !important; } `, }, streamBackgroundImageFixStyle: { id: "betterTheater-streamBackgroundImageFixStyle", getRule: () => ` .ytp-offline-slate-background { background-size: contain !important; max-width: 100% !important; max-height: 100% !important; } `, }, staticTuckRecommendationWidthClampStyle: { id: "betterTheater-staticTuckRecommendationWidthClampStyle", getRule: () => ` #id.ytd-watch-metadata, #top-row.ytd-watch-metadata { max-width: calc( min( calc(100vw - 3 * var(--ytd-watch-flexy-horizontal-page-margin)), 100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin) ) ) !important; } `, }, }, 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; } `, }, fullHeightPlayerStyle: { id: "betterTheater-fullHeightPlayerStyle", getRule: () => { const viewportHeight = state.userSettings.setLowMasthead ? "100vh" : "calc(100vh - var(--ytd-watch-flexy-masthead-height))"; return ` ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy { min-height: ${viewportHeight} !important; max-height: ${viewportHeight} !important; } `; }, }, alwaysShowQuickActionsStyle: { id: "betterTheater-alwaysShowQuickActionsStyle", getRule: () => ` .ytp-fullscreen-quick-actions { display: unset !important; } #show-hide-button.ytd-live-chat-frame { display: none !important; } .ytp-timely-actions-content ytw-timely-actions-overlay-view-model { transform: translateY(-24px) !important; } `, }, mastheadStyle: { id: "betterTheater-mastheadStyle", getRule: () => `#masthead-container.ytd-app { max-width: calc(100% - ${state.chatWidth}px) !important; }`, }, lowMastheadStyle: { id: "betterTheater-lowMastheadStyle", getRule: () => ` #page-manager.ytd-app { margin-top: 0 !important; top: calc(-1 * var(--ytd-toolbar-offset)) !important; position: relative !important; } ytd-watch-flexy:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy { margin-top: var(--ytd-toolbar-offset) !important; } #masthead-container.ytd-app { z-index: 599 !important; top: ${state.moviePlayerHeight}px !important; position: relative !important; } tp-yt-iron-dropdown { top: calc(var(--ytd-masthead-height-accounting-for-hidden) / 2) !important; } `, }, chatClampLimits: { id: "betterTheater-chatClampLimits", getRule: () => { const flexy = api.page.watchFlexy; const originalWidth = "402px"; const originalMinWidth = "402px"; if (flexy) { const style = window.getComputedStyle(flexy); 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; } /* bypass youtube's throttling of style calculations (makes reflow instant) */ ytd-watch-flexy[flexy] #secondary.ytd-watch-flexy { width: var(--bt-chat-width) !important; min-width: var(--bt-chat-width) !important; max-width: var(--bt-chat-width) !important; } ytd-watch-flexy[flexy] #primary.ytd-watch-flexy { /* force the video player side to perfectly flex around the chat */ flex: 1 1 0% !important; min-width: 0 !important; max-width: none !important; } `; } return ""; }, }, tuckRecommendationStyles: { liveStyle: { id: "betterTheater-tuckRecommendationStreamStyle", getRule: () => ` #columns.style-scope.ytd-watch-flexy { flex-direction: column !important; } #secondary { width: auto !important; margin: 0 var(--ytd-watch-flexy-horizontal-page-margin) !important; } #teaser-carousel.ytd-watch-metadata { width: auto !important; } `, }, vodStyle: { id: "betterTheater-tuckRecommendationVodStyle", getRule: () => ` #id.ytd-watch-metadata, #top-row.ytd-watch-metadata { width: calc(100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin)) !important; } #secondary:not(:has(ytd-playlist-panel-renderer)) { transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6)) !important; } #secondary:has(ytd-playlist-panel-renderer) { transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6.5 + 1px)) !important; } `, }, videoStyle: { id: "betterTheater-tuckRecommendationVideoStyle", getRule: () => ` #id.ytd-watch-metadata, #top-row.ytd-watch-metadata { width: calc(100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin)) !important; } #secondary:not(:has(ytd-playlist-panel-renderer)) { transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6)) !important; } #secondary:has(ytd-playlist-panel-renderer) { transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6.5 + 1px)) !important; } `, }, }, }, apply(styleDef, isPersistent = false) { if (typeof styleDef.getRule !== "function") return; const newCss = styleDef.getRule(); let styleElement = document.getElementById(styleDef.id); if (styleElement) { if (styleElement.textContent === newCss) { if (!state.activeStyles.has(styleDef.id)) { state.activeStyles.set(styleDef.id, { element: styleElement, persistent: isPersistent }); } return; } styleElement.textContent = newCss; return; } styleElement = document.createElement("style"); styleElement.id = styleDef.id; styleElement.textContent = newCss; document.head.appendChild(styleElement); state.activeStyles.set(styleDef.id, { element: styleElement, persistent: isPersistent }); }, remove(styleDef) { const element = document.getElementById(styleDef.id); if (element) 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 StorageManager = { getValue: async (key) => { try { return await api.loadFromStorage(CONFIG.STORAGE_PREFIX + key); } catch (error) { console.error(`Failed to parse storage key "${key}"`, error); return null; } }, setValue: async (key, value) => { try { await api.saveToStorage(CONFIG.STORAGE_PREFIX + key, value); } catch (error) { console.error(`Failed to set storage key "${key}"`, error); } }, deleteValue: async (key) => { await api.deleteFromStorage(CONFIG.STORAGE_PREFIX + key); }, listValues: async () => { const fullList = await api.listFromStorage(); const filteredList = fullList .filter((key) => key.startsWith(CONFIG.STORAGE_PREFIX)) .map((key) => key.substring(CONFIG.STORAGE_PREFIX.length)); return filteredList; }, }; const SettingsManager = { async update(key, value) { try { const settings = await StorageManager.getValue("settings", CONFIG.DEFAULT_SETTINGS); settings[key] = value; await StorageManager.setValue("settings", settings); state.userSettings[key] = value; } catch (error) { console.error(`Error updating setting: ${key}.`, error); } }, async load() { try { const storedSettings = await StorageManager.getValue("settings", CONFIG.DEFAULT_SETTINGS); const newSettings = { ...CONFIG.DEFAULT_SETTINGS, ...storedSettings }; state.userSettings = newSettings; if (Object.keys(storedSettings).length !== Object.keys(newSettings).length) { await StorageManager.setValue("settings", state.userSettings); } } catch (error) { console.error("Error loading settings.", error); throw error; } }, async cleanupStorage() { try { const allowedKeys = ["settings"]; const keys = await StorageManager.listValues(); for (const key of keys) { if (!allowedKeys.includes(key)) { await StorageManager.deleteValue(key); } } } catch (error) { console.error("Error cleaning up old storage.", error); } }, }; const MenuManager = { clear() { while (state.menuItems.length) GM.unregisterMenuCommand(state.menuItems.pop()); }, refresh() { this.clear(); const shouldAutoClose = GM?.info?.scriptHandler === "ScriptCat"; const menuConfig = [ { label: () => `${state.userSettings.setLowMasthead ? "✅" : "❌"} ${MENU_LABELS.moveMastheadBelowVideoPlayer}`, id: "toggleLowMasthead", action: () => SettingsManager.update("setLowMasthead", !state.userSettings.setLowMasthead).then(() => App.updateAllStyles(), ), }, { label: () => `${state.userSettings.fullHeightVideo ? "✅" : "❌"} ${MENU_LABELS.fullHeightVideo}`, id: "toggleFullHeightVideo", action: () => SettingsManager.update("fullHeightVideo", !state.userSettings.fullHeightVideo).then(() => { App.updateVideoStyle(); GhostManager.update(); }), }, { label: () => `${state.userSettings.tuckRecommendation ? "✅" : "❌"} ${MENU_LABELS.tuckRecommendation}`, id: "toggleTuckRecommendation", action: () => SettingsManager.update("tuckRecommendation", !state.userSettings.tuckRecommendation).then( () => { App.updateRecommendationTuckStyle(); }, ), }, { label: () => `${state.userSettings.alwaysShowQuickActions ? "✅" : "❌"} ${MENU_LABELS.alwaysShowQuickActions}`, id: "toggleAlwaysShowQuickActions", action: () => SettingsManager.update( "alwaysShowQuickActions", !state.userSettings.alwaysShowQuickActions, ).then(() => { App.updateQuickActionStyle(); }), }, ]; menuConfig.forEach((item) => { const commandId = GM.registerMenuCommand( item.label(), async () => { await item.action(); this.refresh(); }, { id: item.id, autoClose: shouldAutoClose }, ); state.menuItems.push(commandId ?? item.id); }); }, }; const ChatInteractionManager = { addChatWidthResizeHandle() { if (window.innerWidth / 3 <= CONFIG.MIN_CHAT_SIZE.width) return; const chat = api.chat.iFrame; if (!chat || chat.querySelector("#chat-width-resize-handle")) return; const storedWidth = state.userSettings.theaterChatWidth ?? `${CONFIG.MIN_CHAT_SIZE.width}px`; this._applyTheaterWidth(api.page.watchFlexy, 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; let startWidth = 0; let 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(api.page.watchFlexy, chat, `${newWidth}px`); }); }; const _onPointerUp = (event) => { handle.releasePointerCapture(event.pointerId); document.removeEventListener("pointermove", _onPointerMove); document.removeEventListener("pointerup", _onPointerUp); SettingsManager.update( "theaterChatWidth", api.page.watchFlexy.style.getPropertyValue("--bt-chat-width"), ); }; handle.addEventListener("pointerdown", (event) => { if (event.pointerType === "mouse" && event.button !== 0) return; event.preventDefault(); document.body.click(); // Deselect any text startX = event.clientX; startWidth = chat.getBoundingClientRect().width; handle.setPointerCapture(event.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"; } window.dispatchEvent(new Event("resize")); // bypass youtube's throttling of style calculations }, removeChatWidthResizeHandle() { api.chat.iFrame?.querySelector("#chat-width-resize-handle")?.remove(); const flexy = api.page.watchFlexy; const chat = api.chat.iFrame; if (flexy) flexy.style.removeProperty("--bt-chat-width"); if (chat) { chat.style.width = ""; chat.style.zIndex = ""; } }, }; const App = { init() { try { if (api.gmCapabilities.none) throw new Error("Greasemonkey API not detected"); Promise.all([SettingsManager.cleanupStorage(), SettingsManager.load()]).then(() => { if (unsafeWindow.ytInitialData?.responseContext?.mainAppWebResponseContext?.loggedOut !== true) GhostManager.init(); Object.values(StyleManager.styleDefinitions.staticStyles).forEach((style) => StyleManager.apply(style, true), ); this._handlePageUpdate(); this.attachEventListeners(); MenuManager.refresh(); GhostManager.update(); }); } catch (error) { console.error("Initialization failed.", error); } }, _shouldApplyChatStyle() { const chatBox = api.chat.iFrame?.getBoundingClientRect(); const flexy = api.page.watchFlexy; const isSecondaryVisible = flexy?.querySelector("#secondary")?.style.display !== "none"; return ( api.player.isTheater && !api.player.isFullscreen && !api.chat.isCollapsed && chatBox?.width > 0 && isSecondaryVisible ); }, updateChatStyles() { const styles = StyleManager.styleDefinitions; const shouldStyle = this._shouldApplyChatStyle(); StyleManager.toggle(styles.chatStyle, shouldStyle); StyleManager.toggle(styles.chatClampLimits, shouldStyle); shouldStyle ? ChatInteractionManager.addChatWidthResizeHandle() : ChatInteractionManager.removeChatWidthResizeHandle(); this.updateMastheadStyle(shouldStyle); }, updateMastheadStyle(isChatStyled) { const styles = StyleManager.styleDefinitions; const updateLowMastheadStyle = () => { if (!DOM.moviePlayer) return; const shouldApply = state.userSettings.setLowMasthead && api.player.isTheater && !api.player.isFullscreen && api.page.type === "watch"; StyleManager.toggle(styles.lowMastheadStyle, shouldApply); }; if (isChatStyled === undefined) isChatStyled = this._shouldApplyChatStyle(); updateLowMastheadStyle(); const shouldShrinkMasthead = isChatStyled && api.chat.iFrame?.getAttribute("theater-watch-while") === ""; state.chatWidth = api.chat.iFrame?.offsetWidth ?? 0; StyleManager.toggle(styles.mastheadStyle, shouldShrinkMasthead); DOM.moviePlayer?.setCenterCrop?.(); }, updateVideoStyle() { const shouldApply = state.userSettings.fullHeightVideo; StyleManager.toggle(StyleManager.styleDefinitions.fullHeightPlayerStyle, shouldApply); }, updateRecommendationTuckStyle() { const styles = StyleManager.styleDefinitions.tuckRecommendationStyles; Object.values(styles).forEach((style) => StyleManager.toggle(style, false)); if (!state.userSettings.tuckRecommendation) return; if (!api.player.isTheater || api.player.isFullscreen || api.page.type !== "watch") return; const isVod = api.video.wasStreamedOrPremiered; const canHaveChat = api.video.isLiveOrVodContent || isVod; const isCollapsed = !api.chat.container || !api.chat.iFrame || api.chat.isCollapsed; // TODO: Patch helper lib. YouTube can return chat state even when chat elements are missing. if (!canHaveChat || (isVod && isCollapsed)) return StyleManager.toggle(styles.videoStyle, true); if (!isCollapsed) return StyleManager.toggle(isVod ? styles.vodStyle : styles.liveStyle, true); }, updateQuickActionStyle() { const styles = StyleManager.styleDefinitions.alwaysShowQuickActionsStyle; StyleManager.toggle(styles, state.userSettings.alwaysShowQuickActions); }, updateAllStyles() { try { this.updateVideoStyle(); this.updateChatStyles(); this.updateRecommendationTuckStyle(); this.updateQuickActionStyle(); GhostManager.update(); } catch (error) { console.error("Error updating styles.", error); } }, updateMoviePlayerObserver() { const newMoviePlayer = api.player.playerObject ?? document.querySelector("#movie_player"); if (DOM.moviePlayer === newMoviePlayer) return; if (state.resizeObserver) state.resizeObserver.disconnect(); state.resizeObserver = new ResizeObserver((entries) => { window.requestAnimationFrame(() => { if (!Array.isArray(entries) || !entries.length) return; const entry = entries[0]; if (Math.abs(state.moviePlayerHeight - entry.contentRect.height) > 1) { state.moviePlayerHeight = entry.contentRect.height; this.updateAllStyles(); } }); }); DOM.moviePlayer = newMoviePlayer; if (DOM.moviePlayer) state.resizeObserver.observe(DOM.moviePlayer); }, _handlePageUpdate() { this.updateMoviePlayerObserver(); this.updateAllStyles(); }, _handleFullscreenChange() { this.updateAllStyles(); }, _handleTheaterChange() { this.updateAllStyles(); }, _handleChatStateUpdate() { this.updateAllStyles(); }, _handlePageDataFetch() { this._handlePageUpdate(); }, attachEventListeners() { const events = { "yt-set-theater-mode-enabled": () => this._handleTheaterChange(), "yt-page-data-fetched": () => this._handlePageDataFetch(), "yt-page-data-updated": () => this._handlePageUpdate(), fullscreenchange: () => this._handleFullscreenChange(), }; for (const [event, handler] of Object.entries(events)) { window.addEventListener(event, handler.bind(this), { capture: true, passive: true }); } api.eventTarget.addEventListener( "yt-helper-api-chat-state-updated", this._handleChatStateUpdate.bind(this), ); api.eventTarget.addEventListener("yt-helper-api-ready", () => { if (api.page.type === "watch") { this._handlePageUpdate(); } }); let isResizeScheduled = false; window.addEventListener("resize", () => { if (isResizeScheduled) return; isResizeScheduled = true; requestAnimationFrame(() => { this.updateAllStyles(); isResizeScheduled = false; }); }); }, }; App.init(); })();