YouTube Screenshot

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

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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);
            }
        };
    }
})();