Control Panel for YouTube

Gives you more control over YouTube by adding missing options and UI improvements

  1. // ==UserScript==
  2. // @name Control Panel for YouTube
  3. // @description Gives you more control over YouTube by adding missing options and UI improvements
  4. // @icon https://raw.githubusercontent.com/insin/control-panel-for-youtube/master/icons/icon32.png
  5. // @namespace https://jbscript.dev/control-panel-for-youtube
  6. // @match https://www.youtube.com/*
  7. // @match https://m.youtube.com/*
  8. // @exclude https://www.youtube.com/embed/*
  9. // @version 17
  10. // ==/UserScript==
  11. let debug = false
  12. let debugManualHiding = false
  13.  
  14. let mobile = location.hostname == 'm.youtube.com'
  15. let desktop = !mobile
  16. /** @type {import("./types").Version} */
  17. let version = mobile ? 'mobile' : 'desktop'
  18. let lang = mobile ? document.body.lang : document.documentElement.lang
  19. let loggedIn = /(^|; )SID=/.test(document.cookie)
  20.  
  21. function log(...args) {
  22. if (debug) {
  23. console.log('🙋', ...args)
  24. }
  25. }
  26.  
  27. function warn(...args) {
  28. if (debug) {
  29. console.log('❗️', ...args)
  30. }
  31. }
  32.  
  33. //#region Default config
  34. /** @type {import("./types").SiteConfig} */
  35. let config = {
  36. debug: false,
  37. enabled: true,
  38. version,
  39. disableAutoplay: true,
  40. disableHomeFeed: false,
  41. hideAI: true,
  42. hiddenChannels: [],
  43. hideChannels: true,
  44. hideComments: false,
  45. hideHiddenVideos: true,
  46. hideHomeCategories: false,
  47. hideInfoPanels: false,
  48. hideLive: false,
  49. hideMetadata: false,
  50. hideMixes: false,
  51. hideMoviesAndTV: false,
  52. hideNextButton: true,
  53. hideRelated: false,
  54. hideShareThanksClip: false,
  55. hideShorts: true,
  56. hideSponsored: true,
  57. hideStreamed: false,
  58. hideSuggestedSections: true,
  59. hideUpcoming: false,
  60. hideVoiceSearch: false,
  61. hideWatched: true,
  62. hideWatchedThreshold: '80',
  63. redirectShorts: true,
  64. removePink: false,
  65. skipAds: true,
  66. // Desktop only
  67. alwaysUseTheaterMode: false,
  68. downloadTranscript: true,
  69. fullSizeTheaterMode: false,
  70. hideChat: false,
  71. hideEndCards: false,
  72. hideEndVideos: true,
  73. hideMerchEtc: true,
  74. hideMiniplayerButton: false,
  75. hideSubscriptionsLatestBar: false,
  76. minimumGridItemsPerRow: 'auto',
  77. pauseChannelTrailers: true,
  78. searchThumbnailSize: 'medium',
  79. tidyGuideSidebar: false,
  80. // Mobile only
  81. hideExploreButton: true,
  82. hideOpenApp: true,
  83. hideSubscriptionsChannelList: false,
  84. mobileGridView: true,
  85. }
  86. //#endregion
  87.  
  88. //#region Locales
  89. /**
  90. * @type {Record<string, import("./types").Locale>}
  91. */
  92. const locales = {
  93. 'en': {
  94. CLIP: 'Clip',
  95. DOWNLOAD: 'Download',
  96. FOR_YOU: 'For you',
  97. HIDE_CHANNEL: 'Hide channel',
  98. MIXES: 'Mixes',
  99. MUTE: 'Mute',
  100. NEXT_VIDEO: 'Next video',
  101. OPEN_APP: 'Open App',
  102. PREVIOUS_VIDEO: 'Previous video',
  103. SHARE: 'Share',
  104. SHORTS: 'Shorts',
  105. STREAMED_TITLE: 'views Streamed',
  106. TELL_US_WHY: 'Tell us why',
  107. THANKS: 'Thanks',
  108. UNHIDE_CHANNEL: 'Unhide channel',
  109. },
  110. 'ja-JP': {
  111. CLIP: 'クリップ',
  112. DOWNLOAD: 'オフライン',
  113. FOR_YOU: 'あなたへのおすすめ',
  114. HIDE_CHANNEL: 'チャンネルを隠す',
  115. MIXES: 'ミックス',
  116. MUTE: 'ミュート(消音)',
  117. NEXT_VIDEO: '次の動画',
  118. OPEN_APP: 'アプリを開く',
  119. PREVIOUS_VIDEO: '前の動画',
  120. SHARE: '共有',
  121. SHORTS: 'ショート',
  122. STREAMED_TITLE: '前 に配信済み',
  123. TELL_US_WHY: '理由を教えてください',
  124. UNHIDE_CHANNEL: 'チャンネルの再表示',
  125. }
  126. }
  127.  
  128. /**
  129. * @param {import("./types").LocaleKey} code
  130. * @returns {string}
  131. */
  132. function getString(code) {
  133. return (locales[lang] || locales['en'])[code] || locales['en'][code];
  134. }
  135. //#endregion
  136.  
  137. const undoHideDelayMs = 5000
  138.  
  139. const Classes = {
  140. HIDE_CHANNEL: 'cpfyt-hide-channel',
  141. HIDE_HIDDEN: 'cpfyt-hide-hidden',
  142. HIDE_OPEN_APP: 'cpfyt-hide-open-app',
  143. HIDE_STREAMED: 'cpfyt-hide-streamed',
  144. HIDE_WATCHED: 'cpfyt-hide-watched',
  145. HIDE_SHARE_THANKS_CLIP: 'cpfyt-hide-share-thanks-clip',
  146. }
  147.  
  148. const Svgs = {
  149. DELETE: '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M11 17H9V8h2v9zm4-9h-2v9h2V8zm4-4v1h-1v16H6V5H5V4h4V3h6v1h4zm-2 1H7v15h10V5z"></path></svg>',
  150. RESTORE: '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M460-347.692h40V-535.23l84 83.538L612.308-480 480-612.308 347.692-480 376-451.692l84-83.538v187.538ZM304.615-160Q277-160 258.5-178.5 240-197 240-224.615V-720h-40v-40h160v-30.77h240V-760h160v40h-40v495.385Q720-197 701.5-178.5 683-160 655.385-160h-350.77ZM680-720H280v495.385q0 9.23 7.692 16.923Q295.385-200 304.615-200h350.77q9.23 0 16.923-7.692Q680-215.385 680-224.615V-720Zm-400 0v520-520Z"/></svg>',
  151. }
  152.  
  153. // YouTube channel URLs: https://support.google.com/youtube/answer/6180214
  154. const URL_CHANNEL_RE = /\/(?:@[^\/]+|(?:c|channel|user)\/[^\/]+)(?:\/(featured|videos|shorts|playlists|community))?\/?$/
  155.  
  156. //#region State
  157. /** @type {() => void} */
  158. let onAdRemoved
  159. /** @type {Map<string, import("./types").Disconnectable>} */
  160. let globalObservers = new Map()
  161. /** @type {import("./types").Channel} */
  162. let lastClickedChannel
  163. /** @type {HTMLElement} */
  164. let $lastClickedElement
  165. /** @type {() => void} */
  166. let onDialogClosed
  167. /** @type {Map<string, import("./types").Disconnectable>} */
  168. let pageObservers = new Map()
  169. //#endregion
  170.  
  171. //#region Utility functions
  172. function addStyle(css = '') {
  173. let $style = document.createElement('style')
  174. $style.dataset.insertedBy = 'control-panel-for-youtube'
  175. if (css) {
  176. $style.textContent = css
  177. }
  178. document.head.appendChild($style)
  179. return $style
  180. }
  181.  
  182. function currentUrlChanges() {
  183. let currentUrl = getCurrentUrl()
  184. return () => currentUrl != getCurrentUrl()
  185. }
  186.  
  187. /**
  188. * @param {string} str
  189. * @return {string}
  190. */
  191. function dedent(str) {
  192. str = str.replace(/^[ \t]*\r?\n/, '')
  193. let indent = /^[ \t]+/m.exec(str)
  194. if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '')
  195. return str.replace(/(\r?\n)[ \t]+$/, '$1')
  196. }
  197.  
  198. /** @param {Map<string, import("./types").Disconnectable>} observers */
  199. function disconnectObservers(observers, scope) {
  200. if (observers.size == 0) return
  201. log(
  202. `disconnecting ${observers.size} ${scope} observer${s(observers.size)}`,
  203. Array.from(observers.keys())
  204. )
  205. logObserverDisconnects = false
  206. for (let observer of observers.values()) observer.disconnect()
  207. logObserverDisconnects = true
  208. }
  209.  
  210. function getCurrentUrl() {
  211. return location.origin + location.pathname + location.search
  212. }
  213.  
  214. /**
  215. * @typedef {{
  216. * name?: string
  217. * stopIf?: () => boolean
  218. * timeout?: number
  219. * context?: Document | HTMLElement
  220. * }} GetElementOptions
  221. *
  222. * @param {string} selector
  223. * @param {GetElementOptions} options
  224. * @returns {Promise<HTMLElement | null>}
  225. */
  226. function getElement(selector, {
  227. name = null,
  228. stopIf = null,
  229. timeout = Infinity,
  230. context = document,
  231. } = {}) {
  232. return new Promise((resolve) => {
  233. let startTime = Date.now()
  234. let rafId
  235. let timeoutId
  236.  
  237. function stop($element, reason) {
  238. if ($element == null) {
  239. warn(`stopped waiting for ${name || selector} after ${reason}`)
  240. }
  241. else if (Date.now() > startTime) {
  242. log(`${name || selector} appeared after`, Date.now() - startTime, 'ms')
  243. }
  244. if (rafId) {
  245. cancelAnimationFrame(rafId)
  246. }
  247. if (timeoutId) {
  248. clearTimeout(timeoutId)
  249. }
  250. resolve($element)
  251. }
  252.  
  253. if (timeout !== Infinity) {
  254. timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`)
  255. }
  256.  
  257. function queryElement() {
  258. let $element = context.querySelector(selector)
  259. if ($element) {
  260. stop($element)
  261. }
  262. else if (stopIf?.() === true) {
  263. stop(null, 'stopIf condition met')
  264. }
  265. else {
  266. rafId = requestAnimationFrame(queryElement)
  267. }
  268. }
  269.  
  270. queryElement()
  271. })
  272. }
  273.  
  274. /** @param {import("./types").Channel} channel */
  275. function isChannelHidden(channel) {
  276. return config.hiddenChannels.some((hiddenChannel) =>
  277. channel.url && hiddenChannel.url ? channel.url == hiddenChannel.url : hiddenChannel.name == channel.name
  278. )
  279. }
  280.  
  281. let logObserverDisconnects = true
  282.  
  283. /**
  284. * Convenience wrapper for the MutationObserver API:
  285. *
  286. * - Defaults to {childList: true}
  287. * - Observers have associated names
  288. * - Optional leading call for callback
  289. * - Observers are stored in a scope object
  290. * - Observers already in the given scope will be disconnected
  291. * - onDisconnect hook for post-disconnect logic
  292. *
  293. * @param {Node} $target
  294. * @param {MutationCallback} callback
  295. * @param {{
  296. * leading?: boolean
  297. * logElement?: boolean
  298. * name: string
  299. * observers: Map<string, import("./types").Disconnectable> | Map<string, import("./types").Disconnectable>[]
  300. * onDisconnect?: () => void
  301. * }} options
  302. * @param {MutationObserverInit} mutationObserverOptions
  303. * @return {import("./types").CustomMutationObserver}
  304. */
  305. function observeElement($target, callback, options, mutationObserverOptions = {childList: true}) {
  306. let {leading, logElement, name, observers, onDisconnect} = options
  307. let observerMaps = Array.isArray(observers) ? observers : [observers]
  308.  
  309. /** @type {import("./types").CustomMutationObserver} */
  310. let observer = Object.assign(new MutationObserver(callback), {name})
  311. let disconnect = observer.disconnect.bind(observer)
  312. let disconnected = false
  313. observer.disconnect = () => {
  314. if (disconnected) return
  315. disconnected = true
  316. disconnect()
  317. for (let map of observerMaps) map.delete(name)
  318. onDisconnect?.()
  319. if (logObserverDisconnects) {
  320. log(`disconnected ${name} observer`)
  321. }
  322. }
  323.  
  324. if (observerMaps[0].has(name)) {
  325. log(`disconnecting existing ${name} observer`)
  326. logObserverDisconnects = false
  327. observerMaps[0].get(name).disconnect()
  328. logObserverDisconnects = true
  329. }
  330.  
  331. for (let map of observerMaps) map.set(name, observer)
  332. if (logElement) {
  333. log(`observing ${name}`, $target)
  334. } else {
  335. log(`observing ${name}`)
  336. }
  337. observer.observe($target, mutationObserverOptions)
  338. if (leading) {
  339. callback([], observer)
  340. }
  341. return observer
  342. }
  343.  
  344. /**
  345. * Uses a MutationObserver to wait for a specific element. If found, the
  346. * observer will be disconnected. If the observer is disconnected first, the
  347. * resolved value will be null.
  348. *
  349. * @param {Node} $target
  350. * @param {(mutations: MutationRecord[]) => HTMLElement} getter
  351. * @param {{
  352. * logElement?: boolean
  353. * name: string
  354. * targetName: string
  355. * observers: Map<string, import("./types").Disconnectable>
  356. * }} options
  357. * @param {MutationObserverInit} [mutationObserverOptions]
  358. * @return {Promise<HTMLElement>}
  359. */
  360. function observeForElement($target, getter, options, mutationObserverOptions) {
  361. let {targetName, ...observeElementOptions} = options
  362. return new Promise((resolve) => {
  363. let found = false
  364. let startTime = Date.now()
  365. observeElement($target, (mutations, observer) => {
  366. let $result = getter(mutations)
  367. if ($result) {
  368. found = true
  369. if (Date.now() > startTime) {
  370. log(`${targetName} appeared after`, Date.now() - startTime, 'ms')
  371. }
  372. observer.disconnect()
  373. resolve($result)
  374. }
  375. }, {
  376. ...observeElementOptions,
  377. onDisconnect() {
  378. if (!found) resolve(null)
  379. },
  380. }, mutationObserverOptions)
  381. })
  382. }
  383.  
  384. /**
  385. * @param {number} n
  386. * @returns {string}
  387. */
  388. function s(n) {
  389. return n == 1 ? '' : 's'
  390. }
  391. //#endregion
  392.  
  393. //#region CSS
  394. const configureCss = (() => {
  395. /** @type {HTMLStyleElement} */
  396. let $style
  397.  
  398. return function configureCss() {
  399. if (!config.enabled) {
  400. log('removing stylesheet')
  401. $style?.remove()
  402. $style = null
  403. return
  404. }
  405.  
  406. let cssRules = []
  407. let hideCssSelectors = []
  408.  
  409. if (config.skipAds) {
  410. // Display a black overlay while ads are playing
  411. cssRules.push(`
  412. .ytp-ad-player-overlay, .ytp-ad-player-overlay-layout, .ytp-ad-action-interstitial {
  413. background: black;
  414. z-index: 10;
  415. }
  416. `)
  417. // Hide elements while an ad is showing
  418. hideCssSelectors.push(
  419. // Thumbnail for cued ad when autoplay is disabled
  420. '#movie_player.ad-showing .ytp-cued-thumbnail-overlay-image',
  421. // Ad video
  422. '#movie_player.ad-showing video',
  423. // Ad title
  424. '#movie_player.ad-showing .ytp-chrome-top',
  425. // Ad overlay content
  426. '#movie_player.ad-showing .ytp-ad-player-overlay > div',
  427. '#movie_player.ad-showing .ytp-ad-player-overlay-layout > div',
  428. '#movie_player.ad-showing .ytp-ad-action-interstitial > div',
  429. // Yellow ad progress bar
  430. '#movie_player.ad-showing .ytp-play-progress',
  431. // Ad time display
  432. '#movie_player.ad-showing .ytp-time-display',
  433. )
  434. }
  435.  
  436. if (config.disableAutoplay) {
  437. if (desktop) {
  438. hideCssSelectors.push('button[data-tooltip-target-id="ytp-autonav-toggle-button"]')
  439. }
  440. if (mobile) {
  441. hideCssSelectors.push('button.ytm-autonav-toggle-button-container')
  442. }
  443. }
  444.  
  445. if (config.disableHomeFeed && loggedIn) {
  446. if (desktop) {
  447. hideCssSelectors.push(
  448. // Prevent flash of content while redirecting
  449. 'ytd-browse[page-subtype="home"]',
  450. // Hide Home links
  451. 'ytd-guide-entry-renderer:has(> a[href="/"])',
  452. 'ytd-mini-guide-entry-renderer:has(> a[href="/"])',
  453. )
  454. }
  455. if (mobile) {
  456. hideCssSelectors.push(
  457. // Prevent flash of content while redirecting
  458. '.tab-content[tab-identifier="FEwhat_to_watch"]',
  459. // Bottom nav item
  460. 'ytm-pivot-bar-item-renderer:has(> div.pivot-w2w)',
  461. )
  462. }
  463. }
  464.  
  465. if (config.hideAI) {
  466. if (desktop) {
  467. const geminiSvgPath = 'M480-80q0-83-31.5-156T363-363q-54-54-127-85.5T80-480q83 0 156-31.5T363-597q54-54 85.5-127T480-880q0 83 31.5 156T597-597q54 54 127 85.5T880-480q-83 0-156 31.5T597-363q-54 54-85.5 127T480-80Z'
  468. hideCssSelectors.push(`#expandable-metadata:has(path[d="${geminiSvgPath}"])`)
  469. }
  470. if (mobile) {
  471. const geminiSvgPath = 'M6 0c0 3.314-2.69 6-6 6 3.31 0 6 2.686 6 6 0-3.314 2.69-6 6-6-3.31 0-6-2.686-6-6Z'
  472. hideCssSelectors.push(`ytm-expandable-metadata-renderer:has(path[d="${geminiSvgPath}"])`)
  473. }
  474. }
  475.  
  476. if (config.hideHomeCategories) {
  477. if (desktop) {
  478. hideCssSelectors.push('ytd-browse[page-subtype="home"] #header')
  479. }
  480. if (mobile) {
  481. hideCssSelectors.push('.tab-content[tab-identifier="FEwhat_to_watch"] .rich-grid-sticky-header')
  482. }
  483. }
  484.  
  485. // We only hide channels in Home, Search and Related videos
  486. if (config.hideChannels) {
  487. if (config.hiddenChannels.length > 0) {
  488. if (debugManualHiding) {
  489. cssRules.push(`.${Classes.HIDE_CHANNEL} { outline: 2px solid red !important; }`)
  490. } else {
  491. hideCssSelectors.push(`.${Classes.HIDE_CHANNEL}`)
  492. }
  493. }
  494. if (desktop) {
  495. // Custom elements can't be cloned so we need to style our own menu items
  496. cssRules.push(`
  497. .cpfyt-menu-item {
  498. align-items: center;
  499. cursor: pointer;
  500. display: flex !important;
  501. min-height: 36px;
  502. padding: 0 12px 0 16px;
  503. }
  504. .cpfyt-menu-item:focus {
  505. position: relative;
  506. background-color: var(--paper-item-focused-background-color);
  507. outline: 0;
  508. }
  509. .cpfyt-menu-item:focus::before {
  510. position: absolute;
  511. top: 0;
  512. right: 0;
  513. bottom: 0;
  514. left: 0;
  515. pointer-events: none;
  516. background: var(--paper-item-focused-before-background, currentColor);
  517. border-radius: var(--paper-item-focused-before-border-radius, 0);
  518. content: var(--paper-item-focused-before-content, "");
  519. opacity: var(--paper-item-focused-before-opacity, var(--dark-divider-opacity, 0.12));
  520. }
  521. .cpfyt-menu-item:hover {
  522. background-color: var(--yt-spec-10-percent-layer);
  523. }
  524. .cpfyt-menu-icon {
  525. color: var(--yt-spec-text-primary);
  526. fill: currentColor;
  527. height: 24px;
  528. margin-right: 16px;
  529. width: 24px;
  530. }
  531. .cpfyt-menu-text {
  532. color: var(--yt-spec-text-primary);
  533. flex-basis: 0.000000001px;
  534. flex: 1;
  535. font-family: "Roboto","Arial",sans-serif;
  536. font-size: 1.4rem;
  537. font-weight: 400;
  538. line-height: 2rem;
  539. margin-right: 24px;
  540. white-space: nowrap;
  541. }
  542. `)
  543. }
  544. } else {
  545. // Hide menu item if config is changed after it's added
  546. hideCssSelectors.push('#cpfyt-hide-channel-menu-item')
  547. }
  548.  
  549. if (config.hideComments) {
  550. if (desktop) {
  551. hideCssSelectors.push('#comments')
  552. }
  553. if (mobile) {
  554. hideCssSelectors.push('ytm-item-section-renderer[section-identifier="comments-entry-point"]')
  555. }
  556. }
  557.  
  558. if (config.hideHiddenVideos) {
  559. // The mobile version doesn't have any HTML hooks for appearance mode, so
  560. // we'll just use the current backgroundColor.
  561. let bgColor = getComputedStyle(document.documentElement).backgroundColor
  562. cssRules.push(`
  563. .cpfyt-pie {
  564. --cpfyt-pie-background-color: ${bgColor};
  565. --cpfyt-pie-color: ${bgColor == 'rgb(255, 255, 255)' ? '#065fd4' : '#3ea6ff'};
  566. --cpfyt-pie-delay: 0ms;
  567. --cpfyt-pie-direction: normal;
  568. --cpfyt-pie-duration: ${undoHideDelayMs}ms;
  569. width: 1em;
  570. height: 1em;
  571. font-size: 200%;
  572. position: relative;
  573. border-radius: 50%;
  574. margin: 0.5em;
  575. display: inline-block;
  576. }
  577. .cpfyt-pie::before,
  578. .cpfyt-pie::after {
  579. content: "";
  580. width: 50%;
  581. height: 100%;
  582. position: absolute;
  583. left: 0;
  584. border-radius: 0.5em 0 0 0.5em;
  585. transform-origin: center right;
  586. animation-delay: var(--cpfyt-pie-delay);
  587. animation-direction: var(--cpfyt-pie-direction);
  588. animation-duration: var(--cpfyt-pie-duration);
  589. }
  590. .cpfyt-pie::before {
  591. z-index: 1;
  592. background-color: var(--cpfyt-pie-background-color);
  593. animation-name: cpfyt-mask;
  594. animation-timing-function: steps(1);
  595. }
  596. .cpfyt-pie::after {
  597. background-color: var(--cpfyt-pie-color);
  598. animation-name: cpfyt-rotate;
  599. animation-timing-function: linear;
  600. }
  601. @keyframes cpfyt-rotate {
  602. to { transform: rotate(1turn); }
  603. }
  604. @keyframes cpfyt-mask {
  605. 50%, 100% {
  606. background-color: var(--cpfyt-pie-color);
  607. transform: rotate(0.5turn);
  608. }
  609. }
  610. `)
  611. if (debugManualHiding) {
  612. cssRules.push(`.${Classes.HIDE_HIDDEN} { outline: 2px solid magenta !important; }`)
  613. } else {
  614. hideCssSelectors.push(`.${Classes.HIDE_HIDDEN}`)
  615. }
  616. }
  617.  
  618. if (config.hideInfoPanels) {
  619. if (desktop) {
  620. hideCssSelectors.push(
  621. // In Search
  622. 'ytd-clarification-renderer',
  623. 'ytd-info-panel-container-renderer',
  624. // Below video
  625. '#middle-row.ytd-watch-metadata:has(> ytd-info-panel-content-renderer:only-child)',
  626. 'ytd-info-panel-content-renderer',
  627. '#clarify-box',
  628. )
  629. }
  630. if (mobile) {
  631. hideCssSelectors.push(
  632. // In Search and below video
  633. 'ytm-clarification-renderer',
  634. 'ytm-info-panel-container-renderer',
  635. )
  636. }
  637. }
  638.  
  639. if (config.hideLive) {
  640. if (desktop) {
  641. hideCssSelectors.push(
  642. // Grid item (Home, Subscriptions)
  643. 'ytd-browse:not([page-subtype="channels"]) ytd-rich-item-renderer:has(ytd-thumbnail[is-live-video])',
  644. // List item (Search)
  645. 'ytd-video-renderer:has(ytd-thumbnail[is-live-video])',
  646. // Related video
  647. 'ytd-compact-video-renderer:has(> .ytd-compact-video-renderer > ytd-thumbnail[is-live-video])',
  648. )
  649. }
  650. if (mobile) {
  651. hideCssSelectors.push(
  652. // Home
  653. 'ytm-rich-item-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
  654. // Subscriptions
  655. '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
  656. // Search
  657. 'ytm-search ytm-video-with-context-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
  658. // Large item in Related videos
  659. 'ytm-item-section-renderer[section-identifier="related-items"] > lazy-list > ytm-compact-autoplay-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
  660. // Related videos
  661. 'ytm-item-section-renderer[section-identifier="related-items"] > lazy-list > ytm-video-with-context-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
  662. )
  663. }
  664. }
  665.  
  666. if (config.hideMetadata) {
  667. if (desktop) {
  668. hideCssSelectors.push(
  669. // Channel name / Videos / About (but not Transcript or their mutual container)
  670. '#structured-description .ytd-structured-description-content-renderer:not(#items, ytd-video-description-transcript-section-renderer)',
  671. // Game name and Gaming link
  672. '#above-the-fold + ytd-metadata-row-container-renderer',
  673. )
  674. }
  675. if (mobile) {
  676. hideCssSelectors.push(
  677. // Game name and Gaming link
  678. 'ytm-structured-description-content-renderer yt-video-attributes-section-view-model',
  679. 'ytm-video-description-gaming-section-renderer',
  680. // Channel name / Videos / About
  681. 'ytm-structured-description-content-renderer ytm-video-description-infocards-section-renderer',
  682. // Music
  683. 'ytm-structured-description-content-renderer ytm-horizontal-card-list-renderer',
  684. )
  685. }
  686. }
  687.  
  688. if (config.hideMixes) {
  689. if (desktop) {
  690. hideCssSelectors.push(
  691. // Chip in Home
  692. `yt-chip-cloud-chip-renderer:has(> yt-formatted-string[title="${getString('MIXES')}"])`,
  693. // Grid item
  694. 'ytd-rich-item-renderer:has(a[href$="start_radio=1"])',
  695. // List item
  696. 'ytd-radio-renderer',
  697. // Related video
  698. 'ytd-compact-radio-renderer',
  699. // Search result and related video
  700. 'yt-lockup-view-model:has(a[href*="start_radio=1"])',
  701. )
  702. }
  703. if (mobile) {
  704. hideCssSelectors.push(
  705. // Chip in Home
  706. `ytm-chip-cloud-chip-renderer:has(> .chip-container[aria-label="${getString('MIXES')}"])`,
  707. // Home
  708. 'ytm-rich-item-renderer:has(> ytm-radio-renderer)',
  709. // Search result
  710. 'ytm-compact-radio-renderer',
  711. )
  712. }
  713. }
  714.  
  715. if (config.hideMoviesAndTV) {
  716. if (desktop) {
  717. hideCssSelectors.push(
  718. // In Home
  719. 'ytd-rich-item-renderer.ytd-rich-grid-renderer:has(a[href$="pp=sAQB"])',
  720. // In Search
  721. 'ytd-movie-renderer',
  722. // In Related videos
  723. 'ytd-compact-movie-renderer',
  724. 'ytd-compact-video-renderer:has(a[href$="pp=sAQB"])',
  725. )
  726. }
  727. if (mobile) {
  728. hideCssSelectors.push(
  729. // In Home
  730. '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-item-renderer:has(a[href$="pp=sAQB"])',
  731. // In Search
  732. 'ytm-search ytm-video-with-context-renderer:has(ytm-badge[data-type="BADGE_STYLE_TYPE_YPC"])',
  733. // In Related videos
  734. 'ytm-item-section-renderer[data-content-type="related"] ytm-video-with-context-renderer:has(a[href$="pp=sAQB"])'
  735. )
  736. }
  737. }
  738.  
  739. if (config.hideNextButton) {
  740. if (desktop) {
  741. // Hide the Next by default so it doesn't flash in and out of visibility
  742. // Show Next is Previous is enabled (e.g. when viewing a playlist video)
  743. cssRules.push(`
  744. .ytp-chrome-controls .ytp-next-button {
  745. display: none !important;
  746. }
  747. .ytp-chrome-controls .ytp-prev-button[aria-disabled="false"] ~ .ytp-next-button {
  748. display: revert !important;
  749. }
  750. `)
  751. }
  752. if (mobile) {
  753. hideCssSelectors.push(
  754. // Hide the Previous button when it's disabled, as it otherwise takes you to the previously-watched video
  755. `.player-controls-middle-core-buttons > button[aria-label="${getString('PREVIOUS_VIDEO')}"][aria-disabled="true"]`,
  756. // Always hide the Next button as it takes you to a random video, even if you just used Previous
  757. `.player-controls-middle-core-buttons > button[aria-label="${getString('NEXT_VIDEO')}"]`,
  758. )
  759. }
  760. }
  761.  
  762. if (config.hideRelated) {
  763. if (desktop) {
  764. hideCssSelectors.push('#related')
  765. }
  766. if (mobile) {
  767. hideCssSelectors.push('ytm-item-section-renderer[section-identifier="related-items"]')
  768. }
  769. }
  770.  
  771. if (config.hideShareThanksClip) {
  772. if (desktop) {
  773. hideCssSelectors.push(
  774. // Buttons
  775. `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('SHARE')}"])`,
  776. `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('THANKS')}"])`,
  777. `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('CLIP')}"])`,
  778. // Menu items
  779. `.${Classes.HIDE_SHARE_THANKS_CLIP}`,
  780. )
  781. }
  782. if (mobile) {
  783. hideCssSelectors.push(
  784. `ytm-slim-video-action-bar-renderer button-view-model:has(button[aria-label="${getString('SHARE')}"])`,
  785. )
  786. }
  787. }
  788.  
  789. if (config.hideShorts) {
  790. if (desktop) {
  791. hideCssSelectors.push(
  792. // Side nav item
  793. `ytd-guide-entry-renderer:has(> a[title="${getString('SHORTS')}"])`,
  794. // Mini side nav item
  795. `ytd-mini-guide-entry-renderer[aria-label="${getString('SHORTS')}"]`,
  796. // Grid shelf
  797. 'ytd-rich-section-renderer:has(> #content > ytd-rich-shelf-renderer[is-shorts])',
  798. // Group of 3 Shorts in Home grid
  799. 'ytd-browse[page-subtype="home"] ytd-rich-grid-group',
  800. // Chips
  801. `yt-chip-cloud-chip-renderer:has(> yt-formatted-string[title="${getString('SHORTS')}"])`,
  802. // List shelf (except History, so watched Shorts can be removed)
  803. 'ytd-browse:not([page-subtype="history"]) ytd-reel-shelf-renderer',
  804. 'ytd-search ytd-reel-shelf-renderer',
  805. // List item (except History, so watched Shorts can be removed)
  806. 'ytd-browse:not([page-subtype="history"]) ytd-video-renderer:has(a[href^="/shorts"])',
  807. 'ytd-search ytd-video-renderer:has(a[href^="/shorts"])',
  808. // Under video
  809. '#structured-description ytd-reel-shelf-renderer',
  810. // In related
  811. '#related ytd-reel-shelf-renderer',
  812. )
  813. }
  814. if (mobile) {
  815. hideCssSelectors.push(
  816. // Bottom nav item
  817. 'ytm-pivot-bar-item-renderer:has(> div.pivot-shorts)',
  818. // Home shelf
  819. 'ytm-rich-section-renderer:has(ytm-reel-shelf-renderer)',
  820. 'ytm-rich-section-renderer:has(ytm-shorts-lockup-view-model)',
  821. // Subscriptions shelf
  822. '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer)',
  823. // Search shelf
  824. 'ytm-search lazy-list > ytm-reel-shelf-renderer',
  825. // Search
  826. 'ytm-search ytm-video-with-context-renderer:has(a[href^="/shorts"])',
  827. // Under video
  828. 'ytm-structured-description-content-renderer ytm-reel-shelf-renderer',
  829. // In related
  830. 'ytm-item-section-renderer[data-content-type="related"] ytm-video-with-context-renderer:has(a[href^="/shorts"])',
  831. )
  832. }
  833. }
  834.  
  835. if (config.hideSponsored) {
  836. if (desktop) {
  837. hideCssSelectors.push(
  838. // Big ads and promos on Home screen
  839. '#masthead-ad',
  840. '#big-yoodle ytd-statement-banner-renderer',
  841. 'ytd-rich-section-renderer:has(> #content > ytd-statement-banner-renderer)',
  842. 'ytd-rich-section-renderer:has(> #content > ytd-rich-shelf-renderer[has-paygated-featured-badge])',
  843. 'ytd-rich-section-renderer:has(> #content > ytd-brand-video-shelf-renderer)',
  844. 'ytd-rich-section-renderer:has(> #content > ytd-brand-video-singleton-renderer)',
  845. 'ytd-rich-section-renderer:has(> #content > ytd-inline-survey-renderer)',
  846. // Bottom of screen promo
  847. 'tp-yt-paper-dialog:has(> #mealbar-promo-renderer)',
  848. // Video listings
  849. 'ytd-rich-item-renderer:has(> .ytd-rich-item-renderer > ytd-ad-slot-renderer)',
  850. // Search results
  851. 'ytd-search-pyv-renderer.ytd-item-section-renderer',
  852. 'ytd-ad-slot-renderer.ytd-item-section-renderer',
  853. // When an ad is playing
  854. 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"]',
  855. // Suggestd action buttons in player overlay
  856. '#movie_player .ytp-suggested-action',
  857. // Panels linked to those buttons
  858. '#below #panels',
  859. // After an ad
  860. '.ytp-ad-action-interstitial',
  861. // Paid content overlay
  862. '.ytp-paid-content-overlay',
  863. // Above Related videos
  864. '#player-ads',
  865. // In Related videos
  866. '#items > ytd-ad-slot-renderer',
  867. )
  868. }
  869. if (mobile) {
  870. hideCssSelectors.push(
  871. // Big promo on Home screen
  872. 'ytm-statement-banner-renderer',
  873. // Bottom of screen promo
  874. '.mealbar-promo-renderer',
  875. // Search results
  876. 'ytm-search ytm-item-section-renderer:has(> lazy-list > ad-slot-renderer)',
  877. // Paid content overlay
  878. 'ytm-paid-content-overlay-renderer',
  879. // Directly under video
  880. 'ytm-companion-slot:has(> ytm-companion-ad-renderer)',
  881. // Directly under comments entry point (narrow)
  882. 'ytm-item-section-renderer[section-identifier="comments-entry-point"] + ytm-item-section-renderer:has(> lazy-list > ad-slot-renderer)',
  883. // In Related videos (narrow)
  884. 'ytm-watch ytm-item-section-renderer[data-content-type="result"]:has(> lazy-list > ad-slot-renderer)',
  885. // In Related videos (wide)
  886. 'ytm-item-section-renderer[section-identifier="related-items"] > lazy-list > ad-slot-renderer',
  887. )
  888. }
  889. }
  890.  
  891. if (config.hideStreamed) {
  892. if (debugManualHiding) {
  893. cssRules.push(`.${Classes.HIDE_STREAMED} { outline: 2px solid blue; }`)
  894. } else {
  895. hideCssSelectors.push(`.${Classes.HIDE_STREAMED}`)
  896. }
  897. }
  898.  
  899. if (config.hideSuggestedSections) {
  900. if (desktop) {
  901. hideCssSelectors.push(
  902. // Shelves in Home
  903. 'ytd-browse[page-subtype="home"] ytd-rich-section-renderer:not(:has(> #content > ytd-rich-shelf-renderer[is-shorts]))',
  904. // Looking for something different? tile in Home
  905. 'ytd-browse[page-subtype="home"] ytd-rich-item-renderer:has(> #content > ytd-feed-nudge-renderer)',
  906. // Suggested content shelves in Search
  907. `ytd-search #contents.ytd-item-section-renderer > ytd-shelf-renderer`,
  908. // People also search for in Search
  909. 'ytd-search #contents.ytd-item-section-renderer > ytd-horizontal-card-list-renderer',
  910. // Recommended videos in a Playlist
  911. 'ytd-browse[page-subtype="playlist"] ytd-item-section-renderer[is-playlist-video-container]',
  912. // Recommended playlists in a Playlist
  913. 'ytd-browse[page-subtype="playlist"] ytd-item-section-renderer[is-playlist-video-container] + ytd-item-section-renderer',
  914. )
  915. }
  916. if (mobile) {
  917. if (loggedIn) {
  918. hideCssSelectors.push(
  919. // Shelves in Home
  920. '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-section-renderer',
  921. )
  922. } else {
  923. // Logged-out users can get "Try searching to get started" Home page
  924. // sections we don't want to hide.
  925. hideCssSelectors.push(
  926. // Shelves in Home
  927. '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-section-renderer:not(:has(ytm-search-bar-entry-point-view-model, ytm-feed-nudge-renderer))',
  928. )
  929. }
  930. }
  931. }
  932.  
  933. if (config.hideUpcoming) {
  934. if (desktop) {
  935. hideCssSelectors.push(
  936. // Grid item
  937. 'ytd-browse:not([page-subtype="channels"]) ytd-rich-item-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="UPCOMING"])',
  938. // List item
  939. 'ytd-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="UPCOMING"])',
  940. )
  941. }
  942. if (mobile) {
  943. hideCssSelectors.push(
  944. // Subscriptions
  945. '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="UPCOMING"])'
  946. )
  947. }
  948. }
  949.  
  950. if (config.hideVoiceSearch) {
  951. if (desktop) {
  952. hideCssSelectors.push('#voice-search-button')
  953. }
  954. if (mobile) {
  955. hideCssSelectors.push(
  956. // Outside of Search
  957. '.ytSearchboxComponentVoiceSearchWrapper',
  958. // In Search
  959. '.mobile-topbar-header-voice-search-button',
  960. // Logged out home page
  961. '.search-bar-entry-point-voice-search-button',
  962. )
  963. }
  964. }
  965.  
  966. if (config.hideWatched) {
  967. if (debugManualHiding) {
  968. cssRules.push(`.${Classes.HIDE_WATCHED} { outline: 2px solid green; }`)
  969. } else {
  970. hideCssSelectors.push(`.${Classes.HIDE_WATCHED}`)
  971. }
  972. }
  973.  
  974. //#region Desktop-only
  975. if (desktop) {
  976. // Fix spaces & gaps caused by left gutter margin on first column items
  977. cssRules.push(`
  978. /* Remove left gutter margin from first column items */
  979. ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) ytd-rich-item-renderer[rendered-from-rich-grid][is-in-first-column] {
  980. margin-left: calc(var(--ytd-rich-grid-item-margin, 16px) / 2) !important;
  981. }
  982. /* Apply the left gutter as padding in the grid contents instead */
  983. ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) #contents.ytd-rich-grid-renderer {
  984. padding-left: calc(var(--ytd-rich-grid-gutter-margin, 16px) * 2) !important;
  985. }
  986. /* Adjust non-grid items so they don't double the gutter */
  987. ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) #contents.ytd-rich-grid-renderer > :not(ytd-rich-item-renderer) {
  988. margin-left: calc(var(--ytd-rich-grid-gutter-margin, 16px) * -1) !important;
  989. }
  990. `)
  991. if (config.fullSizeTheaterMode) {
  992. // 56px is the height of #container.ytd-masthead
  993. cssRules.push(`
  994. ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container {
  995. max-height: calc(100vh - 56px);
  996. }
  997. `)
  998. }
  999. if (config.hideChat) {
  1000. hideCssSelectors.push(
  1001. // Live chat / Chat replay
  1002. '#chat-container',
  1003. // "Live chat replay" panel in video metadata
  1004. '#teaser-carousel.ytd-watch-metadata',
  1005. // Chat panel in theater mode
  1006. '#full-bleed-container.ytd-watch-flexy #panels-full-bleed-container.ytd-watch-flexy',
  1007. )
  1008. }
  1009. if (config.hideEndCards) {
  1010. hideCssSelectors.push('#movie_player .ytp-ce-element')
  1011. }
  1012. if (config.hideEndVideos) {
  1013. hideCssSelectors.push(
  1014. '#movie_player .ytp-endscreen-content',
  1015. '#movie_player .ytp-endscreen-previous',
  1016. '#movie_player .ytp-endscreen-next',
  1017. )
  1018. }
  1019. if (config.hideMerchEtc) {
  1020. hideCssSelectors.push(
  1021. // Tickets
  1022. '#ticket-shelf',
  1023. // Merch
  1024. 'ytd-merch-shelf-renderer',
  1025. // Offers
  1026. '#offer-module',
  1027. )
  1028. }
  1029. if (config.hideMiniplayerButton) {
  1030. hideCssSelectors.push('#movie_player .ytp-miniplayer-button')
  1031. }
  1032. if (config.hideSubscriptionsLatestBar) {
  1033. hideCssSelectors.push(
  1034. 'ytd-browse[page-subtype="subscriptions"] ytd-rich-grid-renderer > #contents > ytd-rich-section-renderer:first-child'
  1035. )
  1036. }
  1037. if (config.minimumGridItemsPerRow != 'auto') {
  1038. let gridItemsPerRow = Number(config.minimumGridItemsPerRow)
  1039. let exclude = []
  1040. for (let i = 6; i > gridItemsPerRow; i--) {
  1041. exclude.push(`[elements-per-row="${i}"]`)
  1042. }
  1043. cssRules.push(`
  1044. ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) ytd-rich-grid-renderer${exclude.length > 0 ? `:not(${exclude.join(', ')})` : ''} {
  1045. --ytd-rich-grid-items-per-row: ${gridItemsPerRow} !important;
  1046. }
  1047. `)
  1048. }
  1049. if (config.removePink) {
  1050. cssRules.push(`
  1051. .ytp-play-progress,
  1052. #progress.ytd-thumbnail-overlay-resume-playback-renderer,
  1053. .ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment,
  1054. .ytChapteredProgressBarChapteredPlayerBarChapterSeen,
  1055. .ytChapteredProgressBarChapteredPlayerBarFill,
  1056. .ytProgressBarLineProgressBarPlayed,
  1057. #progress.yt-page-navigation-progress,
  1058. .progress-bar-played.ytd-progress-bar-line {
  1059. background: #f03 !important;
  1060. }
  1061. `)
  1062. }
  1063. if (config.searchThumbnailSize != 'large') {
  1064. cssRules.push(`
  1065. ytd-search ytd-video-renderer ytd-thumbnail.ytd-video-renderer,
  1066. ytd-search yt-lockup-view-model .yt-lockup-view-model-wiz__content-image {
  1067. max-width: ${{
  1068. medium: 420,
  1069. small: 360,
  1070. }[config.searchThumbnailSize]}px !important;
  1071. }
  1072. `)
  1073. }
  1074. if (config.tidyGuideSidebar) {
  1075. hideCssSelectors.push(
  1076. // Logged in
  1077. // Subscriptions (2nd of 5)
  1078. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(2):nth-last-child(4)',
  1079. // Explore (3rd of 5)
  1080. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(3):nth-last-child(3)',
  1081. // More from YouTube (4th of 5)
  1082. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(4):nth-last-child(2)',
  1083. // Logged out
  1084. /*
  1085. // Subscriptions - prompts you to log in
  1086. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(1):nth-last-child(7) > #items > ytd-guide-entry-renderer:has(> a[href="/feed/subscriptions"])',
  1087. // You (2nd of 7) - prompts you to log in
  1088. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(2):nth-last-child(6)',
  1089. // Sign in prompt - already have one in the top corner
  1090. '#sections.ytd-guide-renderer > ytd-guide-signin-promo-renderer',
  1091. */
  1092. // Explore (4th of 7)
  1093. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(4):nth-last-child(4)',
  1094. // Browse Channels (5th of 7)
  1095. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(5):nth-last-child(3)',
  1096. // More from YouTube (6th of 7)
  1097. '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(6):nth-last-child(2)',
  1098. // Footer
  1099. '#footer.ytd-guide-renderer',
  1100. )
  1101. }
  1102. }
  1103. //#endregion
  1104.  
  1105. //#region Mobile-only
  1106. if (mobile) {
  1107. if (config.hideExploreButton) {
  1108. // Explore button on Home screen
  1109. hideCssSelectors.push('ytm-chip-cloud-chip-renderer[chip-style="STYLE_EXPLORE_LAUNCHER_CHIP"]')
  1110. }
  1111. if (config.hideOpenApp) {
  1112. hideCssSelectors.push(
  1113. // The user menu is replaced with "Open App" on videos when logged out
  1114. 'html.watch-scroll .mobile-topbar-header-sign-in-button',
  1115. // The overflow menu has an Open App menu item we'll add this class to
  1116. `ytm-menu-item.${Classes.HIDE_OPEN_APP}`,
  1117. // The last item in the full screen menu is Open App
  1118. '#menu .multi-page-menu-system-link-list:has(+ ytm-privacy-tos-footer-renderer)',
  1119. )
  1120. }
  1121. if (config.hideSubscriptionsChannelList) {
  1122. // Channel list at top of Subscriptions
  1123. hideCssSelectors.push('.tab-content[tab-identifier="FEsubscriptions"] ytm-channel-list-sub-menu-renderer')
  1124. }
  1125. if (config.mobileGridView) {
  1126. // Based on the Home grid layout
  1127. // Subscriptions
  1128. cssRules.push(`
  1129. @media (min-width: 550px) and (orientation: portrait) {
  1130. .tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer {
  1131. margin: 0 16px;
  1132. }
  1133. .tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer > lazy-list {
  1134. margin: 16px -8px 0 -8px;
  1135. }
  1136. .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
  1137. width: calc(50% - 16px);
  1138. display: inline-block;
  1139. vertical-align: top;
  1140. border-bottom: none !important;
  1141. margin-bottom: 16px;
  1142. margin-left: 8px;
  1143. margin-right: 8px;
  1144. }
  1145. .tab-content[tab-identifier="FEsubscriptions"] lazy-list ytm-media-item {
  1146. margin-top: 0 !important;
  1147. padding: 0 !important;
  1148. }
  1149. /* Fix shorts if they're not being hidden */
  1150. .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer) {
  1151. width: calc(100% - 16px);
  1152. display: block;
  1153. }
  1154. .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer) > lazy-list {
  1155. margin-left: -16px;
  1156. margin-right: -16px;
  1157. }
  1158. /* Fix the channel list bar if it's not being hidden */
  1159. .tab-content[tab-identifier="FEsubscriptions"] ytm-channel-list-sub-menu-renderer {
  1160. margin-left: -16px;
  1161. margin-right: -16px;
  1162. }
  1163. }
  1164. @media (min-width: 874px) and (orientation: portrait) {
  1165. .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
  1166. width: calc(33.3% - 16px);
  1167. }
  1168. }
  1169. /* The page will probably switch to the list view before it ever hits this */
  1170. @media (min-width: 1160px) and (orientation: portrait) {
  1171. .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
  1172. width: calc(25% - 16px);
  1173. }
  1174. }
  1175. `)
  1176. // Search
  1177. cssRules.push(`
  1178. @media (min-width: 550px) and (orientation: portrait) {
  1179. ytm-search ytm-item-section-renderer {
  1180. margin: 0 16px;
  1181. }
  1182. ytm-search ytm-item-section-renderer > lazy-list {
  1183. margin: 16px -8px 0 -8px;
  1184. }
  1185. ytm-search .adaptive-feed-item {
  1186. width: calc(50% - 16px);
  1187. display: inline-block;
  1188. vertical-align: top;
  1189. border-bottom: none !important;
  1190. margin-bottom: 16px;
  1191. margin-left: 8px;
  1192. margin-right: 8px;
  1193. }
  1194. ytm-search lazy-list ytm-media-item {
  1195. margin-top: 0 !important;
  1196. padding: 0 !important;
  1197. }
  1198. }
  1199. @media (min-width: 874px) and (orientation: portrait) {
  1200. ytm-search .adaptive-feed-item {
  1201. width: calc(33.3% - 16px);
  1202. }
  1203. }
  1204. @media (min-width: 1160px) and (orientation: portrait) {
  1205. ytm-search .adaptive-feed-item {
  1206. width: calc(25% - 16px);
  1207. }
  1208. }
  1209. `)
  1210. }
  1211. if (config.removePink) {
  1212. cssRules.push(`
  1213. .ytp-play-progress,
  1214. .thumbnail-overlay-resume-playback-progress,
  1215. .ytChapteredProgressBarChapteredPlayerBarChapterSeen,
  1216. .ytChapteredProgressBarChapteredPlayerBarFill,
  1217. .ytProgressBarLineProgressBarPlayed,
  1218. .ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment {
  1219. background: #f03 !important;
  1220. }
  1221. `)
  1222. }
  1223. }
  1224. //#endregion
  1225.  
  1226. if (hideCssSelectors.length > 0) {
  1227. cssRules.push(`
  1228. ${hideCssSelectors.join(',\n')} {
  1229. display: none !important;
  1230. }
  1231. `)
  1232. }
  1233.  
  1234. let css = cssRules.map(dedent).join('\n')
  1235. if ($style == null) {
  1236. $style = addStyle(css)
  1237. } else {
  1238. $style.textContent = css
  1239. }
  1240. }
  1241. })()
  1242. //#endregion
  1243.  
  1244. function isHomePage() {
  1245. return location.pathname == '/'
  1246. }
  1247.  
  1248. function isChannelPage() {
  1249. return URL_CHANNEL_RE.test(location.pathname)
  1250. }
  1251.  
  1252. function isSearchPage() {
  1253. return location.pathname == '/results'
  1254. }
  1255.  
  1256. function isSubscriptionsPage() {
  1257. return location.pathname == '/feed/subscriptions'
  1258. }
  1259.  
  1260. function isVideoPage() {
  1261. return location.pathname == '/watch'
  1262. }
  1263.  
  1264. //#region Tweak functions
  1265. async function alwaysUseTheaterMode() {
  1266. let $player = await getElement('#movie_player', {
  1267. name: 'player (alwaysUseTheaterMode)',
  1268. stopIf: currentUrlChanges(),
  1269. })
  1270. if (!$player) return
  1271. if (!$player.closest('#player-full-bleed-container')) {
  1272. let $sizeButton = /** @type {HTMLButtonElement} */ ($player.querySelector('button.ytp-size-button'))
  1273. if ($sizeButton) {
  1274. log('alwaysUseTheaterMode: clicking size button')
  1275. $sizeButton.click()
  1276. } else {
  1277. warn('alwaysUseTheaterMode: size button not found')
  1278. }
  1279. } else {
  1280. log('alwaysUseTheaterMode: already using theater mode')
  1281. }
  1282. }
  1283.  
  1284. async function disableAutoplay() {
  1285. if (desktop) {
  1286. let $autoplayButton = await getElement('button[data-tooltip-target-id="ytp-autonav-toggle-button"]', {
  1287. name: 'Autoplay button',
  1288. stopIf: currentUrlChanges(),
  1289. })
  1290. if (!$autoplayButton) return
  1291.  
  1292. // On desktop, initial Autoplay button HTML has style="display: none" and is
  1293. // always checked on. Once it's displayed, we can determine its real state
  1294. // and take action if needed.
  1295. observeElement($autoplayButton, (_, observer) => {
  1296. if ($autoplayButton.style.display == 'none') return
  1297. if ($autoplayButton.querySelector('.ytp-autonav-toggle-button[aria-checked="true"]')) {
  1298. log('turning Autoplay off')
  1299. $autoplayButton.click()
  1300. } else {
  1301. log('Autoplay is already off')
  1302. }
  1303. observer.disconnect()
  1304. }, {
  1305. leading: true,
  1306. name: 'Autoplay button style (for button being displayed)',
  1307. observers: pageObservers,
  1308. }, {
  1309. attributes: true,
  1310. attributeFilter: ['style'],
  1311. })
  1312. }
  1313.  
  1314. if (mobile) {
  1315. // Appearance of the Autoplay button may be delayed until interaction
  1316. let $customControl = await getElement('#player-control-container > ytm-custom-control', {
  1317. name: 'Autoplay <ytm-custom-control>',
  1318. stopIf: currentUrlChanges(),
  1319. })
  1320. if (!$customControl) return
  1321.  
  1322. observeElement($customControl, (_, observer) => {
  1323. if ($customControl.childElementCount == 0) return
  1324.  
  1325. let $autoplayButton = /** @type {HTMLElement} */ ($customControl.querySelector('button.ytm-autonav-toggle-button-container'))
  1326. if (!$autoplayButton) return
  1327.  
  1328. if ($autoplayButton.getAttribute('aria-pressed') == 'true') {
  1329. log('turning Autoplay off')
  1330. $autoplayButton.click()
  1331. } else {
  1332. log('Autoplay is already off')
  1333. }
  1334. observer.disconnect()
  1335. }, {
  1336. leading: true,
  1337. name: 'Autoplay <ytm-custom-control> (for Autoplay button being added)',
  1338. observers: pageObservers,
  1339. })
  1340. }
  1341. }
  1342.  
  1343. function downloadTranscript() {
  1344. // TODO Check if the transcript is still loading
  1345. let $segments = document.querySelector('.ytd-transcript-search-panel-renderer #segments-container')
  1346. let sections = []
  1347. let parts = []
  1348.  
  1349. for (let $el of $segments.children) {
  1350. if ($el.tagName == 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') {
  1351. if (parts.length > 0) {
  1352. sections.push(parts.join(' '))
  1353. parts = []
  1354. }
  1355. sections.push(/** @type {HTMLElement} */ ($el.querySelector('#title')).innerText.trim())
  1356. } else {
  1357. parts.push(/** @type {HTMLElement} */ ($el.querySelector('.segment-text')).innerText.trim())
  1358. }
  1359. }
  1360. if (parts.length > 0) {
  1361. sections.push(parts.join(' '))
  1362. }
  1363.  
  1364. let $link = document.createElement('a')
  1365. let url = URL.createObjectURL(new Blob([sections.join('\n\n')], {type: "text/plain"}))
  1366. let title = /** @type {HTMLElement} */ (document.querySelector('#above-the-fold #title'))?.innerText ?? 'transcript'
  1367. $link.setAttribute('href', url)
  1368. $link.setAttribute('download', `${title}.txt`)
  1369. $link.click()
  1370. URL.revokeObjectURL(url)
  1371. }
  1372.  
  1373. function handleCurrentUrl() {
  1374. log('handling', getCurrentUrl())
  1375. disconnectObservers(pageObservers, 'page')
  1376.  
  1377. if (isHomePage()) {
  1378. tweakHomePage()
  1379. }
  1380. else if (isSubscriptionsPage()) {
  1381. tweakSubscriptionsPage()
  1382. }
  1383. else if (isVideoPage()) {
  1384. tweakVideoPage()
  1385. }
  1386. else if (isSearchPage()) {
  1387. tweakSearchPage()
  1388. }
  1389. else if (isChannelPage()) {
  1390. tweakChannelPage()
  1391. }
  1392. else if (location.pathname.startsWith('/shorts/')) {
  1393. if (config.redirectShorts) {
  1394. redirectShort()
  1395. }
  1396. }
  1397. }
  1398.  
  1399. /** @param {HTMLElement} $menu */
  1400. function addDownloadTranscriptToDesktopMenu($menu) {
  1401. if (!isVideoPage()) return
  1402.  
  1403. let $transcript = $lastClickedElement.closest('[target-id="engagement-panel-searchable-transcript"]')
  1404. if (!$transcript) return
  1405.  
  1406. if ($menu.querySelector('.cpfyt-menu-item')) return
  1407.  
  1408. let $menuItems = $menu.querySelector('#items')
  1409. $menuItems.insertAdjacentHTML('beforeend', `
  1410. <div class="cpfyt-menu-item" tabindex="0" style="display: none">
  1411. <div class="cpfyt-menu-text">
  1412. ${getString('DOWNLOAD')}
  1413. </div>
  1414. </div>
  1415. `.trim())
  1416. let $item = $menuItems.lastElementChild
  1417. function download() {
  1418. downloadTranscript()
  1419. // Dismiss the menu
  1420. // @ts-ignore
  1421. document.querySelector('#content')?.click()
  1422. }
  1423. $item.addEventListener('click', download)
  1424. $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
  1425. if (e.key == ' ' || e.key == 'Enter') {
  1426. e.preventDefault()
  1427. download()
  1428. }
  1429. })
  1430. }
  1431.  
  1432. /** @param {HTMLElement} $menu */
  1433. function handleDesktopWatchChannelMenu($menu) {
  1434. if (!isVideoPage()) return
  1435.  
  1436. let $channelMenuRenderer = $lastClickedElement.closest('ytd-menu-renderer.ytd-watch-metadata')
  1437. if (!$channelMenuRenderer) return
  1438.  
  1439. if (config.hideShareThanksClip) {
  1440. let $menuItems = /** @type {NodeListOf<HTMLElement>} */ ($menu.querySelectorAll('ytd-menu-service-item-renderer'))
  1441. let testLabels = new Set([getString('SHARE'), getString('THANKS'), getString('CLIP')])
  1442. for (let $menuItem of $menuItems) {
  1443. if (testLabels.has($menuItem.querySelector('yt-formatted-string')?.textContent)) {
  1444. log('tagging Share/Thanks/Clip menu item')
  1445. $menuItem.classList.add(Classes.HIDE_SHARE_THANKS_CLIP)
  1446. }
  1447. }
  1448. }
  1449.  
  1450. if (config.hideChannels) {
  1451. let $channelLink = /** @type {HTMLAnchorElement} */ (document.querySelector('#channel-name a'))
  1452. if (!$channelLink) {
  1453. warn('channel link not found in video page')
  1454. return
  1455. }
  1456.  
  1457. let channel = {
  1458. name: $channelLink.textContent,
  1459. url: $channelLink.pathname,
  1460. }
  1461. lastClickedChannel = channel
  1462.  
  1463. let $item = $menu.querySelector('#cpfyt-hide-channel-menu-item')
  1464.  
  1465. function configureMenuItem(channel) {
  1466. let hidden = isChannelHidden(channel)
  1467. $item.querySelector('.cpfyt-menu-icon').innerHTML = hidden ? Svgs.RESTORE : Svgs.DELETE
  1468. $item.querySelector('.cpfyt-menu-text').textContent = getString(hidden ? 'UNHIDE_CHANNEL' : 'HIDE_CHANNEL')
  1469. }
  1470.  
  1471. // The same menu can be reused, so we reconfigure it if it exists. If the
  1472. // menu item is reused, we're just changing [lastClickedChannel], which is
  1473. // why [toggleHideChannel] uses it.
  1474. if (!$item) {
  1475. let hidden = isChannelHidden(channel)
  1476.  
  1477. function toggleHideChannel() {
  1478. let hidden = isChannelHidden(lastClickedChannel)
  1479. if (hidden) {
  1480. log('unhiding channel', lastClickedChannel)
  1481. config.hiddenChannels = config.hiddenChannels.filter((hiddenChannel) =>
  1482. hiddenChannel.url ? lastClickedChannel.url != hiddenChannel.url : hiddenChannel.name != lastClickedChannel.name
  1483. )
  1484. } else {
  1485. log('hiding channel', lastClickedChannel)
  1486. config.hiddenChannels.unshift(lastClickedChannel)
  1487. }
  1488. configureMenuItem(lastClickedChannel)
  1489. storeConfigChanges({hiddenChannels: config.hiddenChannels})
  1490. configureCss()
  1491. handleCurrentUrl()
  1492. // Dismiss the menu
  1493. let $popupContainer = /** @type {HTMLElement} */ ($menu.closest('ytd-popup-container'))
  1494. $popupContainer.click()
  1495. // XXX Menu isn't dismissing on iPad Safari
  1496. if ($menu.style.display != 'none') {
  1497. $menu.style.display = 'none'
  1498. $menu.setAttribute('aria-hidden', 'true')
  1499. }
  1500. }
  1501.  
  1502. let $menuItems = $menu.querySelector('#items')
  1503. $menuItems.insertAdjacentHTML('beforeend', `
  1504. <div class="cpfyt-menu-item" tabindex="0" id="cpfyt-hide-channel-menu-item" style="display: none">
  1505. <div class="cpfyt-menu-icon">
  1506. ${hidden ? Svgs.RESTORE : Svgs.DELETE}
  1507. </div>
  1508. <div class="cpfyt-menu-text">
  1509. ${getString(hidden ? 'UNHIDE_CHANNEL' : 'HIDE_CHANNEL')}
  1510. </div>
  1511. </div>
  1512. `.trim())
  1513. $item = $menuItems.lastElementChild
  1514. $item.addEventListener('click', toggleHideChannel)
  1515. $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
  1516. if (e.key == ' ' || e.key == 'Enter') {
  1517. e.preventDefault()
  1518. toggleHideChannel()
  1519. }
  1520. })
  1521. } else {
  1522. configureMenuItem(channel)
  1523. }
  1524. }
  1525. }
  1526.  
  1527. /** @param {HTMLElement} $menu */
  1528. function addHideChannelToDesktopVideoMenu($menu) {
  1529. let videoContainerElement
  1530. if (isSearchPage()) {
  1531. videoContainerElement = 'ytd-video-renderer'
  1532. }
  1533. else if (isVideoPage()) {
  1534. videoContainerElement = 'ytd-compact-video-renderer'
  1535. }
  1536. else if (isHomePage()) {
  1537. videoContainerElement = 'ytd-rich-item-renderer'
  1538. }
  1539.  
  1540. if (!videoContainerElement) return
  1541.  
  1542. let $video = /** @type {HTMLElement} */ ($lastClickedElement.closest(videoContainerElement))
  1543. if (!$video) return
  1544.  
  1545. log('found clicked video')
  1546. let channel = getChannelDetailsFromVideo($video)
  1547. if (!channel) return
  1548. lastClickedChannel = channel
  1549.  
  1550. if ($menu.querySelector('#cpfyt-hide-channel-menu-item')) return
  1551.  
  1552. let $menuItems = $menu.querySelector('#items')
  1553. $menuItems.insertAdjacentHTML('beforeend', `
  1554. <div class="cpfyt-menu-item" tabindex="0" id="cpfyt-hide-channel-menu-item" style="display: none">
  1555. <div class="cpfyt-menu-icon">
  1556. ${Svgs.DELETE}
  1557. </div>
  1558. <div class="cpfyt-menu-text">
  1559. ${getString('HIDE_CHANNEL')}
  1560. </div>
  1561. </div>
  1562. `.trim())
  1563. let $item = $menuItems.lastElementChild
  1564. function hideChannel() {
  1565. log('hiding channel', lastClickedChannel)
  1566. config.hiddenChannels.unshift(lastClickedChannel)
  1567. storeConfigChanges({hiddenChannels: config.hiddenChannels})
  1568. configureCss()
  1569. handleCurrentUrl()
  1570. // Dismiss the menu
  1571. let $popupContainer = /** @type {HTMLElement} */ ($menu.closest('ytd-popup-container'))
  1572. $popupContainer.click()
  1573. // XXX Menu isn't dismissing on iPad Safari
  1574. if ($menu.style.display != 'none') {
  1575. $menu.style.display = 'none'
  1576. $menu.setAttribute('aria-hidden', 'true')
  1577. }
  1578. }
  1579. $item.addEventListener('click', hideChannel)
  1580. $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
  1581. if (e.key == ' ' || e.key == 'Enter') {
  1582. e.preventDefault()
  1583. hideChannel()
  1584. }
  1585. })
  1586. }
  1587.  
  1588. /** @param {HTMLElement} $menu */
  1589. async function addHideChannelToMobileVideoMenu($menu) {
  1590. if (!(isHomePage() || isSearchPage() || isVideoPage())) return
  1591.  
  1592. /** @type {HTMLElement} */
  1593. let $video = $lastClickedElement.closest('ytm-video-with-context-renderer')
  1594. if (!$video) return
  1595.  
  1596. log('found clicked video')
  1597. let channel = getChannelDetailsFromVideo($video)
  1598. if (!channel) return
  1599. lastClickedChannel = channel
  1600.  
  1601. let $menuItems = $menu.querySelector($menu.id == 'menu' ? '.menu-content' : '.bottom-sheet-media-menu-item')
  1602. let hasIcon = Boolean($menuItems.querySelector('c3-icon'))
  1603. let hideChannelMenuItemHTML = `
  1604. <ytm-menu-item id="cpfyt-hide-channel-menu-item">
  1605. <button class="menu-item-button">
  1606. ${hasIcon ? `<c3-icon>
  1607. <div style="width: 100%; height: 100%; fill: currentcolor;">
  1608. ${Svgs.DELETE}
  1609. </div>
  1610. </c3-icon>` : ''}
  1611. <span class="yt-core-attributed-string" role="text">
  1612. ${getString('HIDE_CHANNEL')}
  1613. </span>
  1614. </button>
  1615. </ytm-menu-item>
  1616. `.trim()
  1617. let $cancelMenuItem = $menu.querySelector('ytm-menu-item:has(.menu-cancel-button')
  1618. if ($cancelMenuItem) {
  1619. $cancelMenuItem.insertAdjacentHTML('beforebegin', hideChannelMenuItemHTML)
  1620. } else {
  1621. $menuItems.insertAdjacentHTML('beforeend', hideChannelMenuItemHTML)
  1622. }
  1623. let $button = $menuItems.querySelector('#cpfyt-hide-channel-menu-item button')
  1624. $button.addEventListener('click', () => {
  1625. log('hiding channel', lastClickedChannel)
  1626. config.hiddenChannels.unshift(lastClickedChannel)
  1627. storeConfigChanges({hiddenChannels: config.hiddenChannels})
  1628. configureCss()
  1629. handleCurrentUrl()
  1630. })
  1631. }
  1632.  
  1633. /**
  1634. * @param {Element} $video video container element
  1635. * @returns {import("./types").Channel}
  1636. */
  1637. function getChannelDetailsFromVideo($video) {
  1638. if (desktop) {
  1639. if ($video.tagName == 'YTD-VIDEO-RENDERER') {
  1640. let $link = /** @type {HTMLAnchorElement} */ ($video.querySelector('#text.ytd-channel-name a'))
  1641. if ($link) {
  1642. return {
  1643. name: $link.textContent,
  1644. url: $link.pathname,
  1645. }
  1646. }
  1647. }
  1648. else if ($video.tagName == 'YTD-COMPACT-VIDEO-RENDERER') {
  1649. let $link = /** @type {HTMLElement} */ ($video.querySelector('#text.ytd-channel-name'))
  1650. if ($link) {
  1651. return {
  1652. name: $link.getAttribute('title')
  1653. }
  1654. }
  1655. }
  1656. else if ($video.tagName == 'YTD-RICH-ITEM-RENDERER') {
  1657. let $link = /** @type {HTMLAnchorElement} */ ($video.querySelector('#text.ytd-channel-name a'))
  1658. if ($link) {
  1659. return {
  1660. name: $link.textContent,
  1661. url: $link.pathname,
  1662. }
  1663. }
  1664. }
  1665. }
  1666. if (mobile) {
  1667. let $thumbnailLink =/** @type {HTMLAnchorElement} */ ($video.querySelector('ytm-channel-thumbnail-with-link-renderer > a'))
  1668. let $name = /** @type {HTMLElement} */ ($video.querySelector('ytm-badge-and-byline-renderer .yt-core-attributed-string'))
  1669. if ($name) {
  1670. return {
  1671. name: $name.textContent,
  1672. url: $thumbnailLink?.pathname,
  1673. }
  1674. }
  1675. }
  1676. // warn('unable to get channel details from video container', $video)
  1677. }
  1678.  
  1679. /**
  1680. * If you navigate back to Home or Subscriptions (or click their own nav item
  1681. * again) after a period of time, their contents will be refreshed, reusing
  1682. * elements. We need to detect this and re-apply manual hiding preferences for
  1683. * the updated video in each element.
  1684. * @param {Element} $gridItem
  1685. * @param {string} uniqueId
  1686. */
  1687. function observeDesktopRichGridItemContent($gridItem, uniqueId) {
  1688. observeDesktopRichGridVideoProgress($gridItem, uniqueId)
  1689.  
  1690. // For videos, observe the thumbnail link for the videoId being changed
  1691. let $thumbnailLink = /** @type {HTMLAnchorElement} */ ($gridItem.querySelector('ytd-rich-grid-media a#thumbnail'))
  1692. /** @type {import("./types").CustomMutationObserver} */
  1693. let thumbnailObserver
  1694.  
  1695. function observeThumbnail() {
  1696. if (!$thumbnailLink) {
  1697. log(`${uniqueId} has no video #thumbnail`)
  1698. return
  1699. }
  1700. thumbnailObserver = observeElement($thumbnailLink, (mutations) => {
  1701. let searchParams = new URLSearchParams($thumbnailLink.search)
  1702. if (searchParams.has('v') && !mutations[0].oldValue.includes(searchParams.get('v'))) {
  1703. log(`${uniqueId} #thumbnail href changed`, mutations[0].oldValue, '→', $thumbnailLink.href)
  1704. manuallyHideVideo($gridItem)
  1705. }
  1706. }, {
  1707. name: `${uniqueId} #thumbnail href`,
  1708. observers: pageObservers,
  1709. }, {
  1710. attributes: true,
  1711. attributeFilter: ['href'],
  1712. attributeOldValue: true,
  1713. })
  1714. }
  1715.  
  1716. if ($thumbnailLink) {
  1717. observeThumbnail()
  1718. }
  1719.  
  1720. // Observe the content of the grid item for a video being added or removed
  1721. // when grid contents are refreshed.
  1722. let $content = $gridItem.querySelector(':scope > #content')
  1723. observeElement($content, (mutations) => {
  1724. for (let mutation of mutations) {
  1725. for (let $addedNode of mutation.addedNodes) {
  1726. if (!($addedNode instanceof HTMLElement)) continue
  1727. if ($addedNode.nodeName == 'YTD-RICH-GRID-MEDIA') {
  1728. log(uniqueId, 'video added', $addedNode)
  1729. $thumbnailLink = /** @type {HTMLAnchorElement} */ ($gridItem.querySelector('ytd-rich-grid-media a#thumbnail'))
  1730. observeThumbnail()
  1731. manuallyHideVideo($gridItem)
  1732. if (config.hideWatched) {
  1733. observeDesktopRichGridVideoProgress($gridItem, uniqueId)
  1734. }
  1735. }
  1736. }
  1737. for (let $removedNode of mutation.removedNodes) {
  1738. if (!($removedNode instanceof HTMLElement)) continue
  1739. if ($removedNode.nodeName == 'YTD-RICH-GRID-MEDIA') {
  1740. log(uniqueId, 'video removed', $removedNode)
  1741. $thumbnailLink = null
  1742. thumbnailObserver?.disconnect()
  1743. manuallyHideVideo($gridItem)
  1744. }
  1745. }
  1746. }
  1747. }, {
  1748. name: `${uniqueId} #content`,
  1749. observers: pageObservers,
  1750. })
  1751. }
  1752.  
  1753. /**
  1754. * If you watch a video then navigate back to Home or Subscriptions without
  1755. * causing their contents to be refreshed, its watch progress will be updated
  1756. * in-place.
  1757. * @param {Element} $video
  1758. * @param {string} uniqueId
  1759. */
  1760. function observeDesktopRichGridVideoProgress($video, uniqueId) {
  1761. let $overlays = $video.querySelector('ytd-rich-grid-media #overlays')
  1762. if (!$overlays) {
  1763. log(uniqueId, 'has no video #overlay')
  1764. return
  1765. }
  1766.  
  1767. let $progress = $overlays.querySelector('#progress')
  1768. /** @type {import("./types").CustomMutationObserver} */
  1769. let progressObserver
  1770.  
  1771. function observeProgress() {
  1772. if (!$progress) {
  1773. log(`${uniqueId} has no #progress`)
  1774. return
  1775. }
  1776. progressObserver = observeElement($progress, (mutations) => {
  1777. if (mutations.length > 0) {
  1778. log(`${uniqueId} #progress style changed`)
  1779. hideWatched($video)
  1780. }
  1781. }, {
  1782. name: `${uniqueId} #progress (for style changes)`,
  1783. observers: pageObservers,
  1784. }, {
  1785. attributes: true,
  1786. attributeFilter: ['style'],
  1787. })
  1788. }
  1789.  
  1790. if ($progress) {
  1791. observeProgress()
  1792. }
  1793.  
  1794. // Observe overlay contents for a progress bar being added or removed when
  1795. // the video is updated.
  1796. observeElement($overlays, (mutations) => {
  1797. for (let mutation of mutations) {
  1798. for (let $addedNode of mutation.addedNodes) {
  1799. if (!($addedNode instanceof HTMLElement)) continue
  1800. if ($addedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
  1801. $progress = $addedNode.querySelector('#progress')
  1802. observeProgress()
  1803. hideWatched($video)
  1804. }
  1805. }
  1806. for (let $removedNode of mutation.removedNodes) {
  1807. if (!($removedNode instanceof HTMLElement)) continue
  1808. if ($removedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
  1809. $progress = null
  1810. progressObserver?.disconnect()
  1811. hideWatched($video)
  1812. }
  1813. }
  1814. }
  1815. }, {
  1816. name: `${uniqueId} #overlays (for #progress being added or removed)`,
  1817. observers: pageObservers,
  1818. })
  1819. }
  1820.  
  1821. /** @param {{page: 'home' | 'subscriptions'}} options */
  1822. async function observeDesktopRichGridItems(options) {
  1823. let {page} = options
  1824. let itemCount = 0
  1825.  
  1826. let $renderer = await getElement(`ytd-browse[page-subtype="${page}"] ytd-rich-grid-renderer`, {
  1827. name: `${page} <ytd-rich-grid-renderer>`,
  1828. stopIf: currentUrlChanges(),
  1829. })
  1830. if (!$renderer) return
  1831.  
  1832. let $gridContents = $renderer.querySelector(':scope > #contents')
  1833.  
  1834. /**
  1835. * @param {Element} $gridItem
  1836. * @param {string} $gridItem
  1837. */
  1838. function processGridItem($gridItem, uniqueId) {
  1839. manuallyHideVideo($gridItem)
  1840. observeDesktopRichGridItemContent($gridItem, uniqueId)
  1841. }
  1842.  
  1843. function processAllVideos() {
  1844. let $videos = $gridContents.querySelectorAll('ytd-rich-item-renderer.ytd-rich-grid-renderer')
  1845. if ($videos.length > 0) {
  1846. log('processing', $videos.length, `${page} video${s($videos.length)}`)
  1847. }
  1848. for (let $video of $videos) {
  1849. processGridItem($video, `grid item ${++itemCount}`)
  1850. }
  1851. }
  1852.  
  1853. // Process new videos as they're added
  1854. observeElement($gridContents, (mutations) => {
  1855. let videosAdded = 0
  1856. for (let mutation of mutations) {
  1857. for (let $addedNode of mutation.addedNodes) {
  1858. if (!($addedNode instanceof HTMLElement)) continue
  1859. if ($addedNode.nodeName == 'YTD-RICH-ITEM-RENDERER') {
  1860. processGridItem($addedNode, `grid item ${++itemCount}`)
  1861. videosAdded++
  1862. }
  1863. }
  1864. }
  1865. if (videosAdded > 0) {
  1866. log(videosAdded, `video${s(videosAdded)} added`)
  1867. }
  1868. }, {
  1869. name: `${page} <ytd-rich-grid-renderer> #contents (for new videos being added)`,
  1870. observers: pageObservers,
  1871. })
  1872.  
  1873. processAllVideos()
  1874. }
  1875.  
  1876. /** @param {HTMLElement} $menu */
  1877. function onDesktopMenuAppeared($menu) {
  1878. log('menu appeared')
  1879.  
  1880. if (config.downloadTranscript) {
  1881. addDownloadTranscriptToDesktopMenu($menu)
  1882. }
  1883. if (config.hideChannels) {
  1884. addHideChannelToDesktopVideoMenu($menu)
  1885. }
  1886. if (config.hideHiddenVideos) {
  1887. observeVideoHiddenState()
  1888. }
  1889. if (config.hideChannels || config.hideShareThanksClip) {
  1890. handleDesktopWatchChannelMenu($menu)
  1891. }
  1892. }
  1893.  
  1894. async function observePopups() {
  1895. if (desktop) {
  1896. // Desktop dialogs and menus appear in <ytd-popup-container>. Once created,
  1897. // the same elements are reused.
  1898. let $popupContainer = await getElement('ytd-popup-container', {name: 'popup container'})
  1899. let $dropdown = /** @type {HTMLElement} */ ($popupContainer.querySelector('tp-yt-iron-dropdown'))
  1900. let $dialog = /** @type {HTMLElement} */ ($popupContainer.querySelector('tp-yt-paper-dialog'))
  1901.  
  1902. function observeDialog() {
  1903. observeElement($dialog, () => {
  1904. if ($dialog.getAttribute('aria-hidden') == 'true') {
  1905. log('dialog closed')
  1906. if (onDialogClosed) {
  1907. onDialogClosed()
  1908. onDialogClosed = null
  1909. }
  1910. }
  1911. }, {
  1912. name: '<tp-yt-paper-dialog> (for [aria-hidden] being added)',
  1913. observers: globalObservers,
  1914. }, {
  1915. attributes: true,
  1916. attributeFilter: ['aria-hidden'],
  1917. })
  1918. }
  1919.  
  1920. function observeDropdown() {
  1921. observeElement($dropdown, () => {
  1922. if ($dropdown.getAttribute('aria-hidden') != 'true') {
  1923. onDesktopMenuAppeared($dropdown)
  1924. }
  1925. }, {
  1926. leading: true,
  1927. name: '<tp-yt-iron-dropdown> (for [aria-hidden] being removed)',
  1928. observers: globalObservers,
  1929. }, {
  1930. attributes: true,
  1931. attributeFilter: ['aria-hidden'],
  1932. })
  1933. }
  1934.  
  1935. if ($dialog) observeDialog()
  1936. if ($dropdown) observeDropdown()
  1937.  
  1938. if (!$dropdown || !$dialog) {
  1939. observeElement($popupContainer, (mutations, observer) => {
  1940. for (let mutation of mutations) {
  1941. for (let $el of mutation.addedNodes) {
  1942. switch($el.nodeName) {
  1943. case 'TP-YT-IRON-DROPDOWN':
  1944. $dropdown = /** @type {HTMLElement} */ ($el)
  1945. observeDropdown()
  1946. break
  1947. case 'TP-YT-PAPER-DIALOG':
  1948. $dialog = /** @type {HTMLElement} */ ($el)
  1949. observeDialog()
  1950. break
  1951. }
  1952. if ($dropdown && $dialog) {
  1953. observer.disconnect()
  1954. }
  1955. }
  1956. }
  1957. }, {
  1958. name: '<ytd-popup-container> (for initial <tp-yt-iron-dropdown> and <tp-yt-paper-dialog> being added)',
  1959. observers: globalObservers,
  1960. })
  1961. }
  1962. }
  1963.  
  1964. if (mobile) {
  1965. // Depending on resolution, mobile menus appear in <bottom-sheet-container>
  1966. // (lower res) or as a #menu child of <body> (higher res).
  1967. let $body = await getElement('body', {name: '<body>'})
  1968. if (!$body) return
  1969.  
  1970. let $menu = /** @type {HTMLElement} */ (document.querySelector('body > #menu'))
  1971. if ($menu) {
  1972. onMobileMenuAppeared($menu)
  1973. }
  1974.  
  1975. observeElement($body, (mutations) => {
  1976. for (let mutation of mutations) {
  1977. for (let $el of mutation.addedNodes) {
  1978. if ($el instanceof HTMLElement && $el.id == 'menu') {
  1979. onMobileMenuAppeared($el)
  1980. return
  1981. }
  1982. }
  1983. }
  1984. }, {
  1985. name: '<body> (for #menu being added)',
  1986. observers: globalObservers,
  1987. })
  1988.  
  1989. // When switching between screens, <bottom-sheet-container> is replaced
  1990. let $app = await getElement('ytm-app', {name: '<ytm-app>'})
  1991. if (!$app) return
  1992.  
  1993. let $bottomSheet = /** @type {HTMLElement} */ ($app.querySelector('bottom-sheet-container'))
  1994.  
  1995. function observeBottomSheet() {
  1996. observeElement($bottomSheet, () => {
  1997. if ($bottomSheet.childElementCount > 0) {
  1998. onMobileMenuAppeared($bottomSheet)
  1999. }
  2000. }, {
  2001. leading: true,
  2002. name: '<bottom-sheet-container> (for content being added)',
  2003. observers: globalObservers,
  2004. })
  2005. }
  2006.  
  2007. if ($bottomSheet) observeBottomSheet()
  2008.  
  2009. observeElement($app, (mutations) => {
  2010. for (let mutation of mutations) {
  2011. for (let $el of mutation.addedNodes) {
  2012. if ($el.nodeName == 'BOTTOM-SHEET-CONTAINER') {
  2013. log('new bottom sheet appeared')
  2014. $bottomSheet = /** @type {HTMLElement} */ ($el)
  2015. observeBottomSheet()
  2016. return
  2017. }
  2018. }
  2019. }
  2020. }, {
  2021. name: '<ytm-app> (for <bottom-sheet-container> being replaced)',
  2022. observers: globalObservers,
  2023. })
  2024. }
  2025. }
  2026.  
  2027. /**
  2028. * Search pages are a list of sections, which can have video items added to them
  2029. * after they're added, so we watch for new section contents as well as for new
  2030. * sections. When the search is changed, additional sections are removed and the
  2031. * first section is refreshed - it gets a can-show-more attribute while this is
  2032. * happening.
  2033. * @param {{
  2034. * name: string
  2035. * selector: string
  2036. * sectionContentsSelector: string
  2037. * sectionElement: string
  2038. * suggestedSectionElement?: string
  2039. * videoElement: string
  2040. * }} options
  2041. */
  2042. async function observeSearchResultSections(options) {
  2043. let {name, selector, sectionContentsSelector, sectionElement, suggestedSectionElement = null, videoElement} = options
  2044. let sectionNodeName = sectionElement.toUpperCase()
  2045. let suggestedSectionNodeName = suggestedSectionElement?.toUpperCase()
  2046. let videoNodeName = videoElement.toUpperCase()
  2047.  
  2048. let $sections = await getElement(selector, {
  2049. name,
  2050. stopIf: currentUrlChanges(),
  2051. })
  2052. if (!$sections) return
  2053.  
  2054. /** @type {WeakMap<Element, Map<string, import("./types").Disconnectable>>} */
  2055. let sectionObservers = new WeakMap()
  2056. /** @type {WeakMap<Element, Map<string, import("./types").Disconnectable>>} */
  2057. let sectionItemObservers = new WeakMap()
  2058. let sectionCount = 0
  2059.  
  2060. /**
  2061. * @param {HTMLElement} $section
  2062. * @param {number} sectionNum
  2063. */
  2064. function processSection($section, sectionNum) {
  2065. let $contents = /** @type {HTMLElement} */ ($section.querySelector(sectionContentsSelector))
  2066. let itemCount = 0
  2067. let suggestedSectionCount = 0
  2068. /** @type {Map<string, import("./types").Disconnectable>} */
  2069. let observers = new Map()
  2070. /** @type {Map<string, import("./types").Disconnectable>} */
  2071. let itemObservers = new Map()
  2072. sectionObservers.set($section, observers)
  2073. sectionItemObservers.set($section, itemObservers)
  2074.  
  2075. function processCurrentItems() {
  2076. itemCount = 0
  2077. suggestedSectionCount = 0
  2078. for (let $item of $contents.children) {
  2079. if ($item.nodeName == videoNodeName) {
  2080. manuallyHideVideo($item)
  2081. waitForVideoOverlay($item, `section ${sectionNum} item ${++itemCount}`, itemObservers)
  2082. }
  2083. if (!config.hideSuggestedSections && suggestedSectionNodeName != null && $item.nodeName == suggestedSectionNodeName) {
  2084. processSuggestedSection($item)
  2085. }
  2086. }
  2087. }
  2088.  
  2089. /**
  2090. * If suggested sections (Latest from, People also watched, For you, etc.)
  2091. * aren't being hidden, we need to process their videos and watch for more
  2092. * being loaded.
  2093. * @param {Element} $suggestedSection
  2094. */
  2095. function processSuggestedSection($suggestedSection) {
  2096. let suggestedItemCount = 0
  2097. let uniqueId = `section ${sectionNum} suggested section ${++suggestedSectionCount}`
  2098. let $items = $suggestedSection.querySelector('#items')
  2099. for (let $video of $items.children) {
  2100. if ($video.nodeName == videoNodeName) {
  2101. manuallyHideVideo($video)
  2102. waitForVideoOverlay($video, `${uniqueId} item ${++suggestedItemCount}`, itemObservers)
  2103. }
  2104. }
  2105. // More videos are added if the "More" control is used
  2106. observeElement($items, (mutations, observer) => {
  2107. let moreVideosAdded = false
  2108. for (let mutation of mutations) {
  2109. for (let $addedNode of mutation.addedNodes) {
  2110. if (!($addedNode instanceof HTMLElement)) continue
  2111. if ($addedNode.nodeName == videoNodeName) {
  2112. if (!moreVideosAdded) moreVideosAdded = true
  2113. manuallyHideVideo($addedNode)
  2114. waitForVideoOverlay($addedNode, `${uniqueId} item ${++suggestedItemCount}`, itemObservers)
  2115. }
  2116. }
  2117. }
  2118. if (moreVideosAdded) {
  2119. observer.disconnect()
  2120. }
  2121. }, {
  2122. name: `${uniqueId} videos (for more being added)`,
  2123. observers: [itemObservers, pageObservers],
  2124. })
  2125. }
  2126.  
  2127. if (desktop) {
  2128. observeElement($section, () => {
  2129. if ($section.getAttribute('can-show-more') == null) {
  2130. log('can-show-more attribute removed - reprocessing refreshed items')
  2131. for (let observer of itemObservers.values()) {
  2132. observer.disconnect()
  2133. }
  2134. processCurrentItems()
  2135. }
  2136. }, {
  2137. name: `section ${sectionNum} can-show-more attribute`,
  2138. observers: [observers, pageObservers],
  2139. }, {
  2140. attributes: true,
  2141. attributeFilter: ['can-show-more'],
  2142. })
  2143. }
  2144.  
  2145. observeElement($contents, (mutations) => {
  2146. for (let mutation of mutations) {
  2147. for (let $addedNode of mutation.addedNodes) {
  2148. if (!($addedNode instanceof HTMLElement)) continue
  2149. if ($addedNode.nodeName == videoNodeName) {
  2150. manuallyHideVideo($addedNode)
  2151. waitForVideoOverlay($addedNode, `section ${sectionNum} item ${++itemCount}`, observers)
  2152. }
  2153. if (!config.hideSuggestedSections && suggestedSectionNodeName != null && $addedNode.nodeName == suggestedSectionNodeName) {
  2154. processSuggestedSection($addedNode)
  2155. }
  2156. }
  2157. }
  2158. }, {
  2159. name: `section ${sectionNum} contents`,
  2160. observers: [observers, pageObservers],
  2161. })
  2162.  
  2163. processCurrentItems()
  2164. }
  2165.  
  2166. observeElement($sections, (mutations) => {
  2167. for (let mutation of mutations) {
  2168. // New sections are added when more results are loaded
  2169. for (let $addedNode of mutation.addedNodes) {
  2170. if (!($addedNode instanceof HTMLElement)) continue
  2171. if ($addedNode.nodeName == sectionNodeName) {
  2172. let sectionNum = ++sectionCount
  2173. log('search result section', sectionNum, 'added')
  2174. processSection($addedNode, sectionNum)
  2175. }
  2176. }
  2177. // Additional sections are removed when the search is changed
  2178. for (let $removedNode of mutation.removedNodes) {
  2179. if (!($removedNode instanceof HTMLElement)) continue
  2180. if ($removedNode.nodeName == sectionNodeName && sectionObservers.has($removedNode)) {
  2181. log('disconnecting removed section observers')
  2182. for (let observer of sectionObservers.get($removedNode).values()) {
  2183. observer.disconnect()
  2184. }
  2185. sectionObservers.delete($removedNode)
  2186. for (let observer of sectionItemObservers.get($removedNode).values()) {
  2187. observer.disconnect()
  2188. }
  2189. sectionObservers.delete($removedNode)
  2190. sectionItemObservers.delete($removedNode)
  2191. sectionCount--
  2192. }
  2193. }
  2194. }
  2195. }, {
  2196. name: `search <${sectionElement}> contents (for new sections being added)`,
  2197. observers: pageObservers,
  2198. })
  2199.  
  2200. let $initialSections = /** @type {NodeListOf<HTMLElement>} */ ($sections.querySelectorAll(sectionElement))
  2201. log($initialSections.length, `initial search result section${s($initialSections.length)}`)
  2202. for (let $initialSection of $initialSections) {
  2203. processSection($initialSection, ++sectionCount)
  2204. }
  2205. }
  2206.  
  2207. /**
  2208. * Detect navigation between pages for features which apply to specific pages.
  2209. */
  2210. async function observeTitle() {
  2211. let $title = await getElement('title', {name: '<title>'})
  2212. let seenUrl
  2213. observeElement($title, () => {
  2214. let currentUrl = getCurrentUrl()
  2215. if (seenUrl != null && seenUrl == currentUrl) {
  2216. return
  2217. }
  2218. seenUrl = currentUrl
  2219. handleCurrentUrl()
  2220. }, {
  2221. leading: true,
  2222. name: '<title> (for title changes)',
  2223. observers: globalObservers,
  2224. })
  2225. }
  2226.  
  2227. async function observeVideoAds() {
  2228. let $player = await getElement('#movie_player', {
  2229. name: 'player (skipAds)',
  2230. stopIf: currentUrlChanges(),
  2231. })
  2232. if (!$player) return
  2233.  
  2234. let $videoAds = $player.querySelector('.video-ads')
  2235. if (!$videoAds) {
  2236. $videoAds = await observeForElement($player, (mutations) => {
  2237. for (let mutation of mutations) {
  2238. for (let $addedNode of mutation.addedNodes) {
  2239. if (!($addedNode instanceof HTMLElement)) continue
  2240. if ($addedNode.classList.contains('video-ads')) {
  2241. return $addedNode
  2242. }
  2243. }
  2244. }
  2245. }, {
  2246. logElement: true,
  2247. name: '#movie_player (for .video-ads being added)',
  2248. targetName: '.video-ads',
  2249. observers: pageObservers,
  2250. })
  2251. if (!$videoAds) return
  2252. }
  2253.  
  2254. function processAdContent() {
  2255. let $adContent = $videoAds.firstElementChild
  2256. if ($adContent.classList.contains('ytp-ad-player-overlay') || $adContent.classList.contains('ytp-ad-player-overlay-layout')) {
  2257. tweakAdPlayerOverlay($player)
  2258. }
  2259. else if ($adContent.classList.contains('ytp-ad-action-interstitial')) {
  2260. tweakAdInterstitial($adContent)
  2261. }
  2262. else {
  2263. warn('unknown ad content', $adContent.className, $adContent.outerHTML)
  2264. }
  2265. }
  2266.  
  2267. if ($videoAds.childElementCount > 0) {
  2268. log('video ad content present')
  2269. processAdContent()
  2270. }
  2271.  
  2272. observeElement($videoAds, (mutations) => {
  2273. // Something added
  2274. if (mutations.some(mutation => mutation.addedNodes.length > 0)) {
  2275. log('video ad content appeared')
  2276. processAdContent()
  2277. }
  2278. // Something removed
  2279. else if (mutations.some(mutation => mutation.removedNodes.length > 0)) {
  2280. log('video ad content removed')
  2281. if (onAdRemoved) {
  2282. onAdRemoved()
  2283. onAdRemoved = null
  2284. }
  2285. // Only unmute if we know the volume wasn't initially muted
  2286. if (desktop) {
  2287. let $muteButton = /** @type {HTMLElement} */ ($player.querySelector('button.ytp-mute-button'))
  2288. if ($muteButton &&
  2289. $muteButton.dataset.titleNoTooltip != getString('MUTE') &&
  2290. $muteButton.dataset.cpfytWasMuted == 'false') {
  2291. log('unmuting audio after ads')
  2292. delete $muteButton.dataset.cpfytWasMuted
  2293. $muteButton.click()
  2294. }
  2295. }
  2296. if (mobile) {
  2297. let $video = $player.querySelector('video')
  2298. if ($video &&
  2299. $video.muted &&
  2300. $video.dataset.cpfytWasMuted == 'false') {
  2301. log('unmuting audio after ads')
  2302. delete $video.dataset.cpfytWasMuted
  2303. $video.muted = false
  2304. }
  2305. }
  2306. }
  2307. }, {
  2308. logElement: true,
  2309. name: '#movie_player > .video-ads (for content being added or removed)',
  2310. observers: pageObservers,
  2311. })
  2312. }
  2313.  
  2314. /**
  2315. * If a video's action menu was opened, watch for that video being dismissed.
  2316. */
  2317. function observeVideoHiddenState() {
  2318. if (!isHomePage() && !isSubscriptionsPage()) return
  2319.  
  2320. if (desktop) {
  2321. let $video = $lastClickedElement?.closest('ytd-rich-grid-media')
  2322. if (!$video) return
  2323.  
  2324. observeElement($video, (_, observer) => {
  2325. if (!$video.hasAttribute('is-dismissed')) return
  2326.  
  2327. observer.disconnect()
  2328.  
  2329. log('video hidden, showing timer')
  2330. let $actions = $video.querySelector('ytd-notification-multi-action-renderer')
  2331. let $undoButton = $actions.querySelector('button')
  2332. let $tellUsWhyButton = $actions.querySelector(`button[aria-label="${getString('TELL_US_WHY')}"]`)
  2333. let $pie
  2334. let timeout
  2335. let startTime
  2336.  
  2337. function displayPie(options = {}) {
  2338. let {delay, direction, duration} = options
  2339. $pie?.remove()
  2340. $pie = document.createElement('div')
  2341. $pie.classList.add('cpfyt-pie')
  2342. if (delay) $pie.style.setProperty('--cpfyt-pie-delay', `${delay}ms`)
  2343. if (direction) $pie.style.setProperty('--cpfyt-pie-direction', direction)
  2344. if (duration) $pie.style.setProperty('--cpfyt-pie-duration', `${duration}ms`)
  2345. $actions.appendChild($pie)
  2346. }
  2347.  
  2348. function startTimer() {
  2349. startTime = Date.now()
  2350. timeout = setTimeout(() => {
  2351. let $elementToHide = $video.closest('ytd-rich-item-renderer')
  2352. $elementToHide?.classList.add(Classes.HIDE_HIDDEN)
  2353. cleanup()
  2354. // Remove the class if the Undo button is clicked later, e.g. if
  2355. // this feature is disabled after hiding a video.
  2356. $undoButton.addEventListener('click', () => {
  2357. $elementToHide?.classList.remove(Classes.HIDE_HIDDEN)
  2358. })
  2359. }, undoHideDelayMs)
  2360. }
  2361.  
  2362. function cleanup() {
  2363. $undoButton.removeEventListener('click', onUndoClick)
  2364. if ($tellUsWhyButton) {
  2365. $tellUsWhyButton.removeEventListener('click', onTellUsWhyClick)
  2366. }
  2367. $pie.remove()
  2368. }
  2369.  
  2370. function onUndoClick() {
  2371. clearTimeout(timeout)
  2372. cleanup()
  2373. }
  2374.  
  2375. function onTellUsWhyClick() {
  2376. let elapsedTime = Date.now() - startTime
  2377. clearTimeout(timeout)
  2378. displayPie({
  2379. direction: 'reverse',
  2380. delay: Math.round((elapsedTime - undoHideDelayMs) / 4),
  2381. duration: undoHideDelayMs / 4,
  2382. })
  2383. onDialogClosed = () => {
  2384. startTimer()
  2385. displayPie()
  2386. }
  2387. }
  2388.  
  2389. $undoButton.addEventListener('click', onUndoClick)
  2390. if ($tellUsWhyButton) {
  2391. $tellUsWhyButton.addEventListener('click', onTellUsWhyClick)
  2392. }
  2393. startTimer()
  2394. displayPie()
  2395. }, {
  2396. name: '<ytd-rich-grid-media> (for [is-dismissed] being added)',
  2397. observers: pageObservers,
  2398. }, {
  2399. attributes: true,
  2400. attributeFilter: ['is-dismissed'],
  2401. })
  2402. }
  2403.  
  2404. if (mobile) {
  2405. /** @type {HTMLElement} */
  2406. let $container
  2407. if (isHomePage()) {
  2408. $container = $lastClickedElement?.closest('ytm-rich-item-renderer')
  2409. }
  2410. else if (isSubscriptionsPage()) {
  2411. $container = $lastClickedElement?.closest('lazy-list')
  2412. }
  2413. if (!$container) return
  2414.  
  2415. observeElement($container, (mutations, observer) => {
  2416. for (let mutation of mutations) {
  2417. for (let $el of mutation.addedNodes) {
  2418. if ($el.nodeName != 'YTM-NOTIFICATION-MULTI-ACTION-RENDERER') continue
  2419.  
  2420. observer.disconnect()
  2421.  
  2422. log('video hidden, showing timer')
  2423. let $actions = /** @type {HTMLElement} */ ($el).firstElementChild
  2424. let $undoButton = /** @type {HTMLElement} */ ($el).querySelector('button')
  2425. function cleanup() {
  2426. $undoButton.removeEventListener('click', undoClicked)
  2427. $actions.querySelector('.cpfyt-pie')?.remove()
  2428. }
  2429. let hideHiddenVideoTimeout = setTimeout(() => {
  2430. let $elementToHide = $container
  2431. if (isSubscriptionsPage()) {
  2432. $elementToHide = $container.closest('ytm-item-section-renderer')
  2433. }
  2434. $elementToHide?.classList.add(Classes.HIDE_HIDDEN)
  2435. cleanup()
  2436. // Remove the class if the Undo button is clicked later, e.g. if
  2437. // this feature is disabled after hiding a video.
  2438. $undoButton.addEventListener('click', () => {
  2439. $elementToHide?.classList.remove(Classes.HIDE_HIDDEN)
  2440. })
  2441. }, undoHideDelayMs)
  2442. function undoClicked() {
  2443. clearTimeout(hideHiddenVideoTimeout)
  2444. cleanup()
  2445. }
  2446. $undoButton.addEventListener('click', undoClicked)
  2447. $actions.insertAdjacentHTML('beforeend', '<div class="cpfyt-pie"></div>')
  2448. }
  2449. }
  2450. }, {
  2451. name: `<${$container.tagName.toLowerCase()}> (for <ytm-notification-multi-action-renderer> being added)`,
  2452. observers: pageObservers,
  2453. })
  2454. }
  2455. }
  2456.  
  2457. /**
  2458. * Processes initial videos in a list element, and new videos as they're added.
  2459. * @param {{
  2460. * name: string
  2461. * selector: string
  2462. * stopIf?: () => boolean
  2463. * page: string
  2464. * videoElements: Set<string>
  2465. * }} options
  2466. */
  2467. async function observeVideoList(options) {
  2468. let {name, selector, stopIf = currentUrlChanges(), page, videoElements} = options
  2469. let videoNodeNames = new Set(Array.from(videoElements, (name) => name.toUpperCase()))
  2470.  
  2471. let $list = await getElement(selector, {name, stopIf})
  2472. if (!$list) return
  2473.  
  2474. let itemCount = 0
  2475.  
  2476. observeElement($list, (mutations) => {
  2477. let newItemCount = 0
  2478. for (let mutation of mutations) {
  2479. for (let $addedNode of mutation.addedNodes) {
  2480. if (!($addedNode instanceof HTMLElement)) continue
  2481. if (videoNodeNames.has($addedNode.nodeName)) {
  2482. manuallyHideVideo($addedNode)
  2483. waitForVideoOverlay($addedNode, `item ${++itemCount}`)
  2484. newItemCount++
  2485. }
  2486. }
  2487. }
  2488. if (newItemCount > 0) {
  2489. log(newItemCount, `${page} video${s(newItemCount)} added`)
  2490. }
  2491. }, {
  2492. name: `${name} (for new items being added)`,
  2493. observers: pageObservers,
  2494. })
  2495.  
  2496. let initialItemCount = 0
  2497. for (let $initialItem of $list.children) {
  2498. if (videoNodeNames.has($initialItem.nodeName)) {
  2499. manuallyHideVideo($initialItem)
  2500. waitForVideoOverlay($initialItem, `item ${++itemCount}`)
  2501. initialItemCount++
  2502. }
  2503. }
  2504. log(initialItemCount, `initial ${page} video${s(initialItemCount)}`)
  2505. }
  2506.  
  2507. /** @param {MouseEvent} e */
  2508. function onDocumentClick(e) {
  2509. $lastClickedElement = /** @type {HTMLElement} */ (e.target)
  2510. }
  2511.  
  2512. /** @param {HTMLElement} $menu */
  2513. function onMobileMenuAppeared($menu) {
  2514. log('menu appeared')
  2515.  
  2516. if (config.hideOpenApp && (isSearchPage() || isVideoPage())) {
  2517. let menuItems = $menu.querySelectorAll('ytm-menu-item')
  2518. for (let $menuItem of menuItems) {
  2519. if ($menuItem.textContent == getString('OPEN_APP')) {
  2520. log('tagging Open App menu item')
  2521. $menuItem.classList.add(Classes.HIDE_OPEN_APP)
  2522. break
  2523. }
  2524. }
  2525. }
  2526.  
  2527. if (config.hideChannels) {
  2528. addHideChannelToMobileVideoMenu($menu)
  2529. }
  2530. if (config.hideHiddenVideos) {
  2531. observeVideoHiddenState()
  2532. }
  2533. }
  2534.  
  2535. /** @param {Element} $video */
  2536. function hideWatched($video) {
  2537. if (!config.hideWatched || isSearchPage()) return
  2538. // Watch % is obtained from progress bar width when a video has one
  2539. let $progressBar
  2540. if (desktop) {
  2541. $progressBar = $video.querySelector('#progress')
  2542. }
  2543. if (mobile) {
  2544. $progressBar = $video.querySelector('.thumbnail-overlay-resume-playback-progress')
  2545. }
  2546. let hide = false
  2547. if ($progressBar) {
  2548. let progress = parseInt(/** @type {HTMLElement} */ ($progressBar).style.width)
  2549. hide = progress >= Number(config.hideWatchedThreshold)
  2550. }
  2551. $video.classList.toggle(Classes.HIDE_WATCHED, hide)
  2552. }
  2553.  
  2554. /**
  2555. * Tag individual video elements to be hidden by options which would need too
  2556. * complex or broad CSS :has() relative selectors.
  2557. * @param {Element} $video video container element
  2558. */
  2559. function manuallyHideVideo($video) {
  2560. hideWatched($video)
  2561.  
  2562. // Streamed videos are identified using the video title's aria-label
  2563. if (config.hideStreamed) {
  2564. let $videoTitle
  2565. if (desktop) {
  2566. // Subscriptions <ytd-rich-item-renderer> has a different structure
  2567. $videoTitle = $video.querySelector($video.tagName == 'YTD-RICH-ITEM-RENDERER' ? '#video-title-link' : '#video-title')
  2568. }
  2569. if (mobile) {
  2570. $videoTitle = $video.querySelector('.media-item-headline .yt-core-attributed-string')
  2571. }
  2572. let hide = false
  2573. if ($videoTitle) {
  2574. hide = Boolean($videoTitle.getAttribute('aria-label')?.includes(getString('STREAMED_TITLE')))
  2575. }
  2576. $video.classList.toggle(Classes.HIDE_STREAMED, hide)
  2577. }
  2578.  
  2579. if (config.hideChannels && config.hiddenChannels.length > 0 && !isSubscriptionsPage()) {
  2580. let channel = getChannelDetailsFromVideo($video)
  2581. let hide = false
  2582. if (channel) {
  2583. hide = isChannelHidden(channel)
  2584. }
  2585. $video.classList.toggle(Classes.HIDE_CHANNEL, hide)
  2586. }
  2587. }
  2588.  
  2589. async function redirectFromHome() {
  2590. let selector = desktop ? 'a[href="/feed/subscriptions"]' : 'ytm-pivot-bar-item-renderer div.pivot-subs'
  2591. let $subscriptionsLink = await getElement(selector, {
  2592. name: 'Subscriptions link',
  2593. stopIf: currentUrlChanges(),
  2594. })
  2595. if (!$subscriptionsLink) return
  2596. log('redirecting from Home to Subscriptions')
  2597. $subscriptionsLink.click()
  2598. }
  2599.  
  2600. function redirectShort() {
  2601. let videoId = location.pathname.split('/').at(-1)
  2602. let search = location.search ? location.search.replace('?', '&') : ''
  2603. log('redirecting Short to normal player')
  2604. location.replace(`/watch?v=${videoId}${search}`)
  2605. }
  2606.  
  2607. /**
  2608. * Forces the video to resize if options which affect its size are used.
  2609. */
  2610. function triggerVideoPageResize() {
  2611. if (desktop && isVideoPage()) {
  2612. window.dispatchEvent(new Event('resize'))
  2613. }
  2614. }
  2615.  
  2616. function tweakAdInterstitial($adContent) {
  2617. log('ad interstitial showing')
  2618. let $skipButtonSlot = /** @type {HTMLElement} */ ($adContent.querySelector('.ytp-ad-skip-button-slot'))
  2619. if (!$skipButtonSlot) {
  2620. log('skip button slot not found')
  2621. return
  2622. }
  2623.  
  2624. observeElement($skipButtonSlot, (_, observer) => {
  2625. if ($skipButtonSlot.style.display != 'none') {
  2626. let $button = $skipButtonSlot.querySelector('button')
  2627. if ($button) {
  2628. log('clicking skip button')
  2629. // XXX Not working on mobile
  2630. $button.click()
  2631. } else {
  2632. warn('skip button not found')
  2633. }
  2634. observer.disconnect()
  2635. }
  2636. }, {
  2637. leading: true,
  2638. name: 'skip button slot (for skip button becoming visible)',
  2639. observers: pageObservers,
  2640. }, {attributes: true})
  2641. }
  2642.  
  2643. function tweakAdPlayerOverlay($player) {
  2644. log('ad overlay showing')
  2645.  
  2646. // Mute ad audio
  2647. if (desktop) {
  2648. let $muteButton = /** @type {HTMLElement} */ ($player.querySelector('button.ytp-mute-button'))
  2649. if ($muteButton) {
  2650. if ($muteButton.dataset.titleNoTooltip == getString('MUTE')) {
  2651. log('muting ad audio')
  2652. $muteButton.click()
  2653. $muteButton.dataset.cpfytWasMuted = 'false'
  2654. }
  2655. else if ($muteButton.dataset.cpfytWasMuted == null) {
  2656. $muteButton.dataset.cpfytWasMuted = 'true'
  2657. }
  2658. } else {
  2659. warn('mute button not found')
  2660. }
  2661. }
  2662. if (mobile) {
  2663. // Mobile doesn't have a mute button, so we mute the video itself
  2664. let $video = /** @type {HTMLVideoElement} */ ($player.querySelector('video'))
  2665. if ($video) {
  2666. if (!$video.muted) {
  2667. $video.muted = true
  2668. $video.dataset.cpfytWasMuted = 'false'
  2669. }
  2670. else if ($video.dataset.cpfytWasMuted == null) {
  2671. $video.dataset.cpfytWasMuted = 'true'
  2672. }
  2673. } else {
  2674. warn('<video> not found')
  2675. }
  2676. }
  2677.  
  2678. // Try to skip to the end of the ad video
  2679. let $video = /** @type {HTMLVideoElement} */ ($player.querySelector('video'))
  2680. if (!$video) {
  2681. warn('<video> not found')
  2682. return
  2683. }
  2684.  
  2685. if (Number.isFinite($video.duration)) {
  2686. log(`skipping to end of ad (using initial video duration)`)
  2687. $video.currentTime = $video.duration
  2688. }
  2689. else if ($video.readyState == null || $video.readyState < 1) {
  2690. function onLoadedMetadata() {
  2691. if (Number.isFinite($video.duration)) {
  2692. log(`skipping to end of ad (using video duration after loadedmetadata)`)
  2693. $video.currentTime = $video.duration
  2694. } else {
  2695. log(`skipping to end of ad (duration still not available after loadedmetadata)`)
  2696. $video.currentTime = 10_000
  2697. }
  2698. }
  2699. $video.addEventListener('loadedmetadata', onLoadedMetadata, {once: true})
  2700. onAdRemoved = () => {
  2701. $video.removeEventListener('loadedmetadata', onLoadedMetadata)
  2702. }
  2703. }
  2704. else {
  2705. log(`skipping to end of ad (metadata should be available but isn't)`)
  2706. $video.currentTime = 10_000
  2707. }
  2708. }
  2709.  
  2710. async function tweakHomePage() {
  2711. if (config.disableHomeFeed && loggedIn) {
  2712. redirectFromHome()
  2713. return
  2714. }
  2715. if (!config.hideWatched && !config.hideStreamed && !config.hideChannels) return
  2716. if (desktop) {
  2717. observeDesktopRichGridItems({page: 'home'})
  2718. }
  2719. if (mobile) {
  2720. observeVideoList({
  2721. name: 'home <ytm-rich-grid-renderer> contents',
  2722. selector: '.tab-content[tab-identifier="FEwhat_to_watch"] .rich-grid-renderer-contents',
  2723. page: 'home',
  2724. videoElements: new Set(['ytm-rich-item-renderer']),
  2725. })
  2726. }
  2727. }
  2728.  
  2729. async function tweakChannelPage() {
  2730. let seen = new Map()
  2731. function isOnFeaturedTab() {
  2732. if (!seen.has(location.pathname)) {
  2733. let section = location.pathname.match(URL_CHANNEL_RE)[1]
  2734. seen.set(location.pathname, section == undefined || section == 'featured')
  2735. }
  2736. return seen.get(location.pathname)
  2737. }
  2738.  
  2739. if (desktop && config.pauseChannelTrailers && isOnFeaturedTab()) {
  2740. let $channelTrailer = /** @type {HTMLVideoElement} */ (
  2741. await getElement('ytd-channel-video-player-renderer video', {
  2742. name: `channel trailer`,
  2743. stopIf: () => !isOnFeaturedTab(),
  2744. timeout: 2000,
  2745. })
  2746. )
  2747. if ($channelTrailer) {
  2748. $channelTrailer.pause()
  2749. function pauseTrailer() {
  2750. log(`pauseChannelTrailers: pausing channel trailer`)
  2751. $channelTrailer.pause()
  2752. }
  2753. if ($channelTrailer.paused) {
  2754. $channelTrailer.addEventListener('play', pauseTrailer, {once: true})
  2755. } else {
  2756. pauseTrailer()
  2757. }
  2758. }
  2759. }
  2760. }
  2761.  
  2762. // TODO Hide ytd-channel-renderer if a channel is hidden
  2763. function tweakSearchPage() {
  2764. if (!config.hideStreamed && !config.hideChannels) return
  2765.  
  2766. if (desktop) {
  2767. observeSearchResultSections({
  2768. name: 'search <ytd-section-list-renderer> contents',
  2769. selector: 'ytd-search #contents.ytd-section-list-renderer',
  2770. sectionContentsSelector: '#contents',
  2771. sectionElement: 'ytd-item-section-renderer',
  2772. suggestedSectionElement: 'ytd-shelf-renderer',
  2773. videoElement: 'ytd-video-renderer',
  2774. })
  2775. }
  2776.  
  2777. if (mobile) {
  2778. observeSearchResultSections({
  2779. name: 'search <lazy-list>',
  2780. selector: 'ytm-search ytm-section-list-renderer > lazy-list',
  2781. sectionContentsSelector: 'lazy-list',
  2782. sectionElement: 'ytm-item-section-renderer',
  2783. videoElement: 'ytm-video-with-context-renderer',
  2784. })
  2785. }
  2786. }
  2787.  
  2788. async function tweakSubscriptionsPage() {
  2789. if (!config.hideWatched && !config.hideStreamed) return
  2790. if (desktop) {
  2791. observeDesktopRichGridItems({page: 'subscriptions'})
  2792. }
  2793. if (mobile) {
  2794. observeVideoList({
  2795. name: 'subscriptions <lazy-list>',
  2796. selector: '.tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer > lazy-list',
  2797. page: 'subscriptions',
  2798. videoElements: new Set(['ytm-item-section-renderer']),
  2799. })
  2800. }
  2801. }
  2802.  
  2803. async function tweakVideoPage() {
  2804. if (config.skipAds) {
  2805. observeVideoAds()
  2806. }
  2807. if (config.disableAutoplay) {
  2808. disableAutoplay()
  2809. }
  2810. if (desktop && config.alwaysUseTheaterMode) {
  2811. alwaysUseTheaterMode()
  2812. }
  2813.  
  2814. if (config.hideRelated || (!config.hideWatched && !config.hideStreamed && !config.hideChannels)) return
  2815.  
  2816. if (desktop) {
  2817. let $section = await getElement('#related.ytd-watch-flexy ytd-item-section-renderer', {
  2818. name: 'related <ytd-item-section-renderer>',
  2819. stopIf: currentUrlChanges(),
  2820. })
  2821. if (!$section) return
  2822.  
  2823. let $contents = $section.querySelector('#contents')
  2824. let itemCount = 0
  2825.  
  2826. function processCurrentItems() {
  2827. itemCount = 0
  2828. for (let $item of $contents.children) {
  2829. if ($item.nodeName == 'YTD-COMPACT-VIDEO-RENDERER') {
  2830. manuallyHideVideo($item)
  2831. waitForVideoOverlay($item, `related item ${++itemCount}`)
  2832. }
  2833. }
  2834. }
  2835.  
  2836. // If the video changes (e.g. a related video is clicked) on desktop,
  2837. // the related items section is refreshed - the section has a can-show-more
  2838. // attribute while this is happening.
  2839. observeElement($section, () => {
  2840. if ($section.getAttribute('can-show-more') == null) {
  2841. log('can-show-more attribute removed - reprocessing refreshed items')
  2842. processCurrentItems()
  2843. }
  2844. }, {
  2845. name: 'related <ytd-item-section-renderer> can-show-more attribute',
  2846. observers: pageObservers,
  2847. }, {
  2848. attributes: true,
  2849. attributeFilter: ['can-show-more'],
  2850. })
  2851.  
  2852. observeElement($contents, (mutations) => {
  2853. let newItemCount = 0
  2854. for (let mutation of mutations) {
  2855. for (let $addedNode of mutation.addedNodes) {
  2856. if (!($addedNode instanceof HTMLElement)) continue
  2857. if ($addedNode.nodeName == 'YTD-COMPACT-VIDEO-RENDERER') {
  2858. manuallyHideVideo($addedNode)
  2859. waitForVideoOverlay($addedNode, `related item ${++itemCount}`)
  2860. newItemCount++
  2861. }
  2862. }
  2863. }
  2864. if (newItemCount > 0) {
  2865. log(newItemCount, `related item${s(newItemCount)} added`)
  2866. }
  2867. }, {
  2868. name: `related <ytd-item-section-renderer> contents (for new items being added)`,
  2869. observers: pageObservers,
  2870. })
  2871.  
  2872. processCurrentItems()
  2873. }
  2874.  
  2875. if (mobile) {
  2876. // If the video changes on mobile, related videos are rendered from scratch
  2877. observeVideoList({
  2878. name: 'related <lazy-list>',
  2879. selector: 'ytm-item-section-renderer[data-content-type="related"] > lazy-list',
  2880. page: 'related',
  2881. // <ytm-compact-autoplay-renderer> displays as a large item on bigger mobile screens
  2882. videoElements: new Set(['ytm-video-with-context-renderer', 'ytm-compact-autoplay-renderer']),
  2883. })
  2884. }
  2885. }
  2886.  
  2887. /**
  2888. * Wait for video overlays with watch progress when they're loazed lazily.
  2889. * @param {Element} $video
  2890. * @param {string} uniqueId
  2891. * @param {Map<string, import("./types").Disconnectable>} [observers]
  2892. */
  2893. function waitForVideoOverlay($video, uniqueId, observers) {
  2894. if (!config.hideWatched) return
  2895.  
  2896. if (desktop) {
  2897. // The overlay element is initially empty
  2898. let $overlays = $video.querySelector('#overlays')
  2899. if (!$overlays || $overlays.childElementCount > 0) return
  2900.  
  2901. observeElement($overlays, (mutations, observer) => {
  2902. let nodesAdded = false
  2903. for (let mutation of mutations) {
  2904. for (let $addedNode of mutation.addedNodes) {
  2905. if (!nodesAdded) nodesAdded = true
  2906. if ($addedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
  2907. hideWatched($video)
  2908. }
  2909. }
  2910. }
  2911. if (nodesAdded) {
  2912. observer.disconnect()
  2913. }
  2914. }, {
  2915. name: `${uniqueId} #overlays (for overlay elements being added)`,
  2916. observers: [observers, pageObservers].filter(Boolean),
  2917. })
  2918. }
  2919.  
  2920. if (mobile) {
  2921. // The overlay element has a different initial class
  2922. let $placeholder = $video.querySelector('.video-thumbnail-overlay-bottom-group')
  2923. if (!$placeholder) return
  2924.  
  2925. observeElement($placeholder, (mutations, observer) => {
  2926. let nodesAdded = false
  2927. for (let mutation of mutations) {
  2928. for (let $addedNode of mutation.addedNodes) {
  2929. if (!nodesAdded) nodesAdded = true
  2930. if ($addedNode.nodeName == 'YTM-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
  2931. hideWatched($video)
  2932. }
  2933. }
  2934. }
  2935. if (nodesAdded) {
  2936. observer.disconnect()
  2937. }
  2938. }, {
  2939. name: `${uniqueId} .video-thumbnail-overlay-bottom-group (for overlay elements being added)`,
  2940. observers: [observers, pageObservers].filter(Boolean),
  2941. })
  2942. }
  2943. }
  2944. //#endregion
  2945.  
  2946. //#region Main
  2947. let isUserscript = !(
  2948. typeof GM == 'undefined' &&
  2949. typeof chrome != 'undefined' &&
  2950. typeof chrome.storage != 'undefined'
  2951. )
  2952.  
  2953. function main() {
  2954. if (config.enabled) {
  2955. configureCss()
  2956. triggerVideoPageResize()
  2957. observeTitle()
  2958. observePopups()
  2959. document.addEventListener('click', onDocumentClick, true)
  2960. globalObservers.set('document-click', {
  2961. disconnect() {
  2962. document.removeEventListener('click', onDocumentClick, true)
  2963. }
  2964. })
  2965. }
  2966. }
  2967.  
  2968. /** @param {Partial<import("./types").SiteConfig>} changes */
  2969. function configChanged(changes) {
  2970. if (!changes.hasOwnProperty('enabled')) {
  2971. log('config changed', changes)
  2972. configureCss()
  2973. triggerVideoPageResize()
  2974. handleCurrentUrl()
  2975. return
  2976. }
  2977.  
  2978. log(`${changes.enabled ? 'en' : 'dis'}abling extension functionality`)
  2979. if (changes.enabled) {
  2980. main()
  2981. } else {
  2982. configureCss()
  2983. triggerVideoPageResize()
  2984. disconnectObservers(pageObservers, 'page')
  2985. disconnectObservers(globalObservers,' global')
  2986. }
  2987. }
  2988.  
  2989. /** @param {{[key: string]: chrome.storage.StorageChange}} storageChanges */
  2990. function onConfigChange(storageChanges) {
  2991. let configChanges = Object.fromEntries(
  2992. Object.entries(storageChanges)
  2993. // Don't change the version based on other pages
  2994. .filter(([key]) => config.hasOwnProperty(key) && key != 'version')
  2995. .map(([key, {newValue}]) => [key, newValue])
  2996. )
  2997. if (Object.keys(configChanges).length == 0) return
  2998.  
  2999. if ('debug' in configChanges) {
  3000. log('disabling debug mode')
  3001. debug = configChanges.debug
  3002. log('enabled debug mode')
  3003. return
  3004. }
  3005.  
  3006. if ('debugManualHiding' in configChanges) {
  3007. debugManualHiding = configChanges.debugManualHiding
  3008. log(`${debugManualHiding ? 'en' : 'dis'}abled debugging manual hiding`)
  3009. configureCss()
  3010. return
  3011. }
  3012.  
  3013. Object.assign(config, configChanges)
  3014. configChanged(configChanges)
  3015. }
  3016.  
  3017. /** @param {Partial<import("./types").SiteConfig>} configChanges */
  3018. function storeConfigChanges(configChanges) {
  3019. if (isUserscript) return
  3020. chrome.storage.local.onChanged.removeListener(onConfigChange)
  3021. chrome.storage.local.set(configChanges, () => {
  3022. chrome.storage.local.onChanged.addListener(onConfigChange)
  3023. })
  3024. }
  3025.  
  3026. if (!isUserscript) {
  3027. chrome.storage.local.get((storedConfig) => {
  3028. Object.assign(config, storedConfig)
  3029. log('initial config', {...config, version}, {lang, loggedIn})
  3030.  
  3031. if (config.debug) {
  3032. debug = true
  3033. }
  3034. if (config.debugManualHiding) {
  3035. debugManualHiding = true
  3036. }
  3037.  
  3038. // Let the options page know which version is being used
  3039. chrome.storage.local.set({version})
  3040. chrome.storage.local.onChanged.addListener(onConfigChange)
  3041.  
  3042. window.addEventListener('unload', () => {
  3043. chrome.storage.local.onChanged.removeListener(onConfigChange)
  3044. }, {once: true})
  3045.  
  3046. main()
  3047. })
  3048. }
  3049. else {
  3050. main()
  3051. }
  3052. //#endregion

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址