Control Panel for YouTube

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

目前为 2024-02-25 提交的版本。查看 最新版本

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