SPL (SimplePatreonLoader) - Optimized

Enhanced Vimeo video loader with optimized performance, better architecture, and improved HLS download

// ==UserScript==
// @name         SPL (SimplePatreonLoader) - Optimized
// @namespace    https://github.com/5f32797a
// @version      3.0
// @description  Enhanced Vimeo video loader with optimized performance, better architecture, and improved HLS download
// @match        https://vimeo.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @connect      vimeo.com
// @connect      *.vimeocdn.com
// @source       https://github.com/5f32797a/VimeoSPL
// @supportURL   https://github.com/5f32797a/VimeoSPL/issues
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    /**
     * Application Configuration Manager
     */
    class ConfigManager {
        static defaults = {
            preferredQuality: 'auto',
            darkMode: true,
            loadTimeout: 60000,
            userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            maxConcurrentDownloads: 4,
            chunkSize: 4 * 1024 * 1024,
            retryAttempts: 3,
            retryDelay: 1000,
            debugMode: false
        };

        static get(key) {
            return GM_getValue(key, this.defaults[key]);
        }

        static set(key, value) {
            GM_setValue(key, value);
        }

        static getAll() {
            const config = {};
            Object.keys(this.defaults).forEach(key => {
                config[key] = this.get(key);
            });
            return config;
        }
    }

    /**
     * Optimized Logging Utility
     */
    class Logger {
        static levels = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 };
        static currentLevel = ConfigManager.get('debugMode') ? 3 : 2;

        static log(level, message, ...args) {
            if (this.levels[level] <= this.currentLevel) {
                const timestamp = new Date().toISOString();
                console[level.toLowerCase()](`[SPL ${timestamp}] ${message}`, ...args);
            }
        }

        static error(message, ...args) { this.log('ERROR', message, ...args); }
        static warn(message, ...args) { this.log('WARN', message, ...args); }
        static info(message, ...args) { this.log('INFO', message, ...args); }
        static debug(message, ...args) { this.log('DEBUG', message, ...args); }
    }

    /**
     * Utility Functions
     */
    class Utils {
        static formatBytes(bytes) {
            if (bytes === 0) return '0 Bytes';
            const k = 1024;
            const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
        }

        static formatBitrate(bitrate) {
            if (!bitrate) return '';
            return bitrate >= 1000000 
                ? `${(bitrate / 1000000).toFixed(1)} Mbps`
                : `${Math.round(bitrate / 1000)} Kbps`;
        }

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

        static sanitizeFilename(filename) {
            return filename.replace(/[<>:"/\\|?*]/g, '_').trim();
        }

        static async sleep(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }

        static createUUID() {
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                const r = Math.random() * 16 | 0;
                const v = c === 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);
            });
        }
    }

    /**
     * Enhanced URL Parser
     */
    class URLParser {
        static VIMEO_ID_PATTERNS = [
            /vimeo\.com\/(\d+)/,
            /player\.vimeo\.com\/video\/(\d+)/,
            /vimeo\.com\/video\/(\d+)/,
            /\/(\d+)(?:[/?#]|$)/
        ];

        static extractVideoId(url) {
            for (const pattern of this.VIMEO_ID_PATTERNS) {
                const match = url.match(pattern);
                if (match) return match[1];
            }
            throw new Error('Invalid Vimeo URL format');
        }

        static resolveUrl(url, base) {
            if (url.startsWith('http')) return url;
            
            try {
                return new URL(url, base).href;
            } catch {
                // Fallback for complex relative paths
                if (url.startsWith('/')) {
                    const baseUrl = new URL(base);
                    return `${baseUrl.protocol}//${baseUrl.host}${url}`;
                }
                const lastSlash = base.lastIndexOf('/');
                return base.substring(0, lastSlash + 1) + url;
            }
        }
    }

    /**
     * CSS Style Manager
     */
    class StyleManager {
        static styles = `
            @keyframes spl-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
            @keyframes spl-fadeIn { from { opacity: 0; } to { opacity: 1; } }
            @keyframes spl-slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
            
            .spl-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 9998; }
            .spl-container { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
            .spl-spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 40px; height: 40px; animation: spl-spin 1s linear infinite; margin: 0 auto 15px; }
            .spl-fade-in { animation: spl-fadeIn 0.5s ease-out; }
            .spl-slide-in { animation: spl-slideIn 0.3s ease-out; }
            
            .spl-player { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; background: #1a1a1a; }
            .spl-controls { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
            .spl-title { flex: 1; font-weight: 600; font-size: 16px; margin-right: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
            .spl-button-group { display: flex; gap: 8px; }
            .spl-button { background: linear-gradient(135deg, #3498db, #2980b9); color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 500; transition: all 0.2s ease; position: relative; overflow: hidden; }
            .spl-button:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); }
            .spl-button:active { transform: translateY(0); }
            .spl-button.secondary { background: linear-gradient(135deg, #95a5a6, #7f8c8d); }
            .spl-button.danger { background: linear-gradient(135deg, #e74c3c, #c0392b); }
            
            .spl-dropdown { position: absolute; top: 100%; right: 0; min-width: 250px; background: #2c3e50; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); z-index: 10000; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: all 0.2s ease; }
            .spl-dropdown.active { opacity: 1; visibility: visible; transform: translateY(0); }
            .spl-dropdown-item { padding: 12px 16px; cursor: pointer; transition: background 0.2s ease; color: white; border-bottom: 1px solid rgba(255,255,255,0.1); }
            .spl-dropdown-item:hover { background: #3498db; }
            .spl-dropdown-item:last-child { border-bottom: none; border-radius: 0 0 8px 8px; }
            .spl-dropdown-item:first-child { border-radius: 8px 8px 0 0; }
            
            .spl-notification { position: fixed; bottom: 20px; right: 20px; min-width: 320px; max-width: 400px; background: #2c3e50; color: white; padding: 16px; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); z-index: 10001; }
            .spl-notification.success { border-left: 4px solid #27ae60; }
            .spl-notification.error { border-left: 4px solid #e74c3c; }
            .spl-notification.info { border-left: 4px solid #3498db; }
            
            .spl-progress { width: 100%; height: 6px; background: rgba(255,255,255,0.2); border-radius: 3px; overflow: hidden; margin: 8px 0; }
            .spl-progress-bar { height: 100%; background: linear-gradient(90deg, #3498db, #2ecc71); transition: width 0.3s ease; border-radius: 3px; }
            
            .spl-dialog { background: #2c3e50; color: white; padding: 24px; border-radius: 12px; max-width: 600px; width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
            .spl-dialog h3 { margin-top: 0; color: #3498db; font-size: 20px; }
            .spl-quality-option { padding: 12px 16px; margin: 8px 0; background: #34495e; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; display: flex; justify-content: space-between; align-items: center; }
            .spl-quality-option:hover { background: #3498db; transform: translateX(4px); }
            .spl-audio-indicator { font-size: 12px; padding: 2px 8px; border-radius: 12px; font-weight: 500; }
            .spl-audio-indicator.has-audio { background: #27ae60; }
            .spl-audio-indicator.no-audio { background: #e74c3c; }
        `;

        static inject() {
            if (document.getElementById('spl-styles')) return;
            
            const styleSheet = document.createElement('style');
            styleSheet.id = 'spl-styles';
            styleSheet.textContent = this.styles;
            document.head.appendChild(styleSheet);
        }
    }

    /**
     * Video Source Extractor
     */
    class VideoExtractor {
        static CONFIG_PATTERNS = [
            /window\.vimeoPlayerSetup\s*=\s*({.+?});/s,
            /var\s+config\s*=\s*({.+?});/s,
            /window\.playerConfig\s*=\s*({.+?});/s,
            /playerConfig\s*[:=]\s*({.+?})[,;]/s,
            /"config"\s*:\s*({.+?})[,}]/s
        ];

        static HLS_PATTERNS = [
            /"url"\s*:\s*"(https:\/\/[^"]+\.m3u8[^"]*)"/g,
            /https:\/\/[^\s"']+\.m3u8[^\s"']*/g
        ];

        static async extract(htmlContent) {
            const sources = {
                hls: null,
                title: "Vimeo Video",
                quality: {},
                audioStreams: [],
                configUrl: null,
                iframeSrc: null
            };

            try {
                // Method 1: Extract from player config
                const config = this.extractPlayerConfig(htmlContent);
                if (config) {
                    this.extractFromConfig(config, sources);
                    if (sources.hls) return sources;
                }

                // Method 2: Direct HLS URL search
                const hlsUrl = this.extractDirectHLS(htmlContent);
                if (hlsUrl) {
                    sources.hls = hlsUrl;
                    return sources;
                }

                // Method 3: Config URL extraction
                const configUrl = this.extractConfigUrl(htmlContent);
                if (configUrl) {
                    sources.configUrl = configUrl;
                    return sources;
                }

                // Method 4: Iframe fallback
                const iframeSrc = this.extractIframeSrc(htmlContent);
                if (iframeSrc) {
                    sources.iframeSrc = iframeSrc;
                    return sources;
                }

                return null;
            } catch (error) {
                Logger.error('Video extraction failed:', error);
                return null;
            }
        }

        static extractPlayerConfig(htmlContent) {
            for (const pattern of this.CONFIG_PATTERNS) {
                const match = htmlContent.match(pattern);
                if (!match) continue;

                try {
                    let jsonStr = match[1]
                        .replace(/(\w+):/g, '"$1":')
                        .replace(/'/g, '"')
                        .replace(/,\s*}/g, '}')
                        .replace(/,\s*]/g, ']');
                    
                    const config = JSON.parse(jsonStr);
                    if (config && (config.request || config.video)) {
                        Logger.debug('Found player config');
                        return config;
                    }
                } catch (e) {
                    Logger.debug('Failed to parse config:', e.message);
                }
            }
            return null;
        }

        static extractFromConfig(config, sources) {
            if (config.video?.title) {
                sources.title = config.video.title;
            }

            if (config.request?.files?.hls) {
                const hlsConfig = config.request.files.hls;
                const hlsUrl = hlsConfig.cdns?.akfire_interconnect_quic?.url ||
                              hlsConfig.cdns?.[hlsConfig.default_cdn]?.url ||
                              hlsConfig.url;
                
                if (hlsUrl) {
                    sources.hls = hlsUrl;
                    Logger.info('Extracted HLS from config:', hlsUrl);
                }
            }

            // Extract audio streams from config
            if (config.request?.files?.dash?.streams) {
                const dashStreams = config.request.files.dash.streams;
                sources.audioStreams = dashStreams
                    .filter(stream => stream.profile?.includes('audio'))
                    .map(stream => ({
                        url: stream.url,
                        name: 'Audio Track',
                        language: stream.language || 'Unknown',
                        isAudioOnly: true,
                        bandwidth: stream.bandwidth
                    }));
            }

            // Extract progressive (MP4) files if available
            if (config.request?.files?.progressive) {
                const mp4Sources = config.request.files.progressive;
                mp4Sources.sort((a, b) => b.height - a.height);
                
                mp4Sources.forEach(source => {
                    sources.quality[`${source.height}p`] = {
                        url: source.url,
                        quality: `${source.height}p`,
                        format: 'mp4',
                        width: source.width,
                        height: source.height
                    };
                });
            }
        }

        static extractDirectHLS(htmlContent) {
            for (const pattern of this.HLS_PATTERNS) {
                const matches = [...htmlContent.matchAll(pattern)];
                if (matches.length > 0) {
                    const url = matches[0][1] || matches[0][0];
                    Logger.info('Found direct HLS URL:', url);
                    return url.replace(/\\u0026/g, '&');
                }
            }
            return null;
        }

        static extractConfigUrl(htmlContent) {
            const match = htmlContent.match(/(?:master\.json|player\.vimeo\.com\/video\/\d+\/config)[^"'\s]+/);
            if (match) {
                const url = 'https://' + match[0].replace(/^\/\//, '');
                Logger.debug('Found config URL:', url);
                return url;
            }
            return null;
        }

        static extractIframeSrc(htmlContent) {
            const match = htmlContent.match(/src=["']([^"']+player\.vimeo\.com\/video\/[^"']+)["']/);
            if (match) {
                Logger.debug('Found iframe src:', match[1]);
                return match[1];
            }
            return null;
        }
    }

    /**
     * Optimized HLS Parser and Downloader
     */
    class HLSManager {
        constructor() {
            this.activeDownloads = new Map();
            this.downloadQueue = [];
        }

        async parsePlaylist(m3u8Content, baseUrl) {
            if (m3u8Content.includes('#EXT-X-STREAM-INF')) {
                return this.parseMasterPlaylist(m3u8Content, baseUrl);
            } else {
                return this.parseMediaPlaylist(m3u8Content, baseUrl);
            }
        }

        parseMasterPlaylist(content, baseUrl) {
            const streams = [];
            const audioStreams = [];
            const lines = content.split('\n').map(line => line.trim());

            let currentStream = null;

            for (let i = 0; i < lines.length; i++) {
                const line = lines[i];

                if (line.startsWith('#EXT-X-STREAM-INF:')) {
                    currentStream = this.parseStreamInfo(line);
                } else if (line.startsWith('#EXT-X-MEDIA:') && line.includes('TYPE=AUDIO')) {
                    const audioStream = this.parseAudioStream(line, baseUrl);
                    if (audioStream) audioStreams.push(audioStream);
                } else if (line && !line.startsWith('#') && currentStream) {
                    currentStream.url = URLParser.resolveUrl(line, baseUrl);
                    streams.push(currentStream);
                    currentStream = null;
                }
            }

            return {
                type: 'master',
                streams: streams.sort((a, b) => (b.bandwidth || 0) - (a.bandwidth || 0)),
                audioStreams
            };
        }

        parseStreamInfo(line) {
            const stream = { attributes: {}, type: 'video' };
            const attributesStr = line.substring(18);
            
            // More efficient attribute parsing
            const attrRegex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
            let match;
            
            while ((match = attrRegex.exec(attributesStr)) !== null) {
                const key = match[1];
                const value = match[2] || match[3];
                stream.attributes[key] = value;
            }

            // Extract common properties
            stream.resolution = stream.attributes.RESOLUTION;
            stream.bandwidth = parseInt(stream.attributes.BANDWIDTH) || 0;
            stream.frameRate = parseFloat(stream.attributes['FRAME-RATE']) || 0;
            stream.codecs = stream.attributes.CODECS;
            stream.audioGroup = stream.attributes.AUDIO;

            return stream;
        }

        parseAudioStream(line, baseUrl) {
            const attrRegex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
            const attributes = {};
            let match;

            while ((match = attrRegex.exec(line)) !== null) {
                attributes[match[1]] = match[2] || match[3];
            }

            if (attributes.URI) {
                return {
                    url: URLParser.resolveUrl(attributes.URI, baseUrl),
                    groupId: attributes['GROUP-ID'],
                    name: attributes.NAME || 'Audio Track',
                    language: attributes.LANGUAGE || 'Unknown',
                    isAudioOnly: true,
                    attributes
                };
            }
            return null;
        }

        parseMediaPlaylist(content, baseUrl) {
            const segments = [];
            const lines = content.split('\n').map(line => line.trim());
            let currentSegment = null;

            for (const line of lines) {
                if (line.startsWith('#EXTINF:')) {
                    const duration = parseFloat(line.substring(8).split(',')[0]);
                    currentSegment = { duration, url: '' };
                } else if (line && !line.startsWith('#') && currentSegment) {
                    currentSegment.url = URLParser.resolveUrl(line, baseUrl);
                    segments.push(currentSegment);
                    currentSegment = null;
                }
            }

            return { type: 'media', segments };
        }

        async fetchStreamData(m3u8Url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: m3u8Url,
                    headers: {
                        'Referer': 'https://www.patreon.com',
                        'User-Agent': ConfigManager.get('userAgent')
                    },
                    onload: async (response) => {
                        if (response.status !== 200) {
                            reject(new Error(`HTTP ${response.status}`));
                            return;
                        }

                        try {
                            const data = await this.parsePlaylist(response.responseText, m3u8Url);
                            resolve(data);
                        } catch (error) {
                            reject(error);
                        }
                    },
                    onerror: reject
                });
            });
        }

        async downloadStream(m3u8Url, filename, progressCallback) {
            const downloadId = Utils.createUUID();
            this.activeDownloads.set(downloadId, { cancelled: false });

            try {
                progressCallback({ progress: 0, status: 'info', message: 'Analyzing stream...' });
                
                const streamData = await this.fetchStreamData(m3u8Url);
                const isAudioOnly = filename.endsWith('.m4a') || m3u8Url.includes('audio') || m3u8Url.includes('st=audio');
                
                if (streamData.type === 'master') {
                    // For master playlists, find the appropriate stream
                    let selectedStream;
                    
                    if (isAudioOnly && streamData.audioStreams && streamData.audioStreams.length > 0) {
                        // Select first audio stream for audio-only downloads
                        selectedStream = streamData.audioStreams[0];
                        progressCallback({ progress: 0, status: 'info', message: `Selected audio track: ${selectedStream.name || 'Audio Track'}` });
                    } else {
                        // Select highest quality video stream
                        selectedStream = streamData.streams[0];
                        if (!selectedStream) {
                            throw new Error('No streams found');
                        }
                        progressCallback({ progress: 0, status: 'info', message: `Selected quality: ${selectedStream.resolution || 'Unknown'}` });
                    }
                    
                    const mediaData = await this.fetchStreamData(selectedStream.url);
                    if (mediaData.type !== 'media') {
                        throw new Error('Invalid media playlist');
                    }
                    
                    await this.downloadSegments(mediaData.segments, filename, progressCallback, downloadId, isAudioOnly);
                } else {
                    // Direct media playlist
                    await this.downloadSegments(streamData.segments, filename, progressCallback, downloadId, isAudioOnly);
                }
            } catch (error) {
                progressCallback({ progress: 0, status: 'error', message: error.message });
            } finally {
                this.activeDownloads.delete(downloadId);
            }
        }

        async downloadSegments(segments, filename, progressCallback, downloadId, isAudioOnly = false) {
            const totalSegments = segments.length;
            const chunks = new Array(totalSegments);
            const maxConcurrent = ConfigManager.get('maxConcurrentDownloads');
            let completed = 0;
            let totalBytes = 0;

            const mediaType = isAudioOnly ? 'audio segments' : 'video segments';
            progressCallback({ progress: 0, status: 'info', message: `Downloading ${totalSegments} ${mediaType}...` });

            // Download segments in batches
            for (let i = 0; i < totalSegments; i += maxConcurrent) {
                if (this.activeDownloads.get(downloadId)?.cancelled) {
                    throw new Error('Download cancelled');
                }

                const batch = segments.slice(i, i + maxConcurrent);
                const promises = batch.map(async (segment, index) => {
                    const segmentIndex = i + index;
                    try {
                        const data = await this.downloadSegment(segment.url);
                        chunks[segmentIndex] = data;
                        totalBytes += data.byteLength;
                        completed++;
                        
                        progressCallback({
                            progress: completed / totalSegments,
                            status: 'progress',
                            message: `Downloaded ${completed}/${totalSegments} ${mediaType}`,
                            bytes: totalBytes
                        });
                    } catch (error) {
                        Logger.warn(`Failed to download segment ${segmentIndex}:`, error);
                        // Continue with other segments
                    }
                });

                await Promise.allSettled(promises);
            }

            // Combine segments
            progressCallback({ progress: 1, status: 'info', message: 'Combining segments...' });
            const combined = this.combineSegments(chunks.filter(Boolean));
            
            // Download the file
            this.saveFile(combined, filename, isAudioOnly);
            progressCallback({
                progress: 1,
                status: 'complete',
                message: 'Download complete!',
                size: combined.byteLength
            });
        }

        async downloadSegment(url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'arraybuffer',
                    headers: {
                        'Referer': 'https://www.patreon.com',
                        'User-Agent': ConfigManager.get('userAgent')
                    },
                    onload: (response) => {
                        if (response.status === 200) {
                            resolve(response.response);
                        } else {
                            reject(new Error(`HTTP ${response.status}`));
                        }
                    },
                    onerror: reject
                });
            });
        }

        combineSegments(chunks) {
            const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
            const combined = new Uint8Array(totalSize);
            let offset = 0;

            for (const chunk of chunks) {
                combined.set(new Uint8Array(chunk), offset);
                offset += chunk.byteLength;
            }

            return combined;
        }

        saveFile(data, filename, isAudioOnly = false) {
            // Determine correct MIME type based on file extension
            let mimeType = 'video/mp4';
            
            if (isAudioOnly || filename.endsWith('.m4a')) {
                mimeType = 'audio/mp4';
            } else if (filename.endsWith('.mp3')) {
                mimeType = 'audio/mpeg';
            } else if (filename.endsWith('.aac')) {
                mimeType = 'audio/aac';
            }
            
            const blob = new Blob([data], { type: mimeType });
            const url = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = url;
            a.download = Utils.sanitizeFilename(filename);
            a.style.display = 'none';
            
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            
            setTimeout(() => URL.revokeObjectURL(url), 100);
        }

        cancelDownload(downloadId) {
            const download = this.activeDownloads.get(downloadId);
            if (download) {
                download.cancelled = true;
            }
        }
    }

    /**
     * UI Manager
     */
    class UIManager {
        constructor() {
            this.notifications = new Map();
            this.currentDialog = null;
        }

        showLoading(videoId) {
            const overlay = this.createElement('div', 'spl-overlay');
            const container = this.createElement('div', 'spl-container spl-fade-in');
            
            container.innerHTML = `
                <div style="text-align: center; color: white;">
                    <div class="spl-spinner"></div>
                    <div style="font-size: 18px; margin-bottom: 8px;">Loading Video ${videoId}</div>
                    <div id="spl-progress-text" style="color: #95a5a6;">Connecting to Vimeo...</div>
                </div>
            `;

            document.body.appendChild(overlay);
            document.body.appendChild(container);

            return {
                updateProgress: (text) => {
                    const progressEl = document.getElementById('spl-progress-text');
                    if (progressEl) progressEl.textContent = text;
                },
                remove: () => {
                    overlay.remove();
                    container.remove();
                }
            };
        }

        showVideoPlayer(videoUrl, videoId, videoSources) {
            document.body.innerHTML = '';
            document.documentElement.style.cssText = 'background: #1a1a1a; margin: 0; padding: 0;';

            const player = this.createElement('div', 'spl-player');
            
            const controls = this.createElement('div', 'spl-controls');
            controls.innerHTML = `
                <div class="spl-title">${videoSources.title || `Vimeo Video #${videoId}`}</div>
                <div class="spl-button-group">
                    <button id="spl-fullscreen" class="spl-button">⛶ Fullscreen</button>
                    <div style="position: relative;">
                        <button id="spl-download" class="spl-button">⬇ Download</button>
                        <div id="spl-download-dropdown" class="spl-dropdown"></div>
                    </div>
                    <button id="spl-refresh" class="spl-button secondary">↻ Refresh</button>
                </div>
            `;

            const iframe = this.createElement('iframe');
            iframe.src = videoUrl;
            iframe.style.cssText = 'flex: 1; width: 100%; border: none;';
            iframe.allowFullscreen = true;

            player.appendChild(controls);
            player.appendChild(iframe);
            document.body.appendChild(player);

            this.setupPlayerControls(videoId, videoSources);
        }

        setupPlayerControls(videoId, videoSources) {
            // Fullscreen
            document.getElementById('spl-fullscreen').onclick = () => {
                if (document.fullscreenElement) {
                    document.exitFullscreen();
                } else {
                    document.querySelector('.spl-player').requestFullscreen();
                }
            };

            // Refresh
            document.getElementById('spl-refresh').onclick = () => location.reload();

            // Download dropdown
            this.setupDownloadDropdown(videoId, videoSources);
        }

        setupDownloadDropdown(videoId, videoSources) {
            const downloadBtn = document.getElementById('spl-download');
            const dropdown = document.getElementById('spl-download-dropdown');

            const options = [];

            if (videoSources.hls) {
                options.push({
                    text: '🎬 Download Video (HLS)',
                    action: () => this.showQualityDialog(videoSources.hls, videoId, videoSources.title)
                });

                options.push({
                    text: '📄 Save HLS Stream (m3u8)',
                    action: () => this.saveM3U8File(videoSources.hls, `${videoSources.title || `vimeo-${videoId}`}.m3u8`)
                });

                options.push({
                    text: '📋 Copy Stream URL',
                    action: () => this.copyToClipboard(videoSources.hls, 'Stream URL copied!')
                });
            }

            // Add MP4 direct download options if available
            if (videoSources.quality && Object.keys(videoSources.quality).length > 0) {
                options.push({
                    text: '📹 Direct MP4 Downloads',
                    action: () => this.showMP4QualityDialog(videoSources.quality, videoId, videoSources.title)
                });
            }

            if (videoSources.configUrl && !videoSources.hls) {
                options.push({
                    text: '📋 Copy Config URL',
                    action: () => this.copyToClipboard(videoSources.configUrl, 'Config URL copied!')
                });
            }

            if (options.length === 0) {
                options.push({
                    text: '❓ Download Help',
                    action: () => this.showDownloadHelp(videoId)
                });
            }

            dropdown.innerHTML = options.map(opt => 
                `<div class="spl-dropdown-item">${opt.text}</div>`
            ).join('');

            dropdown.querySelectorAll('.spl-dropdown-item').forEach((item, index) => {
                item.onclick = () => {
                    dropdown.classList.remove('active');
                    options[index].action();
                };
            });

            downloadBtn.onclick = (e) => {
                e.stopPropagation();
                dropdown.classList.toggle('active');
            };

            document.addEventListener('click', (e) => {
                if (!downloadBtn.contains(e.target)) {
                    dropdown.classList.remove('active');
                }
            });
        }

        async showQualityDialog(m3u8Url, videoId, title) {
            const overlay = this.createElement('div', 'spl-overlay', {
                style: 'display: flex; align-items: center; justify-content: center;'
            });

            const dialog = this.createElement('div', 'spl-dialog spl-slide-in');
            dialog.innerHTML = `
                <h3>HLS Stream Download</h3>
                <p>Analyzing available qualities...</p>
                <div class="spl-spinner" style="margin: 20px auto;"></div>
                <div id="spl-quality-list"></div>
                <div id="spl-audio-list" style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px; display: none;">
                    <h4 style="margin-top: 0; color: #3498db;">🔊 Audio Tracks (Audio Only)</h4>
                    <div id="spl-audio-tracks"></div>
                </div>
                <div style="display: flex; justify-content: space-between; margin-top: 20px;">
                    <button id="spl-close-dialog" class="spl-button secondary">Close</button>
                    <button id="spl-download-best" class="spl-button">Download Best Quality</button>
                </div>
                <div style="margin-top: 15px; font-size: 12px; color: #95a5a6;">
                    <p><strong>💡 Audio Solutions:</strong></p>
                    <ul style="margin: 5px 0; padding-left: 20px;">
                        <li>🔊 <strong>Audio tracks above</strong> - Download audio separately as .m4a files</li>
                        <li>📄 <strong>Save HLS Stream</strong> - Use VLC player for guaranteed audio+video playback</li>
                        <li>📹 <strong>Direct MP4</strong> - Use menu option if available (includes audio)</li>
                    </ul>
                </div>
            `;

            overlay.appendChild(dialog);
            document.body.appendChild(overlay);
            this.currentDialog = overlay;

            // Close handlers
            document.getElementById('spl-close-dialog').onclick = () => this.closeDialog();
            document.getElementById('spl-download-best').onclick = () => {
                this.startDownload(m3u8Url, title || `vimeo-${videoId}.mp4`);
                this.closeDialog();
            };

            try {
                const hlsManager = new HLSManager();
                const streamData = await hlsManager.fetchStreamData(m3u8Url);
                
                dialog.querySelector('.spl-spinner').remove();
                dialog.querySelector('p').textContent = 'Select quality to download:';

                if (streamData.type === 'master' && streamData.streams.length > 0) {
                    this.populateQualityList(streamData, videoId, title);
                } else {
                    document.getElementById('spl-quality-list').innerHTML = '<p>No quality options found. Use "Download Best Quality" button.</p>';
                }
            } catch (error) {
                this.showError(`Failed to analyze stream: ${error.message}`);
                this.closeDialog();
            }
        }

        populateQualityList(streamData, videoId, title) {
            const qualityList = document.getElementById('spl-quality-list');
            const audioList = document.getElementById('spl-audio-list');
            const audioTracks = document.getElementById('spl-audio-tracks');
            
            // Filter video and audio streams
            const videoStreams = streamData.streams.filter(s => !s.isAudioOnly);
            const audioStreams = streamData.audioStreams || [];
            
            // Populate video quality options
            qualityList.innerHTML = videoStreams.map(stream => {
                const label = this.getQualityLabel(stream);
                const hasAudio = stream.codecs && stream.codecs.includes('mp4a');
                const audioClass = hasAudio ? 'has-audio' : 'no-audio';
                const audioText = hasAudio ? '✓ Audio' : '✗ No Audio';
                
                return `
                    <div class="spl-quality-option" data-url="${stream.url}" data-label="${label}">
                        <div>
                            <strong>${label}</strong>
                            <span class="spl-audio-indicator ${audioClass}">${audioText}</span>
                        </div>
                        <div>${Utils.formatBitrate(stream.bandwidth)}</div>
                    </div>
                `;
            }).join('');

            // Populate audio tracks if available
            if (audioStreams.length > 0) {
                audioList.style.display = 'block';
                audioTracks.innerHTML = audioStreams.map(audioStream => {
                    const audioName = audioStream.name || 'Audio Track';
                    const language = audioStream.language ? ` (${audioStream.language})` : '';
                    
                    return `
                        <div class="spl-quality-option" data-url="${audioStream.url}" data-label="audio" data-name="${audioName}">
                            <div>
                                <strong>🔊 ${audioName}${language}</strong>
                                <span class="spl-audio-indicator has-audio">Audio Only</span>
                            </div>
                            <div>Audio Track</div>
                        </div>
                    `;
                }).join('');

                // Add audio track click handlers
                audioTracks.querySelectorAll('.spl-quality-option').forEach(option => {
                    option.onclick = () => {
                        const url = option.dataset.url;
                        const audioName = option.dataset.name;
                        const filename = `${title || `vimeo-${videoId}`}_${audioName.replace(/[^a-zA-Z0-9]/g, '_')}.m4a`;
                        this.startDownload(url, filename);
                        this.closeDialog();
                    };
                });
            }

            // Add warning if no streams have audio
            const hasAnyAudio = videoStreams.some(s => s.codecs && s.codecs.includes('mp4a')) || audioStreams.length > 0;
            if (!hasAnyAudio) {
                const warningDiv = document.createElement('div');
                warningDiv.style.cssText = 'background: #e74c3c; color: white; padding: 12px; border-radius: 6px; margin: 10px 0;';
                warningDiv.innerHTML = `
                    <strong>⚠️ Audio Warning:</strong> No audio detected in any stream.
                    For guaranteed audio playback, use "Save HLS Stream" option and open in VLC player.
                `;
                qualityList.appendChild(warningDiv);
            }

            // Add video quality click handlers
            qualityList.querySelectorAll('.spl-quality-option').forEach(option => {
                option.onclick = () => {
                    const url = option.dataset.url;
                    const label = option.dataset.label;
                    const filename = `${title || `vimeo-${videoId}`}_${label}.mp4`;
                    this.startDownload(url, filename);
                    this.closeDialog();
                };
            });
        }

        getQualityLabel(stream) {
            if (stream.resolution) {
                const match = stream.resolution.match(/\d+x(\d+)/);
                if (match) {
                    let label = `${match[1]}p`;
                    if (stream.frameRate >= 50) {
                        label += ` ${Math.round(stream.frameRate)}fps`;
                    }
                    return label;
                }
                return stream.resolution;
            }
            return 'Unknown';
        }

        startDownload(url, filename) {
            const hlsManager = new HLSManager();
            const notificationId = this.showNotification({
                type: 'info',
                title: `Downloading ${filename}`,
                message: 'Preparing download...',
                persistent: true,
                showProgress: true
            });

            hlsManager.downloadStream(url, filename, ({ progress, status, message, bytes, size }) => {
                switch (status) {
                    case 'progress':
                        this.updateNotification(notificationId, {
                            message: `${Math.round(progress * 100)}% - ${message}`,
                            progress: progress
                        });
                        break;
                    case 'info':
                        this.updateNotification(notificationId, { message });
                        break;
                    case 'complete':
                        this.updateNotification(notificationId, {
                            type: 'success',
                            message: `Download complete! (${Utils.formatBytes(size)})`,
                            progress: 1,
                            autoClose: 5000
                        });
                        break;
                    case 'error':
                        this.updateNotification(notificationId, {
                            type: 'error',
                            message: `Error: ${message}`,
                            autoClose: 8000
                        });
                        break;
                }
            });
        }

        saveM3U8File(url, filename) {
            const content = `#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1\n${url}`;
            const blob = new Blob([content], { type: 'application/x-mpegurl' });
            const downloadUrl = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = downloadUrl;
            a.download = Utils.sanitizeFilename(filename);
            a.click();
            
            setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
            
            this.showNotification({
                type: 'success',
                title: 'HLS Stream Saved',
                message: 'Open with VLC or similar player for best results',
                autoClose: 5000
            });
        }

        showMP4QualityDialog(qualityOptions, videoId, title) {
            const overlay = this.createElement('div', 'spl-overlay', {
                style: 'display: flex; align-items: center; justify-content: center;'
            });

            const dialog = this.createElement('div', 'spl-dialog spl-slide-in');
            dialog.innerHTML = `
                <h3>📹 Direct MP4 Downloads</h3>
                <p>Select quality to download directly (with guaranteed audio):</p>
                <div id="spl-mp4-quality-list"></div>
                <div style="display: flex; justify-content: space-between; margin-top: 20px;">
                    <button id="spl-close-mp4-dialog" class="spl-button secondary">Close</button>
                </div>
                <div style="margin-top: 15px; font-size: 12px; color: #95a5a6;">
                    <p><strong>✅ Advantage:</strong> MP4 files include both video and audio in a single file</p>
                </div>
            `;

            overlay.appendChild(dialog);
            document.body.appendChild(overlay);
            this.currentDialog = overlay;

            // Close handler
            document.getElementById('spl-close-mp4-dialog').onclick = () => this.closeDialog();

            // Populate MP4 quality options
            const mp4QualityList = document.getElementById('spl-mp4-quality-list');
            mp4QualityList.innerHTML = Object.values(qualityOptions).map(quality => {
                return `
                    <div class="spl-quality-option" data-url="${quality.url}" data-quality="${quality.quality}">
                        <div>
                            <strong>${quality.quality} (${quality.width}x${quality.height})</strong>
                            <span class="spl-audio-indicator has-audio">✓ Video + Audio</span>
                        </div>
                        <div>MP4 Direct</div>
                    </div>
                `;
            }).join('');

            // Add click handlers for MP4 downloads
            mp4QualityList.querySelectorAll('.spl-quality-option').forEach(option => {
                option.onclick = () => {
                    const url = option.dataset.url;
                    const quality = option.dataset.quality;
                    const filename = `${title || `vimeo-${videoId}`}_${quality}.mp4`;
                    this.downloadFile(url, filename);
                    this.closeDialog();
                };
            });
        }

        downloadFile(url, filename) {
            // Create download notification
            const notificationId = this.showNotification({
                type: 'info',
                title: `Downloading ${filename}`,
                message: 'Starting download...',
                persistent: true,
                showProgress: true
            });

            // Use XMLHttpRequest for progress tracking
            const xhr = new XMLHttpRequest();
            xhr.open('GET', url, true);
            xhr.responseType = 'blob';

            xhr.onprogress = (e) => {
                if (e.lengthComputable) {
                    const progress = e.loaded / e.total;
                    const percent = Math.round(progress * 100);
                    this.updateNotification(notificationId, {
                        message: `Downloading: ${percent}% (${Utils.formatBytes(e.loaded)} / ${Utils.formatBytes(e.total)})`,
                        progress: progress
                    });
                } else {
                    this.updateNotification(notificationId, {
                        message: `Downloaded: ${Utils.formatBytes(e.loaded)}`
                    });
                }
            };

            xhr.onload = () => {
                if (xhr.status === 200) {
                    const blob = new Blob([xhr.response], { type: 'video/mp4' });
                    const downloadUrl = URL.createObjectURL(blob);
                    
                    const a = document.createElement('a');
                    a.href = downloadUrl;
                    a.download = Utils.sanitizeFilename(filename);
                    a.style.display = 'none';
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    
                    setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);

                    this.updateNotification(notificationId, {
                        type: 'success',
                        message: `Download complete! (${Utils.formatBytes(xhr.response.size)})`,
                        progress: 1,
                        autoClose: 5000
                    });
                } else {
                    this.updateNotification(notificationId, {
                        type: 'error',
                        message: `Download failed: HTTP ${xhr.status}`,
                        autoClose: 8000
                    });
                }
            };

            xhr.onerror = () => {
                this.updateNotification(notificationId, {
                    type: 'error',
                    message: 'Download failed: Network error',
                    autoClose: 8000
                });
            };

            xhr.send();
        }

        async copyToClipboard(text, successMessage) {
            try {
                await navigator.clipboard.writeText(text);
                this.showNotification({
                    type: 'success',
                    title: 'Copied!',
                    message: successMessage,
                    autoClose: 3000
                });
            } catch (error) {
                // Fallback
                const textarea = document.createElement('textarea');
                textarea.value = text;
                document.body.appendChild(textarea);
                textarea.select();
                document.execCommand('copy');
                document.body.removeChild(textarea);
                
                this.showNotification({
                    type: 'success',
                    title: 'Copied!',
                    message: successMessage,
                    autoClose: 3000
                });
            }
        }

        showNotification({ type = 'info', title, message, autoClose, persistent, showProgress }) {
            const id = Utils.createUUID();
            
            const notification = this.createElement('div', `spl-notification ${type} spl-slide-in`);
            notification.innerHTML = `
                <div style="font-weight: 600; margin-bottom: 4px;">${title}</div>
                <div style="color: rgba(255,255,255,0.9);">${message}</div>
                ${showProgress ? '<div class="spl-progress"><div class="spl-progress-bar" style="width: 0%;"></div></div>' : ''}
                ${!persistent ? '<div style="margin-top: 8px; text-align: right;"><button class="spl-button" style="padding: 4px 8px; font-size: 12px;">✕</button></div>' : ''}
            `;

            document.body.appendChild(notification);
            this.notifications.set(id, notification);

            if (!persistent) {
                const closeBtn = notification.querySelector('button');
                if (closeBtn) {
                    closeBtn.onclick = () => this.removeNotification(id);
                }
            }

            if (autoClose) {
                setTimeout(() => this.removeNotification(id), autoClose);
            }

            return id;
        }

        updateNotification(id, { type, message, progress, autoClose }) {
            const notification = this.notifications.get(id);
            if (!notification) return;

            if (type) {
                notification.className = `spl-notification ${type}`;
            }
            
            if (message) {
                const messageEl = notification.children[1];
                if (messageEl) messageEl.textContent = message;
            }
            
            if (progress !== undefined) {
                const progressBar = notification.querySelector('.spl-progress-bar');
                if (progressBar) {
                    progressBar.style.width = `${progress * 100}%`;
                }
            }
            
            if (autoClose) {
                setTimeout(() => this.removeNotification(id), autoClose);
            }
        }

        removeNotification(id) {
            const notification = this.notifications.get(id);
            if (notification) {
                notification.style.transform = 'translateX(100%)';
                notification.style.opacity = '0';
                setTimeout(() => {
                    notification.remove();
                    this.notifications.delete(id);
                }, 300);
            }
        }

        showDownloadHelp(videoId) {
            const overlay = this.createElement('div', 'spl-overlay', {
                style: 'display: flex; align-items: center; justify-content: center;'
            });

            const dialog = this.createElement('div', 'spl-dialog spl-slide-in');
            dialog.innerHTML = `
                <h3>Download Help</h3>
                <p>This video couldn't be automatically processed. Try these methods:</p>
                
                <h4>Method 1: Browser Developer Tools</h4>
                <ol>
                    <li>Press F12 to open Developer Tools</li>
                    <li>Go to the Network tab</li>
                    <li>Refresh the page and look for .mp4 or .m3u8 files</li>
                    <li>Right-click and save or copy the URL</li>
                </ol>
                
                <h4>Method 2: External Tools</h4>
                <ul>
                    <li>Use yt-dlp: <code>yt-dlp https://vimeo.com/${videoId}</code></li>
                    <li>Browser extensions like "Video DownloadHelper"</li>
                </ul>
                
                <button id="spl-close-help" class="spl-button" style="margin-top: 20px;">Close</button>
            `;

            overlay.appendChild(dialog);
            document.body.appendChild(overlay);
            this.currentDialog = overlay;

            document.getElementById('spl-close-help').onclick = () => this.closeDialog();
        }

        showError(message) {
            document.body.innerHTML = '';
            
            const container = this.createElement('div', 'spl-container spl-fade-in');
            container.innerHTML = `
                <div style="text-align: center; color: white;">
                    <div style="font-size: 48px; color: #e74c3c; margin-bottom: 20px;">⚠</div>
                    <h2 style="color: #e74c3c; margin: 0 0 10px 0;">Error</h2>
                    <p style="color: #ecf0f1; margin-bottom: 20px;">${message}</p>
                    <button class="spl-button" onclick="location.reload()">Try Again</button>
                </div>
            `;
            
            document.body.appendChild(container);
        }

        closeDialog() {
            if (this.currentDialog) {
                this.currentDialog.remove();
                this.currentDialog = null;
            }
        }

        createElement(tag, className = '', attributes = {}) {
            const element = document.createElement(tag);
            if (className) element.className = className;
            Object.assign(element, attributes);
            return element;
        }
    }

    /**
     * Main Application Class
     */
    class VimeoSPL {
        constructor() {
            this.ui = new UIManager();
            this.hlsManager = new HLSManager();
            this.loadingUI = null;
        }

        async init() {
            try {
                StyleManager.inject();
                
                if (!this.isRestrictedVideo()) {
                    Logger.debug('Not a restricted video page');
                    return;
                }

                const videoId = URLParser.extractVideoId(window.location.href);
                this.loadingUI = this.ui.showLoading(videoId);
                
                await this.loadVideo(videoId);
            } catch (error) {
                Logger.error('Initialization failed:', error);
                this.ui.showError(error.message);
            }
        }

        isRestrictedVideo() {
            const indicators = [
                '.exception_title.iris_header',
                '.private-content-banner',
                '[data-test-id="private-video-banner"]'
            ];

            if (indicators.some(selector => document.querySelector(selector))) {
                return true;
            }

            const pageText = document.body.textContent || '';
            const restrictedPhrases = [
                'This video is private',
                'because of its privacy settings',
                'content is available with',
                'Page not found',
                'Due to privacy settings'
            ];

            return restrictedPhrases.some(phrase => pageText.includes(phrase));
        }

        async loadVideo(videoId) {
            const timeout = ConfigManager.get('loadTimeout');
            const timeoutPromise = new Promise((_, reject) =>
                setTimeout(() => reject(new Error('Request timeout')), timeout)
            );

            try {
                this.loadingUI.updateProgress('Fetching video data...');
                
                const response = await Promise.race([
                    this.fetchVideoData(videoId),
                    timeoutPromise
                ]);

                this.loadingUI.updateProgress('Processing video...');
                
                const videoSources = await VideoExtractor.extract(response);
                if (!videoSources) {
                    throw new Error('No video sources found');
                }

                // Fetch additional config if needed
                if (!videoSources.hls && videoSources.configUrl) {
                    this.loadingUI.updateProgress('Fetching additional data...');
                    await this.fetchAdditionalConfig(videoSources);
                }

                // Setup video player
                const videoUrl = videoSources.iframeSrc || 
                    URL.createObjectURL(new Blob([response], { type: 'text/html' }));
                
                this.loadingUI.remove();
                this.ui.showVideoPlayer(videoUrl, videoId, videoSources);
                
                Logger.info('Video loaded successfully');
            } catch (error) {
                if (this.loadingUI) this.loadingUI.remove();
                throw error;
            }
        }

        async fetchVideoData(videoId) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://player.vimeo.com/video/${videoId}`,
                    headers: {
                        'Referer': 'https://www.patreon.com',
                        'User-Agent': ConfigManager.get('userAgent'),
                        'Cache-Control': 'no-cache'
                    },
                    onload: (response) => {
                        if (response.status >= 400) {
                            reject(new Error(`Server error: ${response.status}`));
                            return;
                        }
                        resolve(response.responseText);
                    },
                    onerror: () => reject(new Error('Network error'))
                });
            });
        }

        async fetchAdditionalConfig(videoSources) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: videoSources.configUrl,
                    headers: {
                        'Referer': 'https://www.patreon.com',
                        'User-Agent': ConfigManager.get('userAgent'),
                        'Accept': 'application/json'
                    },
                    onload: (response) => {
                        try {
                            if (response.status >= 400) {
                                throw new Error(`Config request failed: ${response.status}`);
                            }

                            const config = JSON.parse(response.responseText);
                            VideoExtractor.extractFromConfig(config, videoSources);
                            resolve();
                        } catch (error) {
                            Logger.warn('Config parsing failed:', error);
                            resolve(); // Continue without config
                        }
                    },
                    onerror: () => {
                        Logger.warn('Config request failed');
                        resolve(); // Continue without config
                    }
                });
            });
        }
    }

    // Initialize the application
    const app = new VimeoSPL();
    
    // Robust initialization with retry logic
    const initWithRetry = async (attempts = 3) => {
        for (let i = 0; i < attempts; i++) {
            try {
                await app.init();
                break;
            } catch (error) {
                Logger.error(`Initialization attempt ${i + 1} failed:`, error);
                if (i === attempts - 1) {
                    // Final attempt failed
                    const ui = new UIManager();
                    StyleManager.inject();
                    ui.showError('Failed to initialize SPL. Please refresh the page.');
                } else {
                    // Wait before retry
                    await Utils.sleep(1000 * (i + 1));
                }
            }
        }
    };

    // Start initialization based on document state
    if (document.readyState === 'complete') {
        setTimeout(() => initWithRetry(), 500);
    } else {
        window.addEventListener('load', () => {
            setTimeout(() => initWithRetry(), 500);
        });
    }

    // Cleanup on page unload
    window.addEventListener('beforeunload', () => {
        // Cancel any active downloads
        if (app.hlsManager) {
            for (const [id] of app.hlsManager.activeDownloads) {
                app.hlsManager.cancelDownload(id);
            }
        }
    });

})();

QingJ © 2025

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