YouTube Multi Subtitle Downloader

Download subtitles from YouTube videos with enhanced features

目前為 2025-01-13 提交的版本,檢視 最新版本

// ==UserScript==
// @name          YouTube Multi Subtitle Downloader
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Download subtitles from YouTube videos with enhanced features
// @author       anassk (https://github.com/anassk01)
// @license MIT
// @match        https://www.youtube.com/*
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    // Core Types
    const Types = {
        SubtitleTrack: class {
            constructor(langCode, langName, baseUrl) {
                this.languageCode = langCode;
                this.languageName = langName;
                this.baseUrl = baseUrl;
            }
        },

        VideoData: class {
            constructor(id, title) {
                this.id = id;
                this.title = title;
                this.subtitles = [];
            }
        }
    };

    // Configuration
    const CONFIG = {
        MESSAGES: {
            NO_SUBTITLE: 'No Subtitles Available',
            HAVE_SUBTITLE: 'Available Subtitles',
            LOADING: 'Loading Subtitles...',
            COPY_SUCCESS: '✓ Copied!',
            ERROR: {
                COPY: 'Failed to copy to clipboard',
                FETCH: 'Failed to fetch subtitles',
                NO_VIDEO: 'No video found'
            }
        },

        SELECTORS: {
            VIDEO_CONTAINER: '#above-the-fold',
            VIDEO_ELEMENTS: 'ytd-video-renderer, ytd-compact-video-renderer',
            THUMBNAIL: 'a#thumbnail',
            VIDEO_TITLE: '#video-title'
        },

        TIMINGS: {
            PAGE_CHECK_INTERVAL: 1000,
            DOWNLOAD_DELAY: 500,
            COPY_SUCCESS_DURATION: 2000
        },

        FORMATS: {
            SRT: 'srt',
            TEXT: 'txt'
        }
    };

    // Core Utilities
    const Utils = {
        createError: (message, code, originalError = null) => {
            const error = new Error(message);
            error.code = code;
            error.originalError = originalError;
            return error;
        },

        debounce: (func, wait) => {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), wait);
            };
        },

        safeJSONParse: (str, fallback = null) => {
            try {
                return JSON.parse(str);
            } catch (e) {
                console.error('JSON Parse Error:', e);
                return fallback;
            }
        },

        sanitizeFileName: (name) => {
            return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').substring(0, 100);
        }
    };

    // Event Bus for communication between modules
    class EventBus {
        constructor() {
            this.events = new Map();
        }

        on(event, callback) {
            if (!this.events.has(event)) {
                this.events.set(event, new Set());
            }
            this.events.get(event).add(callback);
            return () => this.off(event, callback);
        }

        off(event, callback) {
            const callbacks = this.events.get(event);
            if (callbacks) {
                callbacks.delete(callback);
            }
        }

        emit(event, data) {
            const callbacks = this.events.get(event);
            if (callbacks) {
                callbacks.forEach(callback => {
                    try {
                        callback(data);
                    } catch (error) {
                        console.error(`Error in event ${event}:`, error);
                    }
                });
            }
        }
    }

    // Export to global scope for other modules
    window.YTSubtitles = {
        Types,
        CONFIG,
        Utils,
        EventBus: new EventBus()
    };
})();


(function() {
    'use strict';

    const { Types, CONFIG, Utils } = window.YTSubtitles;

    class SubtitleProcessor {
        static async fetchSubtitleTracks(videoId) {
            try {
                const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
                const html = await response.text();

                const playerDataMatch = html.match(/ytInitialPlayerResponse\s*=\s*({.+?});/);
                if (!playerDataMatch) return null;

                const playerData = Utils.safeJSONParse(playerDataMatch[1]);
                const captionTracks = playerData?.captions?.playerCaptionsTracklistRenderer?.captionTracks;

                if (!captionTracks?.length) return null;

                return captionTracks.map(track => new Types.SubtitleTrack(
                    track.languageCode,
                    track.name.simpleText,
                    track.baseUrl
                ));
            } catch (error) {
                throw Utils.createError('Failed to fetch subtitles', 'SUBTITLE_FETCH_ERROR', error);
            }
        }

        static async getSubtitleContent(track, format = CONFIG.FORMATS.SRT) {
            try {
                const response = await fetch(track.baseUrl);
                const xml = await response.text();
                return format === CONFIG.FORMATS.SRT ?
                    this.convertToSRT(xml) :
                    this.convertToText(xml);
            } catch (error) {
                throw Utils.createError('Failed to fetch subtitle content', 'CONTENT_FETCH_ERROR', error);
            }
        }

        static convertToSRT(xml) {
            try {
                const parser = new DOMParser();
                const doc = parser.parseFromString(xml, "text/xml");
                const textNodes = doc.getElementsByTagName('text');
                let srt = '';

                Array.from(textNodes).forEach((node, index) => {
                    const start = parseFloat(node.getAttribute('start'));
                    const duration = parseFloat(node.getAttribute('dur') || '0');
                    const end = start + duration;

                    srt += `${index + 1}\n`;
                    srt += `${this.formatTime(start)} --> ${this.formatTime(end)}\n`;
                    srt += `${node.textContent}\n\n`;
                });

                return srt;
            } catch (error) {
                throw Utils.createError('Failed to convert to SRT', 'SRT_CONVERSION_ERROR', error);
            }
        }

        static convertToText(xml) {
            try {
                const parser = new DOMParser();
                const doc = parser.parseFromString(xml, "text/xml");
                const textNodes = doc.getElementsByTagName('text');

                return Array.from(textNodes)
                    .map(node => node.textContent.trim())
                    .filter(text => text)
                    .join('\n');
            } catch (error) {
                throw Utils.createError('Failed to convert to text', 'TEXT_CONVERSION_ERROR', error);
            }
        }

        static formatTime(seconds) {
            const pad = num => String(num).padStart(2, '0');
            const ms = String(Math.floor((seconds % 1) * 1000)).padStart(3, '0');

            seconds = Math.floor(seconds);
            const hours = Math.floor(seconds / 3600);
            const minutes = Math.floor((seconds % 3600) / 60);
            const secs = seconds % 60;

            return `${pad(hours)}:${pad(minutes)}:${pad(secs)},${ms}`;
        }

        static downloadSubtitle(content, filename) {
            const blob = new Blob(['\ufeff' + content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');

            link.href = url;
            link.download = filename;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(url);
        }

        static async copyToClipboard(content) {
            try {
                await navigator.clipboard.writeText(content);
                return true;
            } catch (error) {
                throw Utils.createError('Failed to copy to clipboard', 'CLIPBOARD_ERROR', error);
            }
        }
    }

    // Export to global scope
    window.YTSubtitles.SubtitleProcessor = SubtitleProcessor;
})();

(function() {
    'use strict';

    const { CONFIG, Utils } = window.YTSubtitles;

    // UI Styles
    const STYLES = `
        .yt-sub-btn {
            background: #065fd4;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 8px 16px;
            cursor: pointer;
            font-size: 14px;
            margin: 5px;
            transition: background 0.2s;
        }

        .yt-sub-btn:hover {
            background: #0056c7;
        }

        .yt-sub-dialog {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
            z-index: 10000;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 300px;
            color: black;
        }

        .yt-sub-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.7);
            z-index: 9999;
        }

        .yt-sub-track {
            margin: 10px 0;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }

        .yt-sub-loading {
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
            color: white;
            flex-direction: column;
            gap: 10px;
        }

        .yt-sub-spinner {
            width: 30px;
            height: 30px;
            border: 3px solid #f3f3f3;
            border-top: 3px solid #065fd4;
            border-radius: 50%;
            animation: yt-sub-spin 1s linear infinite;
        }

        @keyframes yt-sub-spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
    `;

    class UIComponents {
        static injectStyles() {
            const style = document.createElement('style');
            style.textContent = STYLES;
            document.head.appendChild(style);
        }

        static createButton(text, onClick, className = '') {
            const button = document.createElement('button');
            button.className = `yt-sub-btn ${className}`;
            button.textContent = text;
            button.addEventListener('click', onClick);
            return button;
        }

        static createDialog({ title, content, onClose }) {
            const overlay = document.createElement('div');
            overlay.className = 'yt-sub-overlay';

            const dialog = document.createElement('div');
            dialog.className = 'yt-sub-dialog';

            const header = document.createElement('div');
            header.style.display = 'flex';
            header.style.justifyContent = 'space-between';
            header.style.marginBottom = '15px';

            const titleElem = document.createElement('h2');
            titleElem.textContent = title;
            titleElem.style.margin = '0';

            const closeBtn = this.createButton('×', () => {
                onClose();
                overlay.remove();
            }, 'close-btn');
            closeBtn.style.padding = '5px 10px';

            header.appendChild(titleElem);
            header.appendChild(closeBtn);
            dialog.appendChild(header);
            dialog.appendChild(content);
            overlay.appendChild(dialog);

            return overlay;
        }

        static createSubtitleDialog(tracks, options = {}) {
            const content = document.createElement('div');

            // Format selector
            const formatDiv = document.createElement('div');
            formatDiv.innerHTML = `
                <div style="margin-bottom: 15px;">
                    <label style="margin-right: 10px;">
                        <input type="radio" name="format" value="srt" checked> SRT
                    </label>
                    <label>
                        <input type="radio" name="format" value="txt"> Plain Text
                    </label>
                </div>
            `;

            // Tracks list
            const tracksList = document.createElement('div');
            tracks.forEach(track => {
                const trackDiv = document.createElement('div');
                trackDiv.className = 'yt-sub-track';
                trackDiv.innerHTML = `
                    <label>
                        <input type="checkbox" value="${track.languageCode}">
                        ${track.languageName}
                    </label>
                `;
                tracksList.appendChild(trackDiv);
            });

            // Action buttons
            const actions = document.createElement('div');
            actions.style.display = 'flex';
            actions.style.justifyContent = 'flex-end';
            actions.style.gap = '10px';
            actions.style.marginTop = '20px';

            if (options.onDownload) {
                actions.appendChild(this.createButton('Download', options.onDownload));
            }
            if (options.onCopy) {
                actions.appendChild(this.createButton('Copy', options.onCopy));
            }

            content.appendChild(formatDiv);
            content.appendChild(tracksList);
            content.appendChild(actions);

            return content;
        }

        static showLoading(message = CONFIG.MESSAGES.LOADING) {
            const overlay = document.createElement('div');
            overlay.className = 'yt-sub-overlay';

            const loading = document.createElement('div');
            loading.className = 'yt-sub-loading';

            const spinner = document.createElement('div');
            spinner.className = 'yt-sub-spinner';

            const text = document.createElement('div');
            text.textContent = message;

            loading.appendChild(spinner);
            loading.appendChild(text);
            overlay.appendChild(loading);

            document.body.appendChild(overlay);
            return overlay;
        }

        static removeLoading(loadingElement) {
            if (loadingElement && loadingElement.parentElement) {
                loadingElement.remove();
            }
        }

        static showToast(message, duration = 2000) {
            const toast = document.createElement('div');
            toast.style.cssText = `
                position: fixed;
                bottom: 20px;
                left: 50%;
                transform: translateX(-50%);
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 10px 20px;
                border-radius: 4px;
                z-index: 10001;
            `;
            toast.textContent = message;
            document.body.appendChild(toast);

            setTimeout(() => toast.remove(), duration);
        }
    }

    // Export to global scope
    window.YTSubtitles.UIComponents = UIComponents;

    // Initialize styles
    UIComponents.injectStyles();
})();


(function() {
    'use strict';

    const { Types, CONFIG, Utils, UIComponents, SubtitleProcessor } = window.YTSubtitles;

class VideoManager {
    constructor() {
        this.singleMode = null;
        this.bulkMode = null;
        this.lastPageType = null;
        this.initialized = false;
        console.debug('[VideoManager] Created new instance');
        this.initialize();
    }

    initialize() {
        if (this.initialized) {
            console.debug('[VideoManager] Already initialized, skipping');
            return;
        }

        console.debug('[VideoManager] Initializing');
        // Always initialize bulkMode
        this.bulkMode = new BulkVideoMode();
        this.bulkMode.initialize();

        this.handlePageChange();
        this.setupPageObserver();
        this.initialized = true;
    }

    setupPageObserver() {
        console.debug('[VideoManager] Setting up page observer');
        const observer = new MutationObserver(
            Utils.debounce(() => {
                const currentPageType = this.getPageType();
                if (currentPageType !== this.lastPageType) {
                    console.debug(`[VideoManager] Page type changed from ${this.lastPageType} to ${currentPageType}`);
                    this.handlePageChange();
                }
            }, 500)
        );

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

        document.addEventListener('yt-navigate-finish', () => {
            console.debug('[VideoManager] Navigation event detected');
            this.handlePageChange();
        });
    }

    handlePageChange() {
        const pageType = this.getPageType();
        console.debug(`[VideoManager] Handling page change. Current: ${pageType}, Last: ${this.lastPageType}`);

        // Handle SingleVideoMode
        if (pageType === 'watch') {
            if (!this.singleMode) {
                console.debug('[VideoManager] Initializing SingleVideoMode');
                this.singleMode = new SingleVideoMode();
                this.singleMode.initialize();
            }
        } else {
            if (this.singleMode) {
                console.debug('[VideoManager] Cleaning up SingleVideoMode');
                this.singleMode.cleanup();
                this.singleMode = null;
            }
        }

        // BulkMode is always active, just make sure it's initialized
        if (!this.bulkMode) {
            console.debug('[VideoManager] Reinitializing BulkVideoMode');
            this.bulkMode = new BulkVideoMode();
            this.bulkMode.initialize();
        }

        this.lastPageType = pageType;
    }

    getPageType() {
        const path = window.location.pathname;
        if (path === '/watch') return 'watch';
        if (path === '/results') return 'search';
        if (path === '/') return 'home';
        return 'other';
    }
}
class SingleVideoMode {
    constructor() {
        this.videoId = null;
        this.subtitleTracks = null;
        this.downloadButton = null;
        this.currentVideoUrl = null;
        this.videoObserver = null;
        console.debug('[SingleVideoMode] Created new instance');
    }

    async initialize() {
        console.debug('[SingleVideoMode] Initializing');
        this.setupVideoObserver();
        await this.initializeButton();
    }

    setupVideoObserver() {
        console.debug('[SingleVideoMode] Setting up video observer');
        // Watch for changes to the video player
        this.videoObserver = new MutationObserver(
            Utils.debounce(() => {
                const newVideoUrl = window.location.href;
                if (this.currentVideoUrl !== newVideoUrl) {
                    console.debug(`[SingleVideoMode] Video URL changed from ${this.currentVideoUrl} to ${newVideoUrl}`);
                    this.handleVideoChange();
                }
            }, 500)
        );

        const playerApp = document.querySelector('ytd-app');
        if (playerApp) {
            this.videoObserver.observe(playerApp, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['video-id']
            });
        }

        // Also listen for YouTube's navigation events
        document.addEventListener('yt-navigate-finish', () => {
            console.debug('[SingleVideoMode] Navigation event detected');
            this.handleVideoChange();
        });
    }

    async handleVideoChange() {
        console.debug('[SingleVideoMode] Handling video change');
        const newVideoId = this.extractVideoId();
        const newVideoUrl = window.location.href;

        if (this.videoId !== newVideoId || this.currentVideoUrl !== newVideoUrl) {
            console.debug(`[SingleVideoMode] Video changed from ${this.videoId} to ${newVideoId}`);
            this.videoId = newVideoId;
            this.currentVideoUrl = newVideoUrl;
            this.subtitleTracks = null;
            await this.initializeButton();
        }
    }

    async initializeButton() {
        console.debug('[SingleVideoMode] Initializing button');
        this.videoId = this.extractVideoId();
        if (!this.videoId) {
            console.debug('[SingleVideoMode] No video ID found');
            return;
        }

        // Remove existing button if present
        if (this.downloadButton) {
            console.debug('[SingleVideoMode] Removing existing button');
            this.downloadButton.remove();
        }

        await this.addDownloadButton();
    }

    extractVideoId() {
        const urlParams = new URLSearchParams(window.location.search);
        return urlParams.get('v');
    }

    async addDownloadButton() {
        try {
            const container = await this.waitForElement(CONFIG.SELECTORS.VIDEO_CONTAINER);
            if (!container) {
                console.debug('[SingleVideoMode] Container not found');
                return;
            }

            // Check if a button already exists and remove it
            const existingButton = container.querySelector('.yt-sub-btn');
            if (existingButton) {
                existingButton.remove();
            }

            this.downloadButton = UIComponents.createButton(
                'Download Subtitles',
                () => this.handleButtonClick()
            );

            container.appendChild(this.downloadButton);
            console.debug('[SingleVideoMode] Button added successfully');
        } catch (error) {
            console.error('[SingleVideoMode] Failed to add download button:', error);
        }
    }


    async handleButtonClick() {
        console.debug('[SingleVideoMode] Button clicked for video:', this.videoId);
        const loading = UIComponents.showLoading();

        try {
            // Always fetch fresh subtitle tracks when button is clicked
            this.subtitleTracks = await SubtitleProcessor.fetchSubtitleTracks(this.videoId);

            if (!this.subtitleTracks?.length) {
                UIComponents.showToast(CONFIG.MESSAGES.NO_SUBTITLE);
                return;
            }

            this.showSubtitleDialog();
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
            console.error('[SingleVideoMode] Failed to fetch subtitles:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    showSubtitleDialog() {
        const content = UIComponents.createSubtitleDialog(
            this.subtitleTracks,
            {
                onDownload: () => this.handleDownload(),
                onCopy: () => this.handleCopy()
            }
        );

        const dialog = UIComponents.createDialog({
            title: CONFIG.MESSAGES.HAVE_SUBTITLE,
            content,
            onClose: () => dialog.remove()
        });

        document.body.appendChild(dialog);
    }

    async handleDownload() {
        const tracks = this.getSelectedTracks();
        const format = this.getSelectedFormat();

        if (!tracks.length) {
            UIComponents.showToast('Please select at least one subtitle');
            return;
        }

        const loading = UIComponents.showLoading('Downloading subtitles...');

        try {
            for (const track of tracks) {
                const content = await SubtitleProcessor.getSubtitleContent(track, format);
                const filename = `${Utils.sanitizeFileName(document.title)}_${track.languageCode}.${format}`;
                SubtitleProcessor.downloadSubtitle(content, filename);
                await new Promise(resolve => setTimeout(resolve, CONFIG.TIMINGS.DOWNLOAD_DELAY));
            }
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
            console.error('Download error:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    async handleCopy() {
        const tracks = this.getSelectedTracks();
        const format = this.getSelectedFormat();

        if (!tracks.length) {
            UIComponents.showToast('Please select at least one subtitle');
            return;
        }

        const loading = UIComponents.showLoading('Copying subtitles...');

        try {
            let content = '';
            for (const track of tracks) {
                const subtitleContent = await SubtitleProcessor.getSubtitleContent(track, format);
                content += `=== ${track.languageName} ===\n${subtitleContent}\n\n`;
            }

            await SubtitleProcessor.copyToClipboard(content);
            UIComponents.showToast(CONFIG.MESSAGES.COPY_SUCCESS);
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY);
            console.error('Copy error:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    getSelectedTracks() {
        const checkboxes = document.querySelectorAll('.yt-sub-track input:checked');
        return Array.from(checkboxes)
            .map(cb => this.subtitleTracks
                .find(track => track.languageCode === cb.value))
            .filter(Boolean);
    }

    getSelectedFormat() {
        return document.querySelector('input[name="format"]:checked').value;
    }

    waitForElement(selector, timeout = 5000) {
        return new Promise((resolve, reject) => {
            const element = document.querySelector(selector);
            if (element) {
                resolve(element);
                return;
            }

            const observer = new MutationObserver((mutations, obs) => {
                const element = document.querySelector(selector);
                if (element) {
                    obs.disconnect();
                    resolve(element);
                }
            });

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

            setTimeout(() => {
                observer.disconnect();
                reject(new Error(`Element ${selector} not found`));
            }, timeout);
        });
    }

    cleanup() {
        console.debug('[SingleVideoMode] Cleaning up');
        if (this.downloadButton) {
            this.downloadButton.remove();
        }
        if (this.videoObserver) {
            this.videoObserver.disconnect();
        }
        this.videoId = null;
        this.subtitleTracks = null;
        this.currentVideoUrl = null;
    }
}


class BulkVideoMode {
    constructor() {
        this.selectedVideos = new Map();
        this.isProcessing = false;
        this.selectionControls = null;
        this.videoObserver = null;
        this.isSelectionMode = false;
        this.initialized = false;
        console.debug('[BulkVideoMode] Created new instance');
    }

    initialize() {
        if (this.initialized) {
            console.debug('[BulkVideoMode] Already initialized, skipping');
            return;
        }

        console.debug('[BulkVideoMode] Initializing');
        this.createControls();
        this.initialized = true;
    }

    createControls() {
        console.debug('[BulkVideoMode] Creating controls');
        // Create and add the initial "Select Videos" button
        const bulkButton = UIComponents.createButton(
            'Get Videos Sub',
            () => this.toggleSelectionMode(),
            'yt-sub-bulk-btn'
        );

        bulkButton.style.cssText = `
            position: fixed;
            right: 20px;
            top: 80px;
            z-index: 9999;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
        `;

        // Create select all control
        const selectAllContainer = document.createElement('div');
        selectAllContainer.style.cssText = `
            position: fixed;
            right: 20px;
            top: 130px;
            z-index: 9999;
            background: white;
            padding: 8px 16px;
            border-radius: 4px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
            display: none;
        `;

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.id = 'select-all';
        checkbox.style.marginRight = '8px';
        checkbox.addEventListener('change', (e) => this.handleSelectAll(e.target.checked));

        const label = document.createElement('label');
        label.htmlFor = 'select-all';
        label.textContent = 'Select All';

        selectAllContainer.appendChild(checkbox);
        selectAllContainer.appendChild(label);

        this.selectionControls = {
            button: bulkButton,
            selectAllContainer: selectAllContainer
        };

        document.body.appendChild(bulkButton);
        document.body.appendChild(selectAllContainer);
    }

    toggleSelectionMode() {
        console.debug('[BulkVideoMode] Toggling selection mode. Current:', this.isSelectionMode);
        if (!this.isSelectionMode) {
            this.startSelection();
        } else {
            this.processSelected();
        }
    }

    startSelection() {
        if (this.isSelectionMode) {
            console.debug('[BulkVideoMode] Already in selection mode, skipping');
            return;
        }

        console.debug('[BulkVideoMode] Starting selection mode');
        this.isSelectionMode = true;
        this.selectionControls.button.textContent = "Download Subtitles";
        this.selectionControls.selectAllContainer.style.display = 'flex';
        this.setupVideoObserver();
        this.processVideoElements();
    }

    processSelected() {
        if (this.selectedVideos.size === 0) {
            UIComponents.showToast('Please select at least one video');
            return;
        }

        // Process videos
        this.processSelectedVideos();
    }

    handleSelectAll(checked) {
        if (this.isProcessing) return;

        const checkboxes = document.querySelectorAll('.yt-sub-checkbox');
        checkboxes.forEach(checkbox => {
            const videoElement = checkbox.closest(CONFIG.SELECTORS.VIDEO_ELEMENTS);
            if (videoElement) {
                const videoId = this.extractVideoId(videoElement);
                checkbox.checked = checked;

                if (checked && videoId) {
                    this.selectedVideos.set(videoId, {
                        title: this.extractVideoTitle(videoElement),
                        id: videoId
                    });
                } else if (!checked) {
                    this.selectedVideos.delete(videoId);
                }
            }
        });
    }

    updateButtonState() {
        if (!this.selectionControls?.button) return;

        const hasSelectedItems = this.selectedVideos.size > 0;

        if (this.isProcessing) {
            this.selectionControls.button.textContent = 'Processing...';
            this.selectionControls.button.disabled = true;
        } else if (hasSelectedItems) {
            this.selectionControls.button.textContent = 'Get Subtitles';
            this.selectionControls.button.disabled = false;
        } else {
            this.selectionControls.button.textContent = 'Select Videos Sub';
            this.selectionControls.button.disabled = false;
        }
    }

    setupVideoObserver() {
        console.debug('[BulkVideoMode] Setting up video observer');
        if (this.videoObserver) {
            console.debug('[BulkVideoMode] Disconnecting existing observer');
            this.videoObserver.disconnect();
        }

        this.videoObserver = new MutationObserver((mutations) => {
            const shouldProcess = mutations.some(mutation => {
                return Array.from(mutation.addedNodes).some(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        const matches = node.matches?.(CONFIG.SELECTORS.VIDEO_ELEMENTS) ||
                            node.querySelector?.(CONFIG.SELECTORS.VIDEO_ELEMENTS);
                        if (matches) {
                            console.debug('[BulkVideoMode] New video element detected');
                        }
                        return matches;
                    }
                    return false;
                });
            });

            if (shouldProcess && this.isSelectionMode) {
                console.debug('[BulkVideoMode] Processing new video elements');
                this.processVideoElements();
            }
        });

        const targets = [
            document.querySelector('#content'),
            document.querySelector('ytd-watch-next-secondary-results-renderer'),
            document.querySelector('#related')
        ].filter(Boolean);

        targets.forEach(target => {
            if (target) {
                this.videoObserver.observe(target, {
                    childList: true,
                    subtree: true
                });
            }
        });

        if (this.isSelectionMode) {
            this.processVideoElements();
        }
        console.debug('[BulkVideoMode] Video observer setup complete');
    }

    processVideoElements() {
        console.debug('[BulkVideoMode] Processing video elements. Selection mode:', this.isSelectionMode);
        if (!this.isSelectionMode) {
            console.debug('[BulkVideoMode] Skipping processing - not in selection mode');
            return;
        }

        const selectors = [
            'ytd-video-renderer',
            'ytd-compact-video-renderer',
            '#dismissible'
        ].join(', ');

        const videoElements = document.querySelectorAll(selectors);
        console.debug(`[BulkVideoMode] Found ${videoElements.length} video elements`);
        videoElements.forEach(element => this.addCheckboxToVideo(element));
    }

    addCheckboxToVideo(element) {
        if (element.querySelector('.yt-sub-checkbox')) {
            console.debug('[BulkVideoMode] Checkbox already exists for element');
            return;
        }

        const videoId = this.extractVideoId(element);
        if (!videoId) {
            console.debug('[BulkVideoMode] No video ID found for element');
            return;
        }

        const container = element.querySelector('#dismissible') || element;
        if (!container) {
            console.debug('[BulkVideoMode] No container found for checkbox');
            return;
        }

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.className = 'yt-sub-checkbox';

        const isCompact = element.tagName.toLowerCase() === 'ytd-compact-video-renderer';
        checkbox.style.cssText = `
            position: absolute;
            left: -25px;
            top: ${isCompact ? '5px' : '20px'};
            z-index: 9999;
            cursor: pointer;
            width: 20px;
            height: 20px;
        `;

        checkbox.addEventListener('change', (e) => {
            console.debug(`[BulkVideoMode] Checkbox changed for video ${videoId}:`, e.target.checked);
            if (e.target.checked) {
                this.selectedVideos.set(videoId, {
                    title: this.extractVideoTitle(element),
                    id: videoId
                });
            } else {
                this.selectedVideos.delete(videoId);
            }
            console.debug(`[BulkVideoMode] Selected videos count:`, this.selectedVideos.size);
        });

        if (container.style.position !== 'relative') {
            container.style.position = 'relative';
        }

        container.prepend(checkbox);
        console.debug('[BulkVideoMode] Added checkbox to video:', videoId);
    }

    async processSelectedVideos() {
        const loading = UIComponents.showLoading('Fetching subtitles...');

        try {
            await Promise.all(
                Array.from(this.selectedVideos.entries())
                    .map(async ([videoId, data]) => {
                        try {
                            const tracks = await SubtitleProcessor.fetchSubtitleTracks(videoId);
                            if (tracks) {
                                this.selectedVideos.set(videoId, {
                                    ...data,
                                    subtitles: tracks
                                });
                            }
                        } catch (error) {
                            console.error(`Failed to fetch subtitles for video ${videoId}:`, error);
                        }
                    })
            );

            this.showBulkSubtitleDialog();
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
            console.error('Failed to process videos:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    showBulkSubtitleDialog() {
        const content = document.createElement('div');
        content.appendChild(this.createFormatSelector());

        for (const [videoId, data] of this.selectedVideos) {
            content.appendChild(this.createVideoSection(videoId, data));
        }

        content.appendChild(this.createActionButtons());

        const dialog = UIComponents.createDialog({
            title: 'Select Subtitles to Download',
            content,
            onClose: () => {
                dialog.remove();
                this.cleanup();
                this.isSelectionMode = false;
            }
        });

        document.body.appendChild(dialog);
    }

    createFormatSelector() {
        const formatDiv = document.createElement('div');
        formatDiv.style.marginBottom = '20px';
        formatDiv.innerHTML = `
            <div style="margin-bottom: 15px;">
                <label style="margin-right: 10px;">
                    <input type="radio" name="format" value="srt" checked> SRT
                </label>
                <label>
                    <input type="radio" name="format" value="txt"> Plain Text
                </label>
            </div>
        `;
        return formatDiv;
    }

    createVideoSection(videoId, data) {
        const section = document.createElement('div');
        section.className = 'yt-sub-video-section';
        section.style.cssText = `
            margin-bottom: 20px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        `;

        const title = document.createElement('h3');
        title.style.margin = '0 0 10px 0';
        title.textContent = data.title;
        section.appendChild(title);

        if (data.subtitles?.length) {
            data.subtitles.forEach(track => {
                const trackDiv = document.createElement('div');
                trackDiv.className = 'yt-sub-track';
                trackDiv.innerHTML = `
                    <label>
                        <input type="checkbox"
                               data-video-id="${videoId}"
                               data-lang="${track.languageCode}">
                        ${track.languageName}
                    </label>
                `;
                section.appendChild(trackDiv);
            });
        } else {
            const noSubs = document.createElement('p');
            noSubs.style.color = '#c00';
            noSubs.textContent = CONFIG.MESSAGES.NO_SUBTITLE;
            section.appendChild(noSubs);
        }

        return section;
    }

    createActionButtons() {
        const actions = document.createElement('div');
        actions.style.cssText = `
            display: flex;
            justify-content: flex-end;
            gap: 10px;
            margin-top: 20px;
        `;

        actions.appendChild(UIComponents.createButton(
            'Download Selected',
            () => this.handleBulkDownload()
        ));
        actions.appendChild(UIComponents.createButton(
            'Copy Selected',
            () => this.handleBulkCopy()
        ));

        return actions;
    }

    async handleBulkDownload() {
        const tracks = this.getSelectedTracks();
        const format = document.querySelector('input[name="format"]:checked').value;

        if (!tracks.length) {
            UIComponents.showToast('Please select at least one subtitle');
            return;
        }

        const loading = UIComponents.showLoading('Downloading subtitles...');

        try {
            for (const track of tracks) {
                const video = this.selectedVideos.get(track.videoId);
                const subtitle = video.subtitles.find(s => s.languageCode === track.lang);

                if (subtitle) {
                    const content = await SubtitleProcessor.getSubtitleContent(subtitle, format);
                    const filename = `${Utils.sanitizeFileName(video.title)}_${subtitle.languageCode}.${format}`;
                    SubtitleProcessor.downloadSubtitle(content, filename);
                    await new Promise(resolve => setTimeout(resolve, CONFIG.TIMINGS.DOWNLOAD_DELAY));
                }
            }
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH);
            console.error('Bulk download error:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    async handleBulkCopy() {
        const tracks = this.getSelectedTracks();
        const format = document.querySelector('input[name="format"]:checked').value;

        if (!tracks.length) {
            UIComponents.showToast('Please select at least one subtitle');
            return;
        }

        const loading = UIComponents.showLoading('Copying subtitles...');

        try {
            let content = '';
            for (const track of tracks) {
                const video = this.selectedVideos.get(track.videoId);
                const subtitle = video.subtitles.find(s => s.languageCode === track.lang);

                if (subtitle) {
                    const subtitleContent = await SubtitleProcessor.getSubtitleContent(subtitle, format);
                    content += `=== ${video.title} - ${subtitle.languageName} ===\n${subtitleContent}\n\n`;
                }
            }

            await SubtitleProcessor.copyToClipboard(content);
            UIComponents.showToast(CONFIG.MESSAGES.COPY_SUCCESS);
        } catch (error) {
            UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY);
            console.error('Bulk copy error:', error);
        } finally {
            UIComponents.removeLoading(loading);
        }
    }

    getSelectedTracks() {
        return Array.from(document.querySelectorAll('.yt-sub-track input:checked'))
            .map(checkbox => ({
                videoId: checkbox.dataset.videoId,
                lang: checkbox.dataset.lang
            }));
    }

    extractVideoId(element) {
        const link = element.querySelector(CONFIG.SELECTORS.THUMBNAIL);
        if (!link?.href) return null;

        const url = new URL(link.href);
        return url.searchParams.get('v');
    }

    extractVideoTitle(element) {
        return element.querySelector(CONFIG.SELECTORS.VIDEO_TITLE)?.textContent?.trim() || 'Untitled Video';
    }

    cleanup(fullCleanup = false) {
        console.debug('[BulkVideoMode] Cleanup called. Full cleanup:', fullCleanup);

        if (this.isProcessing) {
            console.debug('[BulkVideoMode] Processing in progress, skipping cleanup');
            return;
        }

        // Always clean up observers to prevent memory leaks
        if (this.videoObserver) {
            console.debug('[BulkVideoMode] Disconnecting observer');
            this.videoObserver.disconnect();
            this.videoObserver = null;
        }

        // Reset UI state without removing elements
        if (this.selectionControls) {
            console.debug('[BulkVideoMode] Resetting UI state');
            this.selectionControls.button.textContent = 'Select Videos Sub';
            this.selectionControls.selectAllContainer.style.display = 'none';
            // Reset select all checkbox
            const selectAllCheckbox = document.getElementById('select-all');
            if (selectAllCheckbox) selectAllCheckbox.checked = false;
        }

        // Clean up video checkboxes
        document.querySelectorAll('.yt-sub-checkbox').forEach(cb => {
            const dismissible = cb.closest('#dismissible');
            if (dismissible) dismissible.style.position = '';
            cb.remove();
        });

        // Reset state
        this.selectedVideos.clear();
        this.isSelectionMode = false;

        // Only if we're completely removing the feature (like when uninstalling)
        if (fullCleanup && this.selectionControls) {
            console.debug('[BulkVideoMode] Performing full cleanup (uninstall)');
            this.selectionControls.button.remove();
            this.selectionControls.selectAllContainer.remove();
            this.selectionControls = null;
            this.initialized = false;
        }

        console.debug('[BulkVideoMode] Cleanup complete');
    }
}
    // Export to global scope
    Object.assign(window.YTSubtitles, {
        VideoManager,
        SingleVideoMode,
        BulkVideoMode
    });
})();

(function() {
    'use strict';

    // Initialize video manager
    window.YTSubtitles.activeManager = new window.YTSubtitles.VideoManager();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址