YouTube - Add Watch Later Button

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

  1. // ==UserScript==
  2. // @name YouTube - Add Watch Later Button
  3. // @namespace https://greasyfork.org/en/users/826711-bartosz-petrynski
  4. // @author Bartosz Petrynski
  5. // @description Adds a new button next to like that quick adds / removes the active video from your "Watch later" playlist
  6. // @license GPL-3.0-only; http://www.gnu.org/licenses/gpl-3.0.txt
  7. // @version 2.0.1
  8. // @match https://www.youtube.com/*
  9. // @require https://greasyfork.org/scripts/419640-onelementready/code/onElementReady.js?version=887637
  10. // ==/UserScript==
  11.  
  12. // Working as of 2024-07-29
  13. // Based on https://openuserjs.org/scripts/zachhardesty7/YouTube_-_Add_Watch_Later_Button
  14. // Fix from https://greasyfork.org/en/scripts/419656-youtube-add-watch-later-button/discussions/229317
  15. // prevent eslint from complaining when redefining private function queryForElements from gist
  16. // eslint-disable-next-line no-unused-vars
  17. /* global onElementReady, queryForElements:true */
  18. /* eslint-disable no-underscore-dangle */
  19.  
  20. const BUTTONS_CONTAINER_ID = "top-level-buttons-computed"
  21. const SVG_ICON_CLASS = "style-scope yt-icon"
  22. const SVG_PATH_FILLED =
  23. "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"
  24. const SVG_PATH_HOLLOW =
  25. "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"
  26.  
  27. /**
  28. * Query for new DOM nodes matching a specified selector.
  29. *
  30. * @override
  31. */
  32. // @ts-ignore
  33. queryForElements = (selector, _, callback) => {
  34. // Search for elements by selector
  35. const elementList = document.querySelectorAll(selector) || []
  36. for (const element of elementList) callback(element)
  37. }
  38.  
  39. /**
  40. * Show notification toast
  41. * @param {string} message - Message to display in the notification
  42. */
  43. function showNotification(message) {
  44. const notificationElement = document.querySelector('yt-notification-action-renderer')
  45. if (notificationElement) {
  46. const textElement = notificationElement.querySelector('#text')
  47. if (textElement) {
  48. textElement.textContent = message
  49. }
  50. const toastElement = notificationElement.querySelector('#toast')
  51. if (toastElement) {
  52. toastElement.removeAttribute('aria-hidden')
  53. toastElement.style.display = 'flex'
  54. setTimeout(() => {
  55. toastElement.setAttribute('aria-hidden', 'true')
  56. toastElement.style.display = 'none'
  57. }, 3000) // Hide after 3 seconds
  58. }
  59. }
  60. }
  61.  
  62. /**
  63. * build the button el tediously but like the rest
  64. *
  65. * @param {HTMLElement} buttons - html node
  66. * @returns {Promise<void>}
  67. */
  68. async function addButton(buttons) {
  69. const zh = document.querySelectorAll("#zh-wl")
  70. // noop if button already present in correct place
  71. if (zh.length === 1 && zh[0].parentElement.id === BUTTONS_CONTAINER_ID) return
  72.  
  73. // YT hydration of DOM can shift elements
  74. if (zh.length >= 1) {
  75. console.debug("watch later button(s) found in wrong place, fixing")
  76. for (const wl of zh) {
  77. if (wl.id !== BUTTONS_CONTAINER_ID) wl.remove()
  78. }
  79. }
  80.  
  81. // normal action
  82. console.debug("no watch later button found, adding new button")
  83. const playlistSaveButton = document.querySelectorAll(
  84. "dislike-button-view-model"
  85. )[0]
  86.  
  87. // needed to force the node to load so we can determine if it's already in WL or not
  88. playlistSaveButton.click()
  89.  
  90. /**
  91. * @typedef {HTMLElement & { buttonRenderer: boolean, isIconButton?: boolean, styleActionButton?: boolean }} ytdButtonRenderer
  92. */
  93. const container = /** @type {ytdButtonRenderer} */ (
  94. document.createElement("ytd-toggle-button-renderer")
  95. )
  96.  
  97. const shareButtonContainer = buttons.children[1]
  98.  
  99. container.className = shareButtonContainer.className // style-scope ytd-menu-renderer
  100. container.id = "zh-wl"
  101. buttons.append(container)
  102.  
  103. const buttonContainer = document.createElement("button")
  104. // TODO: use more dynamic className
  105. buttonContainer.className =
  106. "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"
  107. container.firstElementChild.append(buttonContainer)
  108. buttonContainer["aria-label"] = "Save to Watch Later"
  109.  
  110. const iconContainer = document.createElement("div")
  111. // TODO: use more dynamic className
  112. iconContainer.className = "yt-spec-button-shape-next__icon"
  113. buttonContainer.append(iconContainer)
  114.  
  115. const icon = document.createElement("yt-icon")
  116. buttonContainer.firstElementChild.append(icon)
  117.  
  118. // copy icon from hovering video thumbnails
  119. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  120. svg.setAttribute("viewBox", "0 0 24 24")
  121. svg.setAttribute("preserveAspectRatio", "xMidYMid meet")
  122. svg.setAttribute("focusable", "false")
  123. svg.setAttribute("class", SVG_ICON_CLASS)
  124. svg.setAttribute(
  125. "style",
  126. "pointer-events: none; display: block; width: 100%; height: 100%;"
  127. )
  128. icon.append(svg)
  129.  
  130. const g = document.createElementNS("http://www.w3.org/2000/svg", "g")
  131. g.setAttribute("class", SVG_ICON_CLASS)
  132. svg.append(g)
  133.  
  134. const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  135. path.setAttribute("class", SVG_ICON_CLASS)
  136. path.setAttribute("d", SVG_PATH_HOLLOW)
  137. g.append(path)
  138.  
  139. const textContainer = document.createElement("div")
  140. buttonContainer.append(textContainer)
  141. // TODO: use more dynamic className
  142. textContainer.className =
  143. "cbox yt-spec-button-shape-next--button-text-content"
  144.  
  145. const text = document.createElement("span")
  146. textContainer.append(text)
  147. // TODO: use more dynamic className
  148. text.className =
  149. "yt-core-attributed-string yt-core-attributed-string--white-space-no-wrap"
  150. text.textContent = "Later"
  151.  
  152. container.addEventListener("click", async () => {
  153. const data = document
  154. .querySelector(`#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`)
  155. .__dataHost.__data.items.find(
  156. (item) => item.menuServiceItemRenderer?.icon.iconType === "PLAYLIST_ADD"
  157. ).menuServiceItemRenderer
  158.  
  159. const videoId = data.serviceEndpoint.addToPlaylistServiceEndpoint.videoId
  160.  
  161. const SAPISIDHASH = await getSApiSidHash(
  162. document.cookie.split("SAPISID=")[1].split("; ")[0],
  163. window.origin
  164. )
  165.  
  166. const isVideoInWatchLaterBeforeRequest = await isVideoInWatchLater()
  167.  
  168. const action = isVideoInWatchLaterBeforeRequest
  169. ? "ACTION_REMOVE_VIDEO_BY_VIDEO_ID"
  170. : "ACTION_ADD_VIDEO"
  171.  
  172. await fetch(`https://www.youtube.com/youtubei/v1/browse/edit_playlist`, {
  173. headers: {
  174. authorization: `SAPISIDHASH ${SAPISIDHASH}`,
  175. },
  176. body: JSON.stringify({
  177. context: {
  178. client: {
  179. clientName: "WEB",
  180. clientVersion: ytcfg.data_.INNERTUBE_CLIENT_VERSION,
  181. },
  182. },
  183. actions: [
  184. {
  185. ...(isVideoInWatchLaterBeforeRequest
  186. ? { removedVideoId: videoId }
  187. : { addedVideoId: videoId }),
  188. action,
  189. },
  190. ],
  191. playlistId: "WL",
  192. }),
  193. method: "POST",
  194. })
  195.  
  196. path.setAttribute(
  197. "d",
  198. isVideoInWatchLaterBeforeRequest ? SVG_PATH_HOLLOW : SVG_PATH_FILLED
  199. )
  200.  
  201. // Show notification
  202. const notificationMessage = isVideoInWatchLaterBeforeRequest
  203. ? "Removed from Watch later"
  204. : "Saved to Watch later"
  205. showNotification(notificationMessage)
  206. })
  207.  
  208. // TODO: fetch correct status on page load
  209. // path.setAttribute(
  210. // "d",
  211. // (await isVideoInWatchLater()) ? SVG_PATH_FILLED : SVG_PATH_HOLLOW
  212. // )
  213. }
  214.  
  215. async function isVideoInWatchLater() {
  216. const data = document
  217. .querySelector(`#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`)
  218. .__dataHost.__data.items.find(
  219. (item) => item.menuServiceItemRenderer?.icon.iconType === "PLAYLIST_ADD"
  220. ).menuServiceItemRenderer
  221.  
  222. const videoId = data.serviceEndpoint.addToPlaylistServiceEndpoint.videoId
  223.  
  224. const SAPISIDHASH = await getSApiSidHash(
  225. document.cookie.split("SAPISID=")[1].split("; ")[0],
  226. window.origin
  227. )
  228.  
  229. const response = await fetch(
  230. `https://www.youtube.com/youtubei/v1/playlist/get_add_to_playlist`,
  231. {
  232. headers: { authorization: `SAPISIDHASH ${SAPISIDHASH}` },
  233. body: JSON.stringify({
  234. context: {
  235. client: {
  236. clientName: "WEB",
  237. clientVersion: ytcfg.data_.INNERTUBE_CLIENT_VERSION,
  238. },
  239. },
  240. excludeWatchLater: false,
  241. videoIds: [videoId],
  242. }),
  243. method: "POST",
  244. }
  245. )
  246.  
  247. const json = await response.json()
  248.  
  249. return (
  250. json.contents[0].addToPlaylistRenderer.playlists[0]
  251. .playlistAddToOptionRenderer.containsSelectedVideos === "ALL"
  252. )
  253. }
  254.  
  255. /** @see https://gist.github.com/eyecatchup/2d700122e24154fdc985b7071ec7764a */
  256. async function getSApiSidHash(SAPISID, origin) {
  257. function sha1(str) {
  258. return window.crypto.subtle
  259. .digest("SHA-1", new TextEncoder().encode(str))
  260. .then((buf) => {
  261. return Array.prototype.map
  262. .call(new Uint8Array(buf), (x) => ("00" + x.toString(16)).slice(-2))
  263. .join("")
  264. })
  265. }
  266.  
  267. const TIMESTAMP_MS = Date.now()
  268. const digest = await sha1(`${TIMESTAMP_MS} ${SAPISID} ${origin}`)
  269.  
  270. return `${TIMESTAMP_MS}_${digest}`
  271. }
  272.  
  273. // YouTube uses a bunch of duplicate 'id' tag values. why?
  274. // this makes it much more likely to target right one, but at the cost of being brittle
  275. onElementReady(
  276. `#above-the-fold #menu #${BUTTONS_CONTAINER_ID}`,
  277. { findOnce: false },
  278. addButton
  279. )