YouTube - Add Watch Later Button

reveals the save and report buttons and makes links right clickable

Från och med 2021-04-25. Se den senaste versionen.

// ==UserScript==
// @name         YouTube - Add Watch Later Button
// @namespace    https://openuserjs.org/users/zachhardesty7
// @author       Zach Hardesty <[email protected]> (https://github.com/zachhardesty7)
// @description  reveals the save and report buttons and makes links right clickable
// @copyright    2019, Zach Hardesty (https://zachhardesty.com/)
// @license      GPL-3.0-only; http://www.gnu.org/licenses/gpl-3.0.txt
// @version      1.3.4

// @homepageURL  https://github.com/zachhardesty7/tamper-monkey-scripts-collection/raw/master/youtube-add-watch-later-button.user.js
// @homepageURL  https://openuserjs.org/scripts/zachhardesty7/YouTube_-_Add_Watch_Later_Button
// @supportURL   https://openuserjs.org/scripts/zachhardesty7/YouTube_-_Add_Watch_Later_Button/issues


// @include      https://www.youtube.com*
// @require      https://greasyfork.org/scripts/419640-onelementready/code/onElementReady.js?version=887637
// ==/UserScript==
// prevent eslint from complaining when redefining private function queryForElements from gist
// eslint-disable-next-line no-unused-vars
/* global onElementReady, queryForElements:true */
/* eslint-disable no-underscore-dangle */

/**
 * Query for new DOM nodes matching a specified selector.
 *
 * @override
 */
// @ts-ignore
queryForElements = (selector, _, callback) => {
  // Search for elements by selector
  const elementList = document.querySelectorAll(selector) || []
  elementList.forEach((element) => callback(element))
}

/**
 * build the button el tediously but like the rest
 *
 * @param {HTMLElement} buttons - html node
 */
function addButton(buttons) {
  const zh = document.querySelectorAll("#zh-wl")
  // noop if button already present in correct place
  if (zh.length === 1 && zh[0].parentElement.id === "top-level-buttons") return

  // YT hydration of DOM can shift elements
  if (zh.length >= 1) {
    console.debug("watch later button(s) found in wrong place, fixing")
    zh.forEach((wl) => {
      if (wl.id !== "top-level-buttons") wl.remove()
    })
  }

  // normal action
  console.debug("no watch later button found, adding new button")
  /** @type {HTMLElement & { buttonRenderer: boolean, isIconButton?: boolean, styleActionButton?: boolean }} */
  // @ts-ignore
  const container = document.createElement("ytd-button-renderer")

  // needed for on click style
  container.style.color = "var(--yt-spec-icon-inactive)"
  container.setAttribute("style-action-button", "true")
  container.setAttribute("is-icon-button", "true")
  container.className = buttons.lastElementChild.className
  container.id = "zh-wl"
  buttons.append(container)

  const link = document.createElement("a")
  link.tabIndex = -1
  link.className =
    buttons.children[buttons.children.length - 2].firstElementChild.className
  container.append(link)

  const buttonContainer = document.createElement("yt-icon-button")
  buttonContainer.id = "button"
  buttonContainer.className =
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.firstElementChild.className
  link.append(buttonContainer)

  const icon = document.createElement("yt-icon")
  icon.className =
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.firstElementChild.firstElementChild.firstElementChild.className
  buttonContainer.firstElementChild.append(icon)

  buttonContainer.firstElementChild["aria-label"] = "Save to Watch Later"

  // copy icon from hovering video thumbnails
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  svg.setAttribute("viewBox", "0 0 24 24")
  svg.setAttribute("preserveAspectRatio", "xMidYMid meet")
  svg.setAttribute("focusable", "false")
  svg.setAttribute(
    "class",
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.getAttribute(
      "class"
    )
  )
  svg.setAttribute(
    "style",
    "pointer-events: none; display: block; width: 100%; height: 100%;"
  )
  icon.append(svg)

  const g = document.createElementNS("http://www.w3.org/2000/svg", "g")
  g.setAttribute(
    "class",
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.getAttribute(
      "class"
    )
  )
  svg.append(g)

  const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  path.setAttribute(
    "class",
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.firstElementChild.getAttribute(
      "class"
    )
  )
  path.setAttribute(
    "d",
    "M12 3.67c-4.58 0-8.33 3.75-8.33 8.33s3.75 8.33 8.33 8.33 8.33-3.75 8.33-8.33S16.58 3.67 12 3.67zm3.5 11.83l-4.33-2.67v-5h1.25v4.34l3.75 2.25-.67 1.08z"
  )
  g.append(path)

  const text = document.createElement("yt-formatted-string")
  text.id = "text"
  link.append(text)
  text.className =
    buttons.children[
      buttons.children.length - 2
    ].lastElementChild.lastElementChild.className
  // needed to style on click
  text.style.color = "var(--yt-spec-text-secondary)"
  text.textContent = "later"

  // TODO: will be incorrect if already in WL
  // change to blue when added to WL
  container.addEventListener("click", () => {
    const flippedColorCon =
      container.style.color === "var(--yt-spec-icon-inactive)"
        ? "var(--yt-spec-call-to-action)"
        : "var(--yt-spec-icon-inactive)"
    container.style.color = flippedColorCon

    const flippedColorText =
      text.style.color === "var(--yt-spec-text-secondary)"
        ? "var(--yt-spec-call-to-action)"
        : "var(--yt-spec-text-secondary)"
    text.style.color = flippedColorText
  })

  const window = buttons.ownerDocument.defaultView // escape tampermonkey scope
  link.addEventListener("click", () => post(window)) // meat of the script
}

/**
 * initiate the data post
 *
 * @param {any} window - escape iFrame
 */
async function post(window) {
  // the 3 unique data points required, stored in strange places
  const {
    csn,
  } = window.ytInitialData.responseContext.webResponseContextExtensionData.ytConfigData
  const addedVideoId =
    window.ytInitialData.currentVideoEndpoint.watchEndpoint.videoId
  const token = window.ytcfg.data_.XSRF_TOKEN // location varies when minified build changes

  // // get exact playlist ID
  // document.querySelector('#top-level-buttons').children[3].click()
  // // wait until loaded
  // const playlists = await new Promise((resolve, reject) => {
  //   const intervalId = setInterval(() => {
  //     const el = document.querySelector('#playlists')
  //     const backdrop = document.querySelector('.opened')
  //     if (el
  //       && el.firstElementChild && el.firstElementChild.querySelector('#checkbox') && backdrop) {
  //       && el.firstElementChild.querySelector('#checkbox') && backdrop) {
  //       && backdrop
  //     ) {
  //       clearInterval(intervalId)
  //       resolve(el)
  //     }
  //   }, 100)
  // })

  // document.querySelector('.opened').click()
  // const playlist = playlists.firstElementChild.__data.data
  //   .addToPlaylistServiceEndpoint.playlistEditEndpoint.playlistId

  // encode the form as URI body, starts from an obj bc it's cleaner
  const body = new URLSearchParams({
    sej: JSON.stringify({
      clickTrackingParams: "HASTOBECERTAINLENGTHBUTCONTENTIRRELEVANT=",
      commandMetadata: {
        webCommandMetadata: {
          url: "/service_ajax",
          sendPost: true,
        },
      },
      playlistEditEndpoint: {
        playlistId: "WL",
        // playlistId: playlist, // still does not provide visual feedback
        actions: [
          {
            addedVideoId,
            action: "ACTION_ADD_VIDEO",
          },
        ],
      },
    }),
    csn,
    session_token: token,
  })

  // snagged from inspecting other WL post operations
  fetch("https://www.youtube.com/service_ajax?name=playlistEditEndpoint", {
    credentials: "include",
    headers: {
      accept: "*/*",
      "accept-language": "en-US,en;q=0.9",
      "cache-control": "no-cache",
      "content-type": "application/x-www-form-urlencoded",
      pragma: "no-cache",
    },
    referrerPolicy: "origin-when-cross-origin",
    body,
    method: "POST",
    mode: "cors",
  })
}

// YouTube uses a bunch of duplicate 'id' tag values. why?
// this makes it much more likely to target right one, but at the cost of being brittle
onElementReady(
  "#info #info-contents #menu #top-level-buttons",
  { findOnce: false },
  addButton
)