YouTube Screenshot

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

// ==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);
            }
        };
    }
})();