Better Theater Mode for YouTube

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.

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