YouTube - Add Watch Later Button

adds a new button next to like that quick adds / removes the active video from your "Watch later" playlist

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         YouTube - Add Watch Later Button
// @namespace    https://openuserjs.org/users/zachhardesty7
// @author       Zach Hardesty <[email protected]> (https://github.com/zachhardesty7)
// @description  adds a new button next to like that quick adds / removes the active video from your "Watch later" playlist
// @copyright    2019-2021, Zach Hardesty (https://zachhardesty.com/)
// @license      GPL-3.0-only; http://www.gnu.org/licenses/gpl-3.0.txt
// @version      2.0.0

// @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://github.com/zachhardesty7/tamper-monkey-scripts-collection/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 */

const BUTTONS_CONTAINER_ID = "top-level-buttons-computed"
const SVG_ICON_CLASS = "style-scope yt-icon"
const SVG_PATH_FILLED =
  "M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M14.97,16.95L10,13.87V7h2v5.76 l4.03,2.49L14.97,16.95z"
const SVG_PATH_HOLLOW =
  "M14.97,16.95L10,13.87V7h2v5.76l4.03,2.49L14.97,16.95z M12,3c-4.96,0-9,4.04-9,9s4.04,9,9,9s9-4.04,9-9S16.96,3,12,3 M12,2c5.52,0,10,4.48,10,10s-4.48,10-10,10S2,17.52,2,12S6.48,2,12,2L12,2z"

/**
 * 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) || []
  for (const element of elementList) callback(element)
}

/**
 * build the button el tediously but like the rest
 *
 * @param {HTMLElement} buttons - html node
 * @returns {Promise<void>}
 */
async 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 === BUTTONS_CONTAINER_ID) return

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

  // normal action
  console.debug("no watch later button found, adding new button")
  const playlistSaveButton = document.querySelector(
    "#top-level-buttons-computed > ytd-button-renderer:last-child"
  )

  // needed to force the node to load so we can determine if it's already in WL or not
  playlistSaveButton.click()

  /**
   * @typedef {HTMLElement & { buttonRenderer: boolean, isIconButton?: boolean, styleActionButton?: boolean }} ytdButtonRenderer
   */
  const container = /** @type {ytdButtonRenderer} */ (
    document.createElement("ytd-toggle-button-renderer")
  )

  const shareButtonContainer = buttons.children[1]

  container.className = shareButtonContainer.className // style-scope ytd-menu-renderer
  container.id = "zh-wl"
  buttons.append(container)

  const buttonContainer = document.createElement("button")
  // TODO: use more dynamic className
  buttonContainer.className =
    "yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading"
  container.firstElementChild.append(buttonContainer)
  buttonContainer["aria-label"] = "Save to Watch Later"

  const iconContainer = document.createElement("div")
  // TODO: use more dynamic className
  iconContainer.className = "yt-spec-button-shape-next__icon"
  buttonContainer.append(iconContainer)

  const icon = document.createElement("yt-icon")
  buttonContainer.firstElementChild.append(icon)

  // 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", SVG_ICON_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", SVG_ICON_CLASS)
  svg.append(g)

  const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  path.setAttribute("class", SVG_ICON_CLASS)
  path.setAttribute("d", SVG_PATH_HOLLOW)
  g.append(path)

  const textContainer = document.createElement("div")
  buttonContainer.append(textContainer)
  // TODO: use more dynamic className
  textContainer.className =
    "cbox yt-spec-button-shape-next--button-text-content"

  const text = document.createElement("span")
  textContainer.append(text)
  // TODO: use more dynamic className
  text.className =
    "yt-core-attributed-string yt-core-attributed-string--white-space-no-wrap"
  text.textContent = "Later"

  container.addEventListener("click", async () => {
    const data = document
      .querySelector(`#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`)
      .__dataHost.__data.items.find(
        (item) => item.menuServiceItemRenderer?.icon.iconType === "PLAYLIST_ADD"
      ).menuServiceItemRenderer

    const videoId = data.serviceEndpoint.addToPlaylistServiceEndpoint.videoId

    const SAPISIDHASH = await getSApiSidHash(
      document.cookie.split("SAPISID=")[1].split("; ")[0],
      window.origin
    )

    const isVideoInWatchLaterBeforeRequest = await isVideoInWatchLater()

    const action = isVideoInWatchLaterBeforeRequest
      ? "ACTION_REMOVE_VIDEO_BY_VIDEO_ID"
      : "ACTION_ADD_VIDEO"

    await fetch(`https://www.youtube.com/youtubei/v1/browse/edit_playlist`, {
      headers: {
        authorization: `SAPISIDHASH ${SAPISIDHASH}`,
      },
      body: JSON.stringify({
        context: {
          client: {
            clientName: "WEB",
            clientVersion: ytcfg.data_.INNERTUBE_CLIENT_VERSION,
          },
        },
        actions: [
          {
            ...(isVideoInWatchLaterBeforeRequest
              ? { removedVideoId: videoId }
              : { addedVideoId: videoId }),
            action,
          },
        ],
        playlistId: "WL",
      }),
      method: "POST",
    })

    path.setAttribute(
      "d",
      isVideoInWatchLaterBeforeRequest ? SVG_PATH_HOLLOW : SVG_PATH_FILLED
    )
  })

  // TODO: fetch correct status on page load
  // path.setAttribute(
  //   "d",
  //   (await isVideoInWatchLater()) ? SVG_PATH_FILLED : SVG_PATH_HOLLOW
  // )
}

async function isVideoInWatchLater() {
  const data = document
    .querySelector(`#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`)
    .__dataHost.__data.items.find(
      (item) => item.menuServiceItemRenderer?.icon.iconType === "PLAYLIST_ADD"
    ).menuServiceItemRenderer

  const videoId = data.serviceEndpoint.addToPlaylistServiceEndpoint.videoId

  const SAPISIDHASH = await getSApiSidHash(
    document.cookie.split("SAPISID=")[1].split("; ")[0],
    window.origin
  )

  const response = await fetch(
    `https://www.youtube.com/youtubei/v1/playlist/get_add_to_playlist`,
    {
      headers: { authorization: `SAPISIDHASH ${SAPISIDHASH}` },
      body: JSON.stringify({
        context: {
          client: {
            clientName: "WEB",
            clientVersion: ytcfg.data_.INNERTUBE_CLIENT_VERSION,
          },
        },
        excludeWatchLater: false,
        videoIds: [videoId],
      }),
      method: "POST",
    }
  )

  const json = await response.json()

  return (
    json.contents[0].addToPlaylistRenderer.playlists[0]
      .playlistAddToOptionRenderer.containsSelectedVideos === "ALL"
  )
}

/** @see https://gist.github.com/eyecatchup/2d700122e24154fdc985b7071ec7764a */
async function getSApiSidHash(SAPISID, origin) {
  function sha1(str) {
    return window.crypto.subtle
      .digest("SHA-1", new TextEncoder().encode(str))
      .then((buf) => {
        return Array.prototype.map
          .call(new Uint8Array(buf), (x) => ("00" + x.toString(16)).slice(-2))
          .join("")
      })
  }

  const TIMESTAMP_MS = Date.now()
  const digest = await sha1(`${TIMESTAMP_MS} ${SAPISID} ${origin}`)

  return `${TIMESTAMP_MS}_${digest}`
}

// 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(
  `#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`,
  { findOnce: false },
  addButton
)