YouTube Enhancer (Subtitle Downloader)

Allows you to download available subtitles for YouTube videos in various languages directly from the video page.

当前为 2024-11-10 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Enhancer (Subtitle Downloader)
// @description  Allows you to download available subtitles for YouTube videos in various languages directly from the video page.
// @icon         https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
// @version      1.0
// @author       exyezed
// @namespace    https://github.com/exyezed/youtube-enhancer/
// @supportURL   https://github.com/exyezed/youtube-enhancer/issues
// @license      MIT
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // Utility function to convert XML subtitles to SRT format
    function xmlToSrt(xmlText) {
        const textElements = xmlText.match(/<text[^>]*>(.*?)<\/text>/g) || [];
        let srtContent = '';
        let counter = 1;

        textElements.forEach((element) => {
            try {
                const startMatch = element.match(/start="([^"]+)"/);
                const durMatch = element.match(/dur="([^"]+)"/);
                const textMatch = element.match(/<text[^>]*>(.*?)<\/text>/);

                if (startMatch && textMatch) {
                    const start = parseFloat(startMatch[1]);
                    const duration = durMatch ? parseFloat(durMatch[1]) : 0;
                    const end = start + duration;
                    const text = textMatch[1]
                        .replace(/&amp;/g, '&')
                        .replace(/&lt;/g, '<')
                        .replace(/&gt;/g, '>')
                        .replace(/&quot;/g, '"')
                        .replace(/&#39;/g, "'")
                        .replace(/\n/g, ' ')
                        .trim();

                    if (text) {
                        srtContent += `${counter}\n`;
                        srtContent += `${formatTime(start)} --> ${formatTime(end)}\n`;
                        srtContent += `${text}\n\n`;
                        counter++;
                    }
                }
            } catch (error) {
                console.error('Error parsing text element:', error);
            }
        });

        return srtContent;
    }

    // Helper function to format time for SRT
    function formatTime(time) {
        const hours = Math.floor(time / 3600);
        const minutes = Math.floor((time % 3600) / 60);
        const seconds = Math.floor(time % 60);
        const milliseconds = Math.floor((time % 1) * 1000);

        return `${String(hours).padStart(2, '0')}:${
            String(minutes).padStart(2, '0')}:${
            String(seconds).padStart(2, '0')},${
            String(milliseconds).padStart(3, '0')}`;
    }

    // Function to sanitize filename
    function sanitizeFilename(filename) {
        return filename
            .replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
            .replace(/\s+/g, ' ')
            .trim();
    }

    // Create SVG icon for the button
    function createSVGIcon(className, isHover = false) {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");

        svg.setAttribute("viewBox", "0 0 576 512");
        svg.classList.add(className);

        path.setAttribute("d", isHover 
            ? "M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm56 208l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm256 0l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zM120 336l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm160 0l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
            : "M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l448 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80zM0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM120 240l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm256 0l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zM120 336l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm160 0l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
        );

        svg.appendChild(path);
        return svg;
    }

    // Create styles for the UI
    function createStyles(computedStyle) {
        const style = document.createElement('style');
        style.id = 'yt-subtitle-downloader-styles';
        style.textContent = `
            .custom-subtitle-btn {
                background: none;
                border: none;
                cursor: pointer;
                padding: 0;
                width: ${computedStyle.width};
                height: ${computedStyle.height};
                display: flex;
                align-items: center;
                justify-content: center;
                position: relative;
            }
            .custom-subtitle-btn svg {
                width: 24px;
                height: 24px;
                fill: #fff;
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
            }
            .custom-subtitle-btn .hover-icon { display: none; }
            .custom-subtitle-btn:hover .default-icon { display: none; }
            .custom-subtitle-btn:hover .hover-icon { display: block; }
            .subtitle-dropdown {
                position: fixed;
                background: rgba(28, 28, 28, 0.95);
                border: 1px solid rgba(255, 255, 255, 0.1);
                border-radius: 8px;
                padding: 12px;
                z-index: 9999;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                min-width: 200px;
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
                backdrop-filter: blur(10px);
            }
            .subtitle-dropdown-title {
                color: #fff;
                font-size: 14px;
                font-weight: 500;
                margin-bottom: 8px;
                padding: 0 8px;
                text-align: center;
            }
            .subtitle-option {
                color: #fff;
                padding: 8px 12px;
                margin: 2px 0;
                cursor: pointer;
                border-radius: 4px;
                transition: all 0.2s;
                display: flex;
                align-items: center;
                font-size: 13px;
                white-space: nowrap;
            }
            .subtitle-option:hover {
                background-color: rgba(255, 255, 255, 0.1);
            }
            .subtitle-option::before {
                content: "●";
                margin-right: 8px;
                font-size: 8px;
                color: #aaa;
            }
            .subtitle-option.loading {
                opacity: 0.5;
                pointer-events: none;
            }
            .subtitle-backdrop {
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background: rgba(0, 0, 0, 0.5);
                z-index: 9998;
            }
        `;
        return style;
    }

    // Create dropdown menu for subtitle languages
    function createDropdown(languages) {
        const dropdown = document.createElement('div');
        dropdown.className = 'subtitle-dropdown';

        const title = document.createElement('div');
        title.className = 'subtitle-dropdown-title';
        title.textContent = 'Download Subtitles';
        dropdown.appendChild(title);

        languages.forEach(lang => {
            const option = document.createElement('div');
            option.className = 'subtitle-option';
            option.dataset.url = lang.url;
            option.textContent = lang.label;
            dropdown.appendChild(option);
        });

        return dropdown;
    }

    // Get video title with fallback options
    function getVideoTitle(videoId) {
        const titleElement = document.querySelector('yt-formatted-string.style-scope.ytd-watch-metadata');
        if (titleElement) return titleElement.textContent.trim();

        const fallbackSelectors = [
            'h1.title.style-scope.ytd-video-primary-info-renderer',
            'h1.watch-title',
            '.ytd-watch-metadata #title h1'
        ];

        for (const selector of fallbackSelectors) {
            const element = document.querySelector(selector);
            if (element) return element.textContent.trim();
        }

        return videoId ? `video_${videoId}` : 'untitled_video';
    }

    // Get video ID from URL
    function getVideoId() {
        const urlParams = new URLSearchParams(window.location.search);
        return urlParams.get('v');
    }

    // Handle subtitle download process
    async function handleSubtitleDownload(e) {
        e.preventDefault();
        const videoId = getVideoId();

        if (!videoId) {
            alert('Could not detect video ID. Please try refreshing the page.');
            return;
        }

        try {
            const player = document.querySelector('#movie_player');
            let playerResponse;

            try {
                playerResponse = player.getPlayerResponse();
            } catch (error) {
                playerResponse = window.ytInitialPlayerResponse;
            }

            if (!playerResponse) {
                alert('Could not access video data. Please try refreshing the page.');
                return;
            }

            const captions = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks ||
                           playerResponse?.captions?.captionTracks;

            if (!captions || captions.length === 0) {
                alert('No subtitles available for this video');
                return;
            }

            const languages = captions.map(caption => ({
                label: caption.name?.simpleText || 
                       caption.name?.runs?.[0]?.text || 
                       caption.languageCode || 
                       'Unknown Language',
                url: caption.baseUrl || caption.url
            }));

            const backdrop = document.createElement('div');
            backdrop.className = 'subtitle-backdrop';
            document.body.appendChild(backdrop);

            const videoTitle = getVideoTitle(videoId);
            const dropdown = createDropdown(languages);
            document.body.appendChild(dropdown);

            const closeDropdown = (e) => {
                if (!dropdown.contains(e.target) && !e.target.closest('.custom-subtitle-btn')) {
                    dropdown.remove();
                    backdrop.remove();
                    document.removeEventListener('click', closeDropdown);
                }
            };

            let downloadInProgress = false;

            dropdown.addEventListener('click', async (event) => {
                const option = event.target.closest('.subtitle-option');
                if (!option || downloadInProgress) return;

                downloadInProgress = true;
                option.classList.add('loading');
                const url = option.dataset.url;
                const langLabel = option.textContent.trim();

                try {
                    const response = await fetch(url);
                    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                    
                    const xmlContent = await response.text();
                    const srtContent = xmlToSrt(xmlContent);

                    if (!srtContent) {
                        throw new Error('Failed to convert subtitles');
                    }

                    const blob = new Blob([srtContent], { type: 'text/plain' });
                    const downloadUrl = URL.createObjectURL(blob);
                    const link = document.createElement('a');
                    link.href = downloadUrl;

                    const fileName = sanitizeFilename(`${videoTitle} - ${langLabel}.srt`);
                    link.download = fileName;
                    
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                    URL.revokeObjectURL(downloadUrl);

                    dropdown.remove();
                    backdrop.remove();
                } catch (error) {
                    console.error('Download error:', error);
                    alert(`Error downloading subtitles: ${error.message}`);
                    option.classList.remove('loading');
                } finally {
                    downloadInProgress = false;
                }
            });

            setTimeout(() => {
                document.addEventListener('click', closeDropdown);
            }, 100);

        } catch (error) {
            console.error('Error in handleSubtitleDownload:', error);
            alert(`Error accessing video subtitles: ${error.message}`);
        }
    }

    // Initialize the download button
    function initializeButton() {
        if (document.querySelector('.custom-subtitle-btn')) return;

        const originalButton = document.querySelector('.ytp-subtitles-button');
        if (!originalButton) return;

        const newButton = document.createElement('button');
        const computedStyle = window.getComputedStyle(originalButton);

        Object.assign(newButton, {
            className: 'ytp-button custom-subtitle-btn',
            title: 'Download Subtitles'
        });

        newButton.setAttribute('aria-pressed', 'false');

        if (!document.querySelector('#yt-subtitle-downloader-styles')) {
            const style = createStyles(computedStyle);
            document.head.appendChild(style);
        }

        newButton.append(
            createSVGIcon('default-icon', false),
            createSVGIcon('hover-icon', true)
        );

        newButton.addEventListener('click', (e) => {
            const existingDropdown = document.querySelector('.subtitle-dropdown');
            if (existingDropdown) {
                existingDropdown.remove();
            } else {
                handleSubtitleDownload(e);
            }
        });
        
        originalButton.insertAdjacentElement('afterend', newButton);
    }

    // Initialize observer to watch for YouTube navigation
    function initializeObserver() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.addedNodes.length) {
                    const isVideoPage = window.location.pathname === '/watch';
                    if (isVideoPage && !document.querySelector('.custom-subtitle-btn')) {
                        initializeButton();
                    }
                }
            });
        });

        let observerActive = false;
        let retryInterval = null;

        function startObserving() {
            if (observerActive) return;

            const playerContainer = document.getElementById('player-container');
            const contentContainer = document.getElementById('content');

            if (playerContainer) {
                observer.observe(playerContainer, {
                    childList: true,
                    subtree: true
                });
                observerActive = true;
            }

            if (contentContainer) {
                observer.observe(contentContainer, {
                    childList: true,
                    subtree: true
                });
                observerActive = true;
            }

            if (window.location.pathname === '/watch') {
                initializeButton();
            }
        }

        startObserving();

        // Retry mechanism for slow-loading pages
        if (!document.getElementById('player-container')) {
            retryInterval = setInterval(() => {
                if (document.getElementById('player-container')) {
                    startObserving();
                    clearInterval(retryInterval);
                }
            }, 1000);

            // Cleanup after 10 seconds to prevent infinite retries
            setTimeout(() => {
                if (retryInterval) {
                    clearInterval(retryInterval);
                    retryInterval = null;
                }
            }, 10000);
        }

        // Handle YouTube spa navigation
        const handleNavigation = debounce(() => {
            if (window.location.pathname === '/watch') {
                initializeButton();
            }
        }, 250);

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

        // Cleanup function
        return () => {
            observer.disconnect();
            window.removeEventListener('yt-navigate-finish', handleNavigation);
            if (retryInterval) {
                clearInterval(retryInterval);
            }
            observerActive = false;
        };
    }

    // Debounce utility function
    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    // Start the script
    initializeObserver();
    console.log('YouTube Enhancer (Subtitle Downloader) is running');
})();