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