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