Youtube Screenshot Button

Adds a button that enables you to take screenshots for YouTube videos.

  1. // ==UserScript==
  2. // @name Youtube Screenshot Button
  3. // @namespace https://riophae.com/
  4. // @version 0.1.8
  5. // @description Adds a button that enables you to take screenshots for YouTube videos.
  6. // @author Riophae Lee
  7. // @match https://www.youtube.com/*
  8. // @run-at document-start
  9. // @grant GM.openInTab
  10. // @grant GM_openInTab
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. // Types inspired by
  18. // https://github.com/Microsoft/TypeScript/blob/9d3707d/src/lib/dom.generated.d.ts#L10581
  19. // Type predicate for TypeScript
  20. function isQueryable(object) {
  21. return typeof object.querySelectorAll === 'function';
  22. }
  23. function select(selectors, baseElement) {
  24. // Shortcut with specified-but-null baseElement
  25. if (arguments.length === 2 && !baseElement) {
  26. return null;
  27. }
  28. return (baseElement !== null && baseElement !== void 0 ? baseElement : document).querySelector(String(selectors));
  29. }
  30. function selectLast(selectors, baseElement) {
  31. // Shortcut with specified-but-null baseElement
  32. if (arguments.length === 2 && !baseElement) {
  33. return null;
  34. }
  35. const all = (baseElement !== null && baseElement !== void 0 ? baseElement : document).querySelectorAll(String(selectors));
  36. return all[all.length - 1];
  37. }
  38. /**
  39. * @param selectors One or more CSS selectors separated by commas
  40. * @param [baseElement] The element to look inside of
  41. * @return Whether it's been found
  42. */
  43. function selectExists(selectors, baseElement) {
  44. if (arguments.length === 2) {
  45. return Boolean(select(selectors, baseElement));
  46. }
  47. return Boolean(select(selectors));
  48. }
  49. function selectAll(selectors, baseElements) {
  50. // Shortcut with specified-but-null baseElements
  51. if (arguments.length === 2 && !baseElements) {
  52. return [];
  53. }
  54. // Can be: select.all('selectors') or select.all('selectors', singleElementOrDocument)
  55. if (!baseElements || isQueryable(baseElements)) {
  56. const elements = (baseElements !== null && baseElements !== void 0 ? baseElements : document).querySelectorAll(String(selectors));
  57. return Array.apply(null, elements);
  58. }
  59. const all = [];
  60. for (let i = 0; i < baseElements.length; i++) {
  61. const current = baseElements[i].querySelectorAll(String(selectors));
  62. for (let ii = 0; ii < current.length; ii++) {
  63. all.push(current[ii]);
  64. }
  65. }
  66. // Preserves IE11 support and performs 3x better than `...all` in Safari
  67. const array = [];
  68. all.forEach(function (v) {
  69. array.push(v);
  70. });
  71. return array;
  72. }
  73. select.last = selectLast;
  74. select.exists = selectExists;
  75. select.all = selectAll;
  76.  
  77. var noop2 = noop;
  78.  
  79. // no operation
  80. // null -> null
  81. function noop() {}
  82.  
  83. /* eslint unicorn/consistent-function-scoping:0 */
  84.  
  85. function memoize(fn) {
  86. let value;
  87.  
  88. return () => {
  89. if (fn) {
  90. value = fn();
  91.  
  92. if (value != null) {
  93. fn = null;
  94. }
  95. }
  96.  
  97. return value
  98. }
  99. }
  100.  
  101. function generateButtonHtml(buttonId, buttonSvg) {
  102. return `<button id="${buttonId}" class="ytp-button">${buttonSvg}</button>`
  103. }
  104.  
  105. function generateMenuHtml(menuId, menuItemGenerator, menuItems) {
  106. return `
  107. <div id="${menuId}" class="ytp-popup ytp-settings-menu" style="display: none">
  108. <div class="ytp-panel">
  109. <div class="ytp-panel-menu" role="menu">
  110. ${menuItems.map(menuItemGenerator).join('')}
  111. </div>
  112. </div>
  113. </div>
  114. `
  115. }
  116.  
  117. function getEdgePosition() {
  118. return parseInt(getChromeBottom().style.left, 10)
  119. }
  120.  
  121. function triggerMouseEvent(element, eventType) {
  122. const event = new MouseEvent(eventType);
  123.  
  124. element.dispatchEvent(event);
  125. }
  126.  
  127. const getChromeBottom = memoize(() => select('.ytp-chrome-bottom'));
  128. const getSettingsButton = memoize(() => select('.ytp-button.ytp-settings-button'));
  129. const getTooltip = memoize(() => select('.ytp-tooltip.ytp-bottom'));
  130. const getTooltipText = memoize(() => select('.ytp-tooltip-text'));
  131.  
  132. var createYoutubePlayerButton = opts => {
  133. const {
  134. buttonTitle,
  135. buttonId,
  136. buttonSvg,
  137.  
  138. hasMenu = false,
  139. menuId,
  140. menuItemGenerator,
  141. menuItems,
  142.  
  143. onClickButton = noop2, // optional
  144. onRightClickButton = noop2, // optional
  145. onShowMenu = noop2, // optional
  146. onHideMenu = noop2, // optional
  147. } = opts;
  148.  
  149. const isRightClickButtonBound = onRightClickButton !== noop2;
  150.  
  151. let isMenuOpen = false;
  152. let justOpenedMenu = false;
  153. let isTooltipShown = false;
  154.  
  155. const controls = select('.ytp-right-controls');
  156. controls.insertAdjacentHTML('afterbegin', generateButtonHtml(buttonId, buttonSvg));
  157.  
  158. if (hasMenu) {
  159. const settingsMenu = select('.ytp-settings-menu');
  160. const menuHtml = generateMenuHtml(menuId, menuItemGenerator, menuItems);
  161.  
  162. settingsMenu.insertAdjacentHTML('beforebegin', menuHtml);
  163. }
  164.  
  165. const button = document.getElementById(buttonId);
  166. const menu = hasMenu ? document.getElementById(menuId) : null;
  167. const innerMenu = hasMenu ? select(`#${menuId} .ytp-panel-menu`) : null;
  168.  
  169. button.addEventListener('click', () => {
  170. if (hasMenu && !isMenuOpen) {
  171. justOpenedMenu = true;
  172.  
  173. hideTooltip(true);
  174. showMenu();
  175. }
  176.  
  177. onClickButton();
  178. });
  179.  
  180. button.addEventListener('contextmenu', event => {
  181. if (hasMenu) {
  182. hideMenu();
  183. }
  184.  
  185. if (isRightClickButtonBound) {
  186. event.preventDefault();
  187. event.stopPropagation();
  188.  
  189. showTooltip();
  190. onRightClickButton();
  191. } else {
  192. hideTooltip();
  193. }
  194. });
  195.  
  196. button.addEventListener('mouseenter', () => {
  197. if (!(hasMenu && isMenuOpen)) {
  198. showTooltip();
  199. }
  200. });
  201.  
  202. button.addEventListener('mouseleave', () => {
  203. if (!(hasMenu && isMenuOpen)) {
  204. hideTooltip();
  205. }
  206. });
  207.  
  208. if (hasMenu) {
  209. window.addEventListener('click', () => {
  210. if (!justOpenedMenu) {
  211. hideMenu();
  212. }
  213.  
  214. justOpenedMenu = false;
  215. });
  216.  
  217. window.addEventListener('blur', () => {
  218. hideMenu();
  219. });
  220. }
  221.  
  222. function showTooltip() {
  223. if (isTooltipShown) return
  224. isTooltipShown = true;
  225.  
  226. triggerMouseEvent(getSettingsButton(), 'mouseover');
  227. getTooltipText().textContent = buttonTitle;
  228. adjustTooltipPosition();
  229. }
  230.  
  231. function adjustTooltipPosition() {
  232. const calculateNormal = () => {
  233. getTooltip().style.left = '0';
  234.  
  235. const offsetParentRect = getTooltip().offsetParent.getBoundingClientRect();
  236. const tooltipRect = getTooltip().getBoundingClientRect();
  237. const buttonRect = button.getBoundingClientRect();
  238.  
  239. const tooltipHalfWidth = tooltipRect.width / 2;
  240. const buttonCenterX = buttonRect.x + buttonRect.width / 2;
  241. const normal = buttonCenterX - offsetParentRect.x - tooltipHalfWidth;
  242.  
  243. return normal
  244. };
  245.  
  246. const calculateEdge = () => {
  247. const offsetParentRect = getTooltip().offsetParent.getBoundingClientRect();
  248. const tooltipRect = getTooltip().getBoundingClientRect();
  249. const edge = offsetParentRect.width - getEdgePosition() - tooltipRect.width;
  250.  
  251. return edge
  252. };
  253.  
  254. getTooltip().style.left = Math.min(calculateNormal(), calculateEdge()) + 'px';
  255. }
  256.  
  257. function hideTooltip(immediate = false) {
  258. if (!isTooltipShown) return
  259. isTooltipShown = false;
  260.  
  261. triggerMouseEvent(getSettingsButton(), 'mouseout');
  262.  
  263. if (immediate) {
  264. getTooltip().style.display = 'none';
  265. }
  266. }
  267.  
  268. function showMenu() {
  269. if (isMenuOpen) return
  270. isMenuOpen = true;
  271.  
  272. menu.style.opacity = '1';
  273. menu.style.display = '';
  274.  
  275. const { offsetWidth: width, offsetHeight: height } = innerMenu;
  276.  
  277. setMenuSize(width, height);
  278. adjustMenuPosition();
  279.  
  280. onShowMenu();
  281. }
  282.  
  283. function setMenuSize(width, height) {
  284. width += 'px';
  285. height += 'px';
  286.  
  287. Object.assign(innerMenu.parentElement.style, { width, height });
  288. Object.assign(menu.style, { width, height });
  289. }
  290.  
  291. function adjustMenuPosition() {
  292. menu.style.right = '0';
  293.  
  294. const menuRect = menu.getBoundingClientRect();
  295. const buttonRect = button.getBoundingClientRect();
  296.  
  297. const menuCenterX = menuRect.x + menuRect.width / 2;
  298. const buttonCenterX = buttonRect.x + buttonRect.width / 2;
  299. const diff = menuCenterX - buttonCenterX;
  300.  
  301. menu.style.right = Math.max(diff, getEdgePosition()) + 'px';
  302. }
  303.  
  304. function hideMenu() {
  305. if (!isMenuOpen) return
  306. isMenuOpen = false;
  307.  
  308. menu.style.opacity = '0';
  309. menu.addEventListener(
  310. 'transitionend',
  311. event => {
  312. if (event.propertyName === 'opacity' && menu.style.opacity === '0') {
  313. menu.style.display = 'none';
  314. menu.style.opacity = '';
  315. }
  316. },
  317. { once: true },
  318. );
  319.  
  320. onHideMenu();
  321. }
  322. };
  323.  
  324. const hasLoaded = () => document.readyState === 'interactive' || document.readyState === 'complete';
  325.  
  326. const domLoaded = new Promise(resolve => {
  327. if (hasLoaded()) {
  328. resolve();
  329. } else {
  330. document.addEventListener('DOMContentLoaded', () => {
  331. resolve();
  332. }, {
  333. capture: true,
  334. once: true,
  335. passive: true
  336. });
  337. }
  338. });
  339.  
  340. Object.defineProperty(domLoaded, 'hasLoaded', {
  341. get: () => hasLoaded()
  342. });
  343.  
  344. var domLoaded_1 = domLoaded;
  345.  
  346. const TIMEOUT = 15 * 1000;
  347.  
  348. let readyTime = 0;
  349.  
  350. domLoaded_1.then(() => readyTime = Date.now());
  351.  
  352. var tolerantElementReady = selector => new Promise(resolve => {
  353. const check = () => {
  354. const element = document.querySelector(selector);
  355.  
  356. if (element) {
  357. return resolve(element)
  358. }
  359.  
  360. if (readyTime && readyTime - Date.now() > TIMEOUT) {
  361. return resolve()
  362. }
  363.  
  364. requestAnimationFrame(check);
  365. };
  366.  
  367. check();
  368. });
  369.  
  370.  
  371. // Based on work by Amio:
  372. // https://github.com/amio/youtube-screenshot-button
  373. // (c) MIT License
  374.  
  375. const $ = document.querySelector.bind(document);
  376.  
  377. const BUTTON_ID = 'youtube-screenshot-button';
  378. const isEmbed = window.location.pathname.startsWith('/embed/');
  379.  
  380. const anchorCacheMap = {};
  381.  
  382. function getAnchor(key, initializer) {
  383. // eslint-disable-next-line no-prototype-builtins
  384. if (anchorCacheMap.hasOwnProperty(key)) {
  385. return anchorCacheMap[key]
  386. }
  387.  
  388. const anchor = anchorCacheMap[key] = document.createElement('a');
  389.  
  390. anchor.hidden = true;
  391. anchor.style.position = 'absolute';
  392. initializer && initializer(anchor);
  393. document.body.appendChild(anchor);
  394.  
  395. return anchor
  396. }
  397.  
  398. function createScreenshotBlobUrlForVideo(video) {
  399. return new Promise(resolve => {
  400. const canvas = document.createElement('canvas');
  401. canvas.width = video.clientWidth;
  402. canvas.height = video.clientHeight;
  403.  
  404. const ctx = canvas.getContext('2d');
  405. ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  406.  
  407. canvas.toBlob(blob => {
  408. const blobUrl = URL.createObjectURL(blob);
  409. resolve(blobUrl);
  410.  
  411. setTimeout(() => {
  412. URL.revokeObjectURL(blobUrl);
  413. }, 60 * 1000);
  414. });
  415. })
  416. }
  417.  
  418. function openInNewTab(blobUrl) {
  419. // Older versions of Greasemonkey (3.x) have both GM_openInTab and GM.openInTab.
  420. // Newer versions of Greasemonkey (4.x) seem have deleted GM_openInTab, which
  421. // allows opening blob: urls while GM.openInTab don't.
  422. // GM.openInTab is too strict even base64 urls are not allowed.
  423. // So we prefer GM_openInTab whenever available.
  424.  
  425. // eslint-disable-next-line camelcase
  426. if (typeof GM_openInTab === 'function') {
  427. // eslint-disable-next-line new-cap
  428. GM_openInTab(blobUrl, false);
  429. } else {
  430. // eslint-disable-next-line no-shadow
  431. const anchor = getAnchor('open_in_new_tab', anchor => {
  432. anchor.target = '_blank';
  433. });
  434.  
  435. anchor.href = blobUrl;
  436. // A popup may be blocked by the browser. Make sure to allow it.
  437. // Another reason why GM_openInTab is preferred.
  438. anchor.click();
  439. }
  440. }
  441.  
  442. function download(blobUrl) {
  443. const anchor = getAnchor('download');
  444.  
  445. anchor.href = blobUrl;
  446. anchor.download = getFileName();
  447. anchor.click();
  448. }
  449.  
  450. function getFileName() {
  451. const videoTitle = getVideoTitle();
  452. const videoTime = formatVideoTime(getVideoCurrentTime()).join('-');
  453. // The file name may contain invalid characters for the file system.
  454. // We don't need to handle that ourself, the browser will do.
  455. const fileName = [
  456. 'youtube-video-screenshot',
  457. `[${videoTitle}]`,
  458. videoTime,
  459. ].join(' ') + '.png';
  460.  
  461. return fileName
  462. }
  463.  
  464. function getVideoTitle() {
  465. const titleElement = isEmbed
  466. ? $('.ytp-title-link')
  467. : $('ytd-video-primary-info-renderer h1.title yt-formatted-string');
  468. const videoTitle = titleElement && titleElement.textContent.trim();
  469.  
  470. return videoTitle
  471. }
  472.  
  473. function getVideoCurrentTime() {
  474. const videoElement = isEmbed
  475. ? $('.html5-video-container video')
  476. : $('#ytd-player video');
  477. const videoCurrentTime = videoElement
  478. ? videoElement.currentTime
  479. : NaN;
  480.  
  481. return videoCurrentTime
  482. }
  483.  
  484. // The video that is claimed to be the longest on YouTube:
  485. // https://youtu.be/04cF1m6Jxu8
  486. // Use it to test how this code handles the time in different situations.
  487. function formatVideoTime(totalSeconds) {
  488. // Remove the decimal part (milliseconds).
  489. // e.g. 90.6 -> 90
  490. let m = Math.floor(totalSeconds);
  491. let n;
  492.  
  493. // Do the time format conversion.
  494. let result = [ 60, 60, 24 ].map(factor => {
  495. n = m % factor;
  496. m = (m - n) / factor;
  497. return n
  498. });
  499. result.push(m);
  500. result.reverse();
  501. // result => [ day, hour, minute, second ]
  502.  
  503. // Omit day or hour if 0.
  504. // The minute is always kept even if 0.
  505. // e.g.:
  506. // [ 0, 0 ]
  507. // [ 2, 30 ]
  508. // [ 1, 10, 45 ]
  509. // [ 4, 0, 50, 15 ]
  510. while (result.length > 2 && result[0] === 0) {
  511. result.shift();
  512. }
  513.  
  514. // Left-pad 0 to all numbers but the first (same as YouTube).
  515. // e.g.:
  516. // [ "0", "00" ]
  517. // [ "1", "00", "00" ]
  518. // [ "1", "00", "00", "00" ]
  519. result = result.map((number, index) => {
  520. return index > 0 && number < 10
  521. ? `0${number}`
  522. : String(number)
  523. });
  524.  
  525. return result
  526. }
  527.  
  528. async function main() {
  529. const existingButton = document.getElementById(BUTTON_ID);
  530.  
  531. if (existingButton) {
  532. console.info('Screenshot button already injected.');
  533. return
  534. }
  535.  
  536. const [ video, controls ] = await Promise.all([
  537. tolerantElementReady('.html5-main-video'),
  538. tolerantElementReady('.ytp-right-controls'),
  539. ]);
  540.  
  541. if (!(video && controls)) {
  542. return
  543. }
  544.  
  545. createYoutubePlayerButton({
  546. buttonTitle: 'Take a screenshot',
  547. buttonId: BUTTON_ID,
  548. buttonSvg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#fff" style="transform: scale(0.45)"><path d="M512 107.275c-23.658-33.787-70.696-42.691-104.489-19.033L233.753 209.907l-63.183-44.246c23.526-40.618 12.46-93.179-26.71-120.603-41.364-28.954-98.355-18.906-127.321 22.45-28.953 41.358-18.913 98.361 22.452 127.327 28.384 19.874 64.137 21.364 93.129 6.982l77.388 54.185-77.381 54.179c-28.992-14.375-64.743-12.885-93.129 6.982-41.363 28.966-51.404 85.963-22.452 127.32 28.966 41.363 85.963 51.411 127.32 22.457 39.165-27.424 50.229-79.985 26.71-120.603l63.183-44.246L407.51 423.749c33.793 23.665 80.831 14.755 104.489-19.033l-212.41-148.715L512 107.275zM91.627 167.539c-26.173 0-47.392-21.219-47.392-47.392s21.22-47.392 47.392-47.392c26.179 0 47.392 21.219 47.392 47.392s-21.213 47.392-47.392 47.392zm0 271.714c-26.173 0-47.392-21.219-47.392-47.392 0-26.173 21.219-47.392 47.392-47.392 26.179 0 47.392 21.219 47.392 47.392 0 26.172-21.213 47.392-47.392 47.392z"/></svg>',
  549.  
  550. async onClickButton() {
  551. openInNewTab(await createScreenshotBlobUrlForVideo(video));
  552. },
  553.  
  554. async onRightClickButton() {
  555. download(await createScreenshotBlobUrlForVideo(video));
  556. },
  557. });
  558. }
  559. main();
  560.  
  561. }());