您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a button that enables you to take screenshots for YouTube videos.
- // ==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();
- }());