YouTube Clickbait-Buster

Check whether it's worth watching a video before actually clicking on it by peeking it's visual or verbal content, description, comments, viewing the thumbnail in full-size and displaying the full title. Works on both YouTube's desktop and mobile layouts, and is also compatible with dark theme.

2022/03/05のページです。最新版はこちら。

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name YouTube Clickbait-Buster
  3. // @version 1.10.0
  4. // @description Check whether it's worth watching a video before actually clicking on it by peeking it's visual or verbal content, description, comments, viewing the thumbnail in full-size and displaying the full title. Works on both YouTube's desktop and mobile layouts, and is also compatible with dark theme.
  5. // @author BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
  6. // @copyright 2022+, BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
  7. // @homepage https://github.com/hjk789/Userscripts/tree/master/YouTube-Clickbait-Buster
  8. // @license https://github.com/hjk789/Userscripts/tree/master/YouTube-Clickbait-Buster#license
  9. // @match https://www.youtube.com/*
  10. // @match https://m.youtube.com/*
  11. // @grant none
  12. // @namespace https://greasyfork.org/users/679182
  13. // ==/UserScript==
  14.  
  15.  
  16. //*********** SETTINGS ***********
  17.  
  18. const numberChunkColumns = 1 // The video storyboard YouTube provides is divided in chunks of frames. Set this to the number of chunks per row you
  19. // would like. Note that the size and the number of chunks per row is limited by your device's screen dimensions.
  20.  
  21. const fullTitles = true // Whether the videos' title should be forced to be displayed in full, without any trimmings. In case you are using any other userscript
  22. // or extension that changes YouTube's layout, set this to false if you see anything wrong in the layout, such as titles overlapping.
  23.  
  24. const sortByTopComments = true // Whether the comments popup should be sorted by "Top comments" by default. If set to false, it will be sorted by "Newest first".
  25.  
  26. const preferredTranscriptLanguage = "" // The two letters language-country code of the language you want the transcriptions to always be in. Examples: for US English, en-US,
  27. // for Spain's Spanish, es-ES, and so on. If there's no subtitles in the specified language, another language (if any) will be selected
  28. // and then translated to the specified language. If left blank, the language most likely to be the original is selected.
  29. //********************************
  30.  
  31.  
  32. let selectedVideoURL, continuationToken
  33. const isMobile = !/www/.test(location.hostname)
  34. let isMenuReady = false, hasSpace
  35.  
  36. /* Add some internal functions to the code */
  37. extendFunctions()
  38.  
  39. /* Add the styles */
  40. {
  41. const style = document.createElement("style")
  42. style.innerHTML = ".menu-item-button:hover { background-color: #aaa5 !important; }" + // Add the desktop version's hover for the recommendation menu items.
  43. "transcript text { display: block; margin-top: 10px; }" + // Separate each line of the transcript. By default the transcript lines are displayed in a single continuous line.
  44. ".naturalWidth > div > div { opacity: 0.8 !important; }" + // Make the timestamps more opaque when there's enough screen space for the image to be displayed in full size.
  45. ".hasSpace > div { margin: 1px !important; }" // Add a margin around chunks to visually separate them when there's enough space for more than one column.
  46.  
  47. if (fullTitles)
  48. style.innerHTML += "#video-title, h3, h4 { max-height: initial !important; -webkit-line-clamp: initial !important; }" // The full video title style.
  49.  
  50. document.head.appendChild(style)
  51. }
  52.  
  53.  
  54. /* Remove the popups when the page is clicked */
  55. {
  56. document.body.addEventListener("click", function()
  57. {
  58. const ids = ["storyboard","highresThumbnail","transcriptContainer","channelViewportContainer","commentsContainer"]
  59.  
  60. for (let i=0; i < ids.length; i++)
  61. {
  62. const element = document.getElementById(ids[i])
  63.  
  64. if (element) element.remove()
  65. }
  66.  
  67. hasSpace = undefined
  68. })
  69. }
  70.  
  71.  
  72. /* Create the menu buttons */
  73. {
  74. const elementName = isMobile ? "button" : "div"
  75. const backgroundColor = "var(--yt-spec-brand-background-solid, "+ getComputedStyle(document.documentElement).backgroundColor +")" // This CSS variable holds the background color of either the light theme or dark theme, whatever is the current
  76. // one. But it's only available on desktop, on mobile the color need to be taken from the root element's CSS.
  77. var viewStoryboardButton = createElement(elementName,
  78. {
  79. id: "viewStoryboardButton",
  80. className: "menu-item-button",
  81. style: !isMobile ? "background-color: var(--yt-spec-brand-background-solid); font-size: 14px; padding: 9px 15px 9px 56px; cursor: pointer; min-width: max-content;" : "",
  82. innerHTML: "Peek video content",
  83. onclick: function()
  84. {
  85. const xhr = new XMLHttpRequest()
  86. xhr.open('GET', selectedVideoURL)
  87. xhr.onload = function()
  88. {
  89. const fullStoryboardURL = xhr.responseText.match(/"playerStoryboardSpecRenderer":.+?"(https.+?)"}/)
  90.  
  91. if (!fullStoryboardURL || fullStoryboardURL[1].includes("googleadservices")) // It can happen sometimes that the storyboard provided is of the ad, instead of the video itself.
  92. { // But this seems to only happen on videos that don't have a storyboard available anyway.
  93. alert("Storyboard not available for this video!")
  94.  
  95. return
  96. }
  97.  
  98. const urlSplit = fullStoryboardURL[1].split("|")
  99. let mode = urlSplit[3] ? 3 : 1 // YouTube provides 2 modes of storyboards: one with 25 frames per chunk and another one with 60 frames per chunk. I've choose the former mode,
  100. // as in the second one the frames are too tiny to see anything. But in short videos with less than 30 seconds, only the latter is available.
  101. if (!urlSplit[mode]) // There's also a third mode, videos that have only one mode and ongoing lives storyboards, but I couldn't find any way to make them work.
  102. {
  103. alert("Storyboard not available for this video yet! Try again some hours later.")
  104.  
  105. return
  106. }
  107.  
  108. const storyboardId = urlSplit[mode].replace(/.+#rs/, "&sigh=rs")
  109.  
  110. const storyboardContainer = document.createElement("div")
  111. storyboardContainer.id = "storyboard"
  112. storyboardContainer.style = "position: fixed; z-index: 9999; display: grid; width: min-content; max-width: 100.3vw; overflow-y: inherit; background-color: white; margin: auto; top: 0px; left: 0px; right: 0px;"
  113. storyboardContainer.style.maxHeight = isMobile ? "91.4vh" : "100vh"
  114. storyboardContainer.contentEditable = !isMobile // Make the storyboards container focusable on the desktop layout. From all the other methods to achieve this, this is the only one that works reliably.
  115. storyboardContainer.onmousedown = function() {
  116. window.getSelection().removeAllRanges() // Because contentEditable is true, the storyboards become selectable. This prevents that by immediately deselecting them.
  117. }
  118.  
  119. document.body.appendChild(storyboardContainer)
  120.  
  121.  
  122. if (mode == 3) mode--
  123.  
  124. let num = 0
  125.  
  126. const videoLength = +xhr.responseText.match(/"lengthSeconds":"(\d+)","ownerProfileUrl/)[1]
  127. const secondsGap = videoLength <= 120 ? 1 : videoLength <= 300 ? 2 : videoLength < 900 ? 5 : 10 // Depending on the video length, YouTube takes snapshots with different time spaces.
  128.  
  129. createStoryboardImg(num, storyboardContainer, urlSplit, storyboardId, mode, videoLength, secondsGap)
  130. }
  131. xhr.send()
  132.  
  133. document.body.click() // Dismiss the menu.
  134. }
  135. })
  136.  
  137. var viewTranscriptButton = createElement(elementName,
  138. {
  139. className: viewStoryboardButton.className,
  140. style: viewStoryboardButton.style.cssText,
  141. innerHTML: "Peek audio transcription",
  142. onclick: function()
  143. {
  144. const xhr = new XMLHttpRequest()
  145. xhr.open('GET', selectedVideoURL)
  146. xhr.onload = function()
  147. {
  148. let transcriptObj = xhr.responseText.match(/"playerCaptionsTracklistRenderer":({"captionTracks":\[{"baseUrl".+?}\].+?}.+?\].+?})},"videoDetails"/)
  149.  
  150. if (!transcriptObj)
  151. {
  152. alert("Transcript not available for this video!")
  153.  
  154. return
  155. }
  156.  
  157. transcriptObj = JSON.parse(unescape(decodeURI(transcriptObj[1])).replaceAll("\\u0026","&"))
  158.  
  159. const transcriptContainer = document.createElement("div")
  160. transcriptContainer.id = "transcriptContainer"
  161. transcriptContainer.style = "position: fixed; z-index: 9999; background-color: " + backgroundColor + "; color: var(--paper-listbox-color); font-size: 15px;" +
  162. "width: max-content; max-width: 94vw; overflow: scroll; top: 0; left: 0; right: 0; margin: auto; padding: 10px;"
  163. transcriptContainer.style.maxHeight = isMobile ? "90vh" : "98vh"
  164. transcriptContainer.onclick = function() { event.stopPropagation() }
  165.  
  166. const transcriptLanguageLabel = document.createElement("div")
  167. transcriptLanguageLabel.innerText = "Transcript language: "
  168. transcriptLanguageLabel.style = "margin-bottom: 5px; width: max-content;"
  169. transcriptContainer.appendChild(transcriptLanguageLabel)
  170.  
  171. const transcriptLanguageDropdown = document.createElement("select")
  172. transcriptLanguageDropdown.style = "background-color: " + backgroundColor + "; color: var(--paper-listbox-color); border: 1px solid lightgray; border-radius: 5px; padding: 3px;"
  173. transcriptLanguageDropdown.onchange = function() { loadTranscript(transcriptObj.captionTracks[this.selectedIndex].baseUrl) }
  174. transcriptLanguageLabel.appendChild(transcriptLanguageDropdown)
  175.  
  176. for (let i=0; i < transcriptObj.captionTracks.length; i++)
  177. {
  178. const option = document.createElement("option")
  179. option.innerText = isMobile ? transcriptObj.captionTracks[i].name.runs[0].text : transcriptObj.captionTracks[i].name.simpleText
  180. option.value = transcriptObj.captionTracks[i].languageCode
  181.  
  182. transcriptLanguageDropdown.appendChild(option)
  183. }
  184.  
  185. transcriptLanguageDropdown.value = preferredTranscriptLanguage
  186.  
  187. if (preferredTranscriptLanguage && !transcriptLanguageDropdown.value)
  188. transcriptLanguageDropdown.value = preferredTranscriptLanguage.split("-")[0]
  189.  
  190. if (!transcriptLanguageDropdown.value)
  191. {
  192. if (transcriptObj.captionTracks.length > 1)
  193. {
  194. const index = transcriptObj.audioTracks[0].captionTrackIndices[transcriptObj.audioTracks[0].captionTrackIndices.length-1]
  195. const autogen = transcriptObj.captionTracks[index]
  196.  
  197. if (autogen.vssId[0] == "a")
  198. {
  199. const a = autogen.vssId.split(".")[1]
  200.  
  201. if (transcriptObj.captionTracks[index+1] && transcriptObj.captionTracks[index+1].vssId.includes(a))
  202. transcriptLanguageDropdown.value = transcriptObj.captionTracks[index+1].languageCode
  203. else if (transcriptObj.captionTracks[index-1])
  204. transcriptLanguageDropdown.value = transcriptObj.captionTracks[index-1].languageCode
  205. else
  206. transcriptLanguageDropdown.value = transcriptObj.captionTracks[transcriptObj.audioTracks[0].captionTrackIndices[0]].languageCode
  207. }
  208. }
  209. else transcriptLanguageDropdown.value = transcriptLanguageDropdown.options[0].value
  210. }
  211.  
  212.  
  213. const transcriptTranslationLabel = document.createElement("div")
  214. transcriptTranslationLabel.innerText = "Translate to: "
  215. transcriptTranslationLabel.style = "margin-top: 10px; padding-bottom: 10px;"
  216. transcriptContainer.appendChild(transcriptTranslationLabel)
  217.  
  218. const transcriptTranslationDropdown = document.createElement("select")
  219. transcriptTranslationDropdown.style = "margin-left: 53px; background-color: " + backgroundColor + "; color: var(--paper-listbox-color); border: 1px solid lightgray; border-radius: 5px; padding: 3px;"
  220. transcriptTranslationDropdown.onchange = function() { loadTranscript(transcriptObj.captionTracks[transcriptLanguageDropdown.selectedIndex].baseUrl + "&tlang=" + this.value) }
  221. transcriptTranslationLabel.appendChild(transcriptTranslationDropdown)
  222.  
  223. const emptyOption = document.createElement("option")
  224. transcriptTranslationDropdown.appendChild(emptyOption)
  225.  
  226. for (let i=0; i < transcriptObj.translationLanguages.length; i++)
  227. {
  228. const option = document.createElement("option")
  229. option.innerText = isMobile ? transcriptObj.translationLanguages[i].languageName.runs[0].text : transcriptObj.translationLanguages[i].languageName.simpleText
  230. option.value = transcriptObj.translationLanguages[i].languageCode
  231.  
  232. transcriptTranslationDropdown.appendChild(option)
  233. }
  234.  
  235. transcriptTranslationDropdown.value = preferredTranscriptLanguage
  236.  
  237. if (!transcriptTranslationDropdown.value)
  238. transcriptTranslationDropdown.value = preferredTranscriptLanguage.split("-")[0]
  239.  
  240.  
  241. const transcriptTextContainer = document.createElement("div")
  242. transcriptTextContainer.id = "transcriptTextContainer"
  243. transcriptTextContainer.style = "min-width: max-content; max-width: 94vw; border-top: 1px solid lightgray;"
  244. transcriptContainer.appendChild(transcriptTextContainer)
  245.  
  246. if (isMobile)
  247. {
  248. const closeButton = transcriptContainer.createElement("div",
  249. {
  250. innerText: "X",
  251. style: "position: sticky; width: 25px; float: right; bottom: 0px; background-color: #f0f0f0ab; border-radius: 7px; padding: 10px 15px; text-align: center; font-size: 25px;",
  252. onclick: function() { document.body.click() }
  253. })
  254. }
  255.  
  256.  
  257. document.body.appendChild(transcriptContainer)
  258.  
  259.  
  260. loadTranscript(transcriptObj.captionTracks[transcriptLanguageDropdown.selectedIndex].baseUrl + "&tlang=" + transcriptTranslationDropdown.value)
  261. }
  262. xhr.send()
  263.  
  264. document.body.click()
  265. }
  266. })
  267.  
  268. var viewDescriptionButton = createElement(elementName,
  269. {
  270. className: viewStoryboardButton.className,
  271. style: viewStoryboardButton.style.cssText,
  272. innerHTML: "Peek description",
  273. onclick: function()
  274. {
  275. const xhr = new XMLHttpRequest()
  276. xhr.open('GET', selectedVideoURL)
  277. xhr.onload = function()
  278. {
  279. const description = xhr.responseText.match(/"shortDescription":"(.+?)[^\\]"/)[1].replaceAll("\\n","\n").replaceAll("\\r","\r").replaceAll("\\","")
  280.  
  281. if (description.length < 2)
  282. alert("This video doesn't have a description.")
  283. else
  284. alert(description)
  285. }
  286. xhr.send()
  287.  
  288. document.body.click()
  289. }
  290. })
  291.  
  292. var viewCommentsButton = createElement(elementName,
  293. {
  294. className: viewStoryboardButton.className,
  295. style: viewStoryboardButton.style.cssText,
  296. innerHTML: "Peek comments",
  297. onclick: function()
  298. {
  299. const xhr = new XMLHttpRequest()
  300. xhr.open('GET', selectedVideoURL)
  301. xhr.onload = function()
  302. {
  303. const apiKey = xhr.responseText.match(/"INNERTUBE_API_KEY":"(.+?)"/)[1]
  304. let token = xhr.responseText.match(isMobile ? /\\x22continuationCommand\\x22:\\x7b\\x22token\\x22:\\x22(\w+)\\x22/ : /"continuationCommand":{"token":"(.+?)"/)[1]
  305. token = sortByTopComments ? token.replace("ABeA", "AAeA") : token.replace("AAeA", "ABeA") // One single character in the token is responsible for determining the sorting
  306. // of the comments, being A the "Top comments" and B the "Newest first".
  307. const pageName = selectedVideoURL.includes("/shorts/") ? "browse" : "next"
  308.  
  309. const commentsContainer = document.createElement("div")
  310. commentsContainer.id = "commentsContainer"
  311. commentsContainer.style = "position: fixed; top: 0; left: 0; right: 0; z-index: 9999; margin: auto; width: 700px; max-width: 94vw; overflow-y: scroll; padding: 10px;"+
  312. "border: 1px solid lightgray; background-color: "+ backgroundColor +"; color: var(--paper-listbox-color); font-size: 15px; visibility: hidden;"
  313. commentsContainer.style.maxHeight = isMobile ? "92vh" : "97vh"
  314. commentsContainer.onclick = function() { event.stopPropagation() }
  315.  
  316. const sortingDropdownLabel = commentsContainer.createElement("span", {innerText: "Sort by: "})
  317.  
  318. const sortingDropdown = sortingDropdownLabel.createElement("select",
  319. {
  320. style: "background-color: " + backgroundColor + "; color: var(--paper-listbox-color); border: 1px solid lightgray; border-radius: 5px; padding: 3px; margin-bottom: 10px; margin-left: 5px;",
  321. onchange: function()
  322. {
  323. commentsTextContainer.innerHTML = ""
  324.  
  325. token = token.replace(/A(A|B)eA/, "A"+this.value+"eA")
  326.  
  327. loadCommentsOrReplies(commentsTextContainer, pageName, apiKey, token)
  328. }
  329. })
  330.  
  331. sortingDropdown.createElement("option", {innerText: "Top comments", value: "A"})
  332. sortingDropdown.createElement("option", {innerText: "Newest first", value: "B"})
  333.  
  334. sortingDropdown.value = sortByTopComments ? "A" : "B"
  335.  
  336.  
  337. const commentsTextContainer = commentsContainer.createElement("div", {style: "border-top: 1px solid lightgray; padding-top: 10px;"})
  338.  
  339. document.body.appendChild(commentsContainer)
  340.  
  341. if (isMobile)
  342. {
  343. const closeButtonPositionContainer = commentsContainer.createElement("div", {style: "position: relative; right: 55px;"}, true)
  344. const closeButtonContainer = closeButtonPositionContainer.createElement("div", {style: "position: absolute; right: 0px;"})
  345. const closeButton = closeButtonContainer.createElement("div",
  346. {
  347. innerText: "X",
  348. style: "position: fixed; width: 25px; z-index: 99999; background-color: #ddd8; border-radius: 7px; padding: 10px 15px; text-align: center; font-size: 25px;",
  349. onclick: function() { document.body.click() }
  350. })
  351. }
  352.  
  353. loadCommentsOrReplies(commentsTextContainer, pageName, apiKey, token)
  354. }
  355. xhr.send()
  356.  
  357. document.body.click()
  358. }
  359. })
  360.  
  361. var viewChannelButton = createElement(elementName,
  362. {
  363. className: viewStoryboardButton.className,
  364. style: viewStoryboardButton.style.cssText,
  365. innerHTML: "Peek channel",
  366. onclick: function()
  367. {
  368. const xhr = new XMLHttpRequest()
  369. xhr.open('GET', selectedVideoURL)
  370. xhr.onload = function()
  371. {
  372. const channelId = xhr.responseText.match(/"channelId":"(.+?)"/)
  373.  
  374. const channelViewportContainer = document.body.createElement("div",
  375. {
  376. id: "channelViewportContainer",
  377. style: "position: fixed; width: 720px; max-width: 100vw; height: "+ (isMobile ? "91vh" : "100vh") +"; top: 0; left: 0px; right: 0px; z-index: 9999; margin: auto; background-color: "+ backgroundColor +";"
  378. })
  379.  
  380. const channelViewport = channelViewportContainer.createElement("iframe",
  381. {
  382. style: "width: calc(100% - 4px); height: 100%;",
  383. src: "https://www.youtube.com/channel/" + channelId[1]
  384. })
  385.  
  386. if (isMobile)
  387. {
  388. const closeButton = channelViewportContainer.createElement("div",
  389. {
  390. innerText: "X",
  391. style: "position: absolute; width: 25px; top: 0px; z-index: 99999; background-color: #ddd; border-radius: 7px; padding: 10px 15px; text-align: center; font-size: 25px;",
  392. onclick: function() { document.body.click() }
  393. }, true)
  394. }
  395. }
  396. xhr.send()
  397.  
  398. document.body.click()
  399. }
  400. })
  401.  
  402. var viewThumbnailButton = createElement(elementName,
  403. {
  404. id: "viewThumbnailButton",
  405. className: viewStoryboardButton.className,
  406. style: viewStoryboardButton.style.cssText,
  407. innerHTML: "View high-res thumbnail",
  408. onclick: function()
  409. {
  410. event.stopPropagation() // Prevent the click event from reaching the body, otherwise the thumbnail is removed right after.
  411.  
  412. if (!isMobile)
  413. document.body.click()
  414. else
  415. this.parentElement.click() // On mobile, the menu creates a backdrop above the body which is only dismissed when clicked. Clicking the body element doesn't work in this case.
  416.  
  417. const videoId = cleanVideoUrl(selectedVideoURL).split(/=|shorts\//)[1]
  418.  
  419. const img = document.createElement("img")
  420. img.id = "highresThumbnail"
  421. img.src = "https://i.ytimg.com/vi_webp/" + videoId + "/maxresdefault.webp"
  422. img.style = "position: fixed; z-index: 9999; max-height: 100vh; max-width: 100vw; margin: auto; top: 0px; left: 0px; right: 0px;"
  423. img.onload = function()
  424. {
  425. if (this.clientWidth == 120) // The default thumbnail URL points to the biggest size and highest quality. But sometimes it can happen that this kind of thumbnail is not available (especially on older videos). When this
  426. { // happens, YouTube responds with a small placeholder image with 120 width. This checks if it's the placeholder, and if so, tries again with a lower quality version and then a smaller size.
  427. this.onload = function()
  428. {
  429. if (this.clientWidth == 120)
  430. {
  431. this.onload = function()
  432. {
  433. if (this.clientWidth == 120)
  434. {
  435. this.onload = function()
  436. {
  437. if (this.clientWidth == 120)
  438. {
  439. alert("Thumbnail not found!")
  440.  
  441. this.onload = undefined
  442. }
  443. }
  444.  
  445. this.src = "https://i.ytimg.com/vi/" + videoId + "/hqdefault.jpg"
  446. }
  447. }
  448.  
  449. this.src = "https://i.ytimg.com/vi_webp/" + videoId + "/hqdefault.webp"
  450. }
  451. }
  452.  
  453. this.src = "https://i.ytimg.com/vi/" + videoId + "/maxresdefault.jpg"
  454. }
  455. }
  456.  
  457. document.body.appendChild(img)
  458.  
  459. }
  460. })
  461.  
  462.  
  463. viewStoryboardButton.style.borderTop = "solid 1px #aaa5" // Add a separator between Youtube's menu items and the ones added by the script.
  464.  
  465. }
  466.  
  467.  
  468. main()
  469.  
  470.  
  471. function main()
  472. {
  473. const videosSelector = isMobile ? "ytm-rich-item-renderer, ytm-video-with-context-renderer, ytm-compact-video-renderer, ytm-compact-playlist-renderer, ytm-compact-show-renderer, ytm-playlist-video-renderer"
  474. : "ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, ytd-compact-playlist-renderer, ytd-compact-movie-renderer, ytd-playlist-video-renderer, ytd-reel-item-renderer, ytd-video-renderer, ytd-compact-radio-renderer"
  475.  
  476. if (!isMobile)
  477. addMenuItems()
  478.  
  479. document.body.addEventListener("mousedown", function() // Process all video items every time the user clicks anywhere on the page. Although not
  480. { // ideal, it's the most guaranteed way of working reliably regardless of the situation.
  481. const videoItems = document.querySelectorAll(videosSelector)
  482.  
  483. for (let i=0; i < videoItems.length; i++)
  484. processVideoItem(videoItems[i])
  485. })
  486.  
  487.  
  488. window.onresize = function() { checkScreenSpaceAndAdaptStoryboard() }
  489. screen.orientation.onchange = function() { checkScreenSpaceAndAdaptStoryboard() }
  490. }
  491.  
  492. function addMenuItems()
  493. {
  494. const waitForMenu = setInterval(function()
  495. {
  496. const menu = isMobile ? document.getElementById("menu") : document.querySelector("#details #menu yt-icon, .details #menu yt-icon, #title-wrapper #menu yt-icon, ytd-playlist-video-renderer #menu yt-icon")
  497.  
  498. if (!menu)
  499. return
  500.  
  501. clearInterval(waitForMenu)
  502.  
  503. if (document.getElementById("viewStoryboardButton") || document.getElementById("viewThumbnailButton")) // Only add the menu items if they aren't present already.
  504. {
  505. const menu = document.getElementById("viewStoryboardButton").parentElement.parentElement // YouTube resets the menu size everytime it's opened,
  506. menu.style = "max-height: max-content !important; max-width: max-content !important; height: max-content !important; width: max-content !important;" // so the script needs to force max size right after.
  507.  
  508. if (menu.firstElementChild.getBoundingClientRect().bottom > screen.height) // If the menu is opened when there's little vertical space, the menu's bottom will be displayed
  509. menu.parentElement.parentElement.style.top = "0" // out of bounds. This forces the menu to be displayed at the top of the page when that happens.
  510.  
  511. return
  512. }
  513.  
  514.  
  515. if (isMobile)
  516. {
  517. menu.firstChild.appendChild(viewStoryboardButton)
  518. menu.firstChild.appendChild(viewTranscriptButton)
  519. menu.firstChild.appendChild(viewDescriptionButton)
  520. menu.firstChild.appendChild(viewCommentsButton)
  521. menu.firstChild.appendChild(viewChannelButton)
  522. menu.firstChild.appendChild(viewThumbnailButton)
  523. }
  524. else
  525. {
  526. if (!document.querySelector("ytd-menu-popup-renderer"))
  527. {
  528. menu.click()
  529. menu.click() // The recommendation menu doesn't exist in the HTML before it's clicked for the first time. This forces it to be created and dismisses it immediately.
  530. }
  531.  
  532. const optionsParent = document.querySelector("ytd-menu-popup-renderer")
  533. optionsParent.style = "max-height: max-content !important; max-width: max-content !important; height: max-content !important; width: max-content !important;" // Change the max width and height so that the new items fit in the menu.
  534. optionsParent.firstElementChild.style = "width: inherit;"
  535.  
  536. const waitForMenuItem = setInterval(function()
  537. {
  538. const menuItem = optionsParent.querySelector("ytd-menu-service-item-renderer, ytd-menu-navigation-item-renderer")
  539.  
  540. if (!menuItem)
  541. return
  542.  
  543. clearInterval(waitForMenuItem)
  544.  
  545.  
  546. menuItem.parentElement.appendChild(viewStoryboardButton)
  547. menuItem.parentElement.appendChild(viewTranscriptButton)
  548. menuItem.parentElement.appendChild(viewDescriptionButton)
  549. menuItem.parentElement.appendChild(viewCommentsButton)
  550. menuItem.parentElement.appendChild(viewChannelButton)
  551. menuItem.parentElement.appendChild(viewThumbnailButton)
  552.  
  553. if (!isMenuReady)
  554. {
  555. menu.click() // The menu doesn't apply the width and height adjustments the first time it's opened, but it
  556. menu.click() // does on the second time. This forces the menu to be opened again and dismisses it immediately.
  557.  
  558. isMenuReady = true
  559. }
  560.  
  561. const isChannelOrPlaylistPage = location.pathname.includes("/channel/") || location.pathname.includes("/user/") || location.pathname.includes("/c/") || location.pathname == "/playlist"
  562.  
  563. if (isChannelOrPlaylistPage && document.querySelector("ytd-topbar-menu-button-renderer #avatar-btn")) // In the channel page, when the user is signed in, Youtube already adds a separator at the bottom of the menu.
  564. viewStoryboardButton.style.borderTop = "" // This removes the separator when on these pages.
  565. else
  566. viewStoryboardButton.style.borderTop = "solid 1px #aaa5" // And adds it back when the user switches to another non-channel page.
  567.  
  568. }, 100)
  569. }
  570.  
  571. }, 100)
  572. }
  573.  
  574. function processVideoItem(node)
  575. {
  576. const videoTitleEll = node.querySelector(isMobile ? "h3, h4" : "#video-title, #movie-title")
  577.  
  578. if (!videoTitleEll)
  579. return
  580.  
  581.  
  582. let videoUrl = videoTitleEll.href || videoTitleEll.parentElement.href || videoTitleEll.parentElement.parentElement.href
  583.  
  584. const videoMenuBtn = node.querySelector(isMobile ? "ytm-menu" : "ytd-menu-renderer")
  585.  
  586. // Because the recommendation's side-menu is separated from the recommendations container, this listens to clicks on each three-dot
  587. // button and store in a variable in what recommendation it was clicked, to then be used to load the storyboards or thumbnail.
  588.  
  589. if (videoMenuBtn)
  590. {
  591. videoMenuBtn.parentElement.onclick = function()
  592. {
  593. selectedVideoURL = videoUrl
  594.  
  595. addMenuItems()
  596. }
  597. }
  598. }
  599.  
  600. function createStoryboardImg(num, storyboardContainer, urlSplit, param, mode, videoLength, secondsGap, lastTime = 0)
  601. {
  602. const chunkContainer = document.createElement("div")
  603. chunkContainer.style = "position: relative; display: inline-block; width: min-content;"
  604. chunkContainer.contentEditable = false
  605.  
  606. const timestampsContainer = document.createElement("div")
  607. timestampsContainer.style = "position: absolute; display: grid; width: 100%; align-items: self-end;"+
  608. "justify-items: end; pointer-events: none; opacity: 0.4;"
  609.  
  610. chunkContainer.appendChild(timestampsContainer)
  611.  
  612.  
  613. const base = urlSplit[0].replace("L$L/$N","L"+mode+"/M"+num++) // The storyboard URL uses the "L#/M#" parameter to determine the type and part of the storyboard to load. L1 is the
  614. const img = document.createElement("img") // storyboard chunk with 60 frames, and L2 is the one with 25 frames. M0 is the first chunk, M1 the second, and so on.
  615. img.src = base+param
  616. img.style.verticalAlign = "top"
  617. img.style.maxWidth = isMobile ? "100vw" : "97.6vw"
  618. img.onload = function()
  619. {
  620. const firstImg = storyboardContainer.firstChild.lastChild
  621. const framesWidth = firstImg.naturalWidth / 5
  622. const framesHeight = firstImg.naturalHeight / 5
  623.  
  624. timestampsContainer.style.height = this.height < firstImg.height ? this.height + "px" : "100%"
  625.  
  626. const numColumns = Math.round(this.naturalWidth / framesWidth)
  627. let numRows = Math.round(this.naturalHeight / framesHeight)
  628.  
  629. if (videoLength < 20)
  630. numRows = videoLength < 5 ? 1 : videoLength < 10 ? 2 : videoLength < 15 ? 3 : 4
  631.  
  632. for (let i=0; i < numRows; i++)
  633. {
  634. for (let j=0; j < numColumns; j++)
  635. {
  636. let seconds = 0
  637.  
  638. if (i || j || storyboardContainer.children.length > 1) // Only calculate the gap if it's not the first timestamp of the first chunk.
  639. seconds = lastTime += secondsGap
  640.  
  641. if (seconds > videoLength)
  642. seconds = videoLength
  643.  
  644. const date = new Date(null)
  645. date.setSeconds(seconds)
  646. const time = seconds >= 3600 ? date.toISOString().substr(11, 8) : date.toISOString().substr(14, 5) // If the timestamp is above 1 hour, include the hours digit in the timestamp.
  647.  
  648. const timestamp = document.createElement("a")
  649. timestamp.href = selectedVideoURL + "&t=" + seconds
  650. timestamp.style = "padding: 0px 2px; border-radius: 2px; background-color: #222; color: white;" +
  651. "font-weight: 500; font-size: 11px; text-decoration: none; pointer-events: auto;"
  652.  
  653. timestamp.innerText = time
  654.  
  655. timestampsContainer.style.cssText += "grid-template-columns: repeat("+ numColumns +", 1fr); grid-template-rows: repeat("+ numRows +", 1fr);"
  656.  
  657. timestampsContainer.appendChild(timestamp)
  658.  
  659. if (seconds == videoLength)
  660. break
  661.  
  662. }
  663. }
  664.  
  665.  
  666. if (storyboardContainer.children.length == 2 && hasSpace == undefined)
  667. checkScreenSpaceAndAdaptStoryboard()
  668.  
  669.  
  670. storyboardContainer.focus()
  671.  
  672. createStoryboardImg(num, storyboardContainer, urlSplit, param, mode, videoLength, secondsGap, lastTime) // Keep loading the storyboard chunks until there's no more left.
  673. }
  674. img.onerror = function()
  675. {
  676. storyboardContainer.style.overflowY = storyboardContainer.scrollHeight < screen.height ? "hidden" : "scroll" // Hide the scrollbar when there's not enough chunks to overflow. It's needs to be either
  677. // scroll or hidden, as in the auto mode the scrollbar stays on top of the image.
  678. storyboardContainer.contentEditable = false
  679.  
  680. if (storyboardContainer.children.length == 1)
  681. {
  682. alert("Storyboard not available for this video!")
  683.  
  684. storyboardContainer.remove()
  685. }
  686.  
  687. this.parentElement.remove()
  688.  
  689. checkScreenSpaceAndAdaptStoryboard()
  690. }
  691.  
  692.  
  693. chunkContainer.appendChild(img)
  694.  
  695. storyboardContainer.appendChild(chunkContainer)
  696. }
  697.  
  698. function checkScreenSpaceAndAdaptStoryboard(numChunkColumns = numberChunkColumns)
  699. {
  700. const storyboardContainer = document.getElementById("storyboard")
  701.  
  702. if (!storyboardContainer)
  703. return
  704.  
  705. const firstImg = storyboardContainer.firstChild.lastChild
  706.  
  707. if (numberChunkColumns > 1 && numChunkColumns > 0)
  708. {
  709. if (storyboardContainer.children.length > 1)
  710. {
  711. if (firstImg.naturalWidth == firstImg.width) // If the chunk is in it's full size.
  712. {
  713. const containerSize = (isMobile ? 2 : 21) + firstImg.naturalWidth * numChunkColumns
  714.  
  715. hasSpace = screen.width > containerSize
  716.  
  717. if (hasSpace)
  718. {
  719. storyboardContainer.style.gridTemplateColumns = "repeat("+ numChunkColumns +", 1fr)"
  720.  
  721. if (numChunkColumns > 1)
  722. storyboardContainer.classList.add("hasSpace")
  723. }
  724. else
  725. {
  726. storyboardContainer.classList.remove("hasSpace")
  727.  
  728. checkScreenSpaceAndAdaptStoryboard(numChunkColumns-1) // If the specified number of columns doesn't fit in the screen, fallback to one less column and try again, until a number that fits is found.
  729. }
  730. }
  731. }
  732. }
  733.  
  734. if (storyboardContainer.style.overflowY != "inherit") // Only auto-adapt the storyboard after it finished loading all chunks.
  735. {
  736. const lastChunk = storyboardContainer.lastChild
  737. const lastImg = lastChunk.lastChild
  738.  
  739. lastChunk.firstChild.style.height = lastImg.height <= firstImg.height ? lastImg.height-1 + "px" : "100%"
  740.  
  741.  
  742. storyboardContainer.style.overflowY = storyboardContainer.scrollHeight < screen.height ? "hidden" : "scroll"
  743. }
  744.  
  745. if (firstImg.naturalWidth - firstImg.width < 50)
  746. storyboardContainer.classList.add("naturalWidth")
  747. else
  748. storyboardContainer.classList.remove("naturalWidth")
  749. }
  750.  
  751. function loadTranscript(url)
  752. {
  753. const xhr = new XMLHttpRequest()
  754. xhr.open('GET', url)
  755. xhr.onload = function()
  756. {
  757. const transcriptTextContainer = document.getElementById("transcriptTextContainer")
  758.  
  759. transcriptTextContainer.innerHTML = xhr.responseText.replaceAll("&amp;#39;", "'").replaceAll("&amp;quot;", '"').replaceAll("&amp;", '&')
  760.  
  761. transcriptTextContainer.firstElementChild.style = "width: max-content; max-width: 96vw; display: block; left: 0; right: 0; position: relative; margin: auto;"
  762.  
  763. const lines = transcriptTextContainer.firstElementChild.children
  764.  
  765. for (let i=0; i < lines.length; i++)
  766. {
  767. const seconds = lines[i].attributes.start.value.split(".")[0]
  768.  
  769. const date = new Date(null)
  770. date.setSeconds(seconds)
  771. const time = seconds >= 3600 ? date.toISOString().substr(11, 8) : date.toISOString().substr(14, 5)
  772.  
  773. const timestamp = document.createElement("a")
  774. timestamp.href = selectedVideoURL + "&t=" + seconds
  775. timestamp.style = "background-color: #aaa5; color: var(--paper-listbox-color); padding: 2px 5px 1px 5px; margin-right: 10px; text-decoration: none;"
  776. timestamp.innerText = time
  777.  
  778. lines[i].insertBefore(timestamp, lines[i].firstChild)
  779. }
  780. }
  781. xhr.send()
  782. }
  783.  
  784. function loadCommentsOrReplies(container, pageName, apiKey, token, isReplies = false)
  785. {
  786. const xhrComments = new XMLHttpRequest()
  787. xhrComments.open('POST', "https://www.youtube.com/youtubei/v1/"+ pageName +"?prettyPrint=false&key="+ apiKey)
  788. xhrComments.onload = function()
  789. {
  790. const responseObj = JSON.parse(xhrComments.responseText)
  791. let comments = responseObj.onResponseReceivedEndpoints[isReplies ? 0 : 1]
  792.  
  793. if (!comments)
  794. return alert("Comments are turned off in this video.")
  795.  
  796. comments = comments[isReplies ? "appendContinuationItemsAction" : "reloadContinuationItemsCommand"].continuationItems
  797.  
  798. if (!comments)
  799. return alert("This video doesn't have any comments yet.")
  800.  
  801.  
  802. if (!isReplies)
  803. document.getElementById("commentsContainer").style.visibility = "visible"
  804.  
  805.  
  806. for (let i=0; i < comments.length; i++)
  807. {
  808. const commentData = comments[i].commentThreadRenderer
  809.  
  810. if (!commentData)
  811. {
  812. if (isReplies)
  813. {
  814. if (comments[i].continuationItemRenderer)
  815. {
  816. continuationToken = comments[i].continuationItemRenderer.button.buttonRenderer.command.continuationCommand.token
  817.  
  818. loadCommentsOrReplies(container, pageName, apiKey, continuationToken, true)
  819.  
  820. break
  821. }
  822. }
  823. else
  824. {
  825. continuationToken = comments[i].continuationItemRenderer.continuationEndpoint.continuationCommand.token
  826. break
  827. }
  828. }
  829.  
  830. const comment = isReplies ? comments[i].commentRenderer : commentData.comment.commentRenderer
  831. const commentContents = comment.contentText.runs
  832.  
  833. let commentText = ""
  834.  
  835. for (let j=0; j < commentContents.length; j++) // Every line, link, text formatations, and even emojis, of each comment,
  836. commentText += commentContents[j].text // are all in separated strings. This appends them all in one string.
  837.  
  838. const commentTextContainer = document.createElement("div")
  839. commentTextContainer.innerText = commentText
  840. commentTextContainer.style = isReplies ? "border-top: 1px solid lightgray; margin-top: 10px; padding-top: 10px; margin-left: 60px;"
  841. : "border-bottom: 1px solid lightgray; margin-bottom: 10px; padding-bottom: 10px;"
  842. if (comment.replyCount)
  843. {
  844. const replyToken = commentData.replies.commentRepliesRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token
  845.  
  846. const showRepliesButton = commentTextContainer.createElement("span",
  847. {
  848. style: "display: block; margin-top: 10px; color: #065fd4; font-weight: 500; cursor: pointer; font-size: 14px;",
  849. innerText: "▾ Show replies",
  850. onclick: function()
  851. {
  852. if (this.innerText.includes("Show"))
  853. {
  854. this.innerText = "▴ Hide replies"
  855.  
  856. loadCommentsOrReplies(repliesContainer, pageName, apiKey, replyToken, true)
  857. }
  858. else
  859. {
  860. this.innerText = "▾ Show replies"
  861.  
  862. repliesContainer.innerHTML = ""
  863. }
  864. }
  865. })
  866.  
  867. const repliesContainer = commentTextContainer.createElement("div")
  868. }
  869.  
  870. container.appendChild(commentTextContainer)
  871. }
  872. }
  873. xhrComments.send('{ "context": { "client": { "clientName": "WEB", "clientVersion": "2.2022021" } }, "continuation": "'+ token +'" }') // This is the bare minimum to be able to get the comments list.
  874. }
  875.  
  876. function extendFunctions()
  877. {
  878. Node.prototype.createElement = function(name, attributesObj, insertBeforeFirst) { return createElement(name, attributesObj, this, insertBeforeFirst) }
  879. }
  880.  
  881. function createElement(name, attributesObj, container, insertBeforeFirst)
  882. {
  883. const element = document.createElement(name)
  884.  
  885. let atributesNames = []
  886.  
  887. if (attributesObj)
  888. atributesNames = Object.getOwnPropertyNames(attributesObj)
  889.  
  890. for (let i=0; i < atributesNames.length; i++)
  891. element[atributesNames[i]] = attributesObj[atributesNames[i]]
  892.  
  893. if (container)
  894. {
  895. if (insertBeforeFirst)
  896. container.insertBefore(element, container.firstChild)
  897. else
  898. container.appendChild(element)
  899. }
  900.  
  901. return element
  902. }
  903.  
  904. function cleanVideoUrl(fullUrl)
  905. {
  906. const urlSplit = fullUrl.split("?") // Separate the page path from the parameters.
  907.  
  908. if (!urlSplit[1]) return fullUrl
  909.  
  910. const paramsSplit = urlSplit[1].split("&") // Separate each parameter.
  911.  
  912. for (let i=0; i < paramsSplit.length; i++)
  913. {
  914. if (paramsSplit[i].includes("v=")) // Get the video's id.
  915. return urlSplit[0]+"?"+paramsSplit[i] // Return the cleaned video URL.
  916. }
  917. }
  918.