更佳 YouTube 剧场模式

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

  1. // ==UserScript==
  2. // @name Better YouTube Theater Mode
  3. // @name:zh-TW 更佳 YouTube 劇場模式
  4. // @name:zh-CN 更佳 YouTube 剧场模式
  5. // @name:ja より良いYouTubeシアターモード
  6. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  7. // @author ElectroKnight22
  8. // @namespace electroknight22_youtube_better_theater_mode_namespace
  9. // @version 2.0.0
  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. // @grant GM_getValue
  21. // @grant GM_setValue
  22. // @grant GM_deleteValue
  23. // @grant GM_listValues
  24. // @grant GM_registerMenuCommand
  25. // @grant GM_unregisterMenuCommand
  26. // @grant GM_notification
  27. // @run-at document-start
  28. // @license MIT
  29. // @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.
  30. // @description:zh-TW 改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。另新增可選的、自製風格的浮動聊天室功能(僅限全螢幕模式),與 YouTube 原有的設計語言相融合。
  31. // @description:zh-CN 改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性,也达到了类似B站的网页全屏功能。同时新增可选的、自制风格的浮动聊天室功能(仅限全fullscreen模式),融入了 YouTube 原有的设计语言。
  32. // @description:ja YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、全画面モード専用のオプションとして、カスタマイズ済みフローティングチャット機能を、YouTubeのデザイン言語に沿って統合しています。
  33. // ==/UserScript==
  34. // Note: Both GM.* and GM_.* are granted for compatibility with older script managers.
  35.  
  36. /*jshint esversion: 11 */
  37. (function () {
  38. 'use strict';
  39.  
  40. const CONFIG = {
  41. DRAG_BAR_HEIGHT: '35px',
  42. MIN_CHAT_SIZE: { // YouTube chat minimum size is 300px by 320px, going smaller would require a lot of CSS overrides.
  43. width: 300, // px
  44. height: 355, // px (320 + DRAG_BAR_HEIGHT)
  45. },
  46. DEFAULT_SETTINGS: {
  47. isSimpleMode: true,
  48. enableOnlyForLiveStreams: false,
  49. modifyVideoPlayer: true,
  50. modifyChat: true,
  51. setLowHeadmast: false,
  52. useCustomPlayerHeight: false,
  53. playerHeightPx: 600,
  54. floatingChat: false,
  55. get theaterChatWidth() { return `${CONFIG.MIN_CHAT_SIZE.width}px`; },
  56. chatStyle: {
  57. left: '0px',
  58. top: '-500px',
  59. get width() { return `${CONFIG.MIN_CHAT_SIZE.width}px`; },
  60. get height() { return `${CONFIG.MIN_CHAT_SIZE.height}px`; },
  61. opacity: '0.95',
  62. },
  63. debug: false,
  64. },
  65. DEFAULT_BLACKLIST: [],
  66. REQUIRED_VERSIONS: {
  67. Tampermonkey: '5.4.624',
  68. },
  69. };
  70.  
  71. const BROWSER_LANGUAGE = navigator.language ?? navigator.userLanguage;
  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 getPreferredLanguage() {
  145. if (TRANSLATIONS[BROWSER_LANGUAGE]) {
  146. return BROWSER_LANGUAGE;
  147. }
  148. if (BROWSER_LANGUAGE.startsWith('zh')) {
  149. return 'zh-CN'; // Default to Simplified Mainland Chinese if Chinese variant is not available or not specified.
  150. }
  151. return 'en-US'; // Default to US English if all else fails.
  152. }
  153.  
  154. function getLocalizedText() {
  155. return TRANSLATIONS[getPreferredLanguage()] ?? TRANSLATIONS['en-US'];
  156. }
  157.  
  158. const state = {
  159. userSettings: { ...CONFIG.DEFAULT_SETTINGS },
  160. advancedSettingsBackup: null,
  161. blacklist: new Set(),
  162. gmFallback: false,
  163. menuItems: new Set(),
  164. activeStyles: new Map(),
  165. resizeObserver: null,
  166. videoId: null,
  167. currentPageType: '',
  168. isFullscreen: false,
  169. isTheaterMode: false,
  170. chatCollapsed: true,
  171. isLiveStream: false,
  172. chatWidth: 0,
  173. moviePlayerHeight: 0,
  174. isOldTampermonkey: false,
  175. versionWarningShown: false,
  176. };
  177.  
  178. const DOM = {
  179. moviePlayer: null,
  180. chatContainer: null,
  181. chatFrame: null,
  182. ytdWatchFlexy: null,
  183. };
  184.  
  185. const createGmApi = () => {
  186. const isGmFallback = typeof GM === 'undefined' && typeof GM_info !== 'undefined';
  187. state.gmFallback = isGmFallback;
  188.  
  189. if (isGmFallback) {
  190. return {
  191. registerMenuCommand: GM_registerMenuCommand,
  192. unregisterMenuCommand: GM_unregisterMenuCommand,
  193. getValue: GM_getValue,
  194. setValue: GM_setValue,
  195. listValues: GM_listValues,
  196. deleteValue: GM_deleteValue,
  197. notification: GM_notification,
  198. info: () => GM_info,
  199. };
  200. }
  201.  
  202. return {
  203. registerMenuCommand: (...args) => window.GM?.registerMenuCommand?.(...args),
  204. unregisterMenuCommand: (...args) => window.GM?.unregisterMenuCommand?.(...args),
  205. getValue: (...args) => window.GM?.getValue?.(...args),
  206. setValue: (...args) => window.GM?.setValue?.(...args),
  207. listValues: (...args) => window.GM?.listValues?.(...args),
  208. deleteValue: (...args) => window.GM?.deleteValue?.(...args),
  209. notification: (...args) => window.GM?.notification?.(...args),
  210. info: () => window.GM?.info,
  211. };
  212. };
  213.  
  214. const GM_API = createGmApi();
  215.  
  216. const Utils = {
  217. log(message, level = 'log', data) {
  218. if (!state.userSettings.debug) return;
  219. const consoleMethod = console[level] || console.log;
  220. const prefix = '[Better Theater]';
  221. data !== undefined ? consoleMethod(prefix, message, data) : consoleMethod(prefix, message);
  222. },
  223. compareVersions(v1, v2) {
  224. if (!v1 || !v2) return 0;
  225. const parts1 = v1.split('.').map(Number);
  226. const parts2 = v2.split('.').map(Number);
  227. const len = Math.max(parts1.length, parts2.length);
  228. for (let i = 0; i < len; i++) {
  229. const num1 = parts1[i] ?? 0;
  230. const num2 = parts2[i] ?? 0;
  231. if (num1 > num2) return 1;
  232. if (num1 < num2) return -1;
  233. }
  234. return 0;
  235. },
  236. async promptForNumber(message = 'Enter a number:', validator = null) {
  237. while (true) {
  238. const input = prompt(message);
  239. if (input === null) return null;
  240.  
  241. const value = Number(input.trim());
  242. const isValidNumber = input.trim() !== '' && !isNaN(value);
  243. const passesValidator = typeof validator === 'function' ? validator(value) : true;
  244.  
  245. if (isValidNumber && passesValidator) return value;
  246. alert('⚠️ Please enter a valid number.');
  247. }
  248. },
  249. };
  250.  
  251. const StyleManager = {
  252. styleDefinitions: {
  253. chatStyle: {
  254. id: 'betterTheater-chatStyle',
  255. getRule: () => `
  256. ytd-live-chat-frame[theater-watch-while][rounded-container] {
  257. border-radius: 0 !important;
  258. border-top: 0 !important;
  259. }
  260. ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
  261. top: 0 !important;
  262. border-top: 0 !important;
  263. border-bottom: 0 !important;
  264. }
  265. #chat-container { z-index: 2021 !important; }
  266. `,
  267. },
  268. videoPlayerStyle: {
  269. id: 'betterTheater-videoPlayerStyle',
  270. getRule: () =>
  271. state.userSettings.useCustomPlayerHeight
  272. ? `ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  273. min-height: 0px !important;
  274. height: ${state.userSettings.playerHeightPx}px !important;
  275. }`
  276. : `ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  277. max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
  278. }`,
  279. },
  280. headmastStyle: {
  281. id: 'betterTheater-headmastStyle',
  282. getRule: () =>
  283. `#masthead-container.ytd-app { max-width: calc(100% - ${state.chatWidth}px) !important; }`,
  284. },
  285. lowHeadmastStyle: {
  286. id: 'betterTheater-lowHeadmastStyle',
  287. getRule: () => `
  288. #page-manager.ytd-app {
  289. margin-top: 0 !important;
  290. top: calc(-1 * var(--ytd-toolbar-offset)) !important;
  291. position: relative !important;
  292. }
  293. ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy {
  294. margin-top: var(--ytd-toolbar-offset) !important;
  295. }
  296. ${
  297. state.userSettings.modifyVideoPlayer
  298. ? `
  299. ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
  300. max-height: 100vh !important;
  301. }`
  302. : ''
  303. }
  304. #masthead-container.ytd-app {
  305. z-index: 599 !important;
  306. top: ${state.moviePlayerHeight}px !important;
  307. position: relative !important;
  308. }
  309. `,
  310. },
  311. videoPlayerFixStyle: {
  312. id: 'betterTheater-videoPlayerFixStyle',
  313. getRule: () => `
  314. .html5-video-container { top: -1px !important; }
  315. #skip-navigation.ytd-masthead { left: -500px; }
  316. `,
  317. },
  318. chatRendererFixStyle: {
  319. id: 'betterTheater-chatRendererFixStyle',
  320. getRule: () =>
  321. `ytd-live-chat-frame[theater-watch-while][rounded-container] { border-bottom: 0 !important; }`,
  322. },
  323. floatingChatStyle: {
  324. id: 'betterTheater-floatingChatStyle',
  325. getRule: () => `
  326. #chat-container {
  327. min-width: ${CONFIG.MIN_CHAT_SIZE.width}px !important;
  328. min-height: 0 !important;
  329. max-width: 100vw !important;
  330. max-height: 100vh !important;
  331. position: absolute;
  332. border-radius: 0 0 12px 12px !important;
  333. }
  334. #chat {
  335. top: ${CONFIG.DRAG_BAR_HEIGHT} !important;
  336. height: calc(100% - ${CONFIG.DRAG_BAR_HEIGHT}) !important;
  337. width: inherit !important;
  338. min-width: inherit !important;
  339. max-width: inherit !important;
  340. min-height: ${CONFIG.MIN_CHAT_SIZE.height - parseInt(CONFIG.DRAG_BAR_HEIGHT)}px !important;
  341. max-height: 100vh !important;
  342. pointer-events: auto !important;
  343. }
  344. #chat[collapsed] {
  345. height: ${CONFIG.DRAG_BAR_HEIGHT} !important;
  346. min-height: ${CONFIG.DRAG_BAR_HEIGHT} !important;
  347. }
  348. .chat-drag-bar {
  349. cursor: move !important;
  350. pointer-events: auto !important;
  351. }
  352. `,
  353. },
  354. floatingChatStyleExpanded: {
  355. id: 'betterTheater-floatingChatStyleExpanded',
  356. getRule: () => `
  357. #chat-container { min-height: ${CONFIG.MIN_CHAT_SIZE.height}px !important; }
  358. ytd-live-chat-frame:not([theater-watch-while])[rounded-container] {
  359. border-top-left-radius: 0 !important;
  360. border-top-right-radius: 0 !important;
  361. border-top: 0 !important;
  362. }
  363. ytd-live-chat-frame:not([theater-watch-while])[rounded-container] iframe.ytd-live-chat-frame {
  364. border-top-left-radius: 0 !important;
  365. border-top-right-radius: 0 !important;
  366. }
  367. `,
  368. },
  369. floatingChatStyleCollapsed: {
  370. id: 'betterTheater-floatingChatStyleCollapsed',
  371. getRule: () => `
  372. ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame > ytd-toggle-button-renderer.ytd-live-chat-frame,
  373. ytd-live-chat-frame[round-background] #show-hide-button.ytd-live-chat-frame > ytd-button-renderer.ytd-live-chat-frame {
  374. margin: 0 !important;
  375. border-radius: 0 0 12px 12px !important;
  376. border: 1px solid var(--yt-spec-10-percent-layer) !important;
  377. background-clip: padding-box !important;
  378. border-top: none !important;
  379. }
  380. ytd-live-chat-frame[modern-buttons][collapsed] { border-radius: 0 0 12px 12px !important; }
  381. 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 {
  382. border-radius: 0 0 12px 12px !important;
  383. border: none !important;
  384. }
  385. .chat-resize-handle { visibility: hidden !important; }
  386. #chat-container { pointer-events: none !important; }
  387. `,
  388. },
  389. debugResizeHandleStyle: {
  390. id: 'betterTheater-debugResizeHandleStyle',
  391. getRule: () => `
  392. #chat-container .chat-resize-handle { background: transparent; opacity: 0; }
  393. #chat-container[debug] .chat-resize-handle { opacity: 0.5; }
  394. #chat-container[debug] .rs-right { background: rgba(255, 0, 0, 0.5); }
  395. #chat-container[debug] .rs-left { background: rgba(0, 255, 0, 0.5); }
  396. #chat-container[debug] .rs-bottom { background: rgba(0, 0, 255, 0.5); }
  397. #chat-container[debug] .rs-top { background: rgba(255, 255, 0, 0.5); }
  398. #chat-container[debug] .rs-bottom-left { background: rgba(0, 255, 255, 0.5); }
  399. #chat-container[debug] .rs-top-left { background: rgba(255, 255, 0, 0.5); }
  400. #chat-container[debug] .rs-top-right { background: rgba(255, 0, 0, 0.5); }
  401. #chat-container[debug] .rs-bottom-right { background: rgba(255, 0, 255, 0.5); }
  402. `,
  403. },
  404. chatSliderStyle: {
  405. id: 'betterTheater-chatSliderStyle',
  406. getRule: () => `
  407. .chat-drag-bar input[type=range] {
  408. -webkit-appearance: none; appearance: none;
  409. width: 100px; height: 4px; border-radius: 2px;
  410. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  411. outline: none;
  412. }
  413. .chat-drag-bar input[type=range]::-webkit-slider-thumb {
  414. -webkit-appearance: none; appearance: none;
  415. width: 14px; height: 14px; 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; height: 14px; border-radius: 50%;
  421. background: var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color));
  422. cursor: pointer;
  423. }
  424. `,
  425. },
  426. chatClampLimits: {
  427. id: 'betterTheater-chatClampLimits',
  428. getRule: () => {
  429. const hostEl = DOM.ytdWatchFlexy;
  430. const originalWidth = '402px';
  431. const originalMinWidth = '402px';
  432.  
  433. if (hostEl) {
  434. const style = window.getComputedStyle(hostEl);
  435. const fetchedWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-width')?.trim();
  436. const fetchedMinWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-min-width')?.trim();
  437. return `
  438. ytd-live-chat-frame[theater-watch-while] {
  439. min-width: ${CONFIG.MIN_CHAT_SIZE.width}px !important;
  440. max-width: 33.33vw !important;
  441. }
  442. .ytd-watch-flexy {
  443. --ytd-watch-flexy-sidebar-width: clamp(${
  444. CONFIG.MIN_CHAT_SIZE.width
  445. }px, var(--bt-chat-width), 33.33vw) !important;
  446. --ytd-watch-flexy-sidebar-min-width: clamp(${
  447. CONFIG.MIN_CHAT_SIZE.width
  448. }px, var(--bt-chat-width), 33.33vw) !important;
  449. }
  450. ytd-watch-flexy[flexy] #secondary.ytd-watch-flexy {
  451. --ytd-watch-flexy-sidebar-width: ${fetchedWidth ?? originalWidth} !important;
  452. --ytd-watch-flexy-sidebar-min-width: ${fetchedMinWidth ?? originalMinWidth} !important;
  453. }
  454. ytd-watch-next-secondary-results-renderer {
  455. --ytd-reel-item-compact-layout-width: calc((${
  456. fetchedWidth ?? originalWidth
  457. } - 8px) / 3) !important;
  458. --ytd-reel-item-thumbnail-height: calc((${
  459. fetchedWidth ?? originalWidth
  460. } / 3 / 9 * 16)) !important;
  461. }
  462. ytd-live-chat-frame[theater-watch-while] yt-live-chat-renderer {
  463. width: 100% !important; max-width: 100% !important;
  464. }
  465. `;
  466. }
  467. return ''; // Return empty if host element not found
  468. },
  469. },
  470. },
  471. apply(styleDef, isPersistent = false) {
  472. if (typeof styleDef.getRule !== 'function') return;
  473. this.remove(styleDef); // Ensure no duplicates
  474.  
  475. const styleElement = document.createElement('style');
  476. styleElement.id = styleDef.id;
  477. styleElement.textContent = styleDef.getRule();
  478. document.head.appendChild(styleElement);
  479. state.activeStyles.set(styleDef.id, {
  480. element: styleElement,
  481. persistent: isPersistent,
  482. });
  483. },
  484. remove(styleDef) {
  485. const styleData = state.activeStyles.get(styleDef.id);
  486. if (styleData) {
  487. styleData.element?.remove();
  488. state.activeStyles.delete(styleDef.id);
  489. }
  490. },
  491. removeAll() {
  492. const styleIdsToRemove = [...state.activeStyles.keys()];
  493. styleIdsToRemove.forEach((styleId) => {
  494. const styleData = state.activeStyles.get(styleId);
  495. if (styleData && !styleData.persistent) {
  496. this.remove({ id: styleId });
  497. }
  498. });
  499. },
  500. toggle(styleDef, condition) {
  501. condition ? this.apply(styleDef) : this.remove(styleDef);
  502. },
  503. };
  504.  
  505. const SettingsManager = {
  506. async update(key, value) {
  507. try {
  508. const settings = await GM_API.getValue('settings', CONFIG.DEFAULT_SETTINGS);
  509. settings[key] = value;
  510. await GM_API.setValue('settings', settings);
  511. state.userSettings[key] = value;
  512. } catch (error) {
  513. Utils.log(`Error updating setting: ${key}`, 'error', error);
  514. }
  515. },
  516. async load() {
  517. try {
  518. state.versionWarningShown = await GM_API.getValue('versionWarningShown', false);
  519. const storedSettings = await GM_API.getValue('settings', CONFIG.DEFAULT_SETTINGS);
  520. const newSettings = {
  521. ...CONFIG.DEFAULT_SETTINGS,
  522. ...storedSettings,
  523. };
  524.  
  525. state.userSettings = newSettings;
  526. if (Object.keys(storedSettings).length !== Object.keys(newSettings).length) {
  527. await GM_API.setValue('settings', state.userSettings);
  528. }
  529. this.updateMode();
  530. } catch (error) {
  531. Utils.log('Error loading settings', 'error', error);
  532. throw error;
  533. }
  534. },
  535. updateMode() {
  536. if (state.userSettings.isSimpleMode) {
  537. if (!state.advancedSettingsBackup) {
  538. state.advancedSettingsBackup = {
  539. ...state.userSettings,
  540. isSimpleMode: false,
  541. };
  542. }
  543. state.userSettings = {
  544. ...CONFIG.DEFAULT_SETTINGS,
  545. isSimpleMode: true,
  546. };
  547. Utils.log('Switched to Simple Mode');
  548. } else if (state.advancedSettingsBackup) {
  549. state.userSettings = {
  550. ...state.advancedSettingsBackup,
  551. isSimpleMode: false,
  552. };
  553. state.advancedSettingsBackup = null;
  554. App.warnIfOldTampermonkey();
  555. Utils.log('Switched to Advanced Mode', 'log', state.userSettings);
  556. }
  557. Utils.log('Loaded settings:', 'log', state.userSettings);
  558. },
  559. async loadBlacklist() {
  560. try {
  561. const stored = await GM_API.getValue('blacklist', CONFIG.DEFAULT_BLACKLIST);
  562. state.blacklist = new Set(Array.isArray(stored) ? stored : []);
  563. Utils.log('Loaded blacklist:', 'log', Array.from(state.blacklist));
  564. } catch (error) {
  565. Utils.log('Error loading blacklist', 'error', error);
  566. throw error;
  567. }
  568. },
  569. async updateBlacklist() {
  570. try {
  571. await GM_API.setValue('blacklist', Array.from(state.blacklist));
  572. } catch (error) {
  573. Utils.log('Error updating blacklist', 'error', error);
  574. }
  575. },
  576. async cleanupStorage() {
  577. try {
  578. const allowedKeys = ['settings', 'blacklist', 'versionWarningShown'];
  579. const keys = await GM_API.listValues();
  580. for (const key of keys) {
  581. if (!allowedKeys.includes(key)) {
  582. await GM_API.deleteValue(key);
  583. Utils.log(`Deleted leftover key: ${key}`);
  584. }
  585. }
  586. } catch (error) {
  587. Utils.log('Error cleaning up old storage', 'error', error);
  588. }
  589. },
  590. };
  591.  
  592. const MenuManager = {
  593. clear() {
  594. state.menuItems.forEach((menuId) => GM_API.unregisterMenuCommand(menuId));
  595. state.menuItems.clear();
  596. },
  597. refresh() {
  598. this.clear();
  599. const LABEL = getLocalizedText();
  600. const shouldAutoClose = state.isOldTampermonkey;
  601.  
  602. const menuConfig = [
  603. // Always visible
  604. {
  605. label: () =>
  606. `🚫 ${
  607. state.blacklist.has(state.videoId) ? LABEL.unblacklistVideo : LABEL.blacklistVideo
  608. } [id: ${state.videoId}]`,
  609. id: 'toggleBlacklist',
  610. action: async () => {
  611. state.blacklist.has(state.videoId)
  612. ? state.blacklist.delete(state.videoId)
  613. : state.blacklist.add(state.videoId);
  614. await SettingsManager.updateBlacklist();
  615. App.updateAllStyles();
  616. },
  617. },
  618. {
  619. label: () =>
  620. `${state.userSettings.isSimpleMode ? '🚀 ' + LABEL.simpleMode : '🔧 ' + LABEL.advancedMode}`,
  621. id: 'toggleMode',
  622. action: async () => {
  623. await SettingsManager.update('isSimpleMode', !state.userSettings.isSimpleMode);
  624. SettingsManager.updateMode();
  625. App.updateAllStyles();
  626. },
  627. },
  628. // Advanced only
  629. {
  630. label: () =>
  631. `${state.userSettings.enableOnlyForLiveStreams ? '✅' : '❌'} ${LABEL.livestreamOnlyMode}`,
  632. id: 'toggleLiveOnly',
  633. action: () =>
  634. SettingsManager.update(
  635. 'enableOnlyForLiveStreams',
  636. !state.userSettings.enableOnlyForLiveStreams
  637. ).then(App.updateAllStyles),
  638. advanced: true,
  639. },
  640. {
  641. label: () => `${state.userSettings.modifyChat ? '✅' : '❌'} ${LABEL.applyChatStyles}`,
  642. id: 'toggleChatStyle',
  643. action: () =>
  644. SettingsManager.update('modifyChat', !state.userSettings.modifyChat).then(App.updateAllStyles),
  645. advanced: true,
  646. },
  647. {
  648. label: () =>
  649. `${state.userSettings.modifyVideoPlayer ? '✅' : '❌'} ${LABEL.applyVideoPlayerStyles}`,
  650. id: 'toggleVideoStyle',
  651. action: () =>
  652. SettingsManager.update('modifyVideoPlayer', !state.userSettings.modifyVideoPlayer).then(
  653. App.updateAllStyles
  654. ),
  655. advanced: true,
  656. condition: () => !state.userSettings.useCustomPlayerHeight,
  657. },
  658. {
  659. label: () => `${state.userSettings.setLowHeadmast ? '✅' : '❌'} ${LABEL.moveHeadmastBelowVideoPlayer}`,
  660. id: 'toggleLowHeadmast',
  661. action: () =>
  662. SettingsManager.update('setLowHeadmast', !state.userSettings.setLowHeadmast).then(
  663. App.updateAllStyles
  664. ),
  665. advanced: true,
  666. },
  667. {
  668. label: () =>
  669. `${state.userSettings.useCustomPlayerHeight ? '✅' : '❌'} ${LABEL.useCustomPlayerHeight}`,
  670. id: 'toggleCustomHeight',
  671. action: () =>
  672. SettingsManager.update('useCustomPlayerHeight', !state.userSettings.useCustomPlayerHeight).then(
  673. App.updateAllStyles
  674. ),
  675. advanced: true,
  676. },
  677. {
  678. label: () => `🔢 ${LABEL.playerHeightText} (${state.userSettings.playerHeightPx}px)`,
  679. id: 'setCustomHeight',
  680. action: async () => {
  681. const newHeight = await Utils.promptForNumber();
  682. if (newHeight !== null) {
  683. await SettingsManager.update('playerHeightPx', newHeight);
  684. App.updateAllStyles();
  685. }
  686. },
  687. advanced: true,
  688. condition: () => state.userSettings.useCustomPlayerHeight,
  689. },
  690. {
  691. label: () => `${state.userSettings.floatingChat ? '✅' : '❌'} ${LABEL.floatingChat}`,
  692. id: 'toggleFloatingChat',
  693. action: () =>
  694. SettingsManager.update('floatingChat', !state.userSettings.floatingChat).then(
  695. App.updateAllStyles
  696. ),
  697. advanced: true,
  698. },
  699. {
  700. label: () => `${state.userSettings.debug ? '✅' : '❌'} ${LABEL.debug}`,
  701. id: 'toggleDebug',
  702. action: async () => {
  703. await SettingsManager.update('debug', !state.userSettings.debug);
  704. App.updateDebugStyles();
  705. },
  706. advanced: true,
  707. },
  708. ];
  709.  
  710. menuConfig.forEach((item) => {
  711. const isAdvancedItem = item.advanced;
  712. const inAdvancedMode = !state.userSettings.isSimpleMode;
  713. const conditionMet = item.condition ? item.condition() : true;
  714.  
  715. if (conditionMet && (!isAdvancedItem || inAdvancedMode)) {
  716. const commandId = GM_API.registerMenuCommand(
  717. item.label(),
  718. async () => {
  719. await item.action();
  720. this.refresh();
  721. },
  722. { id: item.id, autoClose: shouldAutoClose }
  723. );
  724. state.menuItems.add(commandId ?? item.id);
  725. }
  726. });
  727. },
  728. };
  729.  
  730. const ChatInteractionManager = {
  731. addTheaterResizeHandle() {
  732. if (window.innerWidth / 3 <= CONFIG.MIN_CHAT_SIZE.width) return;
  733. const chat = DOM.chatFrame;
  734. if (!chat || chat.querySelector('#chat-width-resize-handle')) return;
  735.  
  736. const ytdWatchFlexy = DOM.ytdWatchFlexy;
  737. const storedWidth = state.userSettings.theaterChatWidth ?? `${CONFIG.MIN_CHAT_SIZE.width}px`;
  738. this._applyTheaterWidth(ytdWatchFlexy, chat, storedWidth);
  739.  
  740. const handle = document.createElement('div');
  741. handle.id = 'chat-width-resize-handle';
  742. handle.className = 'style-scope ytd-live-chat-frame';
  743. Object.assign(handle.style, {
  744. position: 'absolute',
  745. top: '0',
  746. left: '0',
  747. width: '6px',
  748. height: '100%',
  749. cursor: 'ew-resize',
  750. zIndex: '10001',
  751. });
  752. chat.appendChild(handle);
  753.  
  754. let startX = 0,
  755. startWidth = 0,
  756. animationFrame;
  757.  
  758. const onPointerMove = (e) => {
  759. if (!handle.hasPointerCapture(e.pointerId)) return;
  760. cancelAnimationFrame(animationFrame);
  761. animationFrame = requestAnimationFrame(() => {
  762. const dx = startX - e.clientX;
  763. const newWidth = Math.max(CONFIG.MIN_CHAT_SIZE.width, startWidth + dx);
  764. this._applyTheaterWidth(ytdWatchFlexy, chat, `${newWidth}px`);
  765. });
  766. };
  767.  
  768. const onPointerUp = (e) => {
  769. handle.releasePointerCapture(e.pointerId);
  770. document.removeEventListener('pointermove', onPointerMove);
  771. document.removeEventListener('pointerup', onPointerUp);
  772. SettingsManager.update('theaterChatWidth', ytdWatchFlexy.style.getPropertyValue('--bt-chat-width'));
  773. };
  774.  
  775. handle.addEventListener('pointerdown', (e) => {
  776. if (e.pointerType === 'mouse' && e.button !== 0) return;
  777. e.preventDefault();
  778. document.body.click(); // Deselect any text
  779. startX = e.clientX;
  780. startWidth = chat.getBoundingClientRect().width;
  781. handle.setPointerCapture(e.pointerId);
  782. document.addEventListener('pointermove', onPointerMove);
  783. document.addEventListener('pointerup', onPointerUp);
  784. });
  785. },
  786. _applyTheaterWidth(flexy, chat, widthCss) {
  787. if (flexy) flexy.style.setProperty('--bt-chat-width', widthCss);
  788. if (chat) {
  789. chat.style.width = widthCss;
  790. chat.style.zIndex = '1999';
  791. }
  792. },
  793. removeTheaterResizeHandle() {
  794. DOM.chatFrame?.querySelector('#chat-width-resize-handle')?.remove();
  795. const flexy = DOM.ytdWatchFlexy;
  796. const chat = DOM.chatFrame;
  797. if (flexy) flexy.style.removeProperty('--bt-chat-width');
  798. if (chat) {
  799. chat.style.width = '';
  800. chat.style.zIndex = '';
  801. }
  802. },
  803.  
  804. initFullscreenChat(chatContainer) {
  805. this.applySavedChatStyle(chatContainer, true);
  806. this.addDragBar(chatContainer);
  807. this.addResizeHandles(chatContainer);
  808. },
  809. cleanupFullscreenChat(chatContainer) {
  810. this.removeDragBar(chatContainer);
  811. this.removeResizeHandles(chatContainer);
  812. chatContainer.style.cssText = '';
  813. },
  814. applySavedChatStyle(chatContainer, shouldSave = false) {
  815. if (!chatContainer || !DOM.moviePlayer) return;
  816.  
  817. const movieRect = DOM.moviePlayer.getBoundingClientRect();
  818. if (movieRect.width === 0 || movieRect.height === 0) return;
  819.  
  820. const parentRect = chatContainer.parentElement.getBoundingClientRect();
  821. const { width, height, top, left, opacity } = state.userSettings.chatStyle;
  822.  
  823. const parsedWidth = parseFloat(width) ?? CONFIG.MIN_CHAT_SIZE.width;
  824. const parsedHeight = parseFloat(height) ?? CONFIG.MIN_CHAT_SIZE.height;
  825.  
  826. let newWidth = Math.min(Math.max(CONFIG.MIN_CHAT_SIZE.width, parsedWidth), movieRect.width);
  827. let newHeight = Math.min(Math.max(CONFIG.MIN_CHAT_SIZE.height, parsedHeight), movieRect.height);
  828.  
  829. const parsedTop = parseFloat(top) ?? 0;
  830. const parsedLeft = parseFloat(left) ?? 0;
  831.  
  832. const minTop = movieRect.top - parentRect.top;
  833. const maxTop = movieRect.bottom - parentRect.top - newHeight;
  834. const minLeft = movieRect.left - parentRect.left;
  835. const maxLeft = movieRect.right - parentRect.left - newWidth;
  836.  
  837. let newTop = Math.max(minTop, Math.min(parsedTop, maxTop));
  838. let newLeft = Math.max(minLeft, Math.min(parsedLeft, maxLeft));
  839.  
  840. Object.assign(chatContainer.style, {
  841. width: `${newWidth}px`,
  842. height: `${newHeight}px`,
  843. top: `${newTop}px`,
  844. left: `${newLeft}px`,
  845. opacity: parseFloat(opacity) ?? 0.95,
  846. });
  847.  
  848. if (shouldSave && (newTop !== parsedTop || newLeft !== parsedLeft)) {
  849. this.saveChatStyle(chatContainer);
  850. }
  851. },
  852. saveChatStyle(chatContainer) {
  853. const style = {
  854. width: chatContainer.style.width,
  855. height: chatContainer.style.height,
  856. left: chatContainer.style.left,
  857. top: chatContainer.style.top,
  858. opacity: chatContainer.style.opacity,
  859. };
  860. return SettingsManager.update('chatStyle', style);
  861. },
  862. addDragBar(chatContainer) {
  863. if (chatContainer.querySelector('.chat-drag-bar')) return;
  864. StyleManager.apply(StyleManager.styleDefinitions.chatSliderStyle, true);
  865.  
  866. const dragBar = document.createElement('div');
  867. dragBar.className = 'chat-drag-bar';
  868. Object.assign(dragBar.style, {
  869. position: 'absolute',
  870. top: '0',
  871. left: '0',
  872. right: '0',
  873. height: '15px',
  874. background: 'var(--yt-live-chat-background-color)',
  875. color: 'var(--yt-live-chat-header-text-color, var(--yt-live-chat-primary-text-color))',
  876. border: '1px solid var(--yt-spec-10-percent-layer)',
  877. backgroundClip: 'padding-box',
  878. display: 'flex',
  879. alignItems: 'center',
  880. justifyContent: 'space-between',
  881. padding: `${(parseInt(CONFIG.DRAG_BAR_HEIGHT) - 15) / 2}px`,
  882. zIndex: '10000',
  883. borderRadius: '12px 12px 0 0',
  884. });
  885.  
  886. const dragHandleIcon = document.createElement('div');
  887. dragHandleIcon.textContent = '⋮⋮';
  888. Object.assign(dragHandleIcon.style, {
  889. fontSize: '18px',
  890. userSelect: 'none',
  891. });
  892.  
  893. const opacitySlider = document.createElement('input');
  894. opacitySlider.type = 'range';
  895. opacitySlider.min = '20';
  896. opacitySlider.max = '100';
  897. opacitySlider.value = String(Math.round((parseFloat(state.userSettings.chatStyle.opacity) ?? 0.95) * 100));
  898.  
  899. opacitySlider.addEventListener(
  900. 'input',
  901. () => (chatContainer.style.opacity = String(opacitySlider.value / 100))
  902. );
  903. opacitySlider.addEventListener('mouseup', () => this.saveChatStyle(chatContainer));
  904. opacitySlider.addEventListener('pointerdown', (e) => e.stopPropagation());
  905.  
  906. dragBar.appendChild(dragHandleIcon);
  907. dragBar.appendChild(opacitySlider);
  908.  
  909. chatContainer.insertBefore(dragBar, chatContainer.firstChild);
  910. this._initDrag(dragBar, chatContainer);
  911. },
  912. _initDrag(dragBar, chatContainer) {
  913. let start = {},
  914. parentRect,
  915. movieRect,
  916. animationFrame,
  917. isDragging = false;
  918.  
  919. const onPointerMove = (e) => {
  920. if (!isDragging) return;
  921.  
  922. const isOutside =
  923. e.clientX < movieRect.left ||
  924. e.clientX > movieRect.right ||
  925. e.clientY < movieRect.top ||
  926. e.clientY > movieRect.bottom;
  927. if (isOutside) return;
  928.  
  929. cancelAnimationFrame(animationFrame);
  930. animationFrame = requestAnimationFrame(() => {
  931. const newLeft = e.clientX - start.offsetX;
  932. const newTop = e.clientY - start.offsetY;
  933.  
  934. const isChatCollapsed = chatContainer.querySelector('#chat[collapsed]');
  935. const showHideButtonHeight = chatContainer.querySelector('#show-hide-button')?.offsetHeight ?? 0;
  936. const chatHeight = isChatCollapsed
  937. ? parseInt(CONFIG.DRAG_BAR_HEIGHT) + showHideButtonHeight
  938. : chatContainer.offsetHeight;
  939.  
  940. const clampedLeft = Math.max(
  941. movieRect.left,
  942. Math.min(newLeft, movieRect.right - chatContainer.offsetWidth)
  943. );
  944. const clampedTop = Math.max(movieRect.top, Math.min(newTop, movieRect.bottom - chatHeight));
  945.  
  946. chatContainer.style.left = `${clampedLeft - parentRect.left}px`;
  947. chatContainer.style.top = `${clampedTop - parentRect.top}px`;
  948. });
  949. };
  950.  
  951. const onPointerUp = (e) => {
  952. isDragging = false;
  953. dragBar.releasePointerCapture(e.pointerId);
  954. document.removeEventListener('pointermove', onPointerMove);
  955. document.removeEventListener('pointerup', onPointerUp);
  956. document.removeEventListener('pointercancel', onPointerUp);
  957. this.saveChatStyle(chatContainer);
  958. };
  959.  
  960. dragBar.addEventListener('pointerdown', (e) => {
  961. if (e.pointerType === 'mouse' && e.button !== 0) return;
  962. e.preventDefault();
  963. isDragging = true;
  964. dragBar.setPointerCapture(e.pointerId);
  965.  
  966. parentRect = chatContainer.parentElement.getBoundingClientRect();
  967. movieRect = DOM.moviePlayer.getBoundingClientRect();
  968. const chatRect = chatContainer.getBoundingClientRect();
  969. start = {
  970. offsetX: e.clientX - chatRect.left,
  971. offsetY: e.clientY - chatRect.top,
  972. };
  973.  
  974. document.addEventListener('pointermove', onPointerMove);
  975. document.addEventListener('pointerup', onPointerUp);
  976. document.addEventListener('pointercancel', onPointerUp);
  977. });
  978. },
  979. removeDragBar(chatContainer) {
  980. chatContainer?.querySelector('.chat-drag-bar')?.remove();
  981. StyleManager.remove(StyleManager.styleDefinitions.chatSliderStyle);
  982. },
  983. addResizeHandles(chatContainer) {
  984. const handleConfigs = {
  985. right: {
  986. cursor: 'ew-resize',
  987. right: '0',
  988. width: '6px',
  989. top: '0',
  990. bottom: '0',
  991. },
  992. left: {
  993. cursor: 'ew-resize',
  994. left: '0',
  995. width: '6px',
  996. top: '0',
  997. bottom: '0',
  998. },
  999. bottom: {
  1000. cursor: 'ns-resize',
  1001. bottom: '0',
  1002. height: '6px',
  1003. left: '0',
  1004. right: '0',
  1005. },
  1006. top: {
  1007. cursor: 'ns-resize',
  1008. top: '0',
  1009. height: '6px',
  1010. left: '0',
  1011. right: '0',
  1012. },
  1013. bottomLeft: {
  1014. cursor: 'nesw-resize',
  1015. left: '0',
  1016. bottom: '0',
  1017. width: '12px',
  1018. height: '12px',
  1019. },
  1020. topLeft: {
  1021. cursor: 'nwse-resize',
  1022. left: '0',
  1023. top: '0',
  1024. width: '12px',
  1025. height: '12px',
  1026. },
  1027. topRight: {
  1028. cursor: 'nesw-resize',
  1029. right: '0',
  1030. top: '0',
  1031. width: '12px',
  1032. height: '12px',
  1033. },
  1034. bottomRight: {
  1035. cursor: 'nwse-resize',
  1036. right: '0',
  1037. bottom: '0',
  1038. width: '12px',
  1039. height: '12px',
  1040. },
  1041. };
  1042.  
  1043. for (const [pos, config] of Object.entries(handleConfigs)) {
  1044. if (chatContainer.querySelector(`.rs-${pos}`)) continue;
  1045. const handle = document.createElement('div');
  1046. handle.className = `chat-resize-handle rs-${pos}`;
  1047. Object.assign(handle.style, {
  1048. position: 'absolute',
  1049. zIndex: '10001',
  1050. ...config,
  1051. });
  1052. chatContainer.appendChild(handle);
  1053. this._initResize(handle, chatContainer);
  1054. }
  1055. },
  1056. _initResize(handle, chatContainer) {
  1057. let start, parentRect, movieRect, animationFrame;
  1058.  
  1059. const onPointerMove = (e) => {
  1060. if (!handle.hasPointerCapture(e.pointerId)) return;
  1061. cancelAnimationFrame(animationFrame);
  1062. animationFrame = requestAnimationFrame(() => {
  1063. const dx = e.clientX - start.x;
  1064. const dy = e.clientY - start.y;
  1065.  
  1066. let newWidth = start.width;
  1067. let newHeight = start.height;
  1068. let newLeft = start.left;
  1069. let newTop = start.top;
  1070.  
  1071. const className = handle.className.toLowerCase();
  1072. const isLeft = className.includes('left');
  1073. const isRight = className.includes('right');
  1074. const isTop = className.includes('top');
  1075. const isBottom = className.includes('bottom');
  1076.  
  1077. if (isRight) newWidth += dx;
  1078. if (isLeft) {
  1079. newWidth -= dx;
  1080. newLeft += dx;
  1081. }
  1082.  
  1083. if (isBottom) newHeight += dy;
  1084. if (isTop) {
  1085. newHeight -= dy;
  1086. newTop += dy;
  1087. }
  1088.  
  1089. newWidth = Math.max(CONFIG.MIN_CHAT_SIZE.width, newWidth);
  1090. newHeight = Math.max(CONFIG.MIN_CHAT_SIZE.height, newHeight);
  1091.  
  1092. if (isLeft)
  1093. newLeft = Math.max(
  1094. movieRect.left,
  1095. Math.min(newLeft, start.left + start.width - CONFIG.MIN_CHAT_SIZE.width)
  1096. );
  1097. if (isTop)
  1098. newTop = Math.max(
  1099. movieRect.top,
  1100. Math.min(newTop, start.top + start.height - CONFIG.MIN_CHAT_SIZE.height)
  1101. );
  1102.  
  1103. newWidth = Math.min(newWidth, movieRect.right - newLeft);
  1104. newHeight = Math.min(newHeight, movieRect.bottom - newTop);
  1105.  
  1106. chatContainer.style.width = `${newWidth}px`;
  1107. chatContainer.style.height = `${newHeight}px`;
  1108. chatContainer.style.left = `${newLeft - parentRect.left}px`;
  1109. chatContainer.style.top = `${newTop - parentRect.top}px`;
  1110. });
  1111. };
  1112.  
  1113. const onPointerUp = (e) => {
  1114. handle.releasePointerCapture(e.pointerId);
  1115. document.removeEventListener('pointermove', onPointerMove);
  1116. document.removeEventListener('pointerup', onPointerUp);
  1117. this.saveChatStyle(chatContainer);
  1118. };
  1119.  
  1120. handle.addEventListener('pointerdown', (e) => {
  1121. if (e.pointerType === 'mouse' && e.button !== 0) return;
  1122. e.preventDefault();
  1123. handle.setPointerCapture(e.pointerId);
  1124.  
  1125. parentRect = chatContainer.parentElement.getBoundingClientRect();
  1126. movieRect = DOM.moviePlayer.getBoundingClientRect();
  1127. const chatRect = chatContainer.getBoundingClientRect();
  1128. start = {
  1129. x: e.clientX,
  1130. y: e.clientY,
  1131. width: chatRect.width,
  1132. height: chatRect.height,
  1133. left: chatRect.left,
  1134. top: chatRect.top,
  1135. };
  1136.  
  1137. document.addEventListener('pointermove', onPointerMove);
  1138. document.addEventListener('pointerup', onPointerUp);
  1139. });
  1140. },
  1141. removeResizeHandles(chatContainer) {
  1142. chatContainer?.querySelectorAll('.chat-resize-handle').forEach((h) => h.remove());
  1143. },
  1144. };
  1145.  
  1146. const App = {
  1147. init() {
  1148. try {
  1149. if (!this.detectGreasemonkey()) throw new Error('Greasemonkey API not detected');
  1150. Utils.log(state.gmFallback ? 'Running in compatibility mode' : 'Running in normal mode', 'warn');
  1151.  
  1152. StyleManager.apply(StyleManager.styleDefinitions.debugResizeHandleStyle, true);
  1153.  
  1154. Promise.all([
  1155. SettingsManager.cleanupStorage(),
  1156. SettingsManager.load(),
  1157. SettingsManager.loadBlacklist(),
  1158. ]).then(() => {
  1159. this.checkTampermonkeyVersion();
  1160. StyleManager.apply(StyleManager.styleDefinitions.chatRendererFixStyle, true);
  1161. StyleManager.apply(StyleManager.styleDefinitions.videoPlayerFixStyle, true);
  1162. this.onPageChange();
  1163. this.attachEventListeners();
  1164. MenuManager.refresh();
  1165. });
  1166. } catch (error) {
  1167. Utils.log('Initialization failed', 'error', error);
  1168. }
  1169. },
  1170. detectGreasemonkey() {
  1171. return typeof window.GM?.info !== 'undefined' || typeof GM_info !== 'undefined';
  1172. },
  1173. checkTampermonkeyVersion() {
  1174. const info = GM_API.info();
  1175. if (info?.scriptHandler === 'Tampermonkey') {
  1176. if (Utils.compareVersions(info.version, CONFIG.REQUIRED_VERSIONS.Tampermonkey) < 0) {
  1177. state.isOldTampermonkey = true;
  1178. this.warnIfOldTampermonkey();
  1179. }
  1180. }
  1181. },
  1182. async warnIfOldTampermonkey() {
  1183. if (state.versionWarningShown || state.userSettings.isSimpleMode || !state.isOldTampermonkey) return;
  1184. GM_API.notification({
  1185. text: getLocalizedText().tampermonkeyOutdatedAlert,
  1186. timeout: 15000,
  1187. });
  1188. state.versionWarningShown = true;
  1189. await GM_API.setValue('versionWarningShown', true);
  1190. },
  1191. updateAllStyles(shouldSaveChatPos = false) {
  1192. try {
  1193. if (state.userSettings.useCustomPlayerHeight) {
  1194. state.userSettings.modifyVideoPlayer = true;
  1195. }
  1196.  
  1197. const isBlacklisted = state.blacklist.has(state.videoId);
  1198. const isLiveOnly = state.userSettings.enableOnlyForLiveStreams && !state.isLiveStream;
  1199.  
  1200. if (isBlacklisted || isLiveOnly) {
  1201. StyleManager.removeAll();
  1202. ChatInteractionManager.removeTheaterResizeHandle();
  1203. DOM.moviePlayer?.setCenterCrop?.();
  1204. return;
  1205. }
  1206.  
  1207. StyleManager.toggle(
  1208. StyleManager.styleDefinitions.videoPlayerStyle,
  1209. state.userSettings.modifyVideoPlayer
  1210. );
  1211. this.updateChatStyles();
  1212. this.updateFullscreenChatStyles(shouldSaveChatPos);
  1213.  
  1214. DOM.moviePlayer?.setCenterCrop?.();
  1215. } catch (error) {
  1216. Utils.log('Error updating styles', 'error', error);
  1217. }
  1218. },
  1219. updateChatStyles() {
  1220. const chatBox = DOM.chatFrame?.getBoundingClientRect();
  1221. const isSecondaryVisible = DOM.ytdWatchFlexy?.querySelector('#secondary')?.style.display !== 'none';
  1222.  
  1223. const shouldApplyChatStyle =
  1224. state.userSettings.modifyChat &&
  1225. state.isTheaterMode &&
  1226. !state.isFullscreen &&
  1227. !state.chatCollapsed &&
  1228. chatBox?.width > 0 &&
  1229. isSecondaryVisible;
  1230.  
  1231. StyleManager.toggle(StyleManager.styleDefinitions.chatStyle, shouldApplyChatStyle);
  1232. StyleManager.toggle(StyleManager.styleDefinitions.chatClampLimits, shouldApplyChatStyle);
  1233.  
  1234. shouldApplyChatStyle
  1235. ? ChatInteractionManager.addTheaterResizeHandle()
  1236. : ChatInteractionManager.removeTheaterResizeHandle();
  1237.  
  1238. this.updateHeadmastStyle(shouldApplyChatStyle);
  1239. },
  1240. updateHeadmastStyle(isChatStyled) {
  1241. this.updateLowHeadmastStyle();
  1242.  
  1243. const shouldShrinkHeadmast =
  1244. isChatStyled &&
  1245. DOM.chatFrame?.getAttribute('theater-watch-while') === '' &&
  1246. (state.userSettings.setLowHeadmast || state.userSettings.modifyChat);
  1247.  
  1248. state.chatWidth = DOM.chatFrame?.offsetWidth ?? 0;
  1249. StyleManager.toggle(StyleManager.styleDefinitions.headmastStyle, shouldShrinkHeadmast);
  1250. },
  1251. updateLowHeadmastStyle() {
  1252. if (!DOM.moviePlayer) return;
  1253. const shouldApply =
  1254. state.userSettings.setLowHeadmast &&
  1255. state.isTheaterMode &&
  1256. !state.isFullscreen &&
  1257. state.currentPageType === 'watch';
  1258. StyleManager.toggle(StyleManager.styleDefinitions.lowHeadmastStyle, shouldApply);
  1259. },
  1260. updateFullscreenChatStyles(shouldSave) {
  1261. const chatContainer = DOM.chatContainer;
  1262. const shouldEnableFloatingChat = state.userSettings.floatingChat && state.isFullscreen;
  1263.  
  1264. if (!chatContainer || !shouldEnableFloatingChat) {
  1265. if (chatContainer) ChatInteractionManager.cleanupFullscreenChat(chatContainer);
  1266. StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyleCollapsed);
  1267. StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyleExpanded);
  1268. StyleManager.remove(StyleManager.styleDefinitions.floatingChatStyle);
  1269. return;
  1270. }
  1271.  
  1272. const isChatAvailable = chatContainer.querySelector('#chat');
  1273. if (isChatAvailable) {
  1274. StyleManager.apply(StyleManager.styleDefinitions.floatingChatStyle);
  1275. StyleManager.toggle(StyleManager.styleDefinitions.floatingChatStyleCollapsed, state.chatCollapsed);
  1276. StyleManager.toggle(StyleManager.styleDefinitions.floatingChatStyleExpanded, !state.chatCollapsed);
  1277. ChatInteractionManager.initFullscreenChat(chatContainer);
  1278. if (shouldSave) {
  1279. ChatInteractionManager.applySavedChatStyle(chatContainer, true);
  1280. }
  1281. } else {
  1282. ChatInteractionManager.cleanupFullscreenChat(chatContainer);
  1283. }
  1284. },
  1285. updateDebugStyles() {
  1286. if (DOM.chatContainer) {
  1287. DOM.chatContainer.toggleAttribute('debug', state.userSettings.debug);
  1288. }
  1289. },
  1290. updateDOMCache() {
  1291. DOM.ytdWatchFlexy = document.querySelector('ytd-watch-flexy');
  1292. DOM.chatContainer = document.querySelector('#chat-container');
  1293. DOM.chatFrame = document.querySelector('ytd-live-chat-frame#chat');
  1294. },
  1295. updateMoviePlayerObserver() {
  1296. const newMoviePlayer = document.querySelector('#movie_player');
  1297. if (DOM.moviePlayer === newMoviePlayer) return;
  1298.  
  1299. if (state.resizeObserver) {
  1300. if (DOM.moviePlayer) {
  1301. state.resizeObserver.unobserve(DOM.moviePlayer);
  1302. }
  1303. } else {
  1304. state.resizeObserver = new ResizeObserver((entries) => {
  1305. for (const entry of entries) {
  1306. state.moviePlayerHeight = entry.contentRect.height;
  1307. this.updateAllStyles();
  1308. }
  1309. });
  1310. }
  1311.  
  1312. DOM.moviePlayer = newMoviePlayer;
  1313. if (DOM.moviePlayer) {
  1314. state.resizeObserver.observe(DOM.moviePlayer);
  1315. }
  1316. },
  1317. onPageChange() {
  1318. this.updateDOMCache();
  1319. this.updateMoviePlayerObserver();
  1320. this.updateAllStyles();
  1321. this.updateDebugStyles();
  1322. MenuManager.refresh();
  1323. },
  1324.  
  1325. handleFullscreenChange() {
  1326. state.isFullscreen = !!document.fullscreenElement;
  1327. this.updateAllStyles(true);
  1328. },
  1329. handleTheaterChange(event) {
  1330. state.isTheaterMode = !!event?.detail?.enabled;
  1331. this.updateAllStyles();
  1332. },
  1333. handleChatCollapse(event) {
  1334. DOM.chatFrame = event.target;
  1335. state.chatCollapsed = event.detail !== false;
  1336. this.updateAllStyles(true);
  1337. },
  1338. handlePageData(event) {
  1339. try {
  1340. const pageData = event.detail.pageData;
  1341. state.currentPageType = pageData.page;
  1342. state.videoId = pageData.playerResponse?.videoDetails?.videoId;
  1343. state.isLiveStream = pageData.playerResponse?.videoDetails?.isLiveContent;
  1344. state.isFullscreen = !!document.fullscreenElement;
  1345. this.onPageChange();
  1346. } catch (error) {
  1347. Utils.log('Failed to process page data', 'error', error);
  1348. }
  1349. },
  1350. attachEventListeners() {
  1351. const events = {
  1352. 'yt-set-theater-mode-enabled': (e) => this.handleTheaterChange(e),
  1353. 'yt-chat-collapsed-changed': (e) => this.handleChatCollapse(e),
  1354. 'yt-page-data-fetched': (e) => this.handlePageData(e),
  1355. 'yt-page-data-updated': () => this.onPageChange(),
  1356. fullscreenchange: () => this.handleFullscreenChange(),
  1357. 'yt-navigate-finish': () => this.onPageChange(),
  1358. };
  1359.  
  1360. for (const [event, handler] of Object.entries(events)) {
  1361. window.addEventListener(event, handler.bind(this), {
  1362. capture: true,
  1363. passive: true,
  1364. });
  1365. }
  1366.  
  1367. let isResizeScheduled = false;
  1368. window.addEventListener('resize', () => {
  1369. if (isResizeScheduled) return;
  1370. isResizeScheduled = true;
  1371. requestAnimationFrame(() => {
  1372. this.updateAllStyles(true);
  1373. isResizeScheduled = false;
  1374. });
  1375. });
  1376. },
  1377. };
  1378.  
  1379. App.init();
  1380. })();