YouTube Enhancer (Loop & Screenshot Buttons)

Add Loop, Save and Copy Screenshot Buttons.

目前為 2025-03-11 提交的版本,檢視 最新版本

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Enhancer (Loop & Screenshot Buttons)
// @description  Add Loop, Save and Copy Screenshot Buttons.
// @icon         https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
// @version      1.5
// @author       exyezed
// @namespace    https://github.com/exyezed/youtube-enhancer/
// @supportURL   https://github.com/exyezed/youtube-enhancer/issues
// @license      MIT
// @match        https://www.youtube.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const buttonConfig = {
        screenshotFormat: "png",
        extension: 'png',
        clickDuration: 500
    };

    const buttonCSS = `
    a.buttonLoopAndScreenshot-loop-button, 
    a.buttonLoopAndScreenshot-save-screenshot-button,
    a.buttonLoopAndScreenshot-copy-screenshot-button {
        text-align: center;
        position: relative;
        display: flex;
        align-items: center;
        justify-content: center;
        width: 48px;
        height: 48px;
    }

    a.buttonLoopAndScreenshot-loop-button svg, 
    a.buttonLoopAndScreenshot-save-screenshot-button svg,
    a.buttonLoopAndScreenshot-copy-screenshot-button svg {
        width: 24px;
        height: 24px;
        vertical-align: middle;
        transition: fill 0.2s ease;
    }

    a.buttonLoopAndScreenshot-loop-button:hover svg,
    a.buttonLoopAndScreenshot-save-screenshot-button:hover svg,
    a.buttonLoopAndScreenshot-copy-screenshot-button:hover svg {
        fill: url(#buttonGradient);
    }

    a.buttonLoopAndScreenshot-loop-button.active svg,
    a.buttonLoopAndScreenshot-save-screenshot-button.clicked svg,
    a.buttonLoopAndScreenshot-copy-screenshot-button.clicked svg {
        fill: url(#successGradient);
    }

    .buttonLoopAndScreenshot-shorts-save-button,
    .buttonLoopAndScreenshot-shorts-copy-button {
        display: flex;
        align-items: center;
        justify-content: center;
        margin-top: 16px;
        width: 48px;
        height: 48px;
        border-radius: 50%;
        cursor: pointer;
        transition: background-color 0.3s;
    }

    .buttonLoopAndScreenshot-shorts-save-button svg,
    .buttonLoopAndScreenshot-shorts-copy-button svg {
        width: 24px;
        height: 24px;
        transition: fill 0.1s ease;
    }

    .buttonLoopAndScreenshot-shorts-save-button svg path,
    .buttonLoopAndScreenshot-shorts-copy-button svg path {
        transition: fill 0.1s ease;
    }

    .buttonLoopAndScreenshot-shorts-save-button:hover svg path,
    .buttonLoopAndScreenshot-shorts-copy-button:hover svg path {
        fill: url(#shortsButtonGradient) !important;
    }

    .buttonLoopAndScreenshot-shorts-save-button.clicked svg path,
    .buttonLoopAndScreenshot-shorts-copy-button.clicked svg path {
        fill: url(#shortsSuccessGradient) !important;
    }

    html[dark] .buttonLoopAndScreenshot-shorts-save-button,
    html[dark] .buttonLoopAndScreenshot-shorts-copy-button {
        background-color: rgba(255, 255, 255, 0.1);
    }

    html[dark] .buttonLoopAndScreenshot-shorts-save-button:hover,
    html[dark] .buttonLoopAndScreenshot-shorts-copy-button:hover {
        background-color: rgba(255, 255, 255, 0.2);
    }

    html[dark] .buttonLoopAndScreenshot-shorts-save-button svg path,
    html[dark] .buttonLoopAndScreenshot-shorts-copy-button svg path {
        fill: white;
    }

    html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button,
    html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button {
        background-color: rgba(0, 0, 0, 0.05);
    }

    html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button:hover,
    html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button:hover {
        background-color: rgba(0, 0, 0, 0.1);
    }

    html:not([dark]) .buttonLoopAndScreenshot-shorts-save-button svg path,
    html:not([dark]) .buttonLoopAndScreenshot-shorts-copy-button svg path {
        fill: #030303;
    }
    `;
    
    const iconUtils = {
        createGradientDefs(isShortsButton = false) {
            const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
            
            const hoverGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
            hoverGradient.setAttribute('id', isShortsButton ? 'shortsButtonGradient' : 'buttonGradient');
            hoverGradient.setAttribute('x1', '0%');
            hoverGradient.setAttribute('y1', '0%');
            hoverGradient.setAttribute('x2', '100%');
            hoverGradient.setAttribute('y2', '100%');
            
            const hoverStop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
            hoverStop1.setAttribute('offset', '0%');
            hoverStop1.setAttribute('style', 'stop-color:#f03');
            
            const hoverStop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
            hoverStop2.setAttribute('offset', '100%');
            hoverStop2.setAttribute('style', 'stop-color:#ff2791');
            
            hoverGradient.appendChild(hoverStop1);
            hoverGradient.appendChild(hoverStop2);
            defs.appendChild(hoverGradient);
            
            const successGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
            successGradient.setAttribute('id', isShortsButton ? 'shortsSuccessGradient' : 'successGradient');
            successGradient.setAttribute('x1', '0%');
            successGradient.setAttribute('y1', '0%');
            successGradient.setAttribute('x2', '100%');
            successGradient.setAttribute('y2', '100%');
            
            const successStop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
            successStop1.setAttribute('offset', '0%');
            successStop1.setAttribute('style', 'stop-color:#0f9d58');
            
            const successStop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
            successStop2.setAttribute('offset', '100%');
            successStop2.setAttribute('style', 'stop-color:#00c853');
            
            successGradient.appendChild(successStop1);
            successGradient.appendChild(successStop2);
            defs.appendChild(successGradient);
            
            return defs;
        },
        
        createBaseSVG(viewBox, fill = '#e8eaed', isShortsButton = false) {
            const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
            svg.setAttribute('height', '24px');
            svg.setAttribute('viewBox', viewBox);
            svg.setAttribute('width', '24px');
            svg.setAttribute('fill', fill);
            svg.appendChild(this.createGradientDefs(isShortsButton));
            return svg;
        },
        
        paths: {
            loopPath: 'M220-260q-92 0-156-64T0-480q0-92 64-156t156-64q37 0 71 13t61 37l68 62-60 54-62-56q-16-14-36-22t-42-8q-58 0-99 41t-41 99q0 58 41 99t99 41q22 0 42-8t36-22l310-280q27-24 61-37t71-13q92 0 156 64t64 156q0 92-64 156t-156 64q-37 0-71-13t-61-37l-68-62 60-54 62 56q16 14 36 22t42 8q58 0 99-41t41-99q0-58-41-99t-99-41q-22 0-42 8t-36 22L352-310q-27 24-61 37t-71 13Z',
            screenshotPath: 'M20 5h-3.17l-1.24-1.35A2 2 0 0 0 14.12 3H9.88c-.56 0-1.1.24-1.47.65L7.17 5H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m-3 12H7a.5.5 0 0 1-.4-.8l2-2.67c.2-.27.6-.27.8 0L11.25 16l2.6-3.47c.2-.27.6-.27.8 0l2.75 3.67a.5.5 0 0 1-.4.8',
            copyScreenshotPath: 'M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm0-2h12V4H8zm-4 6q-.825 0-1.412-.587T2 20V6h2v14h14v2zM8 4h12v12H8z'
        },
        
        createLoopIcon() {
            const svg = this.createBaseSVG('0 -960 960 960');
            
            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            path.setAttribute('d', this.paths.loopPath);
            
            svg.appendChild(path);
            return svg;
        },
        
        createSaveScreenshotIcon(isShortsButton = false) {
            const svg = this.createBaseSVG('0 0 24 24', '#e8eaed', isShortsButton);
            
            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            path.setAttribute('d', this.paths.screenshotPath);
            
            svg.appendChild(path);
            return svg;
        },
        
        createCopyScreenshotIcon(isShortsButton = false) {
            const svg = this.createBaseSVG('0 0 24 24', '#e8eaed', isShortsButton);
            
            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            path.setAttribute('d', this.paths.copyScreenshotPath);
            
            svg.appendChild(path);
            return svg;
        }
    };
    
    const buttonUtils = {
        addStyle(styleString) {
            const style = document.createElement('style');
            style.textContent = styleString;
            document.head.append(style);
        },

        getVideoId() {
            const urlParams = new URLSearchParams(window.location.search);
            return urlParams.get('v') || window.location.pathname.split('/').pop();
        },

        getApiKey() {
            const scripts = document.getElementsByTagName('script');
            for (const script of scripts) {
                const match = script.textContent.match(/"INNERTUBE_API_KEY":\s*"([^"]+)"/);
                if (match && match[1]) return match[1];
            }
            return null;
        },

        getClientInfo() {
            const scripts = document.getElementsByTagName('script');
            let clientName = null;
            let clientVersion = null;
            
            for (const script of scripts) {
                const nameMatch = script.textContent.match(/"INNERTUBE_CLIENT_NAME":\s*"([^"]+)"/);
                const versionMatch = script.textContent.match(/"INNERTUBE_CLIENT_VERSION":\s*"([^"]+)"/);
                
                if (nameMatch && nameMatch[1]) clientName = nameMatch[1];
                if (versionMatch && versionMatch[1]) clientVersion = versionMatch[1];
            }
            
            return { clientName, clientVersion };
        },

        async fetchVideoDetails(videoId) {
            try {
                const apiKey = this.getApiKey();
                if (!apiKey) return null;
                
                const { clientName, clientVersion } = this.getClientInfo();
                if (!clientName || !clientVersion) return null;
                
                const response = await fetch(`https://www.youtube.com/youtubei/v1/player?key=${apiKey}`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        videoId: videoId,
                        context: {
                            client: {
                                clientName: clientName,
                                clientVersion: clientVersion,
                            }
                        }
                    })
                });
                
                if (!response.ok) return null;
                const data = await response.json();
                if (data && data.videoDetails && data.videoDetails.title) {
                    return data.videoDetails.title;
                }
                return 'YouTube Video';
            } catch (error) {
                return 'YouTube Video';
            }
        },

        async getVideoTitle(callback) {
            const videoId = this.getVideoId();
            const title = await this.fetchVideoDetails(videoId);
            callback(title || 'YouTube Video');
        },

        formatTime(time) {
            const date = new Date();
            const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
            const timeString = [
                Math.floor(time / 3600),
                Math.floor((time % 3600) / 60),
                Math.floor(time % 60)
            ].map(v => v.toString().padStart(2, '0')).join('-');
            return `${dateString} ${timeString}`;
        },

        async copyToClipboard(blob) {
            const clipboardItem = new ClipboardItem({ "image/png": blob });
            await navigator.clipboard.write([clipboardItem]);
        },

        downloadScreenshot(blob, filename) {
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.style.display = 'none';
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        },

        captureScreenshot(player, action = 'download') {
            if (!player) return;
            
            const canvas = document.createElement("canvas");
            canvas.width = player.videoWidth;
            canvas.height = player.videoHeight;
            canvas.getContext('2d').drawImage(player, 0, 0, canvas.width, canvas.height);
            
            this.getVideoTitle((title) => {
                const time = player.currentTime;
                const filename = `${title} ${this.formatTime(time)}.${buttonConfig.extension}`;
                
                canvas.toBlob(async (blob) => {
                    if (action === 'copy') {
                        await this.copyToClipboard(blob);
                    } else {
                        this.downloadScreenshot(blob, filename);
                    }
                }, `image/${buttonConfig.screenshotFormat}`);
            });
        }
    };

    const regularVideo = {
        init() {
            this.waitForControls().then(() => {
                this.insertLoopElement();
                this.insertSaveScreenshotElement();
                this.insertCopyScreenshotElement();
                this.addObserver();
                this.addContextMenuListener();
            });
        },

        waitForControls() {
            return new Promise((resolve, reject) => {
                let attempts = 0;
                const maxAttempts = 50;
                
                const checkControls = () => {
                    const controls = document.querySelector('div.ytp-left-controls');
                    if (controls) {
                        resolve(controls);
                    } else if (attempts >= maxAttempts) {
                        reject(new Error('Controls not found after maximum attempts'));
                    } else {
                        attempts++;
                        setTimeout(checkControls, 100);
                    }
                };
                
                checkControls();
            });
        },

        insertLoopElement() {
            const controls = document.querySelector('div.ytp-left-controls');
            if (!controls) return;

            if (document.querySelector('.buttonLoopAndScreenshot-loop-button')) return;

            const newButton = document.createElement('a');
            newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-loop-button');
            newButton.title = 'Loop Video';
            newButton.appendChild(iconUtils.createLoopIcon());
            newButton.addEventListener('click', this.toggleLoopState);

            controls.appendChild(newButton);
        },

        insertSaveScreenshotElement() {
            const controls = document.querySelector('div.ytp-left-controls');
            if (!controls) return;

            if (document.querySelector('.buttonLoopAndScreenshot-save-screenshot-button')) return;

            const newButton = document.createElement('a');
            newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-save-screenshot-button');
            newButton.title = 'Save Screenshot';
            newButton.appendChild(iconUtils.createSaveScreenshotIcon());
            newButton.addEventListener('click', this.handleSaveScreenshotClick);

            const loopButton = document.querySelector('.buttonLoopAndScreenshot-loop-button');
            if (loopButton) {
                loopButton.parentNode.insertBefore(newButton, loopButton.nextSibling);
            } else {
                controls.appendChild(newButton);
            }
        },
        
        insertCopyScreenshotElement() {
            const controls = document.querySelector('div.ytp-left-controls');
            if (!controls) return;

            if (document.querySelector('.buttonLoopAndScreenshot-copy-screenshot-button')) return;

            const newButton = document.createElement('a');
            newButton.classList.add('ytp-button', 'buttonLoopAndScreenshot-copy-screenshot-button');
            newButton.title = 'Copy Screenshot to Clipboard';
            newButton.appendChild(iconUtils.createCopyScreenshotIcon());
            newButton.addEventListener('click', this.handleCopyScreenshotClick);

            const saveButton = document.querySelector('.buttonLoopAndScreenshot-save-screenshot-button');
            if (saveButton) {
                saveButton.parentNode.insertBefore(newButton, saveButton.nextSibling);
            } else {
                controls.appendChild(newButton);
            }
        },

        toggleLoopState() {
            const video = document.querySelector('video');
            video.loop = !video.loop;
            if (video.loop) video.play();

            regularVideo.updateToggleControls();
        },

        updateToggleControls() {
            const youtubeVideoLoop = document.querySelector('.buttonLoopAndScreenshot-loop-button');
            youtubeVideoLoop.classList.toggle('active');
            youtubeVideoLoop.setAttribute('title', this.isActive() ? 'Stop Looping' : 'Loop Video');
        },

        isActive() {
            const youtubeVideoLoop = document.querySelector('.buttonLoopAndScreenshot-loop-button');
            return youtubeVideoLoop.classList.contains('active');
        },

        addObserver() {
            const video = document.querySelector('video');
            new MutationObserver((mutations) => {
                mutations.forEach(() => {
                    if ((video.getAttribute('loop') === null && this.isActive()) ||
                        (video.getAttribute('loop') !== null && !this.isActive())) this.updateToggleControls();
                });
            }).observe(video, { attributes: true, attributeFilter: ['loop'] });
        },

        addContextMenuListener() {
            const video = document.querySelector('video');
            video.addEventListener('contextmenu', () => {
                setTimeout(() => {
                    const checkbox = document.querySelector('[role=menuitemcheckbox]');
                    checkbox.setAttribute('aria-checked', this.isActive());
                    checkbox.addEventListener('click', this.toggleLoopState);
                }, 50);
            });
        },

        handleSaveScreenshotClick(event) {
            const button = event.currentTarget;
            button.classList.add('clicked');
            setTimeout(() => {
                button.classList.remove('clicked');
            }, buttonConfig.clickDuration);

            const player = document.querySelector('video');
            buttonUtils.captureScreenshot(player, 'download');
        },
        
        handleCopyScreenshotClick(event) {
            const button = event.currentTarget;
            button.classList.add('clicked');
            setTimeout(() => {
                button.classList.remove('clicked');
            }, buttonConfig.clickDuration);

            const player = document.querySelector('video');
            buttonUtils.captureScreenshot(player, 'copy');
        }
    };

    const shortsVideo = {
        init() {
            this.insertSaveScreenshotElement();
            this.insertCopyScreenshotElement();
        },
    
        insertSaveScreenshotElement() {
            const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
            if (shortsContainer && !shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-save-button')) {
                const iconDiv = document.createElement('div');
                iconDiv.className = 'buttonLoopAndScreenshot-shorts-save-button';
                iconDiv.title = 'Save Screenshot';
                iconDiv.appendChild(iconUtils.createSaveScreenshotIcon(true));
                
                const customShortsIcon = shortsContainer.querySelector('#custom-shorts-icon');
                if (customShortsIcon) {
                    customShortsIcon.parentNode.insertBefore(iconDiv, customShortsIcon);
                } else {
                    shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
                }
    
                iconDiv.addEventListener('click', (event) => {
                    const button = event.currentTarget;
                    button.classList.add('clicked');
                    
                    setTimeout(() => {
                        button.classList.remove('clicked');
                    }, buttonConfig.clickDuration);
                    
                    this.captureScreenshot('download');
                });
            }
        },
        
        insertCopyScreenshotElement() {
            const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
            if (shortsContainer && !shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-copy-button')) {
                const iconDiv = document.createElement('div');
                iconDiv.className = 'buttonLoopAndScreenshot-shorts-copy-button';
                iconDiv.title = 'Copy Screenshot to Clipboard';
                iconDiv.appendChild(iconUtils.createCopyScreenshotIcon(true));
                
                const saveButton = shortsContainer.querySelector('.buttonLoopAndScreenshot-shorts-save-button');
                if (saveButton) {
                    saveButton.parentNode.insertBefore(iconDiv, saveButton.nextSibling);
                } else {
                    const customShortsIcon = shortsContainer.querySelector('#custom-shorts-icon');
                    if (customShortsIcon) {
                        customShortsIcon.parentNode.insertBefore(iconDiv, customShortsIcon);
                    } else {
                        shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
                    }
                }
    
                iconDiv.addEventListener('click', (event) => {
                    const button = event.currentTarget;
                    button.classList.add('clicked');
                    
                    setTimeout(() => {
                        button.classList.remove('clicked');
                    }, buttonConfig.clickDuration);
                    
                    this.captureScreenshot('copy');
                });
            }
        },

        captureScreenshot(action) {
            const player = document.querySelector('ytd-reel-video-renderer[is-active] video');
            buttonUtils.captureScreenshot(player, action);
        }
    };

    const themeHandler = {
        init() {
            this.updateStyles();
            this.addObserver();
        },

        updateStyles() {
            const isDarkTheme = document.documentElement.hasAttribute('dark');
            document.documentElement.classList.toggle('dark-theme', isDarkTheme);
        },

        addObserver() {
            const observer = new MutationObserver(() => this.updateStyles());
            observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['dark']
            });
        }
    };

    function initialize() {
        buttonUtils.addStyle(buttonCSS);
        waitForVideo().then(initializeWhenReady);
    }

    function waitForVideo() {
        return new Promise((resolve) => {
            const checkVideo = () => {
                if (document.querySelector('video')) {
                    resolve();
                } else {
                    setTimeout(checkVideo, 100);
                }
            };
            checkVideo();
        });
    }

    function initializeWhenReady() {
        initializeFeatures();
    }

    function initializeFeatures() {
        regularVideo.init();
        themeHandler.init();
        initializeShortsFeatures();
    }

    function initializeShortsFeatures() {
        if (window.location.pathname.includes('/shorts/')) {
            setTimeout(shortsVideo.init.bind(shortsVideo), 500);
        }
    }

    const shortsObserver = new MutationObserver((mutations) => {
        for (let mutation of mutations) {
            if (mutation.type === 'childList') {
                initializeShortsFeatures();
            }
        }
    });

    shortsObserver.observe(document.body, { childList: true, subtree: true });

    window.addEventListener('yt-navigate-finish', initializeShortsFeatures);

    document.addEventListener('yt-action', function(event) {
        if (event.detail && event.detail.actionName === 'yt-reload-continuation-items-command') {
            initializeShortsFeatures();
        }
    });

    initialize();
})();