YouTube Screenshot

为 YouTube 播放器注入截图按钮,支持截图并显示在浮动面板中。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Screenshot
// @namespace    https://loongphy.com
// @version      0.1.1
// @description  为 YouTube 播放器注入截图按钮,支持截图并显示在浮动面板中。
// @author       Loongphy
// @license      PolyForm-Noncommercial-1.0.0; https://polyformproject.org/licenses/noncommercial/1.0.0/
// @match        https://*.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @run-at       document-idle
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';

    const BUTTON_ID = 'tm-ytp-screenshot-button';
    const GALLERY_ID = 'tm-ytp-screenshot-gallery';
    const RETRY_LIMIT = 10;
    const RETRY_DELAY = 500;

    GM_addStyle(`
      #${GALLERY_ID} {
        position: fixed;
        top: 16px;
        right: 16px;
        display: flex;
        flex-direction: column;
        gap: 12px;
        max-height: calc(100vh - 32px);
        overflow-y: auto;
        z-index: 2147483647;
        pointer-events: none;
      }
  
      #${GALLERY_ID}::-webkit-scrollbar {
        width: 8px;
      }
  
      #${GALLERY_ID}::-webkit-scrollbar-thumb {
        background: rgba(255, 255, 255, 0.25);
        border-radius: 4px;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item {
        pointer-events: auto;
        background: rgba(15, 15, 15, 0.85);
        border-radius: 10px;
        padding: 8px;
        box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
        display: flex;
        flex-direction: column;
        gap: 6px;
        border: 1px solid rgba(255, 255, 255, 0.1);
        position: relative;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item a {
        display: block;
        text-decoration: none;
        color: inherit;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item img {
        display: block;
        width: 220px;
        max-width: 220px;
        height: auto;
        border-radius: 6px;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-toolbar {
        display: flex;
        justify-content: flex-start;
        align-items: center;
        color: rgba(255, 255, 255, 0.7);
        font-size: 12px;
        word-break: break-all;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-actions {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        display: flex;
        gap: 18px;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.2s ease;
        z-index: 2;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item:hover .tm-ytp-screenshot-actions {
        opacity: 1;
        pointer-events: auto;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-action {
        width: 56px;
        height: 56px;
        border-radius: 50%;
        background: rgba(0, 0, 0, 0.65);
        display: flex;
        align-items: center;
        justify-content: center;
        border: 1px solid rgba(255, 255, 255, 0.25);
        cursor: pointer;
        backdrop-filter: blur(2px);
        transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
        color: #fff;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-action:hover {
        transform: scale(1.05);
        background: rgba(0, 0, 0, 0.82);
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-action svg {
        width: 28px;
        height: 28px;
        stroke: currentColor;
        stroke-width: 2;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item.tm-ytp-screenshot-copied .tm-ytp-screenshot-action--copy {
        background: rgba(24, 144, 255, 0.85);
        border-color: rgba(64, 169, 255, 0.9);
        color: #0a1a2c;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-item.tm-ytp-screenshot-saved .tm-ytp-screenshot-action--save {
        background: rgba(24, 201, 100, 0.85);
        border-color: rgba(76, 238, 164, 0.95);
        color: #0f1c0f;
      }
  
      #${GALLERY_ID} .tm-ytp-screenshot-timestamp {
        position: absolute;
        left: 14px;
        bottom: 14px;
        padding: 4px 8px;
        border-radius: 6px;
        background: rgba(0, 0, 0, 0.6);
        color: #fff;
        font-size: 14px;
        line-height: 1;
      font-family: var(--yt-spec-font-family, "YouTube Sans", "Roboto", sans-serif);
        font-weight: 500;
      }
  
      #${BUTTON_ID} {
        width: 48px;
        height: 40px;
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 0;
      }
  
      #${BUTTON_ID} svg {
        width: 22px;
        height: 22px;
        stroke: currentColor;
        stroke-width: 1.5;
      }
    `);

    init();

    function init() {
        ensureGallery();
        attachObservers();
        ensureButtonWithRetries();
    }

    function ensureButtonWithRetries(attempt = 0) {
        if (attempt > RETRY_LIMIT) {
            return;
        }
        if (!ensureButton()) {
            setTimeout(() => ensureButtonWithRetries(attempt + 1), RETRY_DELAY);
        }
    }

    function attachObservers() {
        const reactRoot = document.body;
        if (!reactRoot) {
            return;
        }

        const observer = new MutationObserver(throttle(ensureButton, 500));
        observer.observe(reactRoot, {
            childList: true,
            subtree: true,
        });

        document.addEventListener('yt-navigate-finish', () => {
            ensureButtonWithRetries();
        });

        window.addEventListener('yt-page-data-updated', () => {
            ensureButtonWithRetries();
        });
    }

    function ensureButton() {
        const controls = document.querySelector('.ytp-right-controls');
        if (!controls) {
            return false;
        }

        if (document.getElementById(BUTTON_ID)) {
            return true;
        }

        const button = createButton();
        controls.insertBefore(button, controls.firstChild);
        return true;
    }

    function createButton() {
        const button = document.createElement('button');
        button.id = BUTTON_ID;
        button.className = 'ytp-button';
        button.type = 'button';
        button.title = '截图';
        button.setAttribute('aria-label', '截图');

        const icon = createCameraIcon();
        button.appendChild(icon);

        button.addEventListener('click', (event) => {
            event.preventDefault();
            captureScreenshot();
        });
        return button;
    }

    function createCameraIcon() {
        const svgNS = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(svgNS, 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('focusable', 'false');
        const group = document.createElementNS(svgNS, 'g');
        group.setAttribute('fill', 'none');
        group.setAttribute('stroke', 'currentColor');
        group.setAttribute('stroke-linecap', 'round');
        group.setAttribute('stroke-linejoin', 'round');
        group.setAttribute('stroke-width', '2');

        const body = document.createElementNS(svgNS, 'path');
        body.setAttribute('d', 'M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z');
        const lens = document.createElementNS(svgNS, 'circle');
        lens.setAttribute('cx', '12');
        lens.setAttribute('cy', '13');
        lens.setAttribute('r', '3');

        group.appendChild(body);
        group.appendChild(lens);
        svg.appendChild(group);
        return svg;
    }

    function createTrashIcon() {
        const svgNS = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(svgNS, 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('focusable', 'false');
        const path = document.createElementNS(svgNS, 'path');
        path.setAttribute('fill', 'none');
        path.setAttribute('stroke', 'currentColor');
        path.setAttribute('stroke-linecap', 'round');
        path.setAttribute('stroke-linejoin', 'round');
        path.setAttribute('stroke-width', '2');
        path.setAttribute('d', 'M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2');

        svg.appendChild(path);
        return svg;
    }

    function createCopyIcon() {
        const svgNS = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(svgNS, 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('focusable', 'false');
        const group = document.createElementNS(svgNS, 'g');
        group.setAttribute('fill', 'none');
        group.setAttribute('stroke', 'currentColor');
        group.setAttribute('stroke-linecap', 'round');
        group.setAttribute('stroke-linejoin', 'round');
        group.setAttribute('stroke-width', '2');

        const rect = document.createElementNS(svgNS, 'rect');
        rect.setAttribute('width', '14');
        rect.setAttribute('height', '14');
        rect.setAttribute('x', '8');
        rect.setAttribute('y', '8');
        rect.setAttribute('rx', '2');
        rect.setAttribute('ry', '2');

        const path = document.createElementNS(svgNS, 'path');
        path.setAttribute('d', 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2');

        group.appendChild(rect);
        group.appendChild(path);
        svg.appendChild(group);
        return svg;
    }

    function createSaveIcon() {
        const svgNS = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(svgNS, 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('focusable', 'false');
        const group = document.createElementNS(svgNS, 'g');
        group.setAttribute('fill', 'none');
        group.setAttribute('stroke', 'currentColor');
        group.setAttribute('stroke-linecap', 'round');
        group.setAttribute('stroke-linejoin', 'round');
        group.setAttribute('stroke-width', '2');

        const body = document.createElementNS(svgNS, 'path');
        body.setAttribute('d', 'M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z');
        const slot = document.createElementNS(svgNS, 'path');
        slot.setAttribute('d', 'M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7');
        const top = document.createElementNS(svgNS, 'path');
        top.setAttribute('d', 'M7 3v4a1 1 0 0 0 1 1h7');

        group.appendChild(body);
        group.appendChild(slot);
        group.appendChild(top);
        svg.appendChild(group);
        return svg;
    }

    async function copyImageToClipboard(dataUrl) {
        const response = await fetch(dataUrl);
        const blob = await response.blob();
        if (!navigator.clipboard || !navigator.clipboard.write) {
            throw new Error('Clipboard API not available');
        }
        const item = new ClipboardItem({ [blob.type]: blob });
        await navigator.clipboard.write([item]);
    }

    function saveImage(dataUrl, filename, item) {
        const link = document.createElement('a');
        link.href = dataUrl;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        item.classList.add('tm-ytp-screenshot-saved');
        setTimeout(() => item.classList.remove('tm-ytp-screenshot-saved'), 600);
    }

    function captureScreenshot() {
        const video = document.querySelector('video.html5-main-video');
        if (!video) {
            console.warn('[YouTube Screenshot] Video element not found.');
            return;
        }

        if (video.readyState < 2 || video.videoWidth === 0 || video.videoHeight === 0) {
            console.warn('[YouTube Screenshot] Video is not ready for capturing.');
            return;
        }

        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        const ctx = canvas.getContext('2d');
        if (!ctx) {
            console.warn('[YouTube Screenshot] Unable to obtain 2D context.');
            return;
        }

        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

        try {
            const dataUrl = canvas.toDataURL('image/png');
            addScreenshotToGallery(dataUrl, video.currentTime);
        } catch (error) {
            console.error('[YouTube Screenshot] Failed to capture screenshot:', error);
        }
    }

    function addScreenshotToGallery(dataUrl, playbackTime) {
        const gallery = ensureGallery();
        const item = document.createElement('div');
        item.className = 'tm-ytp-screenshot-item';

        const link = document.createElement('a');
        const timestamp = new Date();
        const filename = `youtube-screenshot-${formatTimestamp(timestamp)}.png`;
        link.href = dataUrl;
        link.download = filename;
        link.target = '_blank';
        link.rel = 'noopener';

        const img = document.createElement('img');
        img.src = dataUrl;
        img.alt = 'YouTube screenshot';
        link.appendChild(img);

        const toolbar = document.createElement('div');
        toolbar.className = 'tm-ytp-screenshot-toolbar';
        toolbar.textContent = filename;
        toolbar.setAttribute('title', filename);

        const timestampBadge = document.createElement('div');
        timestampBadge.className = 'tm-ytp-screenshot-timestamp';
        timestampBadge.textContent = formatPlaybackTimestamp(playbackTime);

        const actions = document.createElement('div');
        actions.className = 'tm-ytp-screenshot-actions';

        const saveButton = document.createElement('button');
        saveButton.type = 'button';
        saveButton.className = 'tm-ytp-screenshot-action tm-ytp-screenshot-action--save';
        saveButton.title = '保存图片';
        const saveIcon = createSaveIcon();
        saveButton.appendChild(saveIcon);
        saveButton.addEventListener('click', (event) => {
            event.stopPropagation();
            event.preventDefault();
            saveImage(dataUrl, filename, item);
        });

        const copyButton = document.createElement('button');
        copyButton.type = 'button';
        copyButton.className = 'tm-ytp-screenshot-action tm-ytp-screenshot-action--copy';
        copyButton.title = '复制到剪贴板';
        const copyIcon = createCopyIcon();
        copyButton.appendChild(copyIcon);
        copyButton.addEventListener('click', async (event) => {
            event.stopPropagation();
            event.preventDefault();
            try {
                await copyImageToClipboard(dataUrl);
                item.classList.add('tm-ytp-screenshot-copied');
                setTimeout(() => item.classList.remove('tm-ytp-screenshot-copied'), 600);
            } catch (error) {
                console.warn('[YouTube Screenshot] Failed to copy screenshot to clipboard.', error);
            }
        });

        const removeButton = document.createElement('button');
        removeButton.type = 'button';
        removeButton.className = 'tm-ytp-screenshot-action tm-ytp-screenshot-action--remove';
        removeButton.title = '移除截图';
        const removeIcon = createTrashIcon();
        removeButton.appendChild(removeIcon);
        removeButton.addEventListener('click', (event) => {
            event.stopPropagation();
            event.preventDefault();
            item.remove();
        });

        actions.appendChild(saveButton);
        actions.appendChild(copyButton);
        actions.appendChild(removeButton);

        item.appendChild(link);
        item.appendChild(actions);
        item.appendChild(timestampBadge);
        item.appendChild(toolbar);

        gallery.appendChild(item);
    }

    function ensureGallery() {
        let gallery = document.getElementById(GALLERY_ID);
        if (gallery) {
            return gallery;
        }

        gallery = document.createElement('div');
        gallery.id = GALLERY_ID;
        document.body.appendChild(gallery);
        return gallery;
    }

    function formatTimestamp(date) {
        const yyyy = date.getFullYear();
        const mm = String(date.getMonth() + 1).padStart(2, '0');
        const dd = String(date.getDate()).padStart(2, '0');
        const hh = String(date.getHours()).padStart(2, '0');
        const mi = String(date.getMinutes()).padStart(2, '0');
        const ss = String(date.getSeconds()).padStart(2, '0');
        return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
    }

    function formatPlaybackTimestamp(seconds) {
        if (typeof seconds !== 'number' || Number.isNaN(seconds)) {
            return '--:--';
        }

        const totalMs = Math.max(0, Math.round(seconds * 1000));
        const hours = Math.floor(totalMs / 3600000);
        const minutes = Math.floor((totalMs % 3600000) / 60000);
        const secs = Math.floor((totalMs % 60000) / 1000);

        const minutePart = String(minutes).padStart(2, '0');
        const secondPart = String(secs).padStart(2, '0');

        if (hours > 0) {
            const hourPart = String(hours).padStart(2, '0');
            return `${hourPart}:${minutePart}:${secondPart}`;
        }

        return `${minutePart}:${secondPart}`;
    }

    function throttle(fn, wait) {
        let lastCall = 0;
        let timeout = null;
        let lastArgs;

        return function throttled(...args) {
            const now = Date.now();
            const remaining = wait - (now - lastCall);
            lastArgs = args;

            if (remaining <= 0) {
                if (timeout) {
                    clearTimeout(timeout);
                    timeout = null;
                }
                lastCall = now;
                fn.apply(this, lastArgs);
            } else if (!timeout) {
                timeout = setTimeout(() => {
                    lastCall = Date.now();
                    timeout = null;
                    fn.apply(this, lastArgs);
                }, remaining);
            }
        };
    }
})();