您需要先安装一个扩展,例如 篡改猴、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(); }());