exyezed / YouTube Enhancer (Loop & Screenshot Buttons)

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