youtube视频下载插件油猴版(修复第一次观看未加载下载按钮)

此次更新修复了第一次打开youtube视频无法加载下载按钮的问题,目前软件支持视频下载和短视频下载,支持手机端和电脑端。强调:由于调用第三方解析站,下载视频需要等待加载! // ==UserScript== // @name YouTube Downloader (loader.to) // @na...
youtube视频下载插件油猴版(修复第一次观看未加载下载按钮)
youtube视频下载插件油猴版(修复第一次观看未加载下载按钮)

此次更新修复了第一次打开youtube视频无法加载下载按钮的问题,目前软件支持视频下载和短视频下载,支持手机端和电脑端。强调:由于调用第三方解析站,下载视频需要等待加载!

// ==UserScript==
// @name         YouTube Downloader (loader.to)
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Download YouTube videos using loader.to, beautifully integrated into YouTube web and mobile UI.
// @author       You
// @match        *://*.youtube.com/*
// @grant        GM_xmlhttpRequest
// @connect      p.savenow.to
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const SELECTOR_DESKTOP = '#top-level-buttons-computed';
    const SELECTOR_MOBILE = 'ytm-slim-video-action-bar-renderer .slim-video-action-bar-actions';
    const SELECTOR_SHORTS_DESKTOP = 'ytd-reel-video-renderer #actions #button-bar';
    const SELECTOR_SHORTS_MOBILE = 'div.ytShortsCarouselCarouselItem[aria-hidden="false"] .reel-player-overlay-actions';

    // Add base styles
    const style = document.createElement('style');
    style.textContent = `
        #yt-custom-download-btn {
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0 16px;
            height: 36px;
            border-radius: 18px;
            background-color: var(--yt-spec-buttonchip-background-primary, rgba(0, 0, 0, 0.05));
            color: var(--yt-spec-text-primary, #0f0f0f);
            font-size: 14px;
            font-weight: 500;
            font-family: "Roboto", "Arial", sans-serif;
            cursor: pointer;
            margin-right: 8px;
            border: none;
            outline: none;
            transition: background-color 0.2s;
        }
        #yt-custom-download-btn:hover {
            background-color: var(--yt-spec-buttonchip-background-hover, rgba(0, 0, 0, 0.1));
        }
        .yt-is-dark-theme #yt-custom-download-btn {
            background-color: var(--yt-spec-buttonchip-background-primary, rgba(255, 255, 255, 0.1));
            color: var(--yt-spec-text-primary, #f1f1f1);
        }
        .yt-is-dark-theme #yt-custom-download-btn:hover {
            background-color: var(--yt-spec-buttonchip-background-hover, rgba(255, 255, 255, 0.2));
        }
        
        #yt-custom-download-btn svg {
            margin-right: 6px;
            fill: currentColor;
            width: 20px;
            height: 20px;
        }

        #yt-download-popover {
            position: absolute;
            background-color: var(--yt-spec-base-background, #ffffff);
            color: var(--yt-spec-text-primary, #0f0f0f);
            border: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.1));
            border-radius: 12px;
            padding: 16px;
            box-shadow: 0 4px 24px rgba(0,0,0,0.15);
            z-index: 9999;
            display: none;
            flex-direction: column;
            gap: 12px;
            min-width: 250px;
            font-family: inherit;
        }
        
        .yt-is-dark-theme #yt-download-popover {
            background-color: var(--yt-spec-base-background, #0f0f0f);
            border-color: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1));
            box-shadow: 0 4px 24px rgba(0,0,0,0.5);
            color: var(--yt-spec-text-primary, #f1f1f1);
        }

        #yt-download-popover .popover-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 16px;
            font-weight: bold;
        }
        
        #yt-dl-close {
            background: none;
            border: none;
            color: var(--yt-spec-text-primary, #000);
            cursor: pointer;
            font-size: 22px;
            line-height: 1;
            padding: 0 4px;
        }
        .yt-is-dark-theme #yt-dl-close { color: #f1f1f1; }

        #yt-dl-format {
            padding: 8px;
            border-radius: 6px;
            border: 1px solid var(--yt-spec-10-percent-layer, #ccc);
            background: var(--yt-spec-base-background, #fff);
            color: var(--yt-spec-text-primary, #000);
            width: 100%;
            outline: none;
            font-size: 14px;
        }
        .yt-is-dark-theme #yt-dl-format {
            border-color: rgba(255,255,255,0.2);
            background: #272727;
            color: #f1f1f1;
        }

        .yt-dl-actions {
            display: flex;
            gap: 10px;
            margin-top: 5px;
        }

        #yt-dl-confirm {
            flex: 1;
            background-color: #3ea6ff;
            color: #fff;
            border: none;
            padding: 8px;
            border-radius: 6px;
            cursor: pointer;
            font-weight: 500;
            font-size: 14px;
        }
        #yt-dl-confirm:hover { background-color: #65b8ff; }

        #yt-dl-iframe-container {
            width: 100%;
            text-align: center;
            margin-top: 5px;
            min-height: 65px;
            overflow: hidden;
            display: flex;
            justify-content: center;
        }
        
        /* Mobile styles overrides */
        .mobile-layout #yt-custom-download-btn {
            margin: 0 4px;
            height: 32px;
            padding: 0 12px;
            border-radius: 16px;
        }
         
        .mobile-layout #yt-download-popover {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            width: calc(100% - 40px);
            max-width: 400px;
        }
    `;
    document.head.appendChild(style);

    // Context tracking
    let popoverElement = null;

    function getCleanUrl() {
        // Strip out list and index parameters from URL
        let url = window.location.href;
        url = url.replace(/&list=[^&]*/g, '');
        url = url.replace(/\?list=[^&]*&?/g, '?');
        url = url.replace(/&index=[^&]*/g, '');
        url = url.replace(/\?index=[^&]*&?/g, '?');
        url = url.replace(/\?$/g, '');
        return url;
    }

    function isDarkTheme() {
        return document.documentElement.hasAttribute('dark') ||
            document.body.hasAttribute('dark') ||
            (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
    }

    function createPopover() {
        if (popoverElement) return popoverElement;

        popoverElement = document.createElement('div');
        popoverElement.id = 'yt-download-popover';

        // Replace innerHTML with programmatic nodes to satisfy TrustedHTML policy
        // 1. Header
        const headerDiv = document.createElement('div');
        headerDiv.className = 'popover-header';

        const headerTitle = document.createElement('span');
        headerTitle.textContent = 'Download Video';

        const headerClose = document.createElement('button');
        headerClose.id = 'yt-dl-close';
        headerClose.textContent = '×';

        headerDiv.appendChild(headerTitle);
        headerDiv.appendChild(headerClose);

        // 2. Format Select
        const formatSelect = document.createElement('select');
        formatSelect.id = 'yt-dl-format';

        const formats = [
            { value: 'mp3', text: 'MP3 (Audio)' },
            { value: 'm4a', text: 'M4A (Audio)' },
            { value: '360', text: 'MP4 360p' },
            { value: '480', text: 'MP4 480p' },
            { value: '720', text: 'MP4 720p', selected: true },
            { value: '1080', text: 'MP4 1080p' },
            { value: '4k', text: 'WEBM 4K' },
            { value: '8k', text: 'WEBM 8K' }
        ];

        formats.forEach(f => {
            const opt = document.createElement('option');
            opt.value = f.value;
            opt.textContent = f.text;
            if (f.selected) opt.selected = true;
            formatSelect.appendChild(opt);
        });

        // 3. Actions Form
        const actionsDiv = document.createElement('div');
        actionsDiv.className = 'yt-dl-actions';

        const confirmBtn = document.createElement('button');
        confirmBtn.id = 'yt-dl-confirm';
        confirmBtn.textContent = 'Load Download Link';
        actionsDiv.appendChild(confirmBtn);

        // 4. Removed iframeContainer since we open in a new tab

        // 5. Build Popover
        popoverElement.appendChild(headerDiv);
        popoverElement.appendChild(formatSelect);
        popoverElement.appendChild(actionsDiv);
        popoverElement.appendChild(actionsDiv);

        document.body.appendChild(popoverElement);

        // Event listeners (Using the elements we just created above)
        headerClose.addEventListener('click', () => {
            popoverElement.style.display = 'none';
        });

        confirmBtn.addEventListener('click', () => {
            const selectedFormat = formatSelect.value;
            const cleanUrl = getCleanUrl();

            // Encode the cleaned url
            const encodedUrl = encodeURIComponent(cleanUrl);

            // UI State change
            confirmBtn.disabled = true;
            confirmBtn.style.backgroundColor = '#888';
            confirmBtn.textContent = 'Starting...';

            const originalText = 'Load Download Link';
            const originalBg = ''; // reverts to css style

            function resetBtn() {
                confirmBtn.disabled = false;
                confirmBtn.style.backgroundColor = originalBg;
                confirmBtn.textContent = originalText;
            }

            // 1. Init Download Task
            try {
                if (typeof GM_xmlhttpRequest === 'undefined') {
                    throw new Error('GM_xmlhttpRequest not granted.');
                }

                GM_xmlhttpRequest({
                    method: 'GET',
                    url: 'https://p.savenow.to/ajax/download.php?format=' + selectedFormat + '&url=' + encodedUrl,
                    headers: {
                        'Origin': 'https://en.loader.to',
                        'Referer': 'https://en.loader.to/'
                    },
                    onload: function (response) {
                        let data;
                        try {
                            data = JSON.parse(response.responseText);
                        } catch (e) {
                            console.error("Failed to parse init response: ", response.responseText);
                            confirmBtn.textContent = 'Parse Error';
                            setTimeout(resetBtn, 2000);
                            return;
                        }

                        // Extract ID. Sometimes it's directly 'id', sometimes we extract from progress_url
                        let taskId = data.id;
                        if (!taskId && data.progress_url) {
                            try {
                                const urlObj = new URL(data.progress_url);
                                taskId = urlObj.searchParams.get('id');
                            } catch (e) { }
                        }

                        if (!data.success || !taskId || data.text === 'Video too long / Livestream') {
                            console.error('API Error or Video too long', data);
                            confirmBtn.textContent = data.text ? 'Error: ' + data.text : 'API Error';
                            setTimeout(resetBtn, 3000);
                            return;
                        }

                        confirmBtn.textContent = 'Initializing (0%)...';

                        // 2. Poll Progress
                        const pollInterval = setInterval(() => {
                            GM_xmlhttpRequest({
                                method: 'GET',
                                url: 'https://p.savenow.to/api/progress?id=' + taskId,
                                headers: {
                                    'Origin': 'https://en.loader.to',
                                    'Referer': 'https://en.loader.to/'
                                },
                                onload: function (res) {
                                    let progData;
                                    try {
                                        progData = JSON.parse(res.responseText);
                                    } catch (e) {
                                        return; // ignore parse errors on polling, try next tick
                                    }

                                    if (progData.progress !== undefined) {
                                        const rawProgress = parseInt(progData.progress, 10);
                                        const currentProgress = isNaN(rawProgress) ? 0 : rawProgress;
                                        const pct = (currentProgress / 10).toFixed(1);
                                        const statusText = progData.text || 'Downloading';

                                        if (currentProgress < 1000) {
                                            confirmBtn.textContent = statusText + ' (' + pct + '%)...';
                                        } else {
                                            // 100% finished
                                            clearInterval(pollInterval);
                                            confirmBtn.textContent = 'Download Ready!';
                                            confirmBtn.style.backgroundColor = '#4caf50'; // Green

                                            // Trigger native download using window.open
                                            if (progData.download_url) {
                                                window.open(progData.download_url, '_blank');
                                            }

                                            setTimeout(resetBtn, 3000);
                                        }
                                    }
                                },
                                onerror: function (e) {
                                    console.error('Polling error:', e);
                                    clearInterval(pollInterval);
                                    confirmBtn.textContent = 'Polling Error';
                                    setTimeout(resetBtn, 2000);
                                }
                            });
                        }, 1500); // Poll every 1.5 seconds
                    },
                    onerror: function (err) {
                        console.error('Init error:', err);
                        confirmBtn.textContent = 'Network Error';
                        setTimeout(resetBtn, 2000);
                    }
                });
            } catch (err) {
                console.error('Fatal Script Error:', err);
                confirmBtn.textContent = 'Script Error (Update Headers)';
                setTimeout(resetBtn, 3000);
            }
        });

        // Close when clicking outside of the popover
        document.addEventListener('click', (e) => {
            const btn = document.getElementById('yt-custom-download-btn');
            if (popoverElement.style.display === 'flex' &&
                !popoverElement.contains(e.target) &&
                (!btn || !btn.contains(e.target))) {
                popoverElement.style.display = 'none';
            }
        });

        return popoverElement;
    }

    function positionPopover(button) {
        const isMobile = window.location.hostname === 'm.youtube.com';
        const popover = createPopover();

        // removed iframe container reset

        // Update theme class based on YouTube's current theme
        if (isDarkTheme()) {
            document.body.classList.add('yt-is-dark-theme');
        } else {
            document.body.classList.remove('yt-is-dark-theme');
        }

        if (isMobile) {
            document.body.classList.add('mobile-layout');
            // CSS handles fixed bottom position for mobile
        } else {
            document.body.classList.remove('mobile-layout');

            // Calculate position for desktop (relative to clicked button)
            const rect = button.getBoundingClientRect();

            const popoverWidth = 250;
            // Estimated height of the popover menu
            const popoverHeight = 120;

            // Default: position below the button
            let topPos = rect.bottom + window.scrollY + 10;

            // If it would overflow the bottom of the screen, place it ABOVE the button instead
            if (rect.bottom + popoverHeight + 10 > window.innerHeight) {
                topPos = rect.top + window.scrollY - popoverHeight - 10;
            }

            popover.style.top = topPos + 'px';

            // Center popover horizontally relative to the button
            let leftPos = rect.left + window.scrollX - (popoverWidth / 2) + (rect.width / 2);

            // Ensure popover doesn't overflow viewport horizontally (especially on Shorts right-aligned UI)
            if (leftPos + popoverWidth > window.innerWidth) {
                leftPos = window.innerWidth - popoverWidth - 20;
            }
            if (leftPos < 10) {
                leftPos = 10;
            }

            popover.style.left = leftPos + 'px';
        }

        popover.style.display = 'flex';
    }

    function injectButton() {
        // Only run on watch or shorts pages
        const isWatch = window.location.pathname.startsWith('/watch');
        const isShorts = window.location.pathname.startsWith('/shorts');
        if (!isWatch && !isShorts) return;

        const isMobile = window.location.hostname === 'm.youtube.com';

        let targetElement = null;

        // More aggressive find: Check for actual layout presence
        const findTarget = (selectorList) => {
            const selectors = selectorList.split(',').map(s => s.trim());
            for (const selector of selectors) {
                const elms = document.querySelectorAll(selector);
                const found = Array.from(elms).find(el => {
                    const rect = el.getBoundingClientRect();
                    // In SPA, wait for element to have at least some height, 
                    // but don't be too strict on offsetParent
                    return rect.height > 0 || el.childNodes.length > 0;
                });
                if (found) return found;
            }
            return null;
        };

        if (isShorts) {
            targetElement = findTarget(isMobile ? SELECTOR_SHORTS_MOBILE : SELECTOR_SHORTS_DESKTOP);
        } else {
            if (isMobile) {
                targetElement = findTarget(SELECTOR_MOBILE);
            } else {
                // PC Desktop: Target the primary button container, with multiple fallbacks
                targetElement = findTarget('ytd-watch-metadata #top-level-buttons-computed, ytd-video-primary-info-renderer #top-level-buttons-computed, #top-level-buttons-computed, #actions-inner #menu #top-level-buttons-computed');

                // Final fallback for PC: look for the segmented like button container and inject into its parent menu
                if (!targetElement) {
                    const likeBtn = document.querySelector('ytd-segmented-like-dislike-button-renderer');
                    if (likeBtn) {
                        targetElement = likeBtn.closest('#top-level-buttons-computed') || likeBtn.parentElement;
                    }
                }
            }
        }

        if (!targetElement) return;

        // Prevent duplicate injection, but handle infinite scroll for Shorts
        const existingBtn = document.getElementById('yt-custom-download-btn');
        if (existingBtn) {
            if (isShorts && !targetElement.contains(existingBtn)) {
                // Button is attached to an old/inactive Short. Remove it so we inject into the new active one.
                existingBtn.remove();
            } else {
                // Button is already exactly where it should be
                return;
            }
        }

        const btn = document.createElement('button');
        btn.id = 'yt-custom-download-btn';
        // Construct the button safely (No innerHTML)
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
        svg.setAttribute('focusable', 'false');
        svg.style.pointerEvents = 'none';
        svg.style.display = 'block';

        const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', 'M17 18V19H6V18H17ZM16.5 11.4L15.8 10.7L12 14.4V4H11V14.4L7.2 10.6L6.5 11.3L11.5 16.3L16.5 11.4Z');

        g.appendChild(path);
        svg.appendChild(g);

        const textSpan = document.createElement('span');
        textSpan.className = 'btn-text';
        textSpan.textContent = 'Download';

        btn.appendChild(svg);
        btn.appendChild(textSpan);

        // Add tooltip for desktop
        if (!isMobile) {
            btn.title = 'Download Video';
        }

        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();

            const popover = document.getElementById('yt-download-popover');
            if (popover && popover.style.display === 'flex') {
                popover.style.display = 'none'; // Toggle off
            } else {
                positionPopover(btn); // Show
            }
        });

        // Insert into YouTube DOM
        if (isShorts) {
            if (!isMobile) {
                // Desktop Shorts layout needs circular button
                btn.style.marginRight = '0';
                btn.style.marginTop = '16px';
                btn.style.width = '48px';
                btn.style.height = '48px';
                btn.style.borderRadius = '50%';
                btn.style.padding = '0';
                btn.style.backgroundColor = 'var(--yt-spec-badge-chip-background, rgba(0, 0, 0, 0.05))';

                // Hide text on desktop shorts (stack of round icons)
                textSpan.style.display = 'none';
                svg.style.marginRight = '0';
                svg.style.width = '24px';
                svg.style.height = '24px';

                targetElement.appendChild(btn);
            } else {
                // Mobile Shorts layout: transparent, vertical layout
                btn.style.backgroundColor = 'transparent';
                btn.style.marginRight = '0';
                btn.style.marginTop = '16px';
                btn.style.display = 'flex';
                btn.style.flexDirection = 'column';
                btn.style.justifyContent = 'center';
                btn.style.alignItems = 'center';

                svg.style.width = '28px';
                svg.style.height = '28px';
                svg.style.marginRight = '0';
                textSpan.style.display = 'block'; // Ensure text is visible if overridden by media query
                textSpan.style.fontSize = '12px';
                textSpan.style.marginTop = '4px';
                textSpan.style.color = '#fff';

                // Add right after the other vertical buttons
                targetElement.appendChild(btn);
            }
        } else if (isMobile) {
            targetElement.appendChild(btn);
        } else {
            // Desktop standard video: Insert before the first button in the top-level-buttons container
            if (targetElement.firstChild) {
                targetElement.insertBefore(btn, targetElement.firstChild);
            } else {
                targetElement.appendChild(btn);
            }
        }
    }

    // Use MutationObserver because YouTube is an SPA and loads comments/metadata asynchronously
    let domObserver = new MutationObserver((mutations) => {
        injectButton();
    });

    function init() {
        // Start observing DOM changes to inject button when target element appears
        domObserver.observe(document.body, { childList: true, subtree: true });

        // Handle YouTube's SPA navigation events (cleanup and re-inject)
        const handleNav = () => {
            // 1. Hide and reset popover
            const popover = document.getElementById('yt-download-popover');
            if (popover) {
                popover.style.display = 'none';
            }

            // 2. Force remove old button
            const oldBtn = document.getElementById('yt-custom-download-btn');
            if (oldBtn) {
                oldBtn.remove();
            }

            // 3. Polling retry with longer duration (10s)
            let retries = 0;
            const poller = setInterval(() => {
                const btn = document.getElementById('yt-custom-download-btn');
                if (btn || retries > 20) {
                    clearInterval(poller);
                } else {
                    injectButton();
                    retries++;
                }
            }, 500);
        };

        window.addEventListener('yt-navigate-finish', handleNav);
        window.addEventListener('yt-page-data-updated', handleNav);

        // Initial run
        injectButton();
    }

    // Initialize the script
    init();
})();

1 个帖子 - 1 位参与者

阅读完整话题

来源: linux.do查看原文