更佳 YouTube 剧场模式

改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。同时新增可选的、自制风格的浮动聊天室功能(仅限全屏模式),融入了 YouTube 原有的设计语言。

目前为 2025-04-14 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Better Theater Mode for YouTube
  3. // @name:zh-TW 更佳 YouTube 劇場模式
  4. // @name:zh-CN 更佳 YouTube 剧场模式
  5. // @name:ja より良いYouTubeシアターモード
  6. // @icon https://www.youtube.com/img/favicon_48.png
  7. // @author ElectroKnight22
  8. // @namespace electroknight22_youtube_better_theater_mode_namespace
  9. // @version 1.11.4
  10. // @match *://www.youtube.com/*
  11. // @match *://www.youtube-nocookie.com/*
  12. // @grant GM.getValue
  13. // @grant GM.setValue
  14. // @grant GM.deleteValue
  15. // @grant GM.listValues
  16. // @grant GM.registerMenuCommand
  17. // @grant GM.unregisterMenuCommand
  18. // @grant GM.notification
  19. // @noframes
  20. // @license MIT
  21. // @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.
  22. // @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。另新增可選的、自製風格的浮動聊天室功能(僅限全螢幕模式),與 YouTube 原有的設計語言相融合。
  23. // @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。同时新增可选的、自制风格的浮动聊天室功能(仅限全屏模式),融入了 YouTube 原有的设计语言。
  24. // @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、全画面モード専用のオプションとして、カスタマイズ済みフローティングチャット機能を、YouTubeのデザイン言語に沿って統合しています。
  25. // ==/UserScript==
  26.  
  27. /*jshint esversion: 11 */
  28. (function () {
  29. "use strict";
  30.  
  31. // UI Constants
  32. const MIN_CHAT_SIZE = { width: '300px', height: '355px' };
  33. const DRAG_BAR_HEIGHT = '35px';
  34.  
  35. // CONFIG
  36. const CONFIG = {
  37. DEFAULT_SETTINGS: {
  38. isSimpleMode: true,
  39. enableOnlyForLiveStreams: false,
  40. modifyVideoPlayer: true,
  41. modifyChat: true,
  42. setLowHeadmast: false,
  43. useCustomPlayerHeight: false,
  44. playerHeightPx: 600,
  45. floatingChat: false,
  46. chatStyle: {
  47. left: '0px',
  48. top: '-500px',
  49. width: MIN_CHAT_SIZE.width,
  50. height: MIN_CHAT_SIZE.height,
  51. opacity: '0.95',
  52. },
  53. debug: false
  54. },
  55. DEFAULT_BLACKLIST: [],
  56. REQUIRED_VERSIONS: {
  57. Tampermonkey: '5.4.624'
  58. }
  59. };
  60.  
  61. // TRANSLATIONS
  62. const BROWSER_LANGUAGE = navigator.language || navigator.userLanguage;
  63.  
  64. function getPreferredLanguage() {
  65. if (BROWSER_LANGUAGE.startsWith('zh') && BROWSER_LANGUAGE !== 'zh-TW') {
  66. return 'zh-CN';
  67. }
  68. // Check if language is supported, otherwise fall back to English
  69. return ['en-US', 'zh-TW', 'zh-CN', 'ja'].includes(BROWSER_LANGUAGE)
  70. ? BROWSER_LANGUAGE
  71. : 'en-US';
  72. }
  73.  
  74. const TRANSLATIONS = {
  75. 'en-US': {
  76. 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.",
  77. turnOn: 'Turn On',
  78. turnOff: 'Turn Off',
  79. livestreamOnlyMode: 'Livestream Only Mode',
  80. applyChatStyles: 'Apply Chat Styles',
  81. applyVideoPlayerStyles: 'Apply Video Player Styles',
  82. moveHeadmastBelowVideoPlayer: 'Move Headmast Below Video Player',
  83. useCustomPlayerHeight: 'Use Custom Player Height',
  84. playerHeightText: 'Player Height',
  85. floatingChat: 'Floating Chat',
  86. blacklistVideo: 'Blacklist Video',
  87. unblacklistVideo: 'Unblacklist Video',
  88. simpleMode: 'Simple Mode',
  89. advancedMode: 'Advanced Mode',
  90. debug: 'DEBUG'
  91. },
  92. 'zh-TW': {
  93. tampermonkeyOutdatedAlert: "看起來您正在使用較舊版本的篡改猴,可能會導致選單問題。為了獲得最佳體驗,請更新至 5.4.6224 或更高版本。",
  94. turnOn: '開啟',
  95. turnOff: '關閉',
  96. livestreamOnlyMode: '僅限直播模式',
  97. applyChatStyles: '套用聊天樣式',
  98. applyVideoPlayerStyles: '套用影片播放器樣式',
  99. moveHeadmastBelowVideoPlayer: '將頁首橫幅移到影片播放器下方',
  100. useCustomPlayerHeight: '使用自訂播放器高度',
  101. playerHeightText: '播放器高度',
  102. floatingChat: '浮動聊天室',
  103. blacklistVideo: '將影片加入黑名單',
  104. unblacklistVideo: '從黑名單中移除影片',
  105. simpleMode: '簡易模式',
  106. advancedMode: '進階模式',
  107. debug: '偵錯'
  108. },
  109. 'zh-CN': {
  110. tampermonkeyOutdatedAlert: "看起来您正在使用旧版本的篡改猴,这可能会导致菜单问题。为了获得最佳体验,请更新到 5.4.6224 或更高版本。",
  111. turnOn: '开启',
  112. turnOff: '关闭',
  113. livestreamOnlyMode: '仅限直播模式',
  114. applyChatStyles: '应用聊天样式',
  115. applyVideoPlayerStyles: '应用视频播放器样式',
  116. moveHeadmastBelowVideoPlayer: '将页首横幅移动到视频播放器下方',
  117. useCustomPlayerHeight: '使用自定义播放器高度',
  118. playerHeightText: '播放器高度',
  119. floatingChat: '浮动聊天室',
  120. blacklistVideo: '将视频加入黑名单',
  121. unblacklistVideo: '从黑名单中移除视频',
  122. simpleMode: '简易模式',
  123. advancedMode: '高级模式',
  124. debug: '调试'
  125. },
  126. 'ja': {
  127. tampermonkeyOutdatedAlert: "ご利用のTampermonkeyのバージョンが古いため、メニューに問題が発生する可能性があります。より良い体験のため、バージョン5.4.6224以上に更新してください。",
  128. turnOn: "オンにする",
  129. turnOff: "オフにする",
  130. livestreamOnlyMode: "ライブ配信専用モード",
  131. applyChatStyles: "チャットスタイルを適用",
  132. applyVideoPlayerStyles: "ビデオプレイヤースタイルを適用",
  133. moveHeadmastBelowVideoPlayer: "ヘッドマストをビデオプレイヤーの下に移動",
  134. useCustomPlayerHeight: "カスタムプレイヤーの高さを使用",
  135. playerHeightText: "プレイヤーの高さ",
  136. floatingChat: "フローティングチャット",
  137. blacklistVideo: "動画をブラックリストに追加",
  138. unblacklistVideo: "ブラックリストから動画を解除",
  139. simpleMode: "シンプルモード",
  140. advancedMode: "高度モード",
  141. debug: "デバッグ"
  142. }
  143. };
  144.  
  145. function getLocalizedText() {
  146. return TRANSLATIONS[getPreferredLanguage()] || TRANSLATIONS['en-US'];
  147. }
  148.  
  149. // STATE VARIABLES
  150. const state = {
  151. userSettings: { ...CONFIG.DEFAULT_SETTINGS },
  152. advancedSettingsBackup: null,
  153. blacklist: new Set(),
  154. useCompatibilityMode: false,
  155. menuItems: new Set(),
  156. activeStyles: new Map(),
  157. resizeObserver: null,
  158. moviePlayer: null,
  159. videoId: null,
  160. chatFrame: null,
  161. currentPageType: '',
  162. isFullscreen: false,
  163. isTheaterMode: false,
  164. chatCollapsed: true,
  165. isLiveStream: false,
  166. chatWidth: 0,
  167. moviePlayerHeight: 0,
  168. isOldTampermonkey: false,
  169. isScriptRecentlyUpdated: false
  170. };
  171.  
  172. // GM API COMPATIBILITY
  173. const GM = {
  174. registerMenuCommand: state.useCompatibilityMode ? GM_registerMenuCommand : window.GM?.registerMenuCommand,
  175. unregisterMenuCommand: state.useCompatibilityMode ? GM_unregisterMenuCommand : window.GM?.unregisterMenuCommand,
  176. getValue: state.useCompatibilityMode ? GM_getValue : window.GM?.getValue,
  177. setValue: state.useCompatibilityMode ? GM_setValue : window.GM?.setValue,
  178. listValues: state.useCompatibilityMode ? GM_listValues : window.GM?.listValues,
  179. deleteValue: state.useCompatibilityMode ? GM_deleteValue : window.GM?.deleteValue,
  180. notification: state.useCompatibilityMode ? GM_notification : window.GM?.notification
  181. };
  182.  
  183. // STYLE DEFINITIONS
  184. const styleRules = {
  185. chatStyle: {
  186. id: "betterTheater-chatStyle",
  187. getRule: () => `
  188. ytd-live-chat-frame[theater-watch-while][rounded-container] {
  189. border-radius: 0 !important;
  190. border-top: 0 !important;
  191. }
  192. ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
  193. top: 0 !important;
  194. border-top: 0 !important;
  195. border-bottom: 0 !important;
  196. }
  197. `,
  198. },
  199.  
  200. videoPlayerStyle: {
  201. id: "betterTheater-videoPlayerStyle",
  202. getRule: () => {
  203. if (state.userSettings.useCustomPlayerHeight) {
  204. return `
  205. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  206. min-height: 0px !important;
  207. height: ${state.userSettings.playerHeightPx}px !important;
  208. }
  209. `;
  210. } else {
  211. return `
  212. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  213. max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
  214. }
  215. `;
  216. }
  217. }
  218. },
  219.  
  220. headmastStyle: {
  221. id: "betterTheater-headmastStyle",
  222. getRule: () => `
  223. #masthead-container.ytd-app {
  224. max-width: calc(100% - ${state.chatWidth}px) !important;
  225. }
  226. `,
  227. },
  228.  
  229. lowHeadmastStyle: {
  230. id: "betterTheater-lowHeadmastStyle",
  231. getRule: () => `
  232. #page-manager.ytd-app {
  233. margin-top: 0 !important;
  234. top: calc(-1 * var(--ytd-toolbar-offset)) !important;
  235. position: relative !important;
  236. }
  237. ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy {
  238. margin-top: var(--ytd-toolbar-offset) !important;
  239. }
  240. ${state.userSettings.modifyVideoPlayer ? `
  241. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  242. max-height: 100vh !important;
  243. }
  244. ` : ''}
  245. #masthead-container.ytd-app {
  246. z-index: 599 !important;
  247. top: ${state.moviePlayerHeight}px !important;
  248. position: relative !important;
  249. }
  250. `,
  251. },
  252.  
  253. videoPlayerFixStyle: {
  254. id: "betterTheater-videoPlayerFixStyle",
  255. getRule: () => `
  256. .html5-video-container {
  257. top: -1px !important;
  258. }
  259. #skip-navigation.ytd-masthead {
  260. left: -500px;
  261. }
  262. `,
  263. },
  264.  
  265. chatFrameFixStyle: {
  266. id: "betterTheater-chatFrameFixStyle",
  267. getRule: () => {
  268. const chatInputContainer = document.querySelector(
  269. "tp-yt-iron-pages#panel-pages.style-scope.yt-live-chat-renderer"
  270. );
  271. const shouldHideChatInputContainerTopBorder =
  272. chatInputContainer?.clientHeight === 0;
  273. const borderTopStyle = shouldHideChatInputContainerTopBorder
  274. ? 'border-top: 0 !important;'
  275. : '';
  276.  
  277. return `
  278. #panel-pages.yt-live-chat-renderer {
  279. ${borderTopStyle}
  280. border-bottom: 0 !important;
  281. }
  282. `;
  283. },
  284. },
  285.  
  286. chatRendererFixStyle: {
  287. id: "betterTheater-chatRendererFixStyle",
  288. getRule: () => `
  289. ytd-live-chat-frame[theater-watch-while][rounded-container] {
  290. border-bottom: 0 !important;
  291. }
  292. `,
  293. },
  294.  
  295. floatingChatStyle: {
  296. id: "betterTheater-floatingChatStyle",
  297. getRule: () => `
  298. #chat-container {
  299. min-width: ${MIN_CHAT_SIZE.width} !important;
  300. max-width: 100vw !important;
  301. max-height: 100vh !important;
  302. position: absolute;
  303. border-radius: 0 0 12px 12px !important;
  304. }
  305. #chat {
  306. top: ${DRAG_BAR_HEIGHT} !important;
  307. height: calc(100% - ${DRAG_BAR_HEIGHT}) !important;
  308. width: inherit !important;
  309. min-width: inherit !important;
  310. max-width: inherit !important;
  311. min-height: ${parseInt(MIN_CHAT_SIZE.height) - parseInt(DRAG_BAR_HEIGHT)}px !important;
  312. max-height: 100vh !important;
  313. }
  314. `,
  315. },
  316.  
  317. floatingChatStyleExpanded: {
  318. id: "betterTheater-floatingChatStyleExpanded",
  319. getRule: () => `
  320. #chat-container {
  321. min-height: ${MIN_CHAT_SIZE.height} !important;
  322. }
  323. ytd-live-chat-frame:not([theater-watch-while])[rounded-container] {
  324. border-top-left-radius: 0 !important;
  325. border-top-right-radius: 0 !important;
  326. border-top: 0 !important;
  327. }
  328. ytd-live-chat-frame:not([theater-watch-while])[rounded-container] iframe.ytd-live-chat-frame {
  329. border-top-left-radius: 0 !important;
  330. border-top-right-radius: 0 !important;
  331. }
  332. `,
  333. },
  334.  
  335. floatingChatStyleCollapsed: {
  336. id: "betterTheater-floatingChatStyleCollapsed",
  337. getRule: () => `
  338. ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame>ytd-toggle-button-renderer.ytd-live-chat-frame,
  339. ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame>ytd-button-renderer.ytd-live-chat-frame {
  340. margin: 0 !important;
  341. border-radius: 0 0 12px 12px !important;
  342. border-left: 1px solid var(--yt-spec-10-percent-layer) !important;
  343. border-right: 1px solid var(--yt-spec-10-percent-layer) !important;
  344. border-bottom: 1px solid var(--yt-spec-10-percent-layer) !important;
  345. background-clip: padding-box !important;
  346. }
  347. ytd-live-chat-frame[modern-buttons][collapsed] {
  348. border-radius: 0 0 12px 12px !important;
  349. }
  350. 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 {
  351. border-radius: 0 0 12px 12px !important;
  352. border: none !important;
  353. }
  354. .chat-resize-handle {
  355. visibility: hidden !important;
  356. }
  357. `,
  358. },
  359.  
  360. debugResizeHandleStyle: {
  361. id: "betterTheater-debugResizeHandleStyle",
  362. getRule: () => `
  363. /* Default state for resize handles */
  364. #chat-container .chat-resize-handle {
  365. background: transparent;
  366. opacity: 0;
  367. }
  368. /* Debug state for resize handles when #chat-container has [debug] attribute */
  369. #chat-container[debug] .chat-resize-handle {
  370. opacity: 0.5;
  371. }
  372. #chat-container[debug] .chat-resize-handle.rs-right {
  373. background: rgba(255, 0, 0, 0.5);
  374. }
  375. #chat-container[debug] .chat-resize-handle.rs-left {
  376. background: rgba(0, 255, 0, 0.5);
  377. }
  378. #chat-container[debug] .chat-resize-handle.rs-bottom {
  379. background: rgba(0, 0, 255, 0.5);
  380. }
  381. #chat-container[debug] .chat-resize-handle.rs-top {
  382. background: rgba(255, 255, 0, 0.5);
  383. }
  384. #chat-container[debug] .chat-resize-handle.rs-bottom-left {
  385. background: rgba(0, 255, 255, 0.5);
  386. }
  387. #chat-container[debug] .chat-resize-handle.rs-top-left {
  388. background: rgba(255, 255, 0, 0.5);
  389. }
  390. #chat-container[debug] .chat-resize-handle.rs-top-right {
  391. background: rgba(255, 0, 0, 0.5);
  392. }
  393. #chat-container[debug] .chat-resize-handle.rs-bottom-right {
  394. background: rgba(255, 0, 255, 0.5);
  395. }
  396. `,
  397. },
  398.  
  399. chatSliderStyle: {
  400. id: "betterTheater-chatSliderStyle",
  401. getRule: () => `
  402. .chat-drag-bar input[type=range] {
  403. -webkit-appearance: none;
  404. width: 100px;
  405. height: 4px;
  406. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  407. border-radius: 2px;
  408. outline: none;
  409. }
  410. .chat-drag-bar input[type=range]::-webkit-slider-thumb {
  411. -webkit-appearance: none;
  412. appearance: none;
  413. width: 14px;
  414. height: 14px;
  415. border-radius: 50%;
  416. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  417. cursor: pointer;
  418. }
  419. .chat-drag-bar input[type=range]::-moz-range-thumb {
  420. width: 14px;
  421. height: 14px;
  422. border-radius: 50%;
  423. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  424. cursor: pointer;
  425. }
  426. .chat-drag-bar input[type=range]::-moz-range-track {
  427. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  428. height: 4px;
  429. border-radius: 2px;
  430. }
  431. .chat-drag-bar input[type=range]::-ms-thumb {
  432. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  433. }
  434. .chat-drag-bar input[type=range]::-ms-track {
  435. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  436. height: 4px;
  437. border-radius: 2px;
  438. }
  439. `
  440. }
  441. };
  442.  
  443. // STYLE MANAGEMENT
  444. function applyStyle(style, setPersistent = false) {
  445. if (typeof style.getRule !== 'function') return;
  446. if (state.activeStyles.has(style.id)) {
  447. removeStyle(style);
  448. }
  449.  
  450. const styleElement = document.createElement('style');
  451. styleElement.id = style.id;
  452. styleElement.type = 'text/css';
  453. styleElement.textContent = style.getRule();
  454. (document.head || document.documentElement).appendChild(styleElement);
  455. state.activeStyles.set(style.id, {
  456. element: styleElement,
  457. persistent: setPersistent
  458. });
  459. }
  460.  
  461. function removeStyle(style) {
  462. if (!state.activeStyles.has(style.id)) return;
  463.  
  464. const { element: styleElement } = state.activeStyles.get(style.id);
  465. if (styleElement && styleElement.parentNode) {
  466. styleElement.parentNode.removeChild(styleElement);
  467. }
  468.  
  469. state.activeStyles.delete(style.id);
  470. }
  471.  
  472. function removeAllStyles() {
  473. state.activeStyles.forEach((styleData, styleId) => {
  474. if (!styleData.persistent) {
  475. removeStyle({ id: styleId });
  476. }
  477. });
  478. }
  479.  
  480. function setStyleState(style, on = true) {
  481. on ? applyStyle(style) : removeStyle(style);
  482. }
  483.  
  484. function addResizeHandles(chatContainer) {
  485. const handleConfigs = {
  486. right: {
  487. width: "6px", top: "0", right: "0", bottom: "0",
  488. cursor: "ew-resize", horizontal: true, vertical: false
  489. },
  490. left: {
  491. width: "6px", top: "0", left: "0", bottom: "0",
  492. cursor: "ew-resize", horizontal: true, vertical: false
  493. },
  494. bottom: {
  495. height: "6px", left: "0", bottom: "0", right: "0",
  496. cursor: "ns-resize", horizontal: false, vertical: true
  497. },
  498. top: {
  499. height: "6px", left: "0", top: "0", right: "0",
  500. cursor: "ns-resize", horizontal: false, vertical: true
  501. },
  502. bottomLeft: {
  503. width: "12px", height: "12px", left: "0", bottom: "0",
  504. cursor: "nesw-resize", horizontal: true, vertical: true
  505. },
  506. topLeft: {
  507. width: "12px", height: "12px", left: "0", top: "0",
  508. cursor: "nwse-resize", horizontal: true, vertical: true
  509. },
  510. topRight: {
  511. width: "12px", height: "12px", right: "0", top: "0",
  512. cursor: "nesw-resize", horizontal: true, vertical: true
  513. },
  514. bottomRight: {
  515. width: "12px", height: "12px", right: "0", bottom: "0",
  516. cursor: "nwse-resize", horizontal: true, vertical: true
  517. }
  518. };
  519.  
  520. const handles = {};
  521. for (const [position, config] of Object.entries(handleConfigs)) {
  522. const handle = document.createElement("div");
  523. handle.className = `chat-resize-handle rs-${position}`;
  524. handle.style.position = "absolute";
  525. handle.style.zIndex = "10001";
  526. Object.assign(handle.style, config);
  527. chatContainer.appendChild(handle);
  528. handles[position] = handle;
  529. initResizeHandler(handle, config);
  530. }
  531. return handles;
  532.  
  533. function initResizeHandler(handle, config) {
  534. let startX, startY, startWidth, startHeight, startLeft, startTop;
  535. async function saveChatStyle() {
  536. Object.assign(state.userSettings.chatStyle ??= {}, {
  537. width: chatContainer.style.width,
  538. height: chatContainer.style.height,
  539. left: chatContainer.style.left,
  540. top: chatContainer.style.top
  541. });
  542. await updateSetting('chatStyle', state.userSettings.chatStyle);
  543. }
  544.  
  545. handle.addEventListener("pointerdown", function (e) {
  546. if (e.pointerType === "mouse" && e.button !== 0) return;
  547. e.preventDefault();
  548. startX = e.clientX;
  549. startY = e.clientY;
  550. startWidth = chatContainer.offsetWidth;
  551. startHeight = chatContainer.offsetHeight;
  552. startLeft = parseFloat(getComputedStyle(chatContainer).left) || 0;
  553. startTop = parseFloat(getComputedStyle(chatContainer).top) || 0;
  554.  
  555. handle.setPointerCapture(e.pointerId);
  556. });
  557.  
  558. handle.addEventListener("pointermove", function (e) {
  559. if (!handle.hasPointerCapture(e.pointerId)) return;
  560. e.preventDefault();
  561. const movieRect = state.moviePlayer.getBoundingClientRect();
  562. const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
  563. const chatRect = chatContainer.getBoundingClientRect();
  564. const minWidth = parseInt(MIN_CHAT_SIZE.width);
  565. const minHeight = parseInt(MIN_CHAT_SIZE.height);
  566. let dx = e.clientX - startX;
  567. let dy = e.clientY - startY;
  568. let newLeft = startLeft;
  569. let newTop = startTop;
  570. let newWidth = startWidth;
  571. let newHeight = startHeight;
  572. dx = Math.max(-startX, Math.min(dx, movieRect.right - startX));
  573. dy = Math.max(-startY, Math.min(dy, movieRect.bottom - startY));
  574.  
  575. if (config.horizontal) {
  576. const isRightSide = handle.className.toLowerCase().includes('right');
  577. const isLeftSide = handle.className.toLowerCase().includes('left');
  578. if (isRightSide) {
  579. newWidth += dx;
  580. } else if (isLeftSide) {
  581. newWidth -= dx;
  582. newLeft += Math.min(dx, startWidth - minWidth);
  583. }
  584. }
  585.  
  586. if (config.vertical) {
  587. const isBottomSide = handle.className.toLowerCase().includes('bottom');
  588. const isTopSide = handle.className.toLowerCase().includes('top');
  589. if (isBottomSide) {
  590. newHeight += dy;
  591. } else if (isTopSide) {
  592. newHeight -= dy;
  593. newTop += Math.min(dy, startHeight - minHeight);
  594. }
  595. }
  596.  
  597. // constrain to movie rect
  598. const correctedTopBound = movieRect.top - chatParentRect.top;
  599. const correctedLeftBound = movieRect.left - chatParentRect.left;
  600. newWidth = Math.min(Math.max(minWidth, newWidth), movieRect.right - chatRect.left);
  601. newHeight = Math.min(Math.max(minHeight, newHeight), movieRect.bottom - chatRect.top);
  602. newTop = Math.max(newTop, correctedTopBound);
  603. newLeft = Math.max(newLeft, correctedLeftBound);
  604. Object.assign(chatContainer.style, {
  605. left: newLeft + "px",
  606. top: newTop + "px",
  607. width: newWidth + "px",
  608. height: newHeight + "px"
  609. });
  610. });
  611.  
  612. handle.addEventListener("pointerup", function (e) {
  613. handle.releasePointerCapture(e.pointerId);
  614. saveChatStyle();
  615. });
  616. }
  617. }
  618.  
  619. // Removes all resize handles from a container
  620. function removeResizeHandles(chatContainer) {
  621. if (!chatContainer) return;
  622. const handles = chatContainer.querySelectorAll(".chat-resize-handle");
  623. handles.forEach(handle => handle.remove());
  624. }
  625.  
  626. // DRAG BAR & CHAT CONTROLS
  627. function addDragBarWithOpacitySlider(chatContainer) {
  628. let existingBar = chatContainer.querySelector('.chat-drag-bar');
  629. if (existingBar) return existingBar;
  630.  
  631. applyStyle(styleRules.chatSliderStyle, true);
  632.  
  633. const dragBar = document.createElement("div");
  634. dragBar.className = "chat-drag-bar";
  635. dragBar.style.position = "absolute";
  636. dragBar.style.top = "0";
  637. dragBar.style.left = "0";
  638. dragBar.style.right = "0";
  639. dragBar.style.height = "15px";
  640. dragBar.style.background = "var(--yt-live-chat-background-color)";
  641. dragBar.style.color = "var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color))";
  642. dragBar.style.border = "1px solid var(--yt-spec-10-percent-layer)";
  643. dragBar.style.backgroundClip = "padding-box";
  644. dragBar.style.display = "flex";
  645. dragBar.style.alignItems = "center";
  646. dragBar.style.justifyContent = "space-between";
  647. dragBar.style.padding = (parseInt(DRAG_BAR_HEIGHT) - 15) / 2 + "px";
  648. dragBar.style.zIndex = "10000";
  649. dragBar.style.borderRadius = "12px 12px 0 0";
  650.  
  651. const dragLabel = document.createElement("div");
  652. dragLabel.innerText = "⋮⋮";
  653. dragLabel.style.fontSize = "var(--yt-live-chat-header-font-size, 18px)";
  654. dragLabel.style.userSelect = "none";
  655.  
  656. const opacitySlider = document.createElement("input");
  657. opacitySlider.type = "range";
  658. opacitySlider.min = "20";
  659. opacitySlider.max = "100";
  660. opacitySlider.value = Math.round(parseFloat(state.userSettings.chatStyle.opacity) * 100).toString();
  661. opacitySlider.style.marginLeft = "10px";
  662.  
  663. opacitySlider.addEventListener("input", () => {
  664. const newOpacity = opacitySlider.value / 100;
  665. chatContainer.style.opacity = newOpacity;
  666. });
  667.  
  668. opacitySlider.addEventListener("mouseup", () => {
  669. Object.assign(state.userSettings.chatStyle ??= {}, {
  670. opacity: chatContainer.style.opacity
  671. });
  672. updateSetting('chatStyle', state.userSettings.chatStyle);
  673. });
  674.  
  675. ["pointerdown", "pointermove", "pointerup"].forEach(eventType => {
  676. opacitySlider.addEventListener(eventType, (e) => {
  677. e.stopPropagation();
  678. });
  679. });
  680.  
  681. dragBar.appendChild(dragLabel);
  682. dragBar.appendChild(opacitySlider);
  683. chatContainer.insertBefore(dragBar, chatContainer.firstChild);
  684.  
  685. setupDragBehavior(dragBar, chatContainer);
  686.  
  687. return dragBar;
  688. }
  689.  
  690. function setupDragBehavior(dragBar, chatContainer) {
  691. let startX = 0, startY = 0;
  692. let startLeft = 0, startTop = 0;
  693.  
  694. async function saveChatPosition() {
  695. Object.assign(state.userSettings.chatStyle ??= {}, {
  696. left: chatContainer.style.left,
  697. top: chatContainer.style.top
  698. });
  699. await updateSetting('chatStyle', state.userSettings.chatStyle);
  700. }
  701.  
  702. dragBar.addEventListener("pointerdown", function (e) {
  703. if (e.pointerType === "mouse" && e.button !== 0) return;
  704.  
  705. startX = e.clientX;
  706. startY = e.clientY;
  707. startLeft = parseFloat(getComputedStyle(chatContainer).left) || 0;
  708. startTop = parseFloat(getComputedStyle(chatContainer).top) || 0;
  709.  
  710. dragBar.setPointerCapture(e.pointerId);
  711. e.preventDefault();
  712. });
  713.  
  714. // Handle pointer move event
  715. dragBar.addEventListener("pointermove", function (e) {
  716. if (!dragBar.hasPointerCapture(e.pointerId)) return;
  717.  
  718. let dx = e.clientX - startX;
  719. let dy = e.clientY - startY;
  720. let newLeft = startLeft + dx;
  721. let newTop = startTop + dy;
  722.  
  723. const movieRect = state.moviePlayer.getBoundingClientRect();
  724. const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
  725. const correctedTopBound = movieRect.top - chatParentRect.top;
  726. const correctedLeftBound = movieRect.left - chatParentRect.left;
  727. const correctedLowerBound = movieRect.bottom - chatParentRect.top - (
  728. state.chatCollapsed
  729. ? parseInt(DRAG_BAR_HEIGHT) + chatContainer.querySelector('#show-hide-button').offsetHeight
  730. : chatContainer.offsetHeight
  731. );
  732. const correctedRightBound = movieRect.right - chatParentRect.left - chatContainer.offsetWidth;
  733.  
  734. newTop = Math.min(Math.max(newTop, correctedTopBound), correctedLowerBound);
  735. newLeft = Math.min(Math.max(newLeft, correctedLeftBound), correctedRightBound);
  736. Object.assign(chatContainer.style, {
  737. left: newLeft + "px",
  738. top: newTop + "px",
  739. });
  740. e.preventDefault();
  741. });
  742.  
  743. dragBar.addEventListener("pointerup", function (e) {
  744. dragBar.releasePointerCapture(e.pointerId);
  745. saveChatPosition();
  746. });
  747. }
  748.  
  749. function removeDragBarWithOpacitySlider(chatContainer) {
  750. if (!chatContainer) return;
  751. const dragBar = chatContainer.querySelector('.chat-drag-bar');
  752. if (dragBar) dragBar.remove();
  753. }
  754.  
  755. function removeAllChatStyles(chatContainer) {
  756. removeStyle(styleRules.floatingChatStyleCollapsed);
  757. removeStyle(styleRules.floatingChatStyleExpanded);
  758. removeStyle(styleRules.floatingChatStyle);
  759.  
  760. if (chatContainer) chatContainer.style = '';
  761. }
  762.  
  763. function applySavedChatStyle(chatContainer) {
  764. if (!chatContainer) return;
  765.  
  766. const movieRect = state.moviePlayer.getBoundingClientRect();
  767. const chatParentRect = chatContainer.parentElement.getBoundingClientRect();
  768. const chatRect = chatContainer.getBoundingClientRect();
  769. const minWidth = parseInt(MIN_CHAT_SIZE.width);
  770. const minHeight = parseInt(MIN_CHAT_SIZE.height);
  771.  
  772. let width = parseFloat(state.userSettings.chatStyle.width);
  773. let height = parseFloat(state.userSettings.chatStyle.height);
  774. let top = parseFloat(state.userSettings.chatStyle.top);
  775. let left = parseFloat(state.userSettings.chatStyle.left);
  776. const correctedTopBound = movieRect.top - chatParentRect.top;
  777. const correctedLeftBound = movieRect.left - chatParentRect.left;
  778. width = Math.min(Math.max(minWidth, width), movieRect.width);
  779. height = Math.min(Math.max(minHeight, height), movieRect.height);
  780. top = Math.max(top, correctedTopBound) - (
  781. state.chatCollapsed ?
  782. 0 :
  783. chatRect.bottom - movieRect.bottom
  784. );
  785. left = Math.max(left, correctedLeftBound);
  786. console.log('width', width, 'height', height);
  787. Object.assign(chatContainer.style, {
  788. left: left + "px",
  789. top: top + "px",
  790. width: width + "px",
  791. height: height + "px",
  792. opacity: parseFloat(state.userSettings.chatStyle.opacity)
  793. });
  794. }
  795.  
  796. // STYLE UPDATE FUNCTIONS
  797. function updateStyles() {
  798. try {
  799. if (state.userSettings.useCustomPlayerHeight) {
  800. state.userSettings.modifyVideoPlayer = true;
  801. }
  802.  
  803. const shouldNotActivate =
  804. (state.blacklist && state.blacklist.has(state.videoId)) ||
  805. (state.userSettings.enableOnlyForLiveStreams && !state.isLiveStream);
  806.  
  807. if (shouldNotActivate) {
  808. removeAllStyles();
  809. if (state.moviePlayer && state.moviePlayer.setCenterCrop) {
  810. state.moviePlayer.setCenterCrop();
  811. }
  812. return;
  813. }
  814.  
  815. setStyleState(styleRules.chatStyle, state.userSettings.modifyChat);
  816. setStyleState(styleRules.videoPlayerStyle, state.userSettings.modifyVideoPlayer);
  817. updateHeadmastStyle();
  818. updateFullscreenFloatingChatStyle();
  819. if (state.moviePlayer && state.moviePlayer.setCenterCrop) {
  820. state.moviePlayer.setCenterCrop();
  821. }
  822. } catch (error) {
  823. logDebug(`Error when updating styles: ${error}`, 'error');
  824. }
  825. }
  826.  
  827. function updateHeadmastStyle() {
  828. updateLowHeadmastStyle();
  829.  
  830. const shouldShrinkHeadmast =
  831. state.isTheaterMode &&
  832. state.chatFrame?.getAttribute('theater-watch-while') === '' &&
  833. (state.userSettings.setLowHeadmast || state.userSettings.modifyChat);
  834.  
  835. // Update chat width for style calculation
  836. state.chatWidth = state.chatFrame?.offsetWidth || 0;
  837.  
  838. // Apply or remove headmast style
  839. setStyleState(styleRules.headmastStyle, shouldShrinkHeadmast);
  840. }
  841.  
  842. function updateLowHeadmastStyle() {
  843. if (!state.moviePlayer) return;
  844.  
  845. const shouldApplyLowHeadmast =
  846. state.userSettings.setLowHeadmast &&
  847. state.isTheaterMode &&
  848. !state.isFullscreen &&
  849. state.currentPageType === 'watch';
  850.  
  851. setStyleState(styleRules.lowHeadmastStyle, shouldApplyLowHeadmast);
  852. }
  853.  
  854. function updateFullscreenFloatingChatStyle() {
  855. try {
  856. const chatContainer = document.querySelector('#chat-container');
  857. setStyleState(styleRules.floatingChatStyleCollapsed,
  858. state.chatCollapsed && state.isFullscreen);
  859. setStyleState(styleRules.floatingChatStyleExpanded,
  860. !state.chatCollapsed && state.isFullscreen);
  861. setStyleState(styleRules.floatingChatStyle, state.isFullscreen);
  862.  
  863. if (state.userSettings.floatingChat &&
  864. chatContainer.querySelector('#chat') &&
  865. chatContainer &&
  866. state.isFullscreen) {
  867.  
  868. applySavedChatStyle(chatContainer);
  869. removeDragBarWithOpacitySlider(chatContainer);
  870. addDragBarWithOpacitySlider(chatContainer);
  871. addResizeHandles(chatContainer);
  872. } else if (chatContainer) {
  873. removeAllChatStyles(chatContainer);
  874. removeDragBarWithOpacitySlider(chatContainer);
  875. removeResizeHandles(chatContainer);
  876. }
  877. } catch (error) {
  878. logDebug(`Error when updating fullscreen chat styles: ${error}`, 'error');
  879. }
  880. }
  881.  
  882. function updateDebugStyles() {
  883. const chatContainer = document.querySelector('#chat-container');
  884. if (chatContainer) {
  885. if (state.userSettings.debug) {
  886. chatContainer.setAttribute("debug", "");
  887. } else {
  888. chatContainer.removeAttribute("debug");
  889. }
  890. }
  891. }
  892.  
  893. // EVENT HANDLERS
  894. function updateFullscreenStatus() {
  895. state.isFullscreen = !!document.fullscreenElement;
  896. updateStyles();
  897. }
  898.  
  899. function updateTheaterStatus(event) {
  900. state.isTheaterMode = !!event?.detail?.enabled;
  901. updateStyles();
  902. }
  903.  
  904. function updateChatStatus(event) {
  905. state.chatFrame = event.target;
  906. state.chatCollapsed = event.detail !== false;
  907.  
  908. // Wait for player API to be ready before updating styles
  909. window.addEventListener('player-api-ready', updateStyles, { once: true });
  910. }
  911.  
  912. function updateMoviePlayer() {
  913. const newMoviePlayer = document.querySelector('#movie_player');
  914.  
  915. // Create resize observer if needed
  916. if (!state.resizeObserver) {
  917. state.resizeObserver = new ResizeObserver(() => {
  918. state.moviePlayerHeight = state.moviePlayer?.offsetHeight || 0;
  919. updateStyles();
  920. });
  921. }
  922.  
  923. // Stop observing old player
  924. if (state.moviePlayer) {
  925. state.resizeObserver.unobserve(state.moviePlayer);
  926. }
  927.  
  928. // Start observing new player
  929. state.moviePlayer = newMoviePlayer;
  930. if (state.moviePlayer) {
  931. state.resizeObserver.observe(state.moviePlayer);
  932. }
  933. }
  934.  
  935. function updateVideoStatus(event) {
  936. try {
  937. state.currentPageType = event.detail.pageData.page;
  938. state.videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
  939. state.isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent;
  940.  
  941. updateMoviePlayer();
  942. refreshMenuOptions();
  943. } catch (error) {
  944. logDebug(`Failed to update video status: ${error}`, 'error');
  945. }
  946. }
  947.  
  948. // SETTINGS MANAGEMENT
  949. async function updateSetting(key, value) {
  950. try {
  951. let currentSettings = await GM.getValue('settings', CONFIG.DEFAULT_SETTINGS);
  952. currentSettings[key] = value;
  953. await GM.setValue('settings', currentSettings);
  954. state.userSettings[key] = value;
  955. } catch (error) {
  956. logDebug(`Error updating setting: ${error}`, 'error');
  957. }
  958. }
  959.  
  960. async function loadUserSettings() {
  961. try {
  962. const storedSettings = await GM.getValue('settings', CONFIG.DEFAULT_SETTINGS);
  963. const newSettings = {};
  964. let needsSave = false;
  965.  
  966. // Use stored settings or defaults
  967. for (const key in CONFIG.DEFAULT_SETTINGS) {
  968. if (key in storedSettings) {
  969. newSettings[key] = storedSettings[key];
  970. } else {
  971. newSettings[key] = CONFIG.DEFAULT_SETTINGS[key];
  972. needsSave = true;
  973. }
  974. }
  975.  
  976. // Check for obsolete settings
  977. for (const key in storedSettings) {
  978. if (!(key in CONFIG.DEFAULT_SETTINGS)) {
  979. needsSave = true;
  980. }
  981. }
  982.  
  983. // Save settings if needed
  984. state.userSettings = newSettings;
  985. if (needsSave) {
  986. await GM.setValue('settings', state.userSettings);
  987. }
  988.  
  989. updateMode();
  990. } catch (error) {
  991. logDebug(`Error loading user settings: ${error}`, 'error');
  992. throw new Error(`Error loading user settings: ${error}. Aborting script.`);
  993. }
  994. }
  995.  
  996. function updateMode() {
  997. if (state.userSettings.isSimpleMode === true) {
  998. // Backup advanced settings before switching to simple mode
  999. state.advancedSettingsBackup = {
  1000. ...state.userSettings,
  1001. isSimpleMode: false
  1002. };
  1003.  
  1004. // Apply simple mode settings
  1005. state.userSettings = {
  1006. ...CONFIG.DEFAULT_SETTINGS,
  1007. isSimpleMode: true
  1008. };
  1009.  
  1010. logDebug('Using simple mode');
  1011. } else if (state.advancedSettingsBackup) {
  1012. // Restore advanced settings
  1013. state.userSettings = {
  1014. ...state.advancedSettingsBackup,
  1015. isSimpleMode: false
  1016. };
  1017.  
  1018. logDebug('Using advanced mode');
  1019. logDebug('Advanced settings backup:', state.advancedSettingsBackup);
  1020. }
  1021.  
  1022. logDebug(`Loaded settings: ${JSON.stringify(state.userSettings)}`);
  1023. }
  1024.  
  1025. async function loadBlacklist() {
  1026. try {
  1027. let storedBlacklist = await GM.getValue('blacklist', CONFIG.DEFAULT_BLACKLIST);
  1028. state.blacklist = new Set(
  1029. Array.isArray(storedBlacklist) ? storedBlacklist : []
  1030. );
  1031.  
  1032. logDebug(`Loaded blacklist: ${JSON.stringify(Array.from(state.blacklist))}`);
  1033. } catch (error) {
  1034. logDebug(`Error loading blacklist: ${error}`, 'error');
  1035. throw new Error(`Error loading blacklist: ${error}. Aborting script.`);
  1036. }
  1037. }
  1038.  
  1039. async function updateBlacklist() {
  1040. try {
  1041. await GM.setValue('blacklist', Array.from(state.blacklist));
  1042. } catch (error) {
  1043. logDebug(`Error updating blacklist: ${error}`, 'error');
  1044. }
  1045. }
  1046.  
  1047. async function updateScriptInfo() {
  1048. try {
  1049. const oldScriptInfo = await GM.getValue('scriptInfo', null);
  1050. const newScriptInfo = {
  1051. version: getScriptVersionFromMeta(),
  1052. };
  1053.  
  1054. await GM.setValue('scriptInfo', newScriptInfo);
  1055.  
  1056. // Check if script was updated
  1057. if (!oldScriptInfo || compareVersions(newScriptInfo.version, oldScriptInfo?.version) !== 0) {
  1058. state.isScriptRecentlyUpdated = true;
  1059. }
  1060.  
  1061. logDebug(`Previous script info: ${JSON.stringify(oldScriptInfo)}`);
  1062. logDebug(`Updated script info: ${JSON.stringify(newScriptInfo)}`);
  1063. } catch (error) {
  1064. logDebug(`Error updating script info: ${error}`, 'error');
  1065. }
  1066. }
  1067.  
  1068. async function cleanupOldStorage() {
  1069. try {
  1070. const allowedKeys = ['settings', 'scriptInfo', 'blacklist'];
  1071. const keys = await GM.listValues();
  1072.  
  1073. for (const key of keys) {
  1074. if (!allowedKeys.includes(key)) {
  1075. await GM.deleteValue(key);
  1076. logDebug(`Deleted leftover key: ${key}`);
  1077. }
  1078. }
  1079. } catch (error) {
  1080. logDebug(`Error cleaning up old storage keys: ${error}`, 'error');
  1081. }
  1082. }
  1083.  
  1084. // MENU MANAGEMENT
  1085. function removeMenuOptions() {
  1086. state.menuItems.forEach((menuItem) => {
  1087. GM.unregisterMenuCommand(menuItem);
  1088. });
  1089. state.menuItems.clear();
  1090. }
  1091.  
  1092. async function refreshMenuOptions() {
  1093. const shouldAutoClose = state.isOldTampermonkey;
  1094. removeMenuOptions();
  1095.  
  1096. // Advanced mode menu options
  1097. const advancedMenuOptions = state.userSettings.isSimpleMode ? {} : {
  1098. toggleOnlyLiveStreamMode: {
  1099. alwaysShow: true,
  1100. label: () => `${state.userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} ${getLocalizedText().livestreamOnlyMode}`,
  1101. menuId: "toggleOnlyLiveStreamMode",
  1102. handleClick: async function () {
  1103. state.userSettings.enableOnlyForLiveStreams = !state.userSettings.enableOnlyForLiveStreams;
  1104. await updateSetting('enableOnlyForLiveStreams', state.userSettings.enableOnlyForLiveStreams);
  1105. updateStyles();
  1106. refreshMenuOptions();
  1107. },
  1108. },
  1109. toggleChatStyle: {
  1110. alwaysShow: true,
  1111. label: () => `${state.userSettings.modifyChat ? "✅" : "❌"} ${getLocalizedText().applyChatStyles}`,
  1112. menuId: "toggleChatStyle",
  1113. handleClick: async function () {
  1114. state.userSettings.modifyChat = !state.userSettings.modifyChat;
  1115. await updateSetting('modifyChat', state.userSettings.modifyChat);
  1116. updateStyles();
  1117. refreshMenuOptions();
  1118. },
  1119. },
  1120. ...(!state.userSettings.useCustomPlayerHeight ? {
  1121. toggleVideoPlayerStyle: {
  1122. alwaysShow: true,
  1123. label: () => `${state.userSettings.modifyVideoPlayer ? "✅" : "❌"} ${getLocalizedText().applyVideoPlayerStyles}`,
  1124. menuId: "toggleVideoPlayerStyle",
  1125. handleClick: async function () {
  1126. state.userSettings.modifyVideoPlayer = !state.userSettings.modifyVideoPlayer;
  1127. await updateSetting('modifyVideoPlayer', state.userSettings.modifyVideoPlayer);
  1128. updateStyles();
  1129. refreshMenuOptions();
  1130. },
  1131. },
  1132. } : {}),
  1133. toggleLowHeadmast: {
  1134. alwaysShow: true,
  1135. label: () => `${state.userSettings.setLowHeadmast ? "✅" : "❌"} ${getLocalizedText().moveHeadmastBelowVideoPlayer}`,
  1136. menuId: "toggleLowHeadmast",
  1137. handleClick: async function () {
  1138. state.userSettings.setLowHeadmast = !state.userSettings.setLowHeadmast;
  1139. await updateSetting('setLowHeadmast', state.userSettings.setLowHeadmast);
  1140. updateStyles();
  1141. refreshMenuOptions();
  1142. },
  1143. },
  1144. toggleCustomPlayerHeight: {
  1145. alwaysShow: true,
  1146. label: () => `${state.userSettings.useCustomPlayerHeight ? "✅" : "❌"} ${getLocalizedText().useCustomPlayerHeight}`,
  1147. menuId: "toggleCustomPlayerHeight",
  1148. handleClick: async function () {
  1149. state.userSettings.useCustomPlayerHeight = !state.userSettings.useCustomPlayerHeight;
  1150. await updateSetting('useCustomPlayerHeight', state.userSettings.useCustomPlayerHeight);
  1151. updateStyles();
  1152. refreshMenuOptions();
  1153. },
  1154. },
  1155. ...(state.userSettings.useCustomPlayerHeight ? {
  1156. customHeightInputSelector: {
  1157. alwaysShow: true,
  1158. label: () => `🔢 ${getLocalizedText().playerHeightText} (${state.userSettings.playerHeightPx}px)`,
  1159. menuId: "customHeightInputSelector",
  1160. handleClick: async function () {
  1161. const playerHeightInputValue = await promptForNumber();
  1162. if (playerHeightInputValue === null) return;
  1163.  
  1164. state.userSettings.playerHeightPx = playerHeightInputValue;
  1165. await updateSetting('playerHeightPx', playerHeightInputValue);
  1166. updateStyles();
  1167. refreshMenuOptions();
  1168. },
  1169. },
  1170. } : {}),
  1171. toggleFloatingChat: {
  1172. alwaysShow: true,
  1173. label: () => `${state.userSettings.floatingChat ? "✅" : "❌"} ${getLocalizedText().floatingChat}`,
  1174. menuId: "toggleFloatingChat",
  1175. handleClick: async function () {
  1176. state.userSettings.floatingChat = !state.userSettings.floatingChat;
  1177. await updateSetting('floatingChat', state.userSettings.floatingChat);
  1178. refreshMenuOptions();
  1179. },
  1180. },
  1181. toggleDebug: {
  1182. alwaysShow: true,
  1183. label: () => `${state.userSettings.debug ? "✅" : "❌"} ${getLocalizedText().debug}`,
  1184. menuId: "toggleDebug",
  1185. handleClick: async function () {
  1186. state.userSettings.debug = !state.userSettings.debug;
  1187. await updateSetting('debug', state.userSettings.debug);
  1188. updateDebugStyles();
  1189. refreshMenuOptions();
  1190. }
  1191. }
  1192. };
  1193.  
  1194. // Common menu options for both simple and advanced modes
  1195. const commonMenuOptions = {
  1196. addVideoToBlacklist: {
  1197. alwaysShow: true,
  1198. label: () => `🚫 ${state.blacklist.has(state.videoId) ? getLocalizedText().unblacklistVideo : getLocalizedText().blacklistVideo} [id: ${state.videoId}]`,
  1199. menuId: "addVideoToBlacklist",
  1200. handleClick: async function () {
  1201. if (state.blacklist.has(state.videoId)) {
  1202. state.blacklist.delete(state.videoId);
  1203. } else {
  1204. state.blacklist.add(state.videoId);
  1205. }
  1206. await updateBlacklist();
  1207. updateStyles();
  1208. refreshMenuOptions();
  1209. },
  1210. },
  1211. toggleSimpleMode: {
  1212. alwaysShow: true,
  1213. label: () => `${state.userSettings.isSimpleMode ? "🚀 " + getLocalizedText().simpleMode : "🔧 " + getLocalizedText().advancedMode}`,
  1214. menuId: "toggleSimpleMode",
  1215. handleClick: async function () {
  1216. state.userSettings.isSimpleMode = !state.userSettings.isSimpleMode;
  1217. await updateSetting('isSimpleMode', state.userSettings.isSimpleMode);
  1218. updateMode();
  1219. updateStyles();
  1220. refreshMenuOptions();
  1221. },
  1222. },
  1223. };
  1224.  
  1225. // Combine menu options
  1226. const menuOptions = {
  1227. ...commonMenuOptions,
  1228. ...advancedMenuOptions
  1229. };
  1230.  
  1231. // Process and register all menu options
  1232. for (const [_, item] of Object.entries(menuOptions)) {
  1233. if (!item.alwaysShow && !state.userSettings.expandMenu) continue;
  1234.  
  1235. const menuId = GM.registerMenuCommand(item.label(), item.handleClick, {
  1236. id: item.menuId,
  1237. autoClose: shouldAutoClose,
  1238. });
  1239.  
  1240. state.menuItems.add(item.menuId);
  1241. }
  1242. }
  1243.  
  1244. async function promptForNumber(message = "Enter a number:", validator = null) {
  1245. while (true) {
  1246. const input = prompt(message);
  1247.  
  1248. if (input === null) return null;
  1249.  
  1250. const value = Number(input.trim());
  1251. const isValidNumber = input.trim() !== "" && !isNaN(value);
  1252. const passesCustomValidator = typeof validator === "function" ? validator(value) : true;
  1253.  
  1254. if (isValidNumber && passesCustomValidator) {
  1255. return value;
  1256. } else {
  1257. alert("⚠️ Please enter a valid number.");
  1258. }
  1259. }
  1260. }
  1261.  
  1262. // UTILITY FUNCTIONS
  1263. function logDebug(message, level = 'log', data) {
  1264. if (!state.userSettings.debug) return;
  1265.  
  1266. const consoleMethod = console[level] || console.log;
  1267.  
  1268. if (data !== undefined) {
  1269. consoleMethod('[Better Theater] ' + message, data);
  1270. } else {
  1271. consoleMethod('[Better Theater] ' + message);
  1272. }
  1273. }
  1274.  
  1275. function compareVersions(v1, v2) {
  1276. if (!v1 || !v2) return 0;
  1277.  
  1278. const parts1 = v1.split('.').map(Number);
  1279. const parts2 = v2.split('.').map(Number);
  1280. const len = Math.max(parts1.length, parts2.length);
  1281.  
  1282. for (let i = 0; i < len; i++) {
  1283. const num1 = parts1[i] || 0;
  1284. const num2 = parts2[i] || 0;
  1285.  
  1286. if (num1 > num2) return 1;
  1287. if (num1 < num2) return -1;
  1288. }
  1289.  
  1290. return 0;
  1291. }
  1292.  
  1293. function getScriptVersionFromMeta() {
  1294. const versionMatch = GM_info.scriptMetaStr.match(/@version\s+([^\r\n]+)/);
  1295. return versionMatch ? versionMatch[1].trim() : null;
  1296. }
  1297.  
  1298. function detectGreasemonkeyAPI() {
  1299. if (typeof GM !== 'undefined') return true;
  1300. if (typeof GM_info !== 'undefined') {
  1301. state.useCompatibilityMode = true;
  1302. logDebug("Running in compatibility mode", 'warn');
  1303. return true;
  1304. }
  1305.  
  1306. return false;
  1307. }
  1308.  
  1309. function checkTampermonkeyVersion() {
  1310. if (GM_info.scriptHandler === "Tampermonkey" &&
  1311. compareVersions(GM_info.version, CONFIG.REQUIRED_VERSIONS.Tampermonkey) !== 1) {
  1312. state.isOldTampermonkey = true;
  1313. if (state.isScriptRecentlyUpdated) {
  1314. GM.notification({
  1315. text: getLocalizedText().tampermonkeyOutdatedAlert,
  1316. timeout: 15000
  1317. });
  1318. }
  1319. }
  1320. }
  1321.  
  1322. function isLiveChatIFrame() {
  1323. return /^https?:\/\/.*youtube\.com\/live_chat.*$/.test(window.location.href);
  1324. }
  1325.  
  1326. // EVENT LISTENERS
  1327. function attachEventListeners() {
  1328. window.addEventListener('yt-set-theater-mode-enabled', updateTheaterStatus, true);
  1329. window.addEventListener('yt-chat-collapsed-changed', updateChatStatus, true);
  1330. window.addEventListener('yt-page-data-fetched', updateVideoStatus, true);
  1331. window.addEventListener('yt-page-data-updated', updateStyles, true);
  1332. window.addEventListener('fullscreenchange', updateFullscreenStatus, true);
  1333. window.addEventListener('yt-navigate-finish', updateDebugStyles, { once: true });
  1334. }
  1335.  
  1336. // INITIALIZATION
  1337. async function initialize() {
  1338. try {
  1339. if (!detectGreasemonkeyAPI()) {
  1340. throw new Error("Did not detect valid Greasemonkey API");
  1341. }
  1342. applyStyle(styleRules.debugResizeHandleStyle, true);
  1343. await cleanupOldStorage();
  1344. await loadUserSettings();
  1345. await loadBlacklist();
  1346. await updateScriptInfo();
  1347. checkTampermonkeyVersion();
  1348. if (isLiveChatIFrame()) {
  1349. applyStyle(styleRules.chatFrameFixStyle, true);
  1350. return;
  1351. }
  1352. applyStyle(styleRules.chatRendererFixStyle, true);
  1353. applyStyle(styleRules.videoPlayerFixStyle, true);
  1354. updateStyles();
  1355. attachEventListeners();
  1356. refreshMenuOptions();
  1357.  
  1358. } catch (error) {
  1359. logDebug(`Error when initializing script: ${error}. Aborting script.`, 'error');
  1360. }
  1361. }
  1362.  
  1363. initialize();
  1364. })();