YouTube - Add Watch Later Button

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube - Add Watch Later Button
// @namespace    https://greasyfork.org/en/users/826711-bartosz-petrynski
// @author       Bartosz Petrynski
// @description  Adds a new button next to like that quick adds / removes the active video from your "Watch later" playlist
// @license      GPL-3.0-only; http://www.gnu.org/licenses/gpl-3.0.txt
// @version      2.0.1
// @match        https://www.youtube.com/*
// @require      https://greasyfork.org/scripts/419640-onelementready/code/onElementReady.js?version=887637
// ==/UserScript==

// Working as of 2024-07-29
// Based on https://openuserjs.org/scripts/zachhardesty7/YouTube_-_Add_Watch_Later_Button
// Fix from https://greasyfork.org/en/scripts/419656-youtube-add-watch-later-button/discussions/229317
// 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)
}

/**
 * Show notification toast
 * @param {string} message - Message to display in the notification
 */
function showNotification(message) {
  const notificationElement = document.querySelector('yt-notification-action-renderer')
  if (notificationElement) {
    const textElement = notificationElement.querySelector('#text')
    if (textElement) {
      textElement.textContent = message
    }
    const toastElement = notificationElement.querySelector('#toast')
    if (toastElement) {
      toastElement.removeAttribute('aria-hidden')
      toastElement.style.display = 'flex'
      setTimeout(() => {
        toastElement.setAttribute('aria-hidden', 'true')
        toastElement.style.display = 'none'
      }, 3000) // Hide after 3 seconds
    }
  }
}

/**
 * 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.querySelectorAll(
    "dislike-button-view-model"
  )[0]

  // 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
    )

    // Show notification
    const notificationMessage = isVideoInWatchLaterBeforeRequest
      ? "Removed from Watch later"
      : "Saved to Watch later"
    showNotification(notificationMessage)
  })

  // 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
)