- // ==UserScript==
- // @name Control Panel for YouTube
- // @description Gives you more control over YouTube by adding missing options and UI improvements
- // @icon https://raw.githubusercontent.com/insin/control-panel-for-youtube/master/icons/icon32.png
- // @namespace https://jbscript.dev/control-panel-for-youtube
- // @match https://www.youtube.com/*
- // @match https://m.youtube.com/*
- // @exclude https://www.youtube.com/embed/*
- // @version 17
- // ==/UserScript==
- let debug = false
- let debugManualHiding = false
-
- let mobile = location.hostname == 'm.youtube.com'
- let desktop = !mobile
- /** @type {import("./types").Version} */
- let version = mobile ? 'mobile' : 'desktop'
- let lang = mobile ? document.body.lang : document.documentElement.lang
- let loggedIn = /(^|; )SID=/.test(document.cookie)
-
- function log(...args) {
- if (debug) {
- console.log('🙋', ...args)
- }
- }
-
- function warn(...args) {
- if (debug) {
- console.log('❗️', ...args)
- }
- }
-
- //#region Default config
- /** @type {import("./types").SiteConfig} */
- let config = {
- debug: false,
- enabled: true,
- version,
- disableAutoplay: true,
- disableHomeFeed: false,
- hideAI: true,
- hiddenChannels: [],
- hideChannels: true,
- hideComments: false,
- hideHiddenVideos: true,
- hideHomeCategories: false,
- hideInfoPanels: false,
- hideLive: false,
- hideMetadata: false,
- hideMixes: false,
- hideMoviesAndTV: false,
- hideNextButton: true,
- hideRelated: false,
- hideShareThanksClip: false,
- hideShorts: true,
- hideSponsored: true,
- hideStreamed: false,
- hideSuggestedSections: true,
- hideUpcoming: false,
- hideVoiceSearch: false,
- hideWatched: true,
- hideWatchedThreshold: '80',
- redirectShorts: true,
- removePink: false,
- skipAds: true,
- // Desktop only
- alwaysUseTheaterMode: false,
- downloadTranscript: true,
- fullSizeTheaterMode: false,
- hideChat: false,
- hideEndCards: false,
- hideEndVideos: true,
- hideMerchEtc: true,
- hideMiniplayerButton: false,
- hideSubscriptionsLatestBar: false,
- minimumGridItemsPerRow: 'auto',
- pauseChannelTrailers: true,
- searchThumbnailSize: 'medium',
- tidyGuideSidebar: false,
- // Mobile only
- hideExploreButton: true,
- hideOpenApp: true,
- hideSubscriptionsChannelList: false,
- mobileGridView: true,
- }
- //#endregion
-
- //#region Locales
- /**
- * @type {Record<string, import("./types").Locale>}
- */
- const locales = {
- 'en': {
- CLIP: 'Clip',
- DOWNLOAD: 'Download',
- FOR_YOU: 'For you',
- HIDE_CHANNEL: 'Hide channel',
- MIXES: 'Mixes',
- MUTE: 'Mute',
- NEXT_VIDEO: 'Next video',
- OPEN_APP: 'Open App',
- PREVIOUS_VIDEO: 'Previous video',
- SHARE: 'Share',
- SHORTS: 'Shorts',
- STREAMED_TITLE: 'views Streamed',
- TELL_US_WHY: 'Tell us why',
- THANKS: 'Thanks',
- UNHIDE_CHANNEL: 'Unhide channel',
- },
- 'ja-JP': {
- CLIP: 'クリップ',
- DOWNLOAD: 'オフライン',
- FOR_YOU: 'あなたへのおすすめ',
- HIDE_CHANNEL: 'チャンネルを隠す',
- MIXES: 'ミックス',
- MUTE: 'ミュート(消音)',
- NEXT_VIDEO: '次の動画',
- OPEN_APP: 'アプリを開く',
- PREVIOUS_VIDEO: '前の動画',
- SHARE: '共有',
- SHORTS: 'ショート',
- STREAMED_TITLE: '前 に配信済み',
- TELL_US_WHY: '理由を教えてください',
- UNHIDE_CHANNEL: 'チャンネルの再表示',
- }
- }
-
- /**
- * @param {import("./types").LocaleKey} code
- * @returns {string}
- */
- function getString(code) {
- return (locales[lang] || locales['en'])[code] || locales['en'][code];
- }
- //#endregion
-
- const undoHideDelayMs = 5000
-
- const Classes = {
- HIDE_CHANNEL: 'cpfyt-hide-channel',
- HIDE_HIDDEN: 'cpfyt-hide-hidden',
- HIDE_OPEN_APP: 'cpfyt-hide-open-app',
- HIDE_STREAMED: 'cpfyt-hide-streamed',
- HIDE_WATCHED: 'cpfyt-hide-watched',
- HIDE_SHARE_THANKS_CLIP: 'cpfyt-hide-share-thanks-clip',
- }
-
- const Svgs = {
- 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>',
- RESTORE: '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" focusable="false" style="pointer-events: none; display: block; width: 100%; height: 100%;"><path d="M460-347.692h40V-535.23l84 83.538L612.308-480 480-612.308 347.692-480 376-451.692l84-83.538v187.538ZM304.615-160Q277-160 258.5-178.5 240-197 240-224.615V-720h-40v-40h160v-30.77h240V-760h160v40h-40v495.385Q720-197 701.5-178.5 683-160 655.385-160h-350.77ZM680-720H280v495.385q0 9.23 7.692 16.923Q295.385-200 304.615-200h350.77q9.23 0 16.923-7.692Q680-215.385 680-224.615V-720Zm-400 0v520-520Z"/></svg>',
- }
-
- // YouTube channel URLs: https://support.google.com/youtube/answer/6180214
- const URL_CHANNEL_RE = /\/(?:@[^\/]+|(?:c|channel|user)\/[^\/]+)(?:\/(featured|videos|shorts|playlists|community))?\/?$/
-
- //#region State
- /** @type {() => void} */
- let onAdRemoved
- /** @type {Map<string, import("./types").Disconnectable>} */
- let globalObservers = new Map()
- /** @type {import("./types").Channel} */
- let lastClickedChannel
- /** @type {HTMLElement} */
- let $lastClickedElement
- /** @type {() => void} */
- let onDialogClosed
- /** @type {Map<string, import("./types").Disconnectable>} */
- let pageObservers = new Map()
- //#endregion
-
- //#region Utility functions
- function addStyle(css = '') {
- let $style = document.createElement('style')
- $style.dataset.insertedBy = 'control-panel-for-youtube'
- if (css) {
- $style.textContent = css
- }
- document.head.appendChild($style)
- return $style
- }
-
- function currentUrlChanges() {
- let currentUrl = getCurrentUrl()
- return () => currentUrl != getCurrentUrl()
- }
-
- /**
- * @param {string} str
- * @return {string}
- */
- function dedent(str) {
- str = str.replace(/^[ \t]*\r?\n/, '')
- let indent = /^[ \t]+/m.exec(str)
- if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '')
- return str.replace(/(\r?\n)[ \t]+$/, '$1')
- }
-
- /** @param {Map<string, import("./types").Disconnectable>} observers */
- function disconnectObservers(observers, scope) {
- if (observers.size == 0) return
- log(
- `disconnecting ${observers.size} ${scope} observer${s(observers.size)}`,
- Array.from(observers.keys())
- )
- logObserverDisconnects = false
- for (let observer of observers.values()) observer.disconnect()
- logObserverDisconnects = true
- }
-
- function getCurrentUrl() {
- return location.origin + location.pathname + location.search
- }
-
- /**
- * @typedef {{
- * name?: string
- * stopIf?: () => boolean
- * timeout?: number
- * context?: Document | HTMLElement
- * }} GetElementOptions
- *
- * @param {string} selector
- * @param {GetElementOptions} options
- * @returns {Promise<HTMLElement | null>}
- */
- function getElement(selector, {
- name = null,
- stopIf = null,
- timeout = Infinity,
- context = document,
- } = {}) {
- return new Promise((resolve) => {
- let startTime = Date.now()
- let rafId
- let timeoutId
-
- function stop($element, reason) {
- if ($element == null) {
- warn(`stopped waiting for ${name || selector} after ${reason}`)
- }
- else if (Date.now() > startTime) {
- log(`${name || selector} appeared after`, Date.now() - startTime, 'ms')
- }
- if (rafId) {
- cancelAnimationFrame(rafId)
- }
- if (timeoutId) {
- clearTimeout(timeoutId)
- }
- resolve($element)
- }
-
- if (timeout !== Infinity) {
- timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`)
- }
-
- function queryElement() {
- let $element = context.querySelector(selector)
- if ($element) {
- stop($element)
- }
- else if (stopIf?.() === true) {
- stop(null, 'stopIf condition met')
- }
- else {
- rafId = requestAnimationFrame(queryElement)
- }
- }
-
- queryElement()
- })
- }
-
- /** @param {import("./types").Channel} channel */
- function isChannelHidden(channel) {
- return config.hiddenChannels.some((hiddenChannel) =>
- channel.url && hiddenChannel.url ? channel.url == hiddenChannel.url : hiddenChannel.name == channel.name
- )
- }
-
- let logObserverDisconnects = true
-
- /**
- * Convenience wrapper for the MutationObserver API:
- *
- * - Defaults to {childList: true}
- * - Observers have associated names
- * - Optional leading call for callback
- * - Observers are stored in a scope object
- * - Observers already in the given scope will be disconnected
- * - onDisconnect hook for post-disconnect logic
- *
- * @param {Node} $target
- * @param {MutationCallback} callback
- * @param {{
- * leading?: boolean
- * logElement?: boolean
- * name: string
- * observers: Map<string, import("./types").Disconnectable> | Map<string, import("./types").Disconnectable>[]
- * onDisconnect?: () => void
- * }} options
- * @param {MutationObserverInit} mutationObserverOptions
- * @return {import("./types").CustomMutationObserver}
- */
- function observeElement($target, callback, options, mutationObserverOptions = {childList: true}) {
- let {leading, logElement, name, observers, onDisconnect} = options
- let observerMaps = Array.isArray(observers) ? observers : [observers]
-
- /** @type {import("./types").CustomMutationObserver} */
- let observer = Object.assign(new MutationObserver(callback), {name})
- let disconnect = observer.disconnect.bind(observer)
- let disconnected = false
- observer.disconnect = () => {
- if (disconnected) return
- disconnected = true
- disconnect()
- for (let map of observerMaps) map.delete(name)
- onDisconnect?.()
- if (logObserverDisconnects) {
- log(`disconnected ${name} observer`)
- }
- }
-
- if (observerMaps[0].has(name)) {
- log(`disconnecting existing ${name} observer`)
- logObserverDisconnects = false
- observerMaps[0].get(name).disconnect()
- logObserverDisconnects = true
- }
-
- for (let map of observerMaps) map.set(name, observer)
- if (logElement) {
- log(`observing ${name}`, $target)
- } else {
- log(`observing ${name}`)
- }
- observer.observe($target, mutationObserverOptions)
- if (leading) {
- callback([], observer)
- }
- return observer
- }
-
- /**
- * Uses a MutationObserver to wait for a specific element. If found, the
- * observer will be disconnected. If the observer is disconnected first, the
- * resolved value will be null.
- *
- * @param {Node} $target
- * @param {(mutations: MutationRecord[]) => HTMLElement} getter
- * @param {{
- * logElement?: boolean
- * name: string
- * targetName: string
- * observers: Map<string, import("./types").Disconnectable>
- * }} options
- * @param {MutationObserverInit} [mutationObserverOptions]
- * @return {Promise<HTMLElement>}
- */
- function observeForElement($target, getter, options, mutationObserverOptions) {
- let {targetName, ...observeElementOptions} = options
- return new Promise((resolve) => {
- let found = false
- let startTime = Date.now()
- observeElement($target, (mutations, observer) => {
- let $result = getter(mutations)
- if ($result) {
- found = true
- if (Date.now() > startTime) {
- log(`${targetName} appeared after`, Date.now() - startTime, 'ms')
- }
- observer.disconnect()
- resolve($result)
- }
- }, {
- ...observeElementOptions,
- onDisconnect() {
- if (!found) resolve(null)
- },
- }, mutationObserverOptions)
- })
- }
-
- /**
- * @param {number} n
- * @returns {string}
- */
- function s(n) {
- return n == 1 ? '' : 's'
- }
- //#endregion
-
- //#region CSS
- const configureCss = (() => {
- /** @type {HTMLStyleElement} */
- let $style
-
- return function configureCss() {
- if (!config.enabled) {
- log('removing stylesheet')
- $style?.remove()
- $style = null
- return
- }
-
- let cssRules = []
- let hideCssSelectors = []
-
- if (config.skipAds) {
- // Display a black overlay while ads are playing
- cssRules.push(`
- .ytp-ad-player-overlay, .ytp-ad-player-overlay-layout, .ytp-ad-action-interstitial {
- background: black;
- z-index: 10;
- }
- `)
- // Hide elements while an ad is showing
- hideCssSelectors.push(
- // Thumbnail for cued ad when autoplay is disabled
- '#movie_player.ad-showing .ytp-cued-thumbnail-overlay-image',
- // Ad video
- '#movie_player.ad-showing video',
- // Ad title
- '#movie_player.ad-showing .ytp-chrome-top',
- // Ad overlay content
- '#movie_player.ad-showing .ytp-ad-player-overlay > div',
- '#movie_player.ad-showing .ytp-ad-player-overlay-layout > div',
- '#movie_player.ad-showing .ytp-ad-action-interstitial > div',
- // Yellow ad progress bar
- '#movie_player.ad-showing .ytp-play-progress',
- // Ad time display
- '#movie_player.ad-showing .ytp-time-display',
- )
- }
-
- if (config.disableAutoplay) {
- if (desktop) {
- hideCssSelectors.push('button[data-tooltip-target-id="ytp-autonav-toggle-button"]')
- }
- if (mobile) {
- hideCssSelectors.push('button.ytm-autonav-toggle-button-container')
- }
- }
-
- if (config.disableHomeFeed && loggedIn) {
- if (desktop) {
- hideCssSelectors.push(
- // Prevent flash of content while redirecting
- 'ytd-browse[page-subtype="home"]',
- // Hide Home links
- 'ytd-guide-entry-renderer:has(> a[href="/"])',
- 'ytd-mini-guide-entry-renderer:has(> a[href="/"])',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Prevent flash of content while redirecting
- '.tab-content[tab-identifier="FEwhat_to_watch"]',
- // Bottom nav item
- 'ytm-pivot-bar-item-renderer:has(> div.pivot-w2w)',
- )
- }
- }
-
- if (config.hideAI) {
- if (desktop) {
- const geminiSvgPath = 'M480-80q0-83-31.5-156T363-363q-54-54-127-85.5T80-480q83 0 156-31.5T363-597q54-54 85.5-127T480-880q0 83 31.5 156T597-597q54 54 127 85.5T880-480q-83 0-156 31.5T597-363q-54 54-85.5 127T480-80Z'
- hideCssSelectors.push(`#expandable-metadata:has(path[d="${geminiSvgPath}"])`)
- }
- if (mobile) {
- const geminiSvgPath = 'M6 0c0 3.314-2.69 6-6 6 3.31 0 6 2.686 6 6 0-3.314 2.69-6 6-6-3.31 0-6-2.686-6-6Z'
- hideCssSelectors.push(`ytm-expandable-metadata-renderer:has(path[d="${geminiSvgPath}"])`)
- }
- }
-
- if (config.hideHomeCategories) {
- if (desktop) {
- hideCssSelectors.push('ytd-browse[page-subtype="home"] #header')
- }
- if (mobile) {
- hideCssSelectors.push('.tab-content[tab-identifier="FEwhat_to_watch"] .rich-grid-sticky-header')
- }
- }
-
- // We only hide channels in Home, Search and Related videos
- if (config.hideChannels) {
- if (config.hiddenChannels.length > 0) {
- if (debugManualHiding) {
- cssRules.push(`.${Classes.HIDE_CHANNEL} { outline: 2px solid red !important; }`)
- } else {
- hideCssSelectors.push(`.${Classes.HIDE_CHANNEL}`)
- }
- }
- if (desktop) {
- // Custom elements can't be cloned so we need to style our own menu items
- cssRules.push(`
- .cpfyt-menu-item {
- align-items: center;
- cursor: pointer;
- display: flex !important;
- min-height: 36px;
- padding: 0 12px 0 16px;
- }
- .cpfyt-menu-item:focus {
- position: relative;
- background-color: var(--paper-item-focused-background-color);
- outline: 0;
- }
- .cpfyt-menu-item:focus::before {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- pointer-events: none;
- background: var(--paper-item-focused-before-background, currentColor);
- border-radius: var(--paper-item-focused-before-border-radius, 0);
- content: var(--paper-item-focused-before-content, "");
- opacity: var(--paper-item-focused-before-opacity, var(--dark-divider-opacity, 0.12));
- }
- .cpfyt-menu-item:hover {
- background-color: var(--yt-spec-10-percent-layer);
- }
- .cpfyt-menu-icon {
- color: var(--yt-spec-text-primary);
- fill: currentColor;
- height: 24px;
- margin-right: 16px;
- width: 24px;
- }
- .cpfyt-menu-text {
- color: var(--yt-spec-text-primary);
- flex-basis: 0.000000001px;
- flex: 1;
- font-family: "Roboto","Arial",sans-serif;
- font-size: 1.4rem;
- font-weight: 400;
- line-height: 2rem;
- margin-right: 24px;
- white-space: nowrap;
- }
- `)
- }
- } else {
- // Hide menu item if config is changed after it's added
- hideCssSelectors.push('#cpfyt-hide-channel-menu-item')
- }
-
- if (config.hideComments) {
- if (desktop) {
- hideCssSelectors.push('#comments')
- }
- if (mobile) {
- hideCssSelectors.push('ytm-item-section-renderer[section-identifier="comments-entry-point"]')
- }
- }
-
- if (config.hideHiddenVideos) {
- // The mobile version doesn't have any HTML hooks for appearance mode, so
- // we'll just use the current backgroundColor.
- let bgColor = getComputedStyle(document.documentElement).backgroundColor
- cssRules.push(`
- .cpfyt-pie {
- --cpfyt-pie-background-color: ${bgColor};
- --cpfyt-pie-color: ${bgColor == 'rgb(255, 255, 255)' ? '#065fd4' : '#3ea6ff'};
- --cpfyt-pie-delay: 0ms;
- --cpfyt-pie-direction: normal;
- --cpfyt-pie-duration: ${undoHideDelayMs}ms;
- width: 1em;
- height: 1em;
- font-size: 200%;
- position: relative;
- border-radius: 50%;
- margin: 0.5em;
- display: inline-block;
- }
- .cpfyt-pie::before,
- .cpfyt-pie::after {
- content: "";
- width: 50%;
- height: 100%;
- position: absolute;
- left: 0;
- border-radius: 0.5em 0 0 0.5em;
- transform-origin: center right;
- animation-delay: var(--cpfyt-pie-delay);
- animation-direction: var(--cpfyt-pie-direction);
- animation-duration: var(--cpfyt-pie-duration);
- }
- .cpfyt-pie::before {
- z-index: 1;
- background-color: var(--cpfyt-pie-background-color);
- animation-name: cpfyt-mask;
- animation-timing-function: steps(1);
- }
- .cpfyt-pie::after {
- background-color: var(--cpfyt-pie-color);
- animation-name: cpfyt-rotate;
- animation-timing-function: linear;
- }
- @keyframes cpfyt-rotate {
- to { transform: rotate(1turn); }
- }
- @keyframes cpfyt-mask {
- 50%, 100% {
- background-color: var(--cpfyt-pie-color);
- transform: rotate(0.5turn);
- }
- }
- `)
- if (debugManualHiding) {
- cssRules.push(`.${Classes.HIDE_HIDDEN} { outline: 2px solid magenta !important; }`)
- } else {
- hideCssSelectors.push(`.${Classes.HIDE_HIDDEN}`)
- }
- }
-
- if (config.hideInfoPanels) {
- if (desktop) {
- hideCssSelectors.push(
- // In Search
- 'ytd-clarification-renderer',
- 'ytd-info-panel-container-renderer',
- // Below video
- '#middle-row.ytd-watch-metadata:has(> ytd-info-panel-content-renderer:only-child)',
- 'ytd-info-panel-content-renderer',
- '#clarify-box',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // In Search and below video
- 'ytm-clarification-renderer',
- 'ytm-info-panel-container-renderer',
- )
- }
- }
-
- if (config.hideLive) {
- if (desktop) {
- hideCssSelectors.push(
- // Grid item (Home, Subscriptions)
- 'ytd-browse:not([page-subtype="channels"]) ytd-rich-item-renderer:has(ytd-thumbnail[is-live-video])',
- // List item (Search)
- 'ytd-video-renderer:has(ytd-thumbnail[is-live-video])',
- // Related video
- 'ytd-compact-video-renderer:has(> .ytd-compact-video-renderer > ytd-thumbnail[is-live-video])',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Home
- 'ytm-rich-item-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
- // Subscriptions
- '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
- // Search
- 'ytm-search ytm-video-with-context-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
- // Large item in Related videos
- 'ytm-item-section-renderer[section-identifier="related-items"] > lazy-list > ytm-compact-autoplay-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="LIVE"])',
- // Related videos
- '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"])',
- )
- }
- }
-
- if (config.hideMetadata) {
- if (desktop) {
- hideCssSelectors.push(
- // Channel name / Videos / About (but not Transcript or their mutual container)
- '#structured-description .ytd-structured-description-content-renderer:not(#items, ytd-video-description-transcript-section-renderer)',
- // Game name and Gaming link
- '#above-the-fold + ytd-metadata-row-container-renderer',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Game name and Gaming link
- 'ytm-structured-description-content-renderer yt-video-attributes-section-view-model',
- 'ytm-video-description-gaming-section-renderer',
- // Channel name / Videos / About
- 'ytm-structured-description-content-renderer ytm-video-description-infocards-section-renderer',
- // Music
- 'ytm-structured-description-content-renderer ytm-horizontal-card-list-renderer',
- )
- }
- }
-
- if (config.hideMixes) {
- if (desktop) {
- hideCssSelectors.push(
- // Chip in Home
- `yt-chip-cloud-chip-renderer:has(> yt-formatted-string[title="${getString('MIXES')}"])`,
- // Grid item
- 'ytd-rich-item-renderer:has(a[href$="start_radio=1"])',
- // List item
- 'ytd-radio-renderer',
- // Related video
- 'ytd-compact-radio-renderer',
- // Search result and related video
- 'yt-lockup-view-model:has(a[href*="start_radio=1"])',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Chip in Home
- `ytm-chip-cloud-chip-renderer:has(> .chip-container[aria-label="${getString('MIXES')}"])`,
- // Home
- 'ytm-rich-item-renderer:has(> ytm-radio-renderer)',
- // Search result
- 'ytm-compact-radio-renderer',
- )
- }
- }
-
- if (config.hideMoviesAndTV) {
- if (desktop) {
- hideCssSelectors.push(
- // In Home
- 'ytd-rich-item-renderer.ytd-rich-grid-renderer:has(a[href$="pp=sAQB"])',
- // In Search
- 'ytd-movie-renderer',
- // In Related videos
- 'ytd-compact-movie-renderer',
- 'ytd-compact-video-renderer:has(a[href$="pp=sAQB"])',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // In Home
- '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-item-renderer:has(a[href$="pp=sAQB"])',
- // In Search
- 'ytm-search ytm-video-with-context-renderer:has(ytm-badge[data-type="BADGE_STYLE_TYPE_YPC"])',
- // In Related videos
- 'ytm-item-section-renderer[data-content-type="related"] ytm-video-with-context-renderer:has(a[href$="pp=sAQB"])'
- )
- }
- }
-
- if (config.hideNextButton) {
- if (desktop) {
- // Hide the Next by default so it doesn't flash in and out of visibility
- // Show Next is Previous is enabled (e.g. when viewing a playlist video)
- cssRules.push(`
- .ytp-chrome-controls .ytp-next-button {
- display: none !important;
- }
- .ytp-chrome-controls .ytp-prev-button[aria-disabled="false"] ~ .ytp-next-button {
- display: revert !important;
- }
- `)
- }
- if (mobile) {
- hideCssSelectors.push(
- // Hide the Previous button when it's disabled, as it otherwise takes you to the previously-watched video
- `.player-controls-middle-core-buttons > button[aria-label="${getString('PREVIOUS_VIDEO')}"][aria-disabled="true"]`,
- // Always hide the Next button as it takes you to a random video, even if you just used Previous
- `.player-controls-middle-core-buttons > button[aria-label="${getString('NEXT_VIDEO')}"]`,
- )
- }
- }
-
- if (config.hideRelated) {
- if (desktop) {
- hideCssSelectors.push('#related')
- }
- if (mobile) {
- hideCssSelectors.push('ytm-item-section-renderer[section-identifier="related-items"]')
- }
- }
-
- if (config.hideShareThanksClip) {
- if (desktop) {
- hideCssSelectors.push(
- // Buttons
- `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('SHARE')}"])`,
- `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('THANKS')}"])`,
- `ytd-menu-renderer yt-button-view-model:has(> button-view-model > button[aria-label="${getString('CLIP')}"])`,
- // Menu items
- `.${Classes.HIDE_SHARE_THANKS_CLIP}`,
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- `ytm-slim-video-action-bar-renderer button-view-model:has(button[aria-label="${getString('SHARE')}"])`,
- )
- }
- }
-
- if (config.hideShorts) {
- if (desktop) {
- hideCssSelectors.push(
- // Side nav item
- `ytd-guide-entry-renderer:has(> a[title="${getString('SHORTS')}"])`,
- // Mini side nav item
- `ytd-mini-guide-entry-renderer[aria-label="${getString('SHORTS')}"]`,
- // Grid shelf
- 'ytd-rich-section-renderer:has(> #content > ytd-rich-shelf-renderer[is-shorts])',
- // Group of 3 Shorts in Home grid
- 'ytd-browse[page-subtype="home"] ytd-rich-grid-group',
- // Chips
- `yt-chip-cloud-chip-renderer:has(> yt-formatted-string[title="${getString('SHORTS')}"])`,
- // List shelf (except History, so watched Shorts can be removed)
- 'ytd-browse:not([page-subtype="history"]) ytd-reel-shelf-renderer',
- 'ytd-search ytd-reel-shelf-renderer',
- // List item (except History, so watched Shorts can be removed)
- 'ytd-browse:not([page-subtype="history"]) ytd-video-renderer:has(a[href^="/shorts"])',
- 'ytd-search ytd-video-renderer:has(a[href^="/shorts"])',
- // Under video
- '#structured-description ytd-reel-shelf-renderer',
- // In related
- '#related ytd-reel-shelf-renderer',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Bottom nav item
- 'ytm-pivot-bar-item-renderer:has(> div.pivot-shorts)',
- // Home shelf
- 'ytm-rich-section-renderer:has(ytm-reel-shelf-renderer)',
- 'ytm-rich-section-renderer:has(ytm-shorts-lockup-view-model)',
- // Subscriptions shelf
- '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer)',
- // Search shelf
- 'ytm-search lazy-list > ytm-reel-shelf-renderer',
- // Search
- 'ytm-search ytm-video-with-context-renderer:has(a[href^="/shorts"])',
- // Under video
- 'ytm-structured-description-content-renderer ytm-reel-shelf-renderer',
- // In related
- 'ytm-item-section-renderer[data-content-type="related"] ytm-video-with-context-renderer:has(a[href^="/shorts"])',
- )
- }
- }
-
- if (config.hideSponsored) {
- if (desktop) {
- hideCssSelectors.push(
- // Big ads and promos on Home screen
- '#masthead-ad',
- '#big-yoodle ytd-statement-banner-renderer',
- 'ytd-rich-section-renderer:has(> #content > ytd-statement-banner-renderer)',
- 'ytd-rich-section-renderer:has(> #content > ytd-rich-shelf-renderer[has-paygated-featured-badge])',
- 'ytd-rich-section-renderer:has(> #content > ytd-brand-video-shelf-renderer)',
- 'ytd-rich-section-renderer:has(> #content > ytd-brand-video-singleton-renderer)',
- 'ytd-rich-section-renderer:has(> #content > ytd-inline-survey-renderer)',
- // Bottom of screen promo
- 'tp-yt-paper-dialog:has(> #mealbar-promo-renderer)',
- // Video listings
- 'ytd-rich-item-renderer:has(> .ytd-rich-item-renderer > ytd-ad-slot-renderer)',
- // Search results
- 'ytd-search-pyv-renderer.ytd-item-section-renderer',
- 'ytd-ad-slot-renderer.ytd-item-section-renderer',
- // When an ad is playing
- 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"]',
- // Suggestd action buttons in player overlay
- '#movie_player .ytp-suggested-action',
- // Panels linked to those buttons
- '#below #panels',
- // After an ad
- '.ytp-ad-action-interstitial',
- // Paid content overlay
- '.ytp-paid-content-overlay',
- // Above Related videos
- '#player-ads',
- // In Related videos
- '#items > ytd-ad-slot-renderer',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Big promo on Home screen
- 'ytm-statement-banner-renderer',
- // Bottom of screen promo
- '.mealbar-promo-renderer',
- // Search results
- 'ytm-search ytm-item-section-renderer:has(> lazy-list > ad-slot-renderer)',
- // Paid content overlay
- 'ytm-paid-content-overlay-renderer',
- // Directly under video
- 'ytm-companion-slot:has(> ytm-companion-ad-renderer)',
- // Directly under comments entry point (narrow)
- 'ytm-item-section-renderer[section-identifier="comments-entry-point"] + ytm-item-section-renderer:has(> lazy-list > ad-slot-renderer)',
- // In Related videos (narrow)
- 'ytm-watch ytm-item-section-renderer[data-content-type="result"]:has(> lazy-list > ad-slot-renderer)',
- // In Related videos (wide)
- 'ytm-item-section-renderer[section-identifier="related-items"] > lazy-list > ad-slot-renderer',
- )
- }
- }
-
- if (config.hideStreamed) {
- if (debugManualHiding) {
- cssRules.push(`.${Classes.HIDE_STREAMED} { outline: 2px solid blue; }`)
- } else {
- hideCssSelectors.push(`.${Classes.HIDE_STREAMED}`)
- }
- }
-
- if (config.hideSuggestedSections) {
- if (desktop) {
- hideCssSelectors.push(
- // Shelves in Home
- 'ytd-browse[page-subtype="home"] ytd-rich-section-renderer:not(:has(> #content > ytd-rich-shelf-renderer[is-shorts]))',
- // Looking for something different? tile in Home
- 'ytd-browse[page-subtype="home"] ytd-rich-item-renderer:has(> #content > ytd-feed-nudge-renderer)',
- // Suggested content shelves in Search
- `ytd-search #contents.ytd-item-section-renderer > ytd-shelf-renderer`,
- // People also search for in Search
- 'ytd-search #contents.ytd-item-section-renderer > ytd-horizontal-card-list-renderer',
- // Recommended videos in a Playlist
- 'ytd-browse[page-subtype="playlist"] ytd-item-section-renderer[is-playlist-video-container]',
- // Recommended playlists in a Playlist
- 'ytd-browse[page-subtype="playlist"] ytd-item-section-renderer[is-playlist-video-container] + ytd-item-section-renderer',
- )
- }
- if (mobile) {
- if (loggedIn) {
- hideCssSelectors.push(
- // Shelves in Home
- '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-section-renderer',
- )
- } else {
- // Logged-out users can get "Try searching to get started" Home page
- // sections we don't want to hide.
- hideCssSelectors.push(
- // Shelves in Home
- '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-section-renderer:not(:has(ytm-search-bar-entry-point-view-model, ytm-feed-nudge-renderer))',
- )
- }
- }
- }
-
- if (config.hideUpcoming) {
- if (desktop) {
- hideCssSelectors.push(
- // Grid item
- 'ytd-browse:not([page-subtype="channels"]) ytd-rich-item-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="UPCOMING"])',
- // List item
- 'ytd-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="UPCOMING"])',
- )
- }
- if (mobile) {
- hideCssSelectors.push(
- // Subscriptions
- '.tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-thumbnail-overlay-time-status-renderer[data-style="UPCOMING"])'
- )
- }
- }
-
- if (config.hideVoiceSearch) {
- if (desktop) {
- hideCssSelectors.push('#voice-search-button')
- }
- if (mobile) {
- hideCssSelectors.push(
- // Outside of Search
- '.ytSearchboxComponentVoiceSearchWrapper',
- // In Search
- '.mobile-topbar-header-voice-search-button',
- // Logged out home page
- '.search-bar-entry-point-voice-search-button',
- )
- }
- }
-
- if (config.hideWatched) {
- if (debugManualHiding) {
- cssRules.push(`.${Classes.HIDE_WATCHED} { outline: 2px solid green; }`)
- } else {
- hideCssSelectors.push(`.${Classes.HIDE_WATCHED}`)
- }
- }
-
- //#region Desktop-only
- if (desktop) {
- // Fix spaces & gaps caused by left gutter margin on first column items
- cssRules.push(`
- /* Remove left gutter margin from first column items */
- ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) ytd-rich-item-renderer[rendered-from-rich-grid][is-in-first-column] {
- margin-left: calc(var(--ytd-rich-grid-item-margin, 16px) / 2) !important;
- }
- /* Apply the left gutter as padding in the grid contents instead */
- ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) #contents.ytd-rich-grid-renderer {
- padding-left: calc(var(--ytd-rich-grid-gutter-margin, 16px) * 2) !important;
- }
- /* Adjust non-grid items so they don't double the gutter */
- ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) #contents.ytd-rich-grid-renderer > :not(ytd-rich-item-renderer) {
- margin-left: calc(var(--ytd-rich-grid-gutter-margin, 16px) * -1) !important;
- }
- `)
- if (config.fullSizeTheaterMode) {
- // 56px is the height of #container.ytd-masthead
- cssRules.push(`
- ytd-watch-flexy[theater]:not([fullscreen]) #full-bleed-container {
- max-height: calc(100vh - 56px);
- }
- `)
- }
- if (config.hideChat) {
- hideCssSelectors.push(
- // Live chat / Chat replay
- '#chat-container',
- // "Live chat replay" panel in video metadata
- '#teaser-carousel.ytd-watch-metadata',
- // Chat panel in theater mode
- '#full-bleed-container.ytd-watch-flexy #panels-full-bleed-container.ytd-watch-flexy',
- )
- }
- if (config.hideEndCards) {
- hideCssSelectors.push('#movie_player .ytp-ce-element')
- }
- if (config.hideEndVideos) {
- hideCssSelectors.push(
- '#movie_player .ytp-endscreen-content',
- '#movie_player .ytp-endscreen-previous',
- '#movie_player .ytp-endscreen-next',
- )
- }
- if (config.hideMerchEtc) {
- hideCssSelectors.push(
- // Tickets
- '#ticket-shelf',
- // Merch
- 'ytd-merch-shelf-renderer',
- // Offers
- '#offer-module',
- )
- }
- if (config.hideMiniplayerButton) {
- hideCssSelectors.push('#movie_player .ytp-miniplayer-button')
- }
- if (config.hideSubscriptionsLatestBar) {
- hideCssSelectors.push(
- 'ytd-browse[page-subtype="subscriptions"] ytd-rich-grid-renderer > #contents > ytd-rich-section-renderer:first-child'
- )
- }
- if (config.minimumGridItemsPerRow != 'auto') {
- let gridItemsPerRow = Number(config.minimumGridItemsPerRow)
- let exclude = []
- for (let i = 6; i > gridItemsPerRow; i--) {
- exclude.push(`[elements-per-row="${i}"]`)
- }
- cssRules.push(`
- ytd-browse:is([page-subtype="home"], [page-subtype="subscriptions"]) ytd-rich-grid-renderer${exclude.length > 0 ? `:not(${exclude.join(', ')})` : ''} {
- --ytd-rich-grid-items-per-row: ${gridItemsPerRow} !important;
- }
- `)
- }
- if (config.removePink) {
- cssRules.push(`
- .ytp-play-progress,
- #progress.ytd-thumbnail-overlay-resume-playback-renderer,
- .ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment,
- .ytChapteredProgressBarChapteredPlayerBarChapterSeen,
- .ytChapteredProgressBarChapteredPlayerBarFill,
- .ytProgressBarLineProgressBarPlayed,
- #progress.yt-page-navigation-progress,
- .progress-bar-played.ytd-progress-bar-line {
- background: #f03 !important;
- }
- `)
- }
- if (config.searchThumbnailSize != 'large') {
- cssRules.push(`
- ytd-search ytd-video-renderer ytd-thumbnail.ytd-video-renderer,
- ytd-search yt-lockup-view-model .yt-lockup-view-model-wiz__content-image {
- max-width: ${{
- medium: 420,
- small: 360,
- }[config.searchThumbnailSize]}px !important;
- }
- `)
- }
- if (config.tidyGuideSidebar) {
- hideCssSelectors.push(
- // Logged in
- // Subscriptions (2nd of 5)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(2):nth-last-child(4)',
- // Explore (3rd of 5)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(3):nth-last-child(3)',
- // More from YouTube (4th of 5)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(4):nth-last-child(2)',
- // Logged out
- /*
- // Subscriptions - prompts you to log in
- '#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"])',
- // You (2nd of 7) - prompts you to log in
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(2):nth-last-child(6)',
- // Sign in prompt - already have one in the top corner
- '#sections.ytd-guide-renderer > ytd-guide-signin-promo-renderer',
- */
- // Explore (4th of 7)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(4):nth-last-child(4)',
- // Browse Channels (5th of 7)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(5):nth-last-child(3)',
- // More from YouTube (6th of 7)
- '#sections.ytd-guide-renderer > ytd-guide-section-renderer:nth-child(6):nth-last-child(2)',
- // Footer
- '#footer.ytd-guide-renderer',
- )
- }
- }
- //#endregion
-
- //#region Mobile-only
- if (mobile) {
- if (config.hideExploreButton) {
- // Explore button on Home screen
- hideCssSelectors.push('ytm-chip-cloud-chip-renderer[chip-style="STYLE_EXPLORE_LAUNCHER_CHIP"]')
- }
- if (config.hideOpenApp) {
- hideCssSelectors.push(
- // The user menu is replaced with "Open App" on videos when logged out
- 'html.watch-scroll .mobile-topbar-header-sign-in-button',
- // The overflow menu has an Open App menu item we'll add this class to
- `ytm-menu-item.${Classes.HIDE_OPEN_APP}`,
- // The last item in the full screen menu is Open App
- '#menu .multi-page-menu-system-link-list:has(+ ytm-privacy-tos-footer-renderer)',
- )
- }
- if (config.hideSubscriptionsChannelList) {
- // Channel list at top of Subscriptions
- hideCssSelectors.push('.tab-content[tab-identifier="FEsubscriptions"] ytm-channel-list-sub-menu-renderer')
- }
- if (config.mobileGridView) {
- // Based on the Home grid layout
- // Subscriptions
- cssRules.push(`
- @media (min-width: 550px) and (orientation: portrait) {
- .tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer {
- margin: 0 16px;
- }
- .tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer > lazy-list {
- margin: 16px -8px 0 -8px;
- }
- .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
- width: calc(50% - 16px);
- display: inline-block;
- vertical-align: top;
- border-bottom: none !important;
- margin-bottom: 16px;
- margin-left: 8px;
- margin-right: 8px;
- }
- .tab-content[tab-identifier="FEsubscriptions"] lazy-list ytm-media-item {
- margin-top: 0 !important;
- padding: 0 !important;
- }
- /* Fix shorts if they're not being hidden */
- .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer) {
- width: calc(100% - 16px);
- display: block;
- }
- .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer:has(ytm-reel-shelf-renderer) > lazy-list {
- margin-left: -16px;
- margin-right: -16px;
- }
- /* Fix the channel list bar if it's not being hidden */
- .tab-content[tab-identifier="FEsubscriptions"] ytm-channel-list-sub-menu-renderer {
- margin-left: -16px;
- margin-right: -16px;
- }
- }
- @media (min-width: 874px) and (orientation: portrait) {
- .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
- width: calc(33.3% - 16px);
- }
- }
- /* The page will probably switch to the list view before it ever hits this */
- @media (min-width: 1160px) and (orientation: portrait) {
- .tab-content[tab-identifier="FEsubscriptions"] ytm-item-section-renderer {
- width: calc(25% - 16px);
- }
- }
- `)
- // Search
- cssRules.push(`
- @media (min-width: 550px) and (orientation: portrait) {
- ytm-search ytm-item-section-renderer {
- margin: 0 16px;
- }
- ytm-search ytm-item-section-renderer > lazy-list {
- margin: 16px -8px 0 -8px;
- }
- ytm-search .adaptive-feed-item {
- width: calc(50% - 16px);
- display: inline-block;
- vertical-align: top;
- border-bottom: none !important;
- margin-bottom: 16px;
- margin-left: 8px;
- margin-right: 8px;
- }
- ytm-search lazy-list ytm-media-item {
- margin-top: 0 !important;
- padding: 0 !important;
- }
- }
- @media (min-width: 874px) and (orientation: portrait) {
- ytm-search .adaptive-feed-item {
- width: calc(33.3% - 16px);
- }
- }
- @media (min-width: 1160px) and (orientation: portrait) {
- ytm-search .adaptive-feed-item {
- width: calc(25% - 16px);
- }
- }
- `)
- }
- if (config.removePink) {
- cssRules.push(`
- .ytp-play-progress,
- .thumbnail-overlay-resume-playback-progress,
- .ytChapteredProgressBarChapteredPlayerBarChapterSeen,
- .ytChapteredProgressBarChapteredPlayerBarFill,
- .ytProgressBarLineProgressBarPlayed,
- .ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment {
- background: #f03 !important;
- }
- `)
- }
- }
- //#endregion
-
- if (hideCssSelectors.length > 0) {
- cssRules.push(`
- ${hideCssSelectors.join(',\n')} {
- display: none !important;
- }
- `)
- }
-
- let css = cssRules.map(dedent).join('\n')
- if ($style == null) {
- $style = addStyle(css)
- } else {
- $style.textContent = css
- }
- }
- })()
- //#endregion
-
- function isHomePage() {
- return location.pathname == '/'
- }
-
- function isChannelPage() {
- return URL_CHANNEL_RE.test(location.pathname)
- }
-
- function isSearchPage() {
- return location.pathname == '/results'
- }
-
- function isSubscriptionsPage() {
- return location.pathname == '/feed/subscriptions'
- }
-
- function isVideoPage() {
- return location.pathname == '/watch'
- }
-
- //#region Tweak functions
- async function alwaysUseTheaterMode() {
- let $player = await getElement('#movie_player', {
- name: 'player (alwaysUseTheaterMode)',
- stopIf: currentUrlChanges(),
- })
- if (!$player) return
- if (!$player.closest('#player-full-bleed-container')) {
- let $sizeButton = /** @type {HTMLButtonElement} */ ($player.querySelector('button.ytp-size-button'))
- if ($sizeButton) {
- log('alwaysUseTheaterMode: clicking size button')
- $sizeButton.click()
- } else {
- warn('alwaysUseTheaterMode: size button not found')
- }
- } else {
- log('alwaysUseTheaterMode: already using theater mode')
- }
- }
-
- async function disableAutoplay() {
- if (desktop) {
- let $autoplayButton = await getElement('button[data-tooltip-target-id="ytp-autonav-toggle-button"]', {
- name: 'Autoplay button',
- stopIf: currentUrlChanges(),
- })
- if (!$autoplayButton) return
-
- // On desktop, initial Autoplay button HTML has style="display: none" and is
- // always checked on. Once it's displayed, we can determine its real state
- // and take action if needed.
- observeElement($autoplayButton, (_, observer) => {
- if ($autoplayButton.style.display == 'none') return
- if ($autoplayButton.querySelector('.ytp-autonav-toggle-button[aria-checked="true"]')) {
- log('turning Autoplay off')
- $autoplayButton.click()
- } else {
- log('Autoplay is already off')
- }
- observer.disconnect()
- }, {
- leading: true,
- name: 'Autoplay button style (for button being displayed)',
- observers: pageObservers,
- }, {
- attributes: true,
- attributeFilter: ['style'],
- })
- }
-
- if (mobile) {
- // Appearance of the Autoplay button may be delayed until interaction
- let $customControl = await getElement('#player-control-container > ytm-custom-control', {
- name: 'Autoplay <ytm-custom-control>',
- stopIf: currentUrlChanges(),
- })
- if (!$customControl) return
-
- observeElement($customControl, (_, observer) => {
- if ($customControl.childElementCount == 0) return
-
- let $autoplayButton = /** @type {HTMLElement} */ ($customControl.querySelector('button.ytm-autonav-toggle-button-container'))
- if (!$autoplayButton) return
-
- if ($autoplayButton.getAttribute('aria-pressed') == 'true') {
- log('turning Autoplay off')
- $autoplayButton.click()
- } else {
- log('Autoplay is already off')
- }
- observer.disconnect()
- }, {
- leading: true,
- name: 'Autoplay <ytm-custom-control> (for Autoplay button being added)',
- observers: pageObservers,
- })
- }
- }
-
- function downloadTranscript() {
- // TODO Check if the transcript is still loading
- let $segments = document.querySelector('.ytd-transcript-search-panel-renderer #segments-container')
- let sections = []
- let parts = []
-
- for (let $el of $segments.children) {
- if ($el.tagName == 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') {
- if (parts.length > 0) {
- sections.push(parts.join(' '))
- parts = []
- }
- sections.push(/** @type {HTMLElement} */ ($el.querySelector('#title')).innerText.trim())
- } else {
- parts.push(/** @type {HTMLElement} */ ($el.querySelector('.segment-text')).innerText.trim())
- }
- }
- if (parts.length > 0) {
- sections.push(parts.join(' '))
- }
-
- let $link = document.createElement('a')
- let url = URL.createObjectURL(new Blob([sections.join('\n\n')], {type: "text/plain"}))
- let title = /** @type {HTMLElement} */ (document.querySelector('#above-the-fold #title'))?.innerText ?? 'transcript'
- $link.setAttribute('href', url)
- $link.setAttribute('download', `${title}.txt`)
- $link.click()
- URL.revokeObjectURL(url)
- }
-
- function handleCurrentUrl() {
- log('handling', getCurrentUrl())
- disconnectObservers(pageObservers, 'page')
-
- if (isHomePage()) {
- tweakHomePage()
- }
- else if (isSubscriptionsPage()) {
- tweakSubscriptionsPage()
- }
- else if (isVideoPage()) {
- tweakVideoPage()
- }
- else if (isSearchPage()) {
- tweakSearchPage()
- }
- else if (isChannelPage()) {
- tweakChannelPage()
- }
- else if (location.pathname.startsWith('/shorts/')) {
- if (config.redirectShorts) {
- redirectShort()
- }
- }
- }
-
- /** @param {HTMLElement} $menu */
- function addDownloadTranscriptToDesktopMenu($menu) {
- if (!isVideoPage()) return
-
- let $transcript = $lastClickedElement.closest('[target-id="engagement-panel-searchable-transcript"]')
- if (!$transcript) return
-
- if ($menu.querySelector('.cpfyt-menu-item')) return
-
- let $menuItems = $menu.querySelector('#items')
- $menuItems.insertAdjacentHTML('beforeend', `
- <div class="cpfyt-menu-item" tabindex="0" style="display: none">
- <div class="cpfyt-menu-text">
- ${getString('DOWNLOAD')}
- </div>
- </div>
- `.trim())
- let $item = $menuItems.lastElementChild
- function download() {
- downloadTranscript()
- // Dismiss the menu
- // @ts-ignore
- document.querySelector('#content')?.click()
- }
- $item.addEventListener('click', download)
- $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
- if (e.key == ' ' || e.key == 'Enter') {
- e.preventDefault()
- download()
- }
- })
- }
-
- /** @param {HTMLElement} $menu */
- function handleDesktopWatchChannelMenu($menu) {
- if (!isVideoPage()) return
-
- let $channelMenuRenderer = $lastClickedElement.closest('ytd-menu-renderer.ytd-watch-metadata')
- if (!$channelMenuRenderer) return
-
- if (config.hideShareThanksClip) {
- let $menuItems = /** @type {NodeListOf<HTMLElement>} */ ($menu.querySelectorAll('ytd-menu-service-item-renderer'))
- let testLabels = new Set([getString('SHARE'), getString('THANKS'), getString('CLIP')])
- for (let $menuItem of $menuItems) {
- if (testLabels.has($menuItem.querySelector('yt-formatted-string')?.textContent)) {
- log('tagging Share/Thanks/Clip menu item')
- $menuItem.classList.add(Classes.HIDE_SHARE_THANKS_CLIP)
- }
- }
- }
-
- if (config.hideChannels) {
- let $channelLink = /** @type {HTMLAnchorElement} */ (document.querySelector('#channel-name a'))
- if (!$channelLink) {
- warn('channel link not found in video page')
- return
- }
-
- let channel = {
- name: $channelLink.textContent,
- url: $channelLink.pathname,
- }
- lastClickedChannel = channel
-
- let $item = $menu.querySelector('#cpfyt-hide-channel-menu-item')
-
- function configureMenuItem(channel) {
- let hidden = isChannelHidden(channel)
- $item.querySelector('.cpfyt-menu-icon').innerHTML = hidden ? Svgs.RESTORE : Svgs.DELETE
- $item.querySelector('.cpfyt-menu-text').textContent = getString(hidden ? 'UNHIDE_CHANNEL' : 'HIDE_CHANNEL')
- }
-
- // The same menu can be reused, so we reconfigure it if it exists. If the
- // menu item is reused, we're just changing [lastClickedChannel], which is
- // why [toggleHideChannel] uses it.
- if (!$item) {
- let hidden = isChannelHidden(channel)
-
- function toggleHideChannel() {
- let hidden = isChannelHidden(lastClickedChannel)
- if (hidden) {
- log('unhiding channel', lastClickedChannel)
- config.hiddenChannels = config.hiddenChannels.filter((hiddenChannel) =>
- hiddenChannel.url ? lastClickedChannel.url != hiddenChannel.url : hiddenChannel.name != lastClickedChannel.name
- )
- } else {
- log('hiding channel', lastClickedChannel)
- config.hiddenChannels.unshift(lastClickedChannel)
- }
- configureMenuItem(lastClickedChannel)
- storeConfigChanges({hiddenChannels: config.hiddenChannels})
- configureCss()
- handleCurrentUrl()
- // Dismiss the menu
- let $popupContainer = /** @type {HTMLElement} */ ($menu.closest('ytd-popup-container'))
- $popupContainer.click()
- // XXX Menu isn't dismissing on iPad Safari
- if ($menu.style.display != 'none') {
- $menu.style.display = 'none'
- $menu.setAttribute('aria-hidden', 'true')
- }
- }
-
- let $menuItems = $menu.querySelector('#items')
- $menuItems.insertAdjacentHTML('beforeend', `
- <div class="cpfyt-menu-item" tabindex="0" id="cpfyt-hide-channel-menu-item" style="display: none">
- <div class="cpfyt-menu-icon">
- ${hidden ? Svgs.RESTORE : Svgs.DELETE}
- </div>
- <div class="cpfyt-menu-text">
- ${getString(hidden ? 'UNHIDE_CHANNEL' : 'HIDE_CHANNEL')}
- </div>
- </div>
- `.trim())
- $item = $menuItems.lastElementChild
- $item.addEventListener('click', toggleHideChannel)
- $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
- if (e.key == ' ' || e.key == 'Enter') {
- e.preventDefault()
- toggleHideChannel()
- }
- })
- } else {
- configureMenuItem(channel)
- }
- }
- }
-
- /** @param {HTMLElement} $menu */
- function addHideChannelToDesktopVideoMenu($menu) {
- let videoContainerElement
- if (isSearchPage()) {
- videoContainerElement = 'ytd-video-renderer'
- }
- else if (isVideoPage()) {
- videoContainerElement = 'ytd-compact-video-renderer'
- }
- else if (isHomePage()) {
- videoContainerElement = 'ytd-rich-item-renderer'
- }
-
- if (!videoContainerElement) return
-
- let $video = /** @type {HTMLElement} */ ($lastClickedElement.closest(videoContainerElement))
- if (!$video) return
-
- log('found clicked video')
- let channel = getChannelDetailsFromVideo($video)
- if (!channel) return
- lastClickedChannel = channel
-
- if ($menu.querySelector('#cpfyt-hide-channel-menu-item')) return
-
- let $menuItems = $menu.querySelector('#items')
- $menuItems.insertAdjacentHTML('beforeend', `
- <div class="cpfyt-menu-item" tabindex="0" id="cpfyt-hide-channel-menu-item" style="display: none">
- <div class="cpfyt-menu-icon">
- ${Svgs.DELETE}
- </div>
- <div class="cpfyt-menu-text">
- ${getString('HIDE_CHANNEL')}
- </div>
- </div>
- `.trim())
- let $item = $menuItems.lastElementChild
- function hideChannel() {
- log('hiding channel', lastClickedChannel)
- config.hiddenChannels.unshift(lastClickedChannel)
- storeConfigChanges({hiddenChannels: config.hiddenChannels})
- configureCss()
- handleCurrentUrl()
- // Dismiss the menu
- let $popupContainer = /** @type {HTMLElement} */ ($menu.closest('ytd-popup-container'))
- $popupContainer.click()
- // XXX Menu isn't dismissing on iPad Safari
- if ($menu.style.display != 'none') {
- $menu.style.display = 'none'
- $menu.setAttribute('aria-hidden', 'true')
- }
- }
- $item.addEventListener('click', hideChannel)
- $item.addEventListener('keydown', /** @param {KeyboardEvent} e */ (e) => {
- if (e.key == ' ' || e.key == 'Enter') {
- e.preventDefault()
- hideChannel()
- }
- })
- }
-
- /** @param {HTMLElement} $menu */
- async function addHideChannelToMobileVideoMenu($menu) {
- if (!(isHomePage() || isSearchPage() || isVideoPage())) return
-
- /** @type {HTMLElement} */
- let $video = $lastClickedElement.closest('ytm-video-with-context-renderer')
- if (!$video) return
-
- log('found clicked video')
- let channel = getChannelDetailsFromVideo($video)
- if (!channel) return
- lastClickedChannel = channel
-
- let $menuItems = $menu.querySelector($menu.id == 'menu' ? '.menu-content' : '.bottom-sheet-media-menu-item')
- let hasIcon = Boolean($menuItems.querySelector('c3-icon'))
- let hideChannelMenuItemHTML = `
- <ytm-menu-item id="cpfyt-hide-channel-menu-item">
- <button class="menu-item-button">
- ${hasIcon ? `<c3-icon>
- <div style="width: 100%; height: 100%; fill: currentcolor;">
- ${Svgs.DELETE}
- </div>
- </c3-icon>` : ''}
- <span class="yt-core-attributed-string" role="text">
- ${getString('HIDE_CHANNEL')}
- </span>
- </button>
- </ytm-menu-item>
- `.trim()
- let $cancelMenuItem = $menu.querySelector('ytm-menu-item:has(.menu-cancel-button')
- if ($cancelMenuItem) {
- $cancelMenuItem.insertAdjacentHTML('beforebegin', hideChannelMenuItemHTML)
- } else {
- $menuItems.insertAdjacentHTML('beforeend', hideChannelMenuItemHTML)
- }
- let $button = $menuItems.querySelector('#cpfyt-hide-channel-menu-item button')
- $button.addEventListener('click', () => {
- log('hiding channel', lastClickedChannel)
- config.hiddenChannels.unshift(lastClickedChannel)
- storeConfigChanges({hiddenChannels: config.hiddenChannels})
- configureCss()
- handleCurrentUrl()
- })
- }
-
- /**
- * @param {Element} $video video container element
- * @returns {import("./types").Channel}
- */
- function getChannelDetailsFromVideo($video) {
- if (desktop) {
- if ($video.tagName == 'YTD-VIDEO-RENDERER') {
- let $link = /** @type {HTMLAnchorElement} */ ($video.querySelector('#text.ytd-channel-name a'))
- if ($link) {
- return {
- name: $link.textContent,
- url: $link.pathname,
- }
- }
- }
- else if ($video.tagName == 'YTD-COMPACT-VIDEO-RENDERER') {
- let $link = /** @type {HTMLElement} */ ($video.querySelector('#text.ytd-channel-name'))
- if ($link) {
- return {
- name: $link.getAttribute('title')
- }
- }
- }
- else if ($video.tagName == 'YTD-RICH-ITEM-RENDERER') {
- let $link = /** @type {HTMLAnchorElement} */ ($video.querySelector('#text.ytd-channel-name a'))
- if ($link) {
- return {
- name: $link.textContent,
- url: $link.pathname,
- }
- }
- }
- }
- if (mobile) {
- let $thumbnailLink =/** @type {HTMLAnchorElement} */ ($video.querySelector('ytm-channel-thumbnail-with-link-renderer > a'))
- let $name = /** @type {HTMLElement} */ ($video.querySelector('ytm-badge-and-byline-renderer .yt-core-attributed-string'))
- if ($name) {
- return {
- name: $name.textContent,
- url: $thumbnailLink?.pathname,
- }
- }
- }
- // warn('unable to get channel details from video container', $video)
- }
-
- /**
- * If you navigate back to Home or Subscriptions (or click their own nav item
- * again) after a period of time, their contents will be refreshed, reusing
- * elements. We need to detect this and re-apply manual hiding preferences for
- * the updated video in each element.
- * @param {Element} $gridItem
- * @param {string} uniqueId
- */
- function observeDesktopRichGridItemContent($gridItem, uniqueId) {
- observeDesktopRichGridVideoProgress($gridItem, uniqueId)
-
- // For videos, observe the thumbnail link for the videoId being changed
- let $thumbnailLink = /** @type {HTMLAnchorElement} */ ($gridItem.querySelector('ytd-rich-grid-media a#thumbnail'))
- /** @type {import("./types").CustomMutationObserver} */
- let thumbnailObserver
-
- function observeThumbnail() {
- if (!$thumbnailLink) {
- log(`${uniqueId} has no video #thumbnail`)
- return
- }
- thumbnailObserver = observeElement($thumbnailLink, (mutations) => {
- let searchParams = new URLSearchParams($thumbnailLink.search)
- if (searchParams.has('v') && !mutations[0].oldValue.includes(searchParams.get('v'))) {
- log(`${uniqueId} #thumbnail href changed`, mutations[0].oldValue, '→', $thumbnailLink.href)
- manuallyHideVideo($gridItem)
- }
- }, {
- name: `${uniqueId} #thumbnail href`,
- observers: pageObservers,
- }, {
- attributes: true,
- attributeFilter: ['href'],
- attributeOldValue: true,
- })
- }
-
- if ($thumbnailLink) {
- observeThumbnail()
- }
-
- // Observe the content of the grid item for a video being added or removed
- // when grid contents are refreshed.
- let $content = $gridItem.querySelector(':scope > #content')
- observeElement($content, (mutations) => {
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == 'YTD-RICH-GRID-MEDIA') {
- log(uniqueId, 'video added', $addedNode)
- $thumbnailLink = /** @type {HTMLAnchorElement} */ ($gridItem.querySelector('ytd-rich-grid-media a#thumbnail'))
- observeThumbnail()
- manuallyHideVideo($gridItem)
- if (config.hideWatched) {
- observeDesktopRichGridVideoProgress($gridItem, uniqueId)
- }
- }
- }
- for (let $removedNode of mutation.removedNodes) {
- if (!($removedNode instanceof HTMLElement)) continue
- if ($removedNode.nodeName == 'YTD-RICH-GRID-MEDIA') {
- log(uniqueId, 'video removed', $removedNode)
- $thumbnailLink = null
- thumbnailObserver?.disconnect()
- manuallyHideVideo($gridItem)
- }
- }
- }
- }, {
- name: `${uniqueId} #content`,
- observers: pageObservers,
- })
- }
-
- /**
- * If you watch a video then navigate back to Home or Subscriptions without
- * causing their contents to be refreshed, its watch progress will be updated
- * in-place.
- * @param {Element} $video
- * @param {string} uniqueId
- */
- function observeDesktopRichGridVideoProgress($video, uniqueId) {
- let $overlays = $video.querySelector('ytd-rich-grid-media #overlays')
- if (!$overlays) {
- log(uniqueId, 'has no video #overlay')
- return
- }
-
- let $progress = $overlays.querySelector('#progress')
- /** @type {import("./types").CustomMutationObserver} */
- let progressObserver
-
- function observeProgress() {
- if (!$progress) {
- log(`${uniqueId} has no #progress`)
- return
- }
- progressObserver = observeElement($progress, (mutations) => {
- if (mutations.length > 0) {
- log(`${uniqueId} #progress style changed`)
- hideWatched($video)
- }
- }, {
- name: `${uniqueId} #progress (for style changes)`,
- observers: pageObservers,
- }, {
- attributes: true,
- attributeFilter: ['style'],
- })
- }
-
- if ($progress) {
- observeProgress()
- }
-
- // Observe overlay contents for a progress bar being added or removed when
- // the video is updated.
- observeElement($overlays, (mutations) => {
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
- $progress = $addedNode.querySelector('#progress')
- observeProgress()
- hideWatched($video)
- }
- }
- for (let $removedNode of mutation.removedNodes) {
- if (!($removedNode instanceof HTMLElement)) continue
- if ($removedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
- $progress = null
- progressObserver?.disconnect()
- hideWatched($video)
- }
- }
- }
- }, {
- name: `${uniqueId} #overlays (for #progress being added or removed)`,
- observers: pageObservers,
- })
- }
-
- /** @param {{page: 'home' | 'subscriptions'}} options */
- async function observeDesktopRichGridItems(options) {
- let {page} = options
- let itemCount = 0
-
- let $renderer = await getElement(`ytd-browse[page-subtype="${page}"] ytd-rich-grid-renderer`, {
- name: `${page} <ytd-rich-grid-renderer>`,
- stopIf: currentUrlChanges(),
- })
- if (!$renderer) return
-
- let $gridContents = $renderer.querySelector(':scope > #contents')
-
- /**
- * @param {Element} $gridItem
- * @param {string} $gridItem
- */
- function processGridItem($gridItem, uniqueId) {
- manuallyHideVideo($gridItem)
- observeDesktopRichGridItemContent($gridItem, uniqueId)
- }
-
- function processAllVideos() {
- let $videos = $gridContents.querySelectorAll('ytd-rich-item-renderer.ytd-rich-grid-renderer')
- if ($videos.length > 0) {
- log('processing', $videos.length, `${page} video${s($videos.length)}`)
- }
- for (let $video of $videos) {
- processGridItem($video, `grid item ${++itemCount}`)
- }
- }
-
- // Process new videos as they're added
- observeElement($gridContents, (mutations) => {
- let videosAdded = 0
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == 'YTD-RICH-ITEM-RENDERER') {
- processGridItem($addedNode, `grid item ${++itemCount}`)
- videosAdded++
- }
- }
- }
- if (videosAdded > 0) {
- log(videosAdded, `video${s(videosAdded)} added`)
- }
- }, {
- name: `${page} <ytd-rich-grid-renderer> #contents (for new videos being added)`,
- observers: pageObservers,
- })
-
- processAllVideos()
- }
-
- /** @param {HTMLElement} $menu */
- function onDesktopMenuAppeared($menu) {
- log('menu appeared')
-
- if (config.downloadTranscript) {
- addDownloadTranscriptToDesktopMenu($menu)
- }
- if (config.hideChannels) {
- addHideChannelToDesktopVideoMenu($menu)
- }
- if (config.hideHiddenVideos) {
- observeVideoHiddenState()
- }
- if (config.hideChannels || config.hideShareThanksClip) {
- handleDesktopWatchChannelMenu($menu)
- }
- }
-
- async function observePopups() {
- if (desktop) {
- // Desktop dialogs and menus appear in <ytd-popup-container>. Once created,
- // the same elements are reused.
- let $popupContainer = await getElement('ytd-popup-container', {name: 'popup container'})
- let $dropdown = /** @type {HTMLElement} */ ($popupContainer.querySelector('tp-yt-iron-dropdown'))
- let $dialog = /** @type {HTMLElement} */ ($popupContainer.querySelector('tp-yt-paper-dialog'))
-
- function observeDialog() {
- observeElement($dialog, () => {
- if ($dialog.getAttribute('aria-hidden') == 'true') {
- log('dialog closed')
- if (onDialogClosed) {
- onDialogClosed()
- onDialogClosed = null
- }
- }
- }, {
- name: '<tp-yt-paper-dialog> (for [aria-hidden] being added)',
- observers: globalObservers,
- }, {
- attributes: true,
- attributeFilter: ['aria-hidden'],
- })
- }
-
- function observeDropdown() {
- observeElement($dropdown, () => {
- if ($dropdown.getAttribute('aria-hidden') != 'true') {
- onDesktopMenuAppeared($dropdown)
- }
- }, {
- leading: true,
- name: '<tp-yt-iron-dropdown> (for [aria-hidden] being removed)',
- observers: globalObservers,
- }, {
- attributes: true,
- attributeFilter: ['aria-hidden'],
- })
- }
-
- if ($dialog) observeDialog()
- if ($dropdown) observeDropdown()
-
- if (!$dropdown || !$dialog) {
- observeElement($popupContainer, (mutations, observer) => {
- for (let mutation of mutations) {
- for (let $el of mutation.addedNodes) {
- switch($el.nodeName) {
- case 'TP-YT-IRON-DROPDOWN':
- $dropdown = /** @type {HTMLElement} */ ($el)
- observeDropdown()
- break
- case 'TP-YT-PAPER-DIALOG':
- $dialog = /** @type {HTMLElement} */ ($el)
- observeDialog()
- break
- }
- if ($dropdown && $dialog) {
- observer.disconnect()
- }
- }
- }
- }, {
- name: '<ytd-popup-container> (for initial <tp-yt-iron-dropdown> and <tp-yt-paper-dialog> being added)',
- observers: globalObservers,
- })
- }
- }
-
- if (mobile) {
- // Depending on resolution, mobile menus appear in <bottom-sheet-container>
- // (lower res) or as a #menu child of <body> (higher res).
- let $body = await getElement('body', {name: '<body>'})
- if (!$body) return
-
- let $menu = /** @type {HTMLElement} */ (document.querySelector('body > #menu'))
- if ($menu) {
- onMobileMenuAppeared($menu)
- }
-
- observeElement($body, (mutations) => {
- for (let mutation of mutations) {
- for (let $el of mutation.addedNodes) {
- if ($el instanceof HTMLElement && $el.id == 'menu') {
- onMobileMenuAppeared($el)
- return
- }
- }
- }
- }, {
- name: '<body> (for #menu being added)',
- observers: globalObservers,
- })
-
- // When switching between screens, <bottom-sheet-container> is replaced
- let $app = await getElement('ytm-app', {name: '<ytm-app>'})
- if (!$app) return
-
- let $bottomSheet = /** @type {HTMLElement} */ ($app.querySelector('bottom-sheet-container'))
-
- function observeBottomSheet() {
- observeElement($bottomSheet, () => {
- if ($bottomSheet.childElementCount > 0) {
- onMobileMenuAppeared($bottomSheet)
- }
- }, {
- leading: true,
- name: '<bottom-sheet-container> (for content being added)',
- observers: globalObservers,
- })
- }
-
- if ($bottomSheet) observeBottomSheet()
-
- observeElement($app, (mutations) => {
- for (let mutation of mutations) {
- for (let $el of mutation.addedNodes) {
- if ($el.nodeName == 'BOTTOM-SHEET-CONTAINER') {
- log('new bottom sheet appeared')
- $bottomSheet = /** @type {HTMLElement} */ ($el)
- observeBottomSheet()
- return
- }
- }
- }
- }, {
- name: '<ytm-app> (for <bottom-sheet-container> being replaced)',
- observers: globalObservers,
- })
- }
- }
-
- /**
- * Search pages are a list of sections, which can have video items added to them
- * after they're added, so we watch for new section contents as well as for new
- * sections. When the search is changed, additional sections are removed and the
- * first section is refreshed - it gets a can-show-more attribute while this is
- * happening.
- * @param {{
- * name: string
- * selector: string
- * sectionContentsSelector: string
- * sectionElement: string
- * suggestedSectionElement?: string
- * videoElement: string
- * }} options
- */
- async function observeSearchResultSections(options) {
- let {name, selector, sectionContentsSelector, sectionElement, suggestedSectionElement = null, videoElement} = options
- let sectionNodeName = sectionElement.toUpperCase()
- let suggestedSectionNodeName = suggestedSectionElement?.toUpperCase()
- let videoNodeName = videoElement.toUpperCase()
-
- let $sections = await getElement(selector, {
- name,
- stopIf: currentUrlChanges(),
- })
- if (!$sections) return
-
- /** @type {WeakMap<Element, Map<string, import("./types").Disconnectable>>} */
- let sectionObservers = new WeakMap()
- /** @type {WeakMap<Element, Map<string, import("./types").Disconnectable>>} */
- let sectionItemObservers = new WeakMap()
- let sectionCount = 0
-
- /**
- * @param {HTMLElement} $section
- * @param {number} sectionNum
- */
- function processSection($section, sectionNum) {
- let $contents = /** @type {HTMLElement} */ ($section.querySelector(sectionContentsSelector))
- let itemCount = 0
- let suggestedSectionCount = 0
- /** @type {Map<string, import("./types").Disconnectable>} */
- let observers = new Map()
- /** @type {Map<string, import("./types").Disconnectable>} */
- let itemObservers = new Map()
- sectionObservers.set($section, observers)
- sectionItemObservers.set($section, itemObservers)
-
- function processCurrentItems() {
- itemCount = 0
- suggestedSectionCount = 0
- for (let $item of $contents.children) {
- if ($item.nodeName == videoNodeName) {
- manuallyHideVideo($item)
- waitForVideoOverlay($item, `section ${sectionNum} item ${++itemCount}`, itemObservers)
- }
- if (!config.hideSuggestedSections && suggestedSectionNodeName != null && $item.nodeName == suggestedSectionNodeName) {
- processSuggestedSection($item)
- }
- }
- }
-
- /**
- * If suggested sections (Latest from, People also watched, For you, etc.)
- * aren't being hidden, we need to process their videos and watch for more
- * being loaded.
- * @param {Element} $suggestedSection
- */
- function processSuggestedSection($suggestedSection) {
- let suggestedItemCount = 0
- let uniqueId = `section ${sectionNum} suggested section ${++suggestedSectionCount}`
- let $items = $suggestedSection.querySelector('#items')
- for (let $video of $items.children) {
- if ($video.nodeName == videoNodeName) {
- manuallyHideVideo($video)
- waitForVideoOverlay($video, `${uniqueId} item ${++suggestedItemCount}`, itemObservers)
- }
- }
- // More videos are added if the "More" control is used
- observeElement($items, (mutations, observer) => {
- let moreVideosAdded = false
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == videoNodeName) {
- if (!moreVideosAdded) moreVideosAdded = true
- manuallyHideVideo($addedNode)
- waitForVideoOverlay($addedNode, `${uniqueId} item ${++suggestedItemCount}`, itemObservers)
- }
- }
- }
- if (moreVideosAdded) {
- observer.disconnect()
- }
- }, {
- name: `${uniqueId} videos (for more being added)`,
- observers: [itemObservers, pageObservers],
- })
- }
-
- if (desktop) {
- observeElement($section, () => {
- if ($section.getAttribute('can-show-more') == null) {
- log('can-show-more attribute removed - reprocessing refreshed items')
- for (let observer of itemObservers.values()) {
- observer.disconnect()
- }
- processCurrentItems()
- }
- }, {
- name: `section ${sectionNum} can-show-more attribute`,
- observers: [observers, pageObservers],
- }, {
- attributes: true,
- attributeFilter: ['can-show-more'],
- })
- }
-
- observeElement($contents, (mutations) => {
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == videoNodeName) {
- manuallyHideVideo($addedNode)
- waitForVideoOverlay($addedNode, `section ${sectionNum} item ${++itemCount}`, observers)
- }
- if (!config.hideSuggestedSections && suggestedSectionNodeName != null && $addedNode.nodeName == suggestedSectionNodeName) {
- processSuggestedSection($addedNode)
- }
- }
- }
- }, {
- name: `section ${sectionNum} contents`,
- observers: [observers, pageObservers],
- })
-
- processCurrentItems()
- }
-
- observeElement($sections, (mutations) => {
- for (let mutation of mutations) {
- // New sections are added when more results are loaded
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == sectionNodeName) {
- let sectionNum = ++sectionCount
- log('search result section', sectionNum, 'added')
- processSection($addedNode, sectionNum)
- }
- }
- // Additional sections are removed when the search is changed
- for (let $removedNode of mutation.removedNodes) {
- if (!($removedNode instanceof HTMLElement)) continue
- if ($removedNode.nodeName == sectionNodeName && sectionObservers.has($removedNode)) {
- log('disconnecting removed section observers')
- for (let observer of sectionObservers.get($removedNode).values()) {
- observer.disconnect()
- }
- sectionObservers.delete($removedNode)
- for (let observer of sectionItemObservers.get($removedNode).values()) {
- observer.disconnect()
- }
- sectionObservers.delete($removedNode)
- sectionItemObservers.delete($removedNode)
- sectionCount--
- }
- }
- }
- }, {
- name: `search <${sectionElement}> contents (for new sections being added)`,
- observers: pageObservers,
- })
-
- let $initialSections = /** @type {NodeListOf<HTMLElement>} */ ($sections.querySelectorAll(sectionElement))
- log($initialSections.length, `initial search result section${s($initialSections.length)}`)
- for (let $initialSection of $initialSections) {
- processSection($initialSection, ++sectionCount)
- }
- }
-
- /**
- * Detect navigation between pages for features which apply to specific pages.
- */
- async function observeTitle() {
- let $title = await getElement('title', {name: '<title>'})
- let seenUrl
- observeElement($title, () => {
- let currentUrl = getCurrentUrl()
- if (seenUrl != null && seenUrl == currentUrl) {
- return
- }
- seenUrl = currentUrl
- handleCurrentUrl()
- }, {
- leading: true,
- name: '<title> (for title changes)',
- observers: globalObservers,
- })
- }
-
- async function observeVideoAds() {
- let $player = await getElement('#movie_player', {
- name: 'player (skipAds)',
- stopIf: currentUrlChanges(),
- })
- if (!$player) return
-
- let $videoAds = $player.querySelector('.video-ads')
- if (!$videoAds) {
- $videoAds = await observeForElement($player, (mutations) => {
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.classList.contains('video-ads')) {
- return $addedNode
- }
- }
- }
- }, {
- logElement: true,
- name: '#movie_player (for .video-ads being added)',
- targetName: '.video-ads',
- observers: pageObservers,
- })
- if (!$videoAds) return
- }
-
- function processAdContent() {
- let $adContent = $videoAds.firstElementChild
- if ($adContent.classList.contains('ytp-ad-player-overlay') || $adContent.classList.contains('ytp-ad-player-overlay-layout')) {
- tweakAdPlayerOverlay($player)
- }
- else if ($adContent.classList.contains('ytp-ad-action-interstitial')) {
- tweakAdInterstitial($adContent)
- }
- else {
- warn('unknown ad content', $adContent.className, $adContent.outerHTML)
- }
- }
-
- if ($videoAds.childElementCount > 0) {
- log('video ad content present')
- processAdContent()
- }
-
- observeElement($videoAds, (mutations) => {
- // Something added
- if (mutations.some(mutation => mutation.addedNodes.length > 0)) {
- log('video ad content appeared')
- processAdContent()
- }
- // Something removed
- else if (mutations.some(mutation => mutation.removedNodes.length > 0)) {
- log('video ad content removed')
- if (onAdRemoved) {
- onAdRemoved()
- onAdRemoved = null
- }
- // Only unmute if we know the volume wasn't initially muted
- if (desktop) {
- let $muteButton = /** @type {HTMLElement} */ ($player.querySelector('button.ytp-mute-button'))
- if ($muteButton &&
- $muteButton.dataset.titleNoTooltip != getString('MUTE') &&
- $muteButton.dataset.cpfytWasMuted == 'false') {
- log('unmuting audio after ads')
- delete $muteButton.dataset.cpfytWasMuted
- $muteButton.click()
- }
- }
- if (mobile) {
- let $video = $player.querySelector('video')
- if ($video &&
- $video.muted &&
- $video.dataset.cpfytWasMuted == 'false') {
- log('unmuting audio after ads')
- delete $video.dataset.cpfytWasMuted
- $video.muted = false
- }
- }
- }
- }, {
- logElement: true,
- name: '#movie_player > .video-ads (for content being added or removed)',
- observers: pageObservers,
- })
- }
-
- /**
- * If a video's action menu was opened, watch for that video being dismissed.
- */
- function observeVideoHiddenState() {
- if (!isHomePage() && !isSubscriptionsPage()) return
-
- if (desktop) {
- let $video = $lastClickedElement?.closest('ytd-rich-grid-media')
- if (!$video) return
-
- observeElement($video, (_, observer) => {
- if (!$video.hasAttribute('is-dismissed')) return
-
- observer.disconnect()
-
- log('video hidden, showing timer')
- let $actions = $video.querySelector('ytd-notification-multi-action-renderer')
- let $undoButton = $actions.querySelector('button')
- let $tellUsWhyButton = $actions.querySelector(`button[aria-label="${getString('TELL_US_WHY')}"]`)
- let $pie
- let timeout
- let startTime
-
- function displayPie(options = {}) {
- let {delay, direction, duration} = options
- $pie?.remove()
- $pie = document.createElement('div')
- $pie.classList.add('cpfyt-pie')
- if (delay) $pie.style.setProperty('--cpfyt-pie-delay', `${delay}ms`)
- if (direction) $pie.style.setProperty('--cpfyt-pie-direction', direction)
- if (duration) $pie.style.setProperty('--cpfyt-pie-duration', `${duration}ms`)
- $actions.appendChild($pie)
- }
-
- function startTimer() {
- startTime = Date.now()
- timeout = setTimeout(() => {
- let $elementToHide = $video.closest('ytd-rich-item-renderer')
- $elementToHide?.classList.add(Classes.HIDE_HIDDEN)
- cleanup()
- // Remove the class if the Undo button is clicked later, e.g. if
- // this feature is disabled after hiding a video.
- $undoButton.addEventListener('click', () => {
- $elementToHide?.classList.remove(Classes.HIDE_HIDDEN)
- })
- }, undoHideDelayMs)
- }
-
- function cleanup() {
- $undoButton.removeEventListener('click', onUndoClick)
- if ($tellUsWhyButton) {
- $tellUsWhyButton.removeEventListener('click', onTellUsWhyClick)
- }
- $pie.remove()
- }
-
- function onUndoClick() {
- clearTimeout(timeout)
- cleanup()
- }
-
- function onTellUsWhyClick() {
- let elapsedTime = Date.now() - startTime
- clearTimeout(timeout)
- displayPie({
- direction: 'reverse',
- delay: Math.round((elapsedTime - undoHideDelayMs) / 4),
- duration: undoHideDelayMs / 4,
- })
- onDialogClosed = () => {
- startTimer()
- displayPie()
- }
- }
-
- $undoButton.addEventListener('click', onUndoClick)
- if ($tellUsWhyButton) {
- $tellUsWhyButton.addEventListener('click', onTellUsWhyClick)
- }
- startTimer()
- displayPie()
- }, {
- name: '<ytd-rich-grid-media> (for [is-dismissed] being added)',
- observers: pageObservers,
- }, {
- attributes: true,
- attributeFilter: ['is-dismissed'],
- })
- }
-
- if (mobile) {
- /** @type {HTMLElement} */
- let $container
- if (isHomePage()) {
- $container = $lastClickedElement?.closest('ytm-rich-item-renderer')
- }
- else if (isSubscriptionsPage()) {
- $container = $lastClickedElement?.closest('lazy-list')
- }
- if (!$container) return
-
- observeElement($container, (mutations, observer) => {
- for (let mutation of mutations) {
- for (let $el of mutation.addedNodes) {
- if ($el.nodeName != 'YTM-NOTIFICATION-MULTI-ACTION-RENDERER') continue
-
- observer.disconnect()
-
- log('video hidden, showing timer')
- let $actions = /** @type {HTMLElement} */ ($el).firstElementChild
- let $undoButton = /** @type {HTMLElement} */ ($el).querySelector('button')
- function cleanup() {
- $undoButton.removeEventListener('click', undoClicked)
- $actions.querySelector('.cpfyt-pie')?.remove()
- }
- let hideHiddenVideoTimeout = setTimeout(() => {
- let $elementToHide = $container
- if (isSubscriptionsPage()) {
- $elementToHide = $container.closest('ytm-item-section-renderer')
- }
- $elementToHide?.classList.add(Classes.HIDE_HIDDEN)
- cleanup()
- // Remove the class if the Undo button is clicked later, e.g. if
- // this feature is disabled after hiding a video.
- $undoButton.addEventListener('click', () => {
- $elementToHide?.classList.remove(Classes.HIDE_HIDDEN)
- })
- }, undoHideDelayMs)
- function undoClicked() {
- clearTimeout(hideHiddenVideoTimeout)
- cleanup()
- }
- $undoButton.addEventListener('click', undoClicked)
- $actions.insertAdjacentHTML('beforeend', '<div class="cpfyt-pie"></div>')
- }
- }
- }, {
- name: `<${$container.tagName.toLowerCase()}> (for <ytm-notification-multi-action-renderer> being added)`,
- observers: pageObservers,
- })
- }
- }
-
- /**
- * Processes initial videos in a list element, and new videos as they're added.
- * @param {{
- * name: string
- * selector: string
- * stopIf?: () => boolean
- * page: string
- * videoElements: Set<string>
- * }} options
- */
- async function observeVideoList(options) {
- let {name, selector, stopIf = currentUrlChanges(), page, videoElements} = options
- let videoNodeNames = new Set(Array.from(videoElements, (name) => name.toUpperCase()))
-
- let $list = await getElement(selector, {name, stopIf})
- if (!$list) return
-
- let itemCount = 0
-
- observeElement($list, (mutations) => {
- let newItemCount = 0
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if (videoNodeNames.has($addedNode.nodeName)) {
- manuallyHideVideo($addedNode)
- waitForVideoOverlay($addedNode, `item ${++itemCount}`)
- newItemCount++
- }
- }
- }
- if (newItemCount > 0) {
- log(newItemCount, `${page} video${s(newItemCount)} added`)
- }
- }, {
- name: `${name} (for new items being added)`,
- observers: pageObservers,
- })
-
- let initialItemCount = 0
- for (let $initialItem of $list.children) {
- if (videoNodeNames.has($initialItem.nodeName)) {
- manuallyHideVideo($initialItem)
- waitForVideoOverlay($initialItem, `item ${++itemCount}`)
- initialItemCount++
- }
- }
- log(initialItemCount, `initial ${page} video${s(initialItemCount)}`)
- }
-
- /** @param {MouseEvent} e */
- function onDocumentClick(e) {
- $lastClickedElement = /** @type {HTMLElement} */ (e.target)
- }
-
- /** @param {HTMLElement} $menu */
- function onMobileMenuAppeared($menu) {
- log('menu appeared')
-
- if (config.hideOpenApp && (isSearchPage() || isVideoPage())) {
- let menuItems = $menu.querySelectorAll('ytm-menu-item')
- for (let $menuItem of menuItems) {
- if ($menuItem.textContent == getString('OPEN_APP')) {
- log('tagging Open App menu item')
- $menuItem.classList.add(Classes.HIDE_OPEN_APP)
- break
- }
- }
- }
-
- if (config.hideChannels) {
- addHideChannelToMobileVideoMenu($menu)
- }
- if (config.hideHiddenVideos) {
- observeVideoHiddenState()
- }
- }
-
- /** @param {Element} $video */
- function hideWatched($video) {
- if (!config.hideWatched || isSearchPage()) return
- // Watch % is obtained from progress bar width when a video has one
- let $progressBar
- if (desktop) {
- $progressBar = $video.querySelector('#progress')
- }
- if (mobile) {
- $progressBar = $video.querySelector('.thumbnail-overlay-resume-playback-progress')
- }
- let hide = false
- if ($progressBar) {
- let progress = parseInt(/** @type {HTMLElement} */ ($progressBar).style.width)
- hide = progress >= Number(config.hideWatchedThreshold)
- }
- $video.classList.toggle(Classes.HIDE_WATCHED, hide)
- }
-
- /**
- * Tag individual video elements to be hidden by options which would need too
- * complex or broad CSS :has() relative selectors.
- * @param {Element} $video video container element
- */
- function manuallyHideVideo($video) {
- hideWatched($video)
-
- // Streamed videos are identified using the video title's aria-label
- if (config.hideStreamed) {
- let $videoTitle
- if (desktop) {
- // Subscriptions <ytd-rich-item-renderer> has a different structure
- $videoTitle = $video.querySelector($video.tagName == 'YTD-RICH-ITEM-RENDERER' ? '#video-title-link' : '#video-title')
- }
- if (mobile) {
- $videoTitle = $video.querySelector('.media-item-headline .yt-core-attributed-string')
- }
- let hide = false
- if ($videoTitle) {
- hide = Boolean($videoTitle.getAttribute('aria-label')?.includes(getString('STREAMED_TITLE')))
- }
- $video.classList.toggle(Classes.HIDE_STREAMED, hide)
- }
-
- if (config.hideChannels && config.hiddenChannels.length > 0 && !isSubscriptionsPage()) {
- let channel = getChannelDetailsFromVideo($video)
- let hide = false
- if (channel) {
- hide = isChannelHidden(channel)
- }
- $video.classList.toggle(Classes.HIDE_CHANNEL, hide)
- }
- }
-
- async function redirectFromHome() {
- let selector = desktop ? 'a[href="/feed/subscriptions"]' : 'ytm-pivot-bar-item-renderer div.pivot-subs'
- let $subscriptionsLink = await getElement(selector, {
- name: 'Subscriptions link',
- stopIf: currentUrlChanges(),
- })
- if (!$subscriptionsLink) return
- log('redirecting from Home to Subscriptions')
- $subscriptionsLink.click()
- }
-
- function redirectShort() {
- let videoId = location.pathname.split('/').at(-1)
- let search = location.search ? location.search.replace('?', '&') : ''
- log('redirecting Short to normal player')
- location.replace(`/watch?v=${videoId}${search}`)
- }
-
- /**
- * Forces the video to resize if options which affect its size are used.
- */
- function triggerVideoPageResize() {
- if (desktop && isVideoPage()) {
- window.dispatchEvent(new Event('resize'))
- }
- }
-
- function tweakAdInterstitial($adContent) {
- log('ad interstitial showing')
- let $skipButtonSlot = /** @type {HTMLElement} */ ($adContent.querySelector('.ytp-ad-skip-button-slot'))
- if (!$skipButtonSlot) {
- log('skip button slot not found')
- return
- }
-
- observeElement($skipButtonSlot, (_, observer) => {
- if ($skipButtonSlot.style.display != 'none') {
- let $button = $skipButtonSlot.querySelector('button')
- if ($button) {
- log('clicking skip button')
- // XXX Not working on mobile
- $button.click()
- } else {
- warn('skip button not found')
- }
- observer.disconnect()
- }
- }, {
- leading: true,
- name: 'skip button slot (for skip button becoming visible)',
- observers: pageObservers,
- }, {attributes: true})
- }
-
- function tweakAdPlayerOverlay($player) {
- log('ad overlay showing')
-
- // Mute ad audio
- if (desktop) {
- let $muteButton = /** @type {HTMLElement} */ ($player.querySelector('button.ytp-mute-button'))
- if ($muteButton) {
- if ($muteButton.dataset.titleNoTooltip == getString('MUTE')) {
- log('muting ad audio')
- $muteButton.click()
- $muteButton.dataset.cpfytWasMuted = 'false'
- }
- else if ($muteButton.dataset.cpfytWasMuted == null) {
- $muteButton.dataset.cpfytWasMuted = 'true'
- }
- } else {
- warn('mute button not found')
- }
- }
- if (mobile) {
- // Mobile doesn't have a mute button, so we mute the video itself
- let $video = /** @type {HTMLVideoElement} */ ($player.querySelector('video'))
- if ($video) {
- if (!$video.muted) {
- $video.muted = true
- $video.dataset.cpfytWasMuted = 'false'
- }
- else if ($video.dataset.cpfytWasMuted == null) {
- $video.dataset.cpfytWasMuted = 'true'
- }
- } else {
- warn('<video> not found')
- }
- }
-
- // Try to skip to the end of the ad video
- let $video = /** @type {HTMLVideoElement} */ ($player.querySelector('video'))
- if (!$video) {
- warn('<video> not found')
- return
- }
-
- if (Number.isFinite($video.duration)) {
- log(`skipping to end of ad (using initial video duration)`)
- $video.currentTime = $video.duration
- }
- else if ($video.readyState == null || $video.readyState < 1) {
- function onLoadedMetadata() {
- if (Number.isFinite($video.duration)) {
- log(`skipping to end of ad (using video duration after loadedmetadata)`)
- $video.currentTime = $video.duration
- } else {
- log(`skipping to end of ad (duration still not available after loadedmetadata)`)
- $video.currentTime = 10_000
- }
- }
- $video.addEventListener('loadedmetadata', onLoadedMetadata, {once: true})
- onAdRemoved = () => {
- $video.removeEventListener('loadedmetadata', onLoadedMetadata)
- }
- }
- else {
- log(`skipping to end of ad (metadata should be available but isn't)`)
- $video.currentTime = 10_000
- }
- }
-
- async function tweakHomePage() {
- if (config.disableHomeFeed && loggedIn) {
- redirectFromHome()
- return
- }
- if (!config.hideWatched && !config.hideStreamed && !config.hideChannels) return
- if (desktop) {
- observeDesktopRichGridItems({page: 'home'})
- }
- if (mobile) {
- observeVideoList({
- name: 'home <ytm-rich-grid-renderer> contents',
- selector: '.tab-content[tab-identifier="FEwhat_to_watch"] .rich-grid-renderer-contents',
- page: 'home',
- videoElements: new Set(['ytm-rich-item-renderer']),
- })
- }
- }
-
- async function tweakChannelPage() {
- let seen = new Map()
- function isOnFeaturedTab() {
- if (!seen.has(location.pathname)) {
- let section = location.pathname.match(URL_CHANNEL_RE)[1]
- seen.set(location.pathname, section == undefined || section == 'featured')
- }
- return seen.get(location.pathname)
- }
-
- if (desktop && config.pauseChannelTrailers && isOnFeaturedTab()) {
- let $channelTrailer = /** @type {HTMLVideoElement} */ (
- await getElement('ytd-channel-video-player-renderer video', {
- name: `channel trailer`,
- stopIf: () => !isOnFeaturedTab(),
- timeout: 2000,
- })
- )
- if ($channelTrailer) {
- $channelTrailer.pause()
- function pauseTrailer() {
- log(`pauseChannelTrailers: pausing channel trailer`)
- $channelTrailer.pause()
- }
- if ($channelTrailer.paused) {
- $channelTrailer.addEventListener('play', pauseTrailer, {once: true})
- } else {
- pauseTrailer()
- }
- }
- }
- }
-
- // TODO Hide ytd-channel-renderer if a channel is hidden
- function tweakSearchPage() {
- if (!config.hideStreamed && !config.hideChannels) return
-
- if (desktop) {
- observeSearchResultSections({
- name: 'search <ytd-section-list-renderer> contents',
- selector: 'ytd-search #contents.ytd-section-list-renderer',
- sectionContentsSelector: '#contents',
- sectionElement: 'ytd-item-section-renderer',
- suggestedSectionElement: 'ytd-shelf-renderer',
- videoElement: 'ytd-video-renderer',
- })
- }
-
- if (mobile) {
- observeSearchResultSections({
- name: 'search <lazy-list>',
- selector: 'ytm-search ytm-section-list-renderer > lazy-list',
- sectionContentsSelector: 'lazy-list',
- sectionElement: 'ytm-item-section-renderer',
- videoElement: 'ytm-video-with-context-renderer',
- })
- }
- }
-
- async function tweakSubscriptionsPage() {
- if (!config.hideWatched && !config.hideStreamed) return
- if (desktop) {
- observeDesktopRichGridItems({page: 'subscriptions'})
- }
- if (mobile) {
- observeVideoList({
- name: 'subscriptions <lazy-list>',
- selector: '.tab-content[tab-identifier="FEsubscriptions"] ytm-section-list-renderer > lazy-list',
- page: 'subscriptions',
- videoElements: new Set(['ytm-item-section-renderer']),
- })
- }
- }
-
- async function tweakVideoPage() {
- if (config.skipAds) {
- observeVideoAds()
- }
- if (config.disableAutoplay) {
- disableAutoplay()
- }
- if (desktop && config.alwaysUseTheaterMode) {
- alwaysUseTheaterMode()
- }
-
- if (config.hideRelated || (!config.hideWatched && !config.hideStreamed && !config.hideChannels)) return
-
- if (desktop) {
- let $section = await getElement('#related.ytd-watch-flexy ytd-item-section-renderer', {
- name: 'related <ytd-item-section-renderer>',
- stopIf: currentUrlChanges(),
- })
- if (!$section) return
-
- let $contents = $section.querySelector('#contents')
- let itemCount = 0
-
- function processCurrentItems() {
- itemCount = 0
- for (let $item of $contents.children) {
- if ($item.nodeName == 'YTD-COMPACT-VIDEO-RENDERER') {
- manuallyHideVideo($item)
- waitForVideoOverlay($item, `related item ${++itemCount}`)
- }
- }
- }
-
- // If the video changes (e.g. a related video is clicked) on desktop,
- // the related items section is refreshed - the section has a can-show-more
- // attribute while this is happening.
- observeElement($section, () => {
- if ($section.getAttribute('can-show-more') == null) {
- log('can-show-more attribute removed - reprocessing refreshed items')
- processCurrentItems()
- }
- }, {
- name: 'related <ytd-item-section-renderer> can-show-more attribute',
- observers: pageObservers,
- }, {
- attributes: true,
- attributeFilter: ['can-show-more'],
- })
-
- observeElement($contents, (mutations) => {
- let newItemCount = 0
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!($addedNode instanceof HTMLElement)) continue
- if ($addedNode.nodeName == 'YTD-COMPACT-VIDEO-RENDERER') {
- manuallyHideVideo($addedNode)
- waitForVideoOverlay($addedNode, `related item ${++itemCount}`)
- newItemCount++
- }
- }
- }
- if (newItemCount > 0) {
- log(newItemCount, `related item${s(newItemCount)} added`)
- }
- }, {
- name: `related <ytd-item-section-renderer> contents (for new items being added)`,
- observers: pageObservers,
- })
-
- processCurrentItems()
- }
-
- if (mobile) {
- // If the video changes on mobile, related videos are rendered from scratch
- observeVideoList({
- name: 'related <lazy-list>',
- selector: 'ytm-item-section-renderer[data-content-type="related"] > lazy-list',
- page: 'related',
- // <ytm-compact-autoplay-renderer> displays as a large item on bigger mobile screens
- videoElements: new Set(['ytm-video-with-context-renderer', 'ytm-compact-autoplay-renderer']),
- })
- }
- }
-
- /**
- * Wait for video overlays with watch progress when they're loazed lazily.
- * @param {Element} $video
- * @param {string} uniqueId
- * @param {Map<string, import("./types").Disconnectable>} [observers]
- */
- function waitForVideoOverlay($video, uniqueId, observers) {
- if (!config.hideWatched) return
-
- if (desktop) {
- // The overlay element is initially empty
- let $overlays = $video.querySelector('#overlays')
- if (!$overlays || $overlays.childElementCount > 0) return
-
- observeElement($overlays, (mutations, observer) => {
- let nodesAdded = false
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!nodesAdded) nodesAdded = true
- if ($addedNode.nodeName == 'YTD-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
- hideWatched($video)
- }
- }
- }
- if (nodesAdded) {
- observer.disconnect()
- }
- }, {
- name: `${uniqueId} #overlays (for overlay elements being added)`,
- observers: [observers, pageObservers].filter(Boolean),
- })
- }
-
- if (mobile) {
- // The overlay element has a different initial class
- let $placeholder = $video.querySelector('.video-thumbnail-overlay-bottom-group')
- if (!$placeholder) return
-
- observeElement($placeholder, (mutations, observer) => {
- let nodesAdded = false
- for (let mutation of mutations) {
- for (let $addedNode of mutation.addedNodes) {
- if (!nodesAdded) nodesAdded = true
- if ($addedNode.nodeName == 'YTM-THUMBNAIL-OVERLAY-RESUME-PLAYBACK-RENDERER') {
- hideWatched($video)
- }
- }
- }
- if (nodesAdded) {
- observer.disconnect()
- }
- }, {
- name: `${uniqueId} .video-thumbnail-overlay-bottom-group (for overlay elements being added)`,
- observers: [observers, pageObservers].filter(Boolean),
- })
- }
- }
- //#endregion
-
- //#region Main
- let isUserscript = !(
- typeof GM == 'undefined' &&
- typeof chrome != 'undefined' &&
- typeof chrome.storage != 'undefined'
- )
-
- function main() {
- if (config.enabled) {
- configureCss()
- triggerVideoPageResize()
- observeTitle()
- observePopups()
- document.addEventListener('click', onDocumentClick, true)
- globalObservers.set('document-click', {
- disconnect() {
- document.removeEventListener('click', onDocumentClick, true)
- }
- })
- }
- }
-
- /** @param {Partial<import("./types").SiteConfig>} changes */
- function configChanged(changes) {
- if (!changes.hasOwnProperty('enabled')) {
- log('config changed', changes)
- configureCss()
- triggerVideoPageResize()
- handleCurrentUrl()
- return
- }
-
- log(`${changes.enabled ? 'en' : 'dis'}abling extension functionality`)
- if (changes.enabled) {
- main()
- } else {
- configureCss()
- triggerVideoPageResize()
- disconnectObservers(pageObservers, 'page')
- disconnectObservers(globalObservers,' global')
- }
- }
-
- /** @param {{[key: string]: chrome.storage.StorageChange}} storageChanges */
- function onConfigChange(storageChanges) {
- let configChanges = Object.fromEntries(
- Object.entries(storageChanges)
- // Don't change the version based on other pages
- .filter(([key]) => config.hasOwnProperty(key) && key != 'version')
- .map(([key, {newValue}]) => [key, newValue])
- )
- if (Object.keys(configChanges).length == 0) return
-
- if ('debug' in configChanges) {
- log('disabling debug mode')
- debug = configChanges.debug
- log('enabled debug mode')
- return
- }
-
- if ('debugManualHiding' in configChanges) {
- debugManualHiding = configChanges.debugManualHiding
- log(`${debugManualHiding ? 'en' : 'dis'}abled debugging manual hiding`)
- configureCss()
- return
- }
-
- Object.assign(config, configChanges)
- configChanged(configChanges)
- }
-
- /** @param {Partial<import("./types").SiteConfig>} configChanges */
- function storeConfigChanges(configChanges) {
- if (isUserscript) return
- chrome.storage.local.onChanged.removeListener(onConfigChange)
- chrome.storage.local.set(configChanges, () => {
- chrome.storage.local.onChanged.addListener(onConfigChange)
- })
- }
-
- if (!isUserscript) {
- chrome.storage.local.get((storedConfig) => {
- Object.assign(config, storedConfig)
- log('initial config', {...config, version}, {lang, loggedIn})
-
- if (config.debug) {
- debug = true
- }
- if (config.debugManualHiding) {
- debugManualHiding = true
- }
-
- // Let the options page know which version is being used
- chrome.storage.local.set({version})
- chrome.storage.local.onChanged.addListener(onConfigChange)
-
- window.addEventListener('unload', () => {
- chrome.storage.local.onChanged.removeListener(onConfigChange)
- }, {once: true})
-
- main()
- })
- }
- else {
- main()
- }
- //#endregion