Youtube Screenshot Button

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Youtube Screenshot Button
// @namespace   https://riophae.com/
// @version     0.1.8
// @description Adds a button that enables you to take screenshots for YouTube videos.
// @author      Riophae Lee
// @match       https://www.youtube.com/*
// @run-at      document-start
// @grant       GM.openInTab
// @grant       GM_openInTab
// @license     MIT
// ==/UserScript==

(function () {
    'use strict';

    // Types inspired by
    // https://github.com/Microsoft/TypeScript/blob/9d3707d/src/lib/dom.generated.d.ts#L10581
    // Type predicate for TypeScript
    function isQueryable(object) {
        return typeof object.querySelectorAll === 'function';
    }
    function select(selectors, baseElement) {
        // Shortcut with specified-but-null baseElement
        if (arguments.length === 2 && !baseElement) {
            return null;
        }
        return (baseElement !== null && baseElement !== void 0 ? baseElement : document).querySelector(String(selectors));
    }
    function selectLast(selectors, baseElement) {
        // Shortcut with specified-but-null baseElement
        if (arguments.length === 2 && !baseElement) {
            return null;
        }
        const all = (baseElement !== null && baseElement !== void 0 ? baseElement : document).querySelectorAll(String(selectors));
        return all[all.length - 1];
    }
    /**
     * @param selectors      One or more CSS selectors separated by commas
     * @param [baseElement]  The element to look inside of
     * @return               Whether it's been found
     */
    function selectExists(selectors, baseElement) {
        if (arguments.length === 2) {
            return Boolean(select(selectors, baseElement));
        }
        return Boolean(select(selectors));
    }
    function selectAll(selectors, baseElements) {
        // Shortcut with specified-but-null baseElements
        if (arguments.length === 2 && !baseElements) {
            return [];
        }
        // Can be: select.all('selectors') or select.all('selectors', singleElementOrDocument)
        if (!baseElements || isQueryable(baseElements)) {
            const elements = (baseElements !== null && baseElements !== void 0 ? baseElements : document).querySelectorAll(String(selectors));
            return Array.apply(null, elements);
        }
        const all = [];
        for (let i = 0; i < baseElements.length; i++) {
            const current = baseElements[i].querySelectorAll(String(selectors));
            for (let ii = 0; ii < current.length; ii++) {
                all.push(current[ii]);
            }
        }
        // Preserves IE11 support and performs 3x better than `...all` in Safari
        const array = [];
        all.forEach(function (v) {
            array.push(v);
        });
        return array;
    }
    select.last = selectLast;
    select.exists = selectExists;
    select.all = selectAll;

    var noop2 = noop;

    // no operation
    // null -> null
    function noop() {}

    /* eslint unicorn/consistent-function-scoping:0 */

    function memoize(fn) {
      let value;

      return () => {
        if (fn) {
          value = fn();

          if (value != null) {
            fn = null;
          }
        }

        return value
      }
    }

    function generateButtonHtml(buttonId, buttonSvg) {
      return `<button id="${buttonId}" class="ytp-button">${buttonSvg}</button>`
    }

    function generateMenuHtml(menuId, menuItemGenerator, menuItems) {
      return `
<div id="${menuId}" class="ytp-popup ytp-settings-menu" style="display: none">
  <div class="ytp-panel">
    <div class="ytp-panel-menu" role="menu">
      ${menuItems.map(menuItemGenerator).join('')}
    </div>
  </div>
</div>
`
    }

    function getEdgePosition() {
      return parseInt(getChromeBottom().style.left, 10)
    }

    function triggerMouseEvent(element, eventType) {
      const event = new MouseEvent(eventType);

      element.dispatchEvent(event);
    }

    const getChromeBottom = memoize(() => select('.ytp-chrome-bottom'));
    const getSettingsButton = memoize(() => select('.ytp-button.ytp-settings-button'));
    const getTooltip = memoize(() => select('.ytp-tooltip.ytp-bottom'));
    const getTooltipText = memoize(() => select('.ytp-tooltip-text'));

    var createYoutubePlayerButton = opts => {
      const {
        buttonTitle,
        buttonId,
        buttonSvg,

        hasMenu = false,
        menuId,
        menuItemGenerator,
        menuItems,

        onClickButton = noop2, // optional
        onRightClickButton = noop2, // optional
        onShowMenu = noop2, // optional
        onHideMenu = noop2, // optional
      } = opts;

      const isRightClickButtonBound = onRightClickButton !== noop2;

      let isMenuOpen = false;
      let justOpenedMenu = false;
      let isTooltipShown = false;

      const controls = select('.ytp-right-controls');
      controls.insertAdjacentHTML('afterbegin', generateButtonHtml(buttonId, buttonSvg));

      if (hasMenu) {
        const settingsMenu = select('.ytp-settings-menu');
        const menuHtml = generateMenuHtml(menuId, menuItemGenerator, menuItems);

        settingsMenu.insertAdjacentHTML('beforebegin', menuHtml);
      }

      const button = document.getElementById(buttonId);
      const menu = hasMenu ? document.getElementById(menuId) : null;
      const innerMenu = hasMenu ? select(`#${menuId} .ytp-panel-menu`) : null;

      button.addEventListener('click', () => {
        if (hasMenu && !isMenuOpen) {
          justOpenedMenu = true;

          hideTooltip(true);
          showMenu();
        }

        onClickButton();
      });

      button.addEventListener('contextmenu', event => {
        if (hasMenu) {
          hideMenu();
        }

        if (isRightClickButtonBound) {
          event.preventDefault();
          event.stopPropagation();

          showTooltip();
          onRightClickButton();
        } else {
          hideTooltip();
        }
      });

      button.addEventListener('mouseenter', () => {
        if (!(hasMenu && isMenuOpen)) {
          showTooltip();
        }
      });

      button.addEventListener('mouseleave', () => {
        if (!(hasMenu && isMenuOpen)) {
          hideTooltip();
        }
      });

      if (hasMenu) {
        window.addEventListener('click', () => {
          if (!justOpenedMenu) {
            hideMenu();
          }

          justOpenedMenu = false;
        });

        window.addEventListener('blur', () => {
          hideMenu();
        });
      }

      function showTooltip() {
        if (isTooltipShown) return
        isTooltipShown = true;

        triggerMouseEvent(getSettingsButton(), 'mouseover');
        getTooltipText().textContent = buttonTitle;
        adjustTooltipPosition();
      }

      function adjustTooltipPosition() {
        const calculateNormal = () => {
          getTooltip().style.left = '0';

          const offsetParentRect = getTooltip().offsetParent.getBoundingClientRect();
          const tooltipRect = getTooltip().getBoundingClientRect();
          const buttonRect = button.getBoundingClientRect();

          const tooltipHalfWidth = tooltipRect.width / 2;
          const buttonCenterX = buttonRect.x + buttonRect.width / 2;
          const normal = buttonCenterX - offsetParentRect.x - tooltipHalfWidth;

          return normal
        };

        const calculateEdge = () => {
          const offsetParentRect = getTooltip().offsetParent.getBoundingClientRect();
          const tooltipRect = getTooltip().getBoundingClientRect();
          const edge = offsetParentRect.width - getEdgePosition() - tooltipRect.width;

          return edge
        };

        getTooltip().style.left = Math.min(calculateNormal(), calculateEdge()) + 'px';
      }

      function hideTooltip(immediate = false) {
        if (!isTooltipShown) return
        isTooltipShown = false;

        triggerMouseEvent(getSettingsButton(), 'mouseout');

        if (immediate) {
          getTooltip().style.display = 'none';
        }
      }

      function showMenu() {
        if (isMenuOpen) return
        isMenuOpen = true;

        menu.style.opacity = '1';
        menu.style.display = '';

        const { offsetWidth: width, offsetHeight: height } = innerMenu;

        setMenuSize(width, height);
        adjustMenuPosition();

        onShowMenu();
      }

      function setMenuSize(width, height) {
        width += 'px';
        height += 'px';

        Object.assign(innerMenu.parentElement.style, { width, height });
        Object.assign(menu.style, { width, height });
      }

      function adjustMenuPosition() {
        menu.style.right = '0';

        const menuRect = menu.getBoundingClientRect();
        const buttonRect = button.getBoundingClientRect();

        const menuCenterX = menuRect.x + menuRect.width / 2;
        const buttonCenterX = buttonRect.x + buttonRect.width / 2;
        const diff = menuCenterX - buttonCenterX;

        menu.style.right = Math.max(diff, getEdgePosition()) + 'px';
      }

      function hideMenu() {
        if (!isMenuOpen) return
        isMenuOpen = false;

        menu.style.opacity = '0';
        menu.addEventListener(
          'transitionend',
          event => {
            if (event.propertyName === 'opacity' && menu.style.opacity === '0') {
              menu.style.display = 'none';
              menu.style.opacity = '';
            }
          },
          { once: true },
        );

        onHideMenu();
      }
    };

    const hasLoaded = () => document.readyState === 'interactive' || document.readyState === 'complete';

    const domLoaded = new Promise(resolve => {
    	if (hasLoaded()) {
    		resolve();
    	} else {
    		document.addEventListener('DOMContentLoaded', () => {
    			resolve();
    		}, {
    			capture: true,
    			once: true,
    			passive: true
    		});
    	}
    });

    Object.defineProperty(domLoaded, 'hasLoaded', {
    	get: () => hasLoaded()
    });

    var domLoaded_1 = domLoaded;

    const TIMEOUT = 15 * 1000;

    let readyTime = 0;

    domLoaded_1.then(() => readyTime = Date.now());

    var tolerantElementReady = selector => new Promise(resolve => {
      const check = () => {
        const element = document.querySelector(selector);

        if (element) {
          return resolve(element)
        }

        if (readyTime && readyTime - Date.now() > TIMEOUT) {
          return resolve()
        }

        requestAnimationFrame(check);
      };

      check();
    });

    

    // Based on work by Amio:
    // https://github.com/amio/youtube-screenshot-button
    // (c) MIT License

    const $ = document.querySelector.bind(document);

    const BUTTON_ID = 'youtube-screenshot-button';
    const isEmbed = window.location.pathname.startsWith('/embed/');

    const anchorCacheMap = {};

    function getAnchor(key, initializer) {
      // eslint-disable-next-line no-prototype-builtins
      if (anchorCacheMap.hasOwnProperty(key)) {
        return anchorCacheMap[key]
      }

      const anchor = anchorCacheMap[key] = document.createElement('a');

      anchor.hidden = true;
      anchor.style.position = 'absolute';
      initializer && initializer(anchor);
      document.body.appendChild(anchor);

      return anchor
    }

    function createScreenshotBlobUrlForVideo(video) {
      return new Promise(resolve => {
        const canvas = document.createElement('canvas');
        canvas.width = video.clientWidth;
        canvas.height = video.clientHeight;

        const ctx = canvas.getContext('2d');
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

        canvas.toBlob(blob => {
          const blobUrl = URL.createObjectURL(blob);
          resolve(blobUrl);

          setTimeout(() => {
            URL.revokeObjectURL(blobUrl);
          }, 60 * 1000);
        });
      })
    }

    function openInNewTab(blobUrl) {
      // Older versions of Greasemonkey (3.x) have both GM_openInTab and GM.openInTab.
      // Newer versions of Greasemonkey (4.x) seem have deleted GM_openInTab, which
      // allows opening blob: urls while GM.openInTab don't.
      // GM.openInTab is too strict even base64 urls are not allowed.
      // So we prefer GM_openInTab whenever available.

      // eslint-disable-next-line camelcase
      if (typeof GM_openInTab === 'function') {
        // eslint-disable-next-line new-cap
        GM_openInTab(blobUrl, false);
      } else {
        // eslint-disable-next-line no-shadow
        const anchor = getAnchor('open_in_new_tab', anchor => {
          anchor.target = '_blank';
        });

        anchor.href = blobUrl;
        // A popup may be blocked by the browser. Make sure to allow it.
        // Another reason why GM_openInTab is preferred.
        anchor.click();
      }
    }

    function download(blobUrl) {
      const anchor = getAnchor('download');

      anchor.href = blobUrl;
      anchor.download = getFileName();
      anchor.click();
    }

    function getFileName() {
      const videoTitle = getVideoTitle();
      const videoTime = formatVideoTime(getVideoCurrentTime()).join('-');
      // The file name may contain invalid characters for the file system.
      // We don't need to handle that ourself, the browser will do.
      const fileName = [
        'youtube-video-screenshot',
        `[${videoTitle}]`,
        videoTime,
      ].join(' ') + '.png';

      return fileName
    }

    function getVideoTitle() {
      const titleElement = isEmbed
        ? $('.ytp-title-link')
        : $('ytd-video-primary-info-renderer h1.title yt-formatted-string');
      const videoTitle = titleElement && titleElement.textContent.trim();

      return videoTitle
    }

    function getVideoCurrentTime() {
      const videoElement = isEmbed
        ? $('.html5-video-container video')
        : $('#ytd-player video');
      const videoCurrentTime = videoElement
        ? videoElement.currentTime
        : NaN;

      return videoCurrentTime
    }

    // The video that is claimed to be the longest on YouTube:
    // https://youtu.be/04cF1m6Jxu8
    // Use it to test how this code handles the time in different situations.
    function formatVideoTime(totalSeconds) {
      // Remove the decimal part (milliseconds).
      // e.g. 90.6 -> 90
      let m = Math.floor(totalSeconds);
      let n;

      // Do the time format conversion.
      let result = [ 60, 60, 24 ].map(factor => {
        n = m % factor;
        m = (m - n) / factor;
        return n
      });
      result.push(m);
      result.reverse();
      // result => [ day, hour, minute, second ]

      // Omit day or hour if 0.
      // The minute is always kept even if 0.
      // e.g.:
      //   [ 0, 0 ]
      //   [ 2, 30 ]
      //   [ 1, 10, 45 ]
      //   [ 4, 0, 50, 15 ]
      while (result.length > 2 && result[0] === 0) {
        result.shift();
      }

      // Left-pad 0 to all numbers but the first (same as YouTube).
      // e.g.:
      //   [ "0", "00" ]
      //   [ "1", "00", "00" ]
      //   [ "1", "00", "00", "00" ]
      result = result.map((number, index) => {
        return index > 0 && number < 10
          ? `0${number}`
          : String(number)
      });

      return result
    }

    async function main() {
      const existingButton = document.getElementById(BUTTON_ID);

      if (existingButton) {
        console.info('Screenshot button already injected.');
        return
      }

      const [ video, controls ] = await Promise.all([
        tolerantElementReady('.html5-main-video'),
        tolerantElementReady('.ytp-right-controls'),
      ]);

      if (!(video && controls)) {
        return
      }

      createYoutubePlayerButton({
        buttonTitle: 'Take a screenshot',
        buttonId: BUTTON_ID,
        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>',

        async onClickButton() {
          openInNewTab(await createScreenshotBlobUrlForVideo(video));
        },

        async onRightClickButton() {
          download(await createScreenshotBlobUrlForVideo(video));
        },
      });
    }
    main();

}());