YouTube Screenshot Button

Adds a screenshot button to the YouTube player.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         YouTube Screenshot Button
// @namespace    http://tampermonkey.net/
// @version      2.0.0
// @author       Yukiteru
// @description  Adds a screenshot button to the YouTube player.
// @license      MIT
// @match        https://www.youtube.com/*
// @grant        GM_log
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[YT Screenshot]';
    const log = (msg) => GM_log(`${LOG_PREFIX} ${msg}`);

    // 1 = Download Only, 2 = Copy to Clipboard, 3 = Both
    const CONFIG = {
        ACTION: GM_getValue('screenshot_action', 3) 
    };

    const SVG_PATH = "M19.5,7h-3.14l-1.39-2H9.03L7.64,7H4.5C3.67,7,3,7.67,3,8.5v11C3,20.33,3.67,21,4.5,21h15c0.83,0,1.5-0.67,1.5-1.5v-11 C21,7.67,20.33,7,19.5,7z M19.5,19.5h-15v-11h4.05l1.83-2h3.24l1.83,2h4.05V19.5z M12,10.25c-2.07,0-3.75,1.68-3.75,3.75S9.93,17.75,12,17.75s3.75-1.68,3.75-3.75S14.07,10.25,12,10.25z M12,16.25 c-1.24,0-2.25-1.01-2.25-2.25s1.01-2.25,2.25-2.25s2.25,1.01,2.25,2.25S13.24,16.25,12,16.25z";
    let currentSettingsBtn = null;
    let menuCmdId = null;

    function getTranslations() {
        const lang = (navigator.language || 'en').split('-')[0];
        const dict = { en: "Screenshot (s)", zh: "截图 (s)", ja: "スクリーンショット (s)", ko: "스크린샷 (s)" };
        return dict[lang] || dict.en;
    }

    // Dynamic Tampermonkey menu
    function setupMenu() {
        const actionNames = {
            1: 'Download Only',
            2: 'Copy to Clipboard',
            3: 'Download & Copy'
        };

        if (menuCmdId !== null) {
            GM_unregisterMenuCommand(menuCmdId);
        }

        menuCmdId = GM_registerMenuCommand(`Action: ${actionNames[CONFIG.ACTION]}`, () => {
            const promptText = "Select screenshot behavior:\n\n1 = Download Only\n2 = Copy to Clipboard\n3 = Download & Copy";
            const newAction = prompt(promptText, CONFIG.ACTION);
            
            if (newAction && ['1', '2', '3'].includes(newAction.trim())) {
                const parsedAction = parseInt(newAction.trim(), 10);
                GM_setValue('screenshot_action', parsedAction);
                CONFIG.ACTION = parsedAction;
                setupMenu(); // Refresh menu UI instantly
            } else if (newAction !== null) {
                alert("Invalid input. Please enter 1, 2, or 3.");
            }
        });
    }

    // Tooltip hijack to match YouTube native UI
    function showNativeTooltip(text, referenceBtn) {
        if (currentSettingsBtn) {
            currentSettingsBtn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
        }

        const tooltipEl = document.querySelector('.ytp-tooltip');
        const textWrapper = document.querySelector('.ytp-tooltip-text-wrapper');
        const player = document.querySelector('.html5-video-player');

        if (tooltipEl && textWrapper && player) {
            const textSpan = textWrapper.querySelector('.ytp-tooltip-text');
            if (textSpan) {
                textSpan.textContent = text;
                const btnRect = referenceBtn.getBoundingClientRect();
                const playerRect = player.getBoundingClientRect();
                const tooltipRect = tooltipEl.getBoundingClientRect();
                
                const leftPos = (btnRect.left - playerRect.left) + (btnRect.width / 2) - (tooltipRect.width / 2);
                tooltipEl.style.left = `${leftPos}px`;
            }
        }
    }

    function hideNativeTooltip() {
        if (currentSettingsBtn) {
            currentSettingsBtn.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
        }
    }

    function createButton() {
        const btn = document.createElement('button');
        btn.className = 'ytp-button';
        btn.id = 'yt-custom-screenshot-btn';
        btn.setAttribute('aria-label', getTranslations());

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '2 3 20 20'); // Centered & padded
        svg.setAttribute('width', '24');
        svg.setAttribute('height', '24');
        svg.setAttribute('fill', 'white');

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', SVG_PATH);
        
        svg.appendChild(path);
        btn.appendChild(svg);

        btn.addEventListener('click', handleScreenshot);
        btn.addEventListener('mouseover', () => showNativeTooltip(getTranslations(), btn));
        btn.addEventListener('mouseout', hideNativeTooltip);

        return btn;
    }

    async function handleScreenshot() {
        const video = document.querySelector('video.video-stream');
        if (!video) return log('Error: Video element not found.');

        try {
            const canvas = document.createElement('canvas');
            canvas.width = video.videoWidth;
            canvas.height = video.videoHeight;
            canvas.getContext('2d', { alpha: false }).drawImage(video, 0, 0);

            const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
            if (!blob) throw new Error('Failed to generate blob.');

            if (CONFIG.ACTION === 2 || CONFIG.ACTION === 3) {
                const clipItem = new window.ClipboardItem({ [blob.type]: blob });
                await navigator.clipboard.write([clipItem]).catch(e => log(`Clipboard error: ${e}`));
            }

            if (CONFIG.ACTION === 1 || CONFIG.ACTION === 3) {
                downloadBlob(blob);
            }
        } catch (err) {
            log(`Capture failed: ${err}`);
        }
    }

    function downloadBlob(blob) {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = generateFilename('png');
        a.click();
        URL.revokeObjectURL(url);
    }

    function generateFilename(ext) {
        try {
            const titleNode = document.querySelector('.ytd-watch-metadata #title') || document.querySelector('title');
            const safeTitle = (titleNode ? titleNode.textContent.trim() : 'yt_video').replace(/[\\/:*?"<>|]/g, '_'); 
            
            const video = document.querySelector('video.video-stream');
            const progress = video ? video.currentTime.toFixed(2) : '0.00';
            
            const d = new Date();
            const timeStr = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}_${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}${d.getSeconds().toString().padStart(2,'0')}`;
            
            return `${safeTitle}_${progress}s_${timeStr}.${ext}`;
        } catch (e) {
            return `screenshot_${Date.now()}.${ext}`;
        }
    }

    function setupShortcut() {
        document.addEventListener('keydown', (e) => {
            const activeTag = document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
            const isTyping = activeTag === 'input' || activeTag === 'textarea' || (document.activeElement && document.activeElement.isContentEditable);
            
            if (!isTyping && e.key.toLowerCase() === 's') {
                handleScreenshot();
            }
        });
    }

    function injectButton() {
        if (document.getElementById('yt-custom-screenshot-btn')) return;

        const settingsBtn = document.querySelector('.ytp-right-controls .ytp-settings-button') || 
                            document.querySelector('.ytp-right-controls-left > .ytp-settings-button');
        const video = document.querySelector('video.video-stream');

        if (settingsBtn && video) {
            currentSettingsBtn = settingsBtn;
            settingsBtn.insertAdjacentElement('beforebegin', createButton());
        }
    }

    // Bypass iframe clipboard restriction for embedded videos
    function fixIframeClipboard() {
        if (window.self !== window.top) return;
        document.querySelectorAll('iframe').forEach(iframe => {
            if (/youtube\.com\/embed/.test(iframe.src) && !iframe.allow.includes('clipboard-write')) {
                iframe.allow += ' clipboard-write;';
            }
        });
    }

    function init() {
        fixIframeClipboard();
        setupMenu();
        setupShortcut();

        const observer = new MutationObserver(injectButton);
        observer.observe(document.body, { childList: true, subtree: true });
    }

    if (location.host.includes('youtube.com')) {
        init();
    }

})();