Control Panel for YouTube

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

// ==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     9
// ==/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 = {
  enabled: true,
  version,
  disableAutoplay: true,
  disableHomeFeed: false,
  hiddenChannels: [],
  hideChannels: true,
  hideComments: false,
  hideHiddenVideos: true,
  hideHomeCategories: false,
  hideLive: false,
  hideMetadata: false,
  hideMixes: 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,
  skipAds: true,
  // Desktop only
  downloadTranscript: true,
  fullSizeTheaterMode: false,
  hideChat: false,
  hideEndCards: false,
  hideEndVideos: true,
  hideMerchEtc: true,
  hideMiniplayerButton: false,
  hideSubscriptionsLatestBar: false,
  minimumGridItemsPerRow: 'auto',
  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>',
}

//#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.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.hideChat) {
      if (desktop) {
        hideCssSelectors.push('#chat-container')
      }
    }

    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.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',
        )
      }
    }

    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#thumbnail[href$="start_radio=1"])',
          // List item
          'ytd-radio-renderer',
          // Related video
          'ytd-compact-radio-renderer',
        )
      }
      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.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])',
          // 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)',
          // 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)
          '.related-chips-slot-wrapper ytm-item-section-renderer[section-identifier="comments-entry-point"] + ytm-item-section-renderer:has(> lazy-list > ad-slot-renderer)',
          // In Relatd 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) {
        // Logged-out users can get a "Try searching to get started" Home page
        // section which this would hide.
        if (loggedIn) {
          hideCssSelectors.push(
            // Shelves in Home
            '.tab-content[tab-identifier="FEwhat_to_watch"] ytm-rich-section-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('.searchbox-voice-search-wrapper')
      }
    }

    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.hideEndCards) {
        hideCssSelectors.push('#movie_player .ytp-ce-element')
      }
      if (config.hideEndVideos) {
        hideCssSelectors.push('#movie_player .ytp-endscreen-content')
      }
      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-cairo-refresh-signature-moments .ytp-play-progress,
          #progress.ytd-thumbnail-overlay-resume-playback-renderer {
            background: #f03 !important;
          }
        `)
      }
      if (config.searchThumbnailSize != 'large') {
        cssRules.push(`
          ytd-search ytd-video-renderer ytd-thumbnail.ytd-video-renderer {
            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);
            }
          }
        `)
      }
    }
    //#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 isSearchPage() {
  return location.pathname == '/results'
}

function isSubscriptionsPage() {
  return location.pathname == '/feed/subscriptions'
}

function isVideoPage() {
  return location.pathname == '/watch'
}

//#region Tweak functions
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 (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')
  // TOOO Figure out what we have to wait for to add menu items ASAP without them getting removed
  await new Promise((resolve) => setTimeout(resolve, 50))
  let hasIcon = Boolean($menuItems.querySelector('c3-icon'))
  $menuItems.insertAdjacentHTML('beforeend', `
    <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 $button = $menuItems.lastElementChild.querySelector('button')
  $button.addEventListener('click', () => {
    log('hiding channel', lastClickedChannel)
    config.hiddenChannels.unshift(lastClickedChannel)
    storeConfigChanges({hiddenChannels: config.hiddenChannels})
    configureCss()
    handleCurrentUrl()
    // Dismiss the menu
    let $overlay = $menu.id == 'menu' ? $menu.querySelector('c3-overlay') : document.querySelector('.bottom-sheet-overlay')
    // @ts-ignore
    $overlay?.click()
  })
}

/**
 * @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)
}

/** @param {{page: 'home' | 'subscriptions'}} options */
async function observeDesktopRichGridVideos(options) {
  let {page} = options

  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')
  let observingRefreshCanary = false

  /** @param {Element} $video */
  function processVideo($video) {
    manuallyHideVideo($video)

    // Re-hide hidden videos if they're re-rendered, e.g. grid size changes
    if (config.hideHiddenVideos) {
      $video.classList.toggle(Classes.HIDE_HIDDEN, Boolean($video.querySelector('ytd-rich-grid-media[is-dismissed]')))
    }

    // When grid contents are refreshed (e.g. clicking the Subscriptions nav
    // item on the Subscriptions page after some time), video elements are
    // re-used, so need to be re-checked for manual hiding. We observe the first
    // video's URL as a signal this has happened.
    if (!observingRefreshCanary) {
      let $thumbnailLink = /** @type {HTMLAnchorElement} */ ($video.querySelector('a#thumbnail'))
      // Some Home screen items (e.g. Mixes, promoted videos) won't have a link
      if (!$thumbnailLink) return

      observeElement($thumbnailLink, (mutations, observer) => {
        if (!$thumbnailLink.href.endsWith(mutations[0].oldValue)) {
          log('refresh canary href changed', mutations[0].oldValue, '→', $thumbnailLink.href)
          // On the Home page, the type of video might change after a refresh,
          // so also re-observe the refresh canary when re-processing videos.
          if (page == 'home') {
            observer.disconnect()
            observingRefreshCanary = false
          }
          processAllVideos()
        }
      }, {
        name: 'refresh canary href',
        observers: pageObservers,
      }, {
        attributes: true,
        attributeFilter: ['href'],
        attributeOldValue: true,
      })
      observingRefreshCanary = true
    }
  }

  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)}`)
    }
    $videos.forEach(processVideo)
  }

  // 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') {
          processVideo($addedNode)
          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',
    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
 */
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 (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) {
    observeDesktopRichGridVideos({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']),
    })
  }
}

// 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) {
    observeDesktopRichGridVideos({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 (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)
  }
}

/** @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')
    document.removeEventListener('click', onDocumentClick, true)
  }
}

/** @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