Greasy Fork

Greasy Fork is available in English.

M3U8 超级下载器 Pro v5.0.1

适配最新Chrome,支持多线程并发数调节、深色模式、主播放列表选择、内存优化,进度/速度/剩余时间更准确

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         M3U8 超级下载器 Pro v5.0.1
// @namespace    https://github.com/yourusername/m3u8-downloader-pro
// @version      5.0.1
// @description  适配最新Chrome,支持多线程并发数调节、深色模式、主播放列表选择、内存优化,进度/速度/剩余时间更准确
// @author       Optimized
// @match        *://*/*
// @exclude      *://*.google.com/*
// @exclude      *://*.baidu.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-end
// @connect      *
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js
// ==/UserScript==

(function() {
    'use strict';

    // ========== 环境与依赖检查 ==========
    if (typeof document === 'undefined' || typeof window === 'undefined') {
        console.error('【M3U8下载器】该脚本仅支持在浏览器的用户脚本管理器中运行!');
        return;
    }

    const waitForParser = () => {
        return new Promise((resolve, reject) => {
            if (typeof m3u8Parser !== 'undefined') {
                resolve(m3u8Parser);
            } else {
                let attempts = 0;
                const checkInterval = setInterval(() => {
                    attempts++;
                    if (typeof m3u8Parser !== 'undefined') {
                        clearInterval(checkInterval);
                        resolve(m3u8Parser);
                    } else if (attempts > 50) {
                        clearInterval(checkInterval);
                        reject(new Error('m3u8-parser 库加载失败,请检查网络连接或脚本源。'));
                    }
                }, 100);
            }
        });
    };

    // ==================== 默认配置中心 ====================
    const DEFAULT_CONFIG = {
        maxConcurrent: 6,
        maxConcurrentMin: 1,
        maxConcurrentMax: 16,
        timeout: 60000,
        retryCount: 5,
        fileNamePrefix: '',
        saveSubdirectory: 'M3U8下载',
        useGMdownload: true,
        speedWindow: 2000,
        uiUpdateInterval: 100,
        theme: 'auto', // 'light' | 'dark' | 'auto'
    };

    const TITLE_RULES = {
        default: [/^(.+?)\s*[-_|]\s*.*$/],
        'youtube.com': [/^(.+?)\s*-\s*YouTube$/],
        'bilibili.com': [/^(.+?)\s*[-_|]\s*哔哩哔哩.*$/],
    };

    // ==================== 工具函数库 ====================
    const Utils = {
        getConfig() {
            try {
                const saved = GM_getValue('m3u8_downloader_settings', {});
                saved.maxConcurrent = Math.max(DEFAULT_CONFIG.maxConcurrentMin,
                                               Math.min(DEFAULT_CONFIG.maxConcurrentMax,
                                               saved.maxConcurrent || DEFAULT_CONFIG.maxConcurrent));
                return { ...DEFAULT_CONFIG, ...saved };
            } catch (e) {
                return DEFAULT_CONFIG;
            }
        },
        saveConfig(newConfig) {
            const current = this.getConfig();
            const merged = { ...current, ...newConfig };
            merged.maxConcurrent = Math.max(DEFAULT_CONFIG.maxConcurrentMin,
                                            Math.min(DEFAULT_CONFIG.maxConcurrentMax,
                                            merged.maxConcurrent));
            GM_setValue('m3u8_downloader_settings', merged);
            return merged;
        },
        extractTitle() {
            let title = document.title;
            const hostname = window.location.hostname;
            const config = this.getConfig();
            let rules = TITLE_RULES.default;
            for (const [domain, domainRules] of Object.entries(TITLE_RULES)) {
                if (hostname.includes(domain)) { rules = domainRules; break; }
            }
            for (const rule of rules) {
                const match = title.match(rule);
                if (match && match[1]) { title = match[1].trim(); break; }
            }
            title = title.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ');
            if (title.length < 2) title = `video_${new Date().getTime()}`;
            return (config.fileNamePrefix || '') + title;
        },
        formatSize(bytes) {
            if (!bytes || bytes === 0) return '0 B';
            const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        },
        formatTime(seconds) {
            if (!seconds || seconds <= 0) return '未知时长';
            seconds = Math.max(1, Math.ceil(seconds));
            if (seconds < 60) return `${seconds}秒`;
            if (seconds < 3600) {
                const mins = Math.floor(seconds / 60);
                const secs = seconds % 60;
                return `${mins}分${secs}秒`;
            }
            const hours = Math.floor(seconds / 3600);
            const mins = Math.floor((seconds % 3600) / 60);
            const secs = seconds % 60;
            return `${hours}小时${mins}分${secs}秒`;
        },
        generateId() { return Math.random().toString(36).substr(2, 9); },
        getBaseUrl(url) { const parts = url.split('/'); parts.pop(); return parts.join('/') + '/'; },
        resolveUrl(uri, baseUrl) {
            if (!uri) return '';
            if (uri.startsWith('http://') || uri.startsWith('https://')) return uri;
            try { return new URL(uri, baseUrl).href; } catch (e) {
                try {
                    const base = new URL(baseUrl);
                    return uri.startsWith('/') ? base.origin + uri : base.origin + base.pathname.split('/').slice(0, -1).join('/') + '/' + uri;
                } catch (e2) {
                    return uri;
                }
            }
        },
        async aesDecrypt(data, key, iv, segmentIndex = 0) {
            try {
                let cryptoKey = key;
                if (key instanceof Uint8Array) {
                    cryptoKey = await crypto.subtle.importKey(
                        'raw',
                        key,
                        { name: 'AES-CBC' },
                        false,
                        ['decrypt']
                    );
                }
                const actualIv = this.padIV(iv, segmentIndex);
                return await crypto.subtle.decrypt(
                    { name: 'AES-CBC', iv: actualIv },
                    cryptoKey,
                    data
                );
            } catch (e) {
                console.error('解密失败详情:', e);
                throw new Error(`视频解密失败 (可能是不支持的加密方式): ${e.message}`);
            }
        },
        padIV(iv, segmentIndex) {
            const buffer = new ArrayBuffer(16);
            const view = new DataView(buffer);
            if (iv && iv.length > 0) {
                const src = new Uint8Array(iv.buffer || iv);
                const dst = new Uint8Array(buffer);
                dst.set(src.slice(0, 16));
            } else {
                view.setUint32(12, segmentIndex, false);
            }
            return new Uint8Array(buffer);
        },
        hexToBytes(hex) {
            if (!hex) return null;
            hex = hex.replace(/^0x/, '');
            if (hex.length % 2) hex = '0' + hex;
            const bytes = new Uint8Array(hex.length / 2);
            for (let i = 0; i < bytes.length; i++) {
                bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
            }
            return bytes;
        },
        debounce(func, wait, immediate = false) {
            let timeout;
            return function(...args) {
                const context = this;
                const later = () => {
                    timeout = null;
                    if (!immediate) func.apply(context, args);
                };
                const callNow = immediate && !timeout;
                clearTimeout(timeout);
                timeout = setTimeout(later, wait);
                if (callNow) func.apply(context, args);
            };
        }
    };

    // ==================== 增强型网络请求层 ====================
    class NetworkManager {
        constructor() {
            this.requestQueue = [];
            this.activeRequests = new Map();
            this.keyCache = new Map();
            this.refreshConfig();
        }

        refreshConfig() {
            const config = Utils.getConfig();
            this.maxConcurrent = config.maxConcurrent;
            this.timeout = config.timeout;
            this.retryCount = config.retryCount;
        }

        async request(options, context = {}) {
            this.refreshConfig();
            return new Promise((resolve, reject) => {
                const requestId = Utils.generateId();
                const request = {
                    id: requestId,
                    ...options,
                    resolve,
                    reject,
                    retries: 0,
                    context
                };

                if (context.isCancelled) {
                    reject(new Error('Request cancelled'));
                    return;
                }

                this.requestQueue.push(request);
                this.processQueue();
            });
        }

        processQueue() {
            this.refreshConfig();
            while (this.activeRequests.size < this.maxConcurrent && this.requestQueue.length > 0) {
                const request = this.requestQueue.shift();
                if (request.context.isCancelled) {
                    request.reject(new Error('Download cancelled'));
                    continue;
                }
                this.executeRequest(request);
            }
        }

        executeRequest(request) {
            const xhrOptions = {
                method: request.method || 'GET',
                url: request.url,
                headers: {
                    'Referer': window.location.href,
                    'Origin': window.location.origin,
                    'User-Agent': navigator.userAgent,
                    ...request.headers
                },
                timeout: request.timeout || this.timeout,
                responseType: request.responseType || 'text',
                onload: (response) => {
                    this.activeRequests.delete(request.id);
                    if (request.context.isCancelled) {
                        request.reject(new Error('Download cancelled'));
                        this.processQueue();
                        return;
                    }
                    if (response.status >= 200 && response.status < 300) {
                        request.resolve(response);
                    } else {
                        this.handleError(request, new Error(`HTTP ${response.status} (${response.statusText})`));
                    }
                    this.processQueue();
                },
                onerror: (error) => {
                    this.activeRequests.delete(request.id);
                    if (request.context.isCancelled) request.reject(new Error('Download cancelled'));
                    else this.handleError(request, error || new Error('Network Error'));
                    this.processQueue();
                },
                ontimeout: () => {
                    this.activeRequests.delete(request.id);
                    if (request.context.isCancelled) request.reject(new Error('Download cancelled'));
                    else this.handleError(request, new Error('Request timeout'));
                    this.processQueue();
                }
            };

            try {
                GM_xmlhttpRequest(xhrOptions);
                this.activeRequests.set(request.id, { request });
            } catch (e) {
                this.handleError(request, new Error(`XHR Init Failed: ${e.message}`));
            }
        }

        handleError(request, error) {
            request.retries++;
            if (request.retries < this.retryCount && !request.context.isCancelled) {
                const delay = Math.min(1000 * Math.pow(2, request.retries - 1), 10000);
                console.warn(`[M3U8 Downloader] 请求失败 (${request.url}),  ${request.retries} 次重试 (${delay}ms)...`);
                setTimeout(() => {
                    if (!request.context.isCancelled) {
                        this.requestQueue.unshift(request);
                        this.processQueue();
                    } else {
                        request.reject(new Error('Download cancelled'));
                    }
                }, delay);
            } else {
                request.reject(error);
            }
        }

        async getDecryptKey(keyUrl, context) {
            if (this.keyCache.has(keyUrl)) return this.keyCache.get(keyUrl);
            try {
                const response = await this.request({ url: keyUrl, responseType: 'arraybuffer' }, context);
                const key = new Uint8Array(response.response);
                this.keyCache.set(keyUrl, key);
                return key;
            } catch (e) {
                console.error('获取解密 Key 失败:', e);
                throw new Error(`无法获取解密 Key: ${e.message}`);
            }
        }
    }

    // ==================== M3U8解析器 ====================
    class M3U8Parser {
        constructor() {
            this.network = new NetworkManager();
        }

        async parse(url, context, parserLib) {
            try {
                const response = await this.network.request({ url, responseType: 'text' }, context);
                if (!response || !response.responseText) {
                    throw new Error('获取到的 M3U8 内容为空');
                }

                const parser = new parserLib.Parser();
                parser.push(response.responseText);
                parser.end();

                const manifest = parser.manifest;
                if (!manifest) {
                    throw new Error('m3u8-parser 无法解析该链接 (格式错误或非标准 M3U8)');
                }

                const baseUrl = Utils.getBaseUrl(url);

                if (manifest.playlists && manifest.playlists.length > 0) {
                    return {
                        success: true,
                        isMaster: true,
                        playlists: manifest.playlists.map(pl => ({
                            uri: Utils.resolveUrl(pl.uri, baseUrl),
                            attributes: pl.attributes || {}
                        })),
                        baseUrl
                    };
                }

                return { success: true, isMaster: false, manifest, baseUrl };
            } catch (error) {
                console.error('M3U8 解析错误:', error);
                return { success: false, error: error.message };
            }
        }

        async parseVariant(url, context, parserLib) {
            return this.parse(url, context, parserLib);
        }

        async getVideoInfo(url, context = {}, parserLib, variantUrl = null) {
            try {
                const parseResult = await this.parse(url, context, parserLib);
                if (!parseResult.success) {
                    return { success: false, error: parseResult.error };
                }

                let targetManifest = parseResult.manifest;
                if (parseResult.isMaster) {
                    // 返回主列表信息,不自动选择
                    return {
                        success: true,
                        isMaster: true,
                        playlists: parseResult.playlists,
                        baseUrl: parseResult.baseUrl
                    };
                }

                const manifest = targetManifest;
                const isLive = !manifest.endList;

                const totalDuration = manifest.segments ? manifest.segments.reduce((sum, seg) => sum + (seg.duration || 0), 0) : 0;

                let estimatedSize = 0;
                let bandwidth = 0;
                if (!isLive && parseResult.masterAttributes?.BANDWIDTH) {
                    bandwidth = parseResult.masterAttributes.BANDWIDTH;
                    estimatedSize = (bandwidth * totalDuration) / 8;
                }

                return {
                    success: true,
                    isMaster: false,
                    totalDuration,
                    estimatedSize,
                    bandwidth,
                    segmentCount: manifest.segments ? manifest.segments.length : 0,
                    isLive
                };
            } catch (error) {
                return { success: false, error: error.message };
            }
        }

        prepareSegments(manifest, baseUrl) {
            if (!manifest?.segments) return [];
            let currentKey = null;
            return manifest.segments.map((segment, index) => {
                if (segment.key) {
                    currentKey = {
                        uri: segment.key.uri ? Utils.resolveUrl(segment.key.uri, baseUrl) : null,
                        iv: segment.key.iv ? (typeof segment.key.iv === 'string' ? Utils.hexToBytes(segment.key.iv) : new Uint8Array(segment.key.iv.buffer)) : null,
                        method: segment.key.method || 'AES-128'
                    };
                }
                return {
                    index,
                    url: Utils.resolveUrl(segment.uri, baseUrl),
                    duration: segment.duration || 0,
                    key: currentKey ? { ...currentKey } : null,
                    downloaded: false,
                    data: null
                };
            });
        }
    }

    // ==================== 下载管理器 ====================
    class DownloadManager {
        constructor() {
            this.network = new NetworkManager();
            this.parser = new M3U8Parser();
            this.downloads = new Map();
            this.ui = null;
            this.parserLib = null;
            this.config = Utils.getConfig();
            this.updateTimers = new Map();
        }

        setParserLib(lib) { this.parserLib = lib; }
        setUI(ui) { this.ui = ui; }

        updateConcurrent(count) {
            Utils.saveConfig({ maxConcurrent: count });
            this.config = Utils.getConfig();
            this.ui?.updateConcurrentDisplay(count);
            this.network.refreshConfig();
            this.network.processQueue();
            this.ui?.showToast(`并发数已设置为 ${count},新任务立即生效,正在下载的任务请暂停后恢复`, 3000);
        }

        async addDownload(url, customTitle) {
            if (!this.parserLib) {
                this.ui?.showToast('解析库未就绪,请稍后重试', 3000);
                return;
            }

            const id = Utils.generateId();
            const context = {
                isPaused: false,
                isCancelled: false,
                pausePromise: null,
                pauseResolve: null
            };
            const speedStats = {
                queue: [],
                maxLength: Math.ceil(this.config.speedWindow / 100)
            };
            const download = {
                id, url, title: customTitle || Utils.extractTitle(),
                status: 'preparing', progress: 0, speed: 0, size: 0,
                estimatedTotalSize: 0,
                remainingTime: 0,
                downloadedCount: 0, totalSegments: 0, startTime: performance.now(),
                segmentSizes: [],
                error: null, context: context,
                speedStats: speedStats,
                lastProgress: 0,
                variantUri: null
            };
            this.downloads.set(id, download);
            this.ui.updateDownloads();
            this.startDownloadTask(id, download, context);
        }

        async startDownloadTask(id, download, context) {
            try {
                this.ui?.showToast('正在解析视频地址...');
                const parseResult = await this.parser.parse(download.url, context, this.parserLib);
                if (context.isCancelled) throw new Error('下载已取消');

                if (!parseResult.success) {
                    download.status = 'error';
                    download.error = parseResult.error;
                    this.ui.updateDownloads();
                    return;
                }

                if (parseResult.isMaster) {
                    // 主播放列表:弹出选择框
                    const selectedUri = await this.ui.showVariantSelector(parseResult.playlists);
                    if (!selectedUri) {
                        download.status = 'cancelled';
                        download.error = '未选择清晰度';
                        this.ui.updateDownloads();
                        return;
                    }
                    download.variantUri = selectedUri;
                    // 重新解析选中的子列表
                    const subResult = await this.parser.parseVariant(selectedUri, context, this.parserLib);
                    if (!subResult.success) {
                        download.status = 'error';
                        download.error = subResult.error;
                        this.ui.updateDownloads();
                        return;
                    }
                    download.url = selectedUri; // 更新实际地址
                    const videoSegments = this.parser.prepareSegments(subResult.manifest, subResult.baseUrl);
                    if (videoSegments.length === 0) {
                        download.status = 'error';
                        download.error = '未检测到有效视频分片';
                        this.ui.updateDownloads();
                        return;
                    }
                    download.videoSegments = videoSegments;
                    download.totalSegments = videoSegments.length;
                    // 估算大小
                    const dur = videoSegments.reduce((sum, s) => sum + (s.duration || 0), 0);
                    download.estimatedTotalSize = dur * 1024 * 1024; // 粗略估计
                } else {
                    const videoSegments = this.parser.prepareSegments(parseResult.manifest, parseResult.baseUrl);
                    if (videoSegments.length === 0) {
                        download.status = 'error';
                        download.error = '未检测到有效视频分片';
                        this.ui.updateDownloads();
                        return;
                    }
                    download.videoSegments = videoSegments;
                    download.totalSegments = videoSegments.length;
                    if (parseResult.masterAttributes?.BANDWIDTH) {
                        const totalDur = videoSegments.reduce((sum, s) => sum + (s.duration || 0), 0);
                        download.estimatedTotalSize = (parseResult.masterAttributes.BANDWIDTH * totalDur) / 8;
                    } else {
                        download.estimatedTotalSize = videoSegments.length * 1024 * 1024;
                    }
                }

                download.status = 'downloading';
                this.ui.updateDownloads();

                this.startUIAutoUpdate(id, download);

                await this.downloadAndDecryptSegments(id, download, context);
                if (context.isCancelled) throw new Error('下载已取消');

                this.stopUIAutoUpdate(id);

                download.status = 'merging';
                this.ui.updateDownloads();
                await this.mergeSegments(download);

                download.status = 'completed';
                download.progress = 100;
                download.remainingTime = 0;
                download.speed = 0;
                this.ui.updateDownloads();
                this.ui.showToast(`【${download.title}】下载完成!`, 3000);
            } catch (error) {
                this.stopUIAutoUpdate(id);
                if (context.isCancelled) {
                    download.status = 'cancelled';
                    download.error = '下载已取消';
                } else {
                    download.status = 'error';
                    download.error = error.message;
                }
                this.ui.updateDownloads();
            } finally {
                if (download.videoSegments) {
                    download.videoSegments.forEach(seg => seg.data = null);
                }
            }
        }

        startUIAutoUpdate(id, download) {
            if (this.updateTimers.has(id)) return;
            const timer = setInterval(() => {
                if (download.context.isCancelled || download.context.isPaused || download.status !== 'downloading') {
                    this.stopUIAutoUpdate(id);
                    return;
                }
                this.calculateProgressAndETA(download);
                this.ui.updateDownloads();
            }, this.config.uiUpdateInterval);
            this.updateTimers.set(id, timer);
        }

        stopUIAutoUpdate(id) {
            const timer = this.updateTimers.get(id);
            if (timer) {
                clearInterval(timer);
                this.updateTimers.delete(id);
            }
        }

        pauseDownload(id) {
            const download = this.downloads.get(id);
            if (download && download.context && !download.context.isCancelled) {
                download.context.isPaused = true;
                download.status = 'paused';
                this.stopUIAutoUpdate(id);
                this.ui.updateDownloads();
            }
        }

        resumeDownload(id) {
            const download = this.downloads.get(id);
            if (download && download.context && download.context.isPaused) {
                download.context.isPaused = false;
                download.context.pausePromise = null;
                download.context.pauseResolve = null;
                download.status = 'downloading';
                download.startTime = performance.now();
                this.network.refreshConfig();
                this.startUIAutoUpdate(id);
                this.ui.updateDownloads();
                this.network.processQueue();
            }
        }

        cancelDownload(id) {
            const download = this.downloads.get(id);
            if (download && download.context) {
                download.context.isCancelled = true;
                if (download.context.pauseResolve) {
                    download.context.pauseResolve();
                    download.context.pauseResolve = null;
                }
                download.status = 'cancelled';
                this.stopUIAutoUpdate(id);
                this.ui.updateDownloads();
            }
        }

        deleteDownload(id) {
            const download = this.downloads.get(id);
            if (download) {
                this.stopUIAutoUpdate(id);
                if (download.context) {
                    download.context.isCancelled = true;
                    if (download.context.pauseResolve) download.context.pauseResolve();
                }
                if (download.videoSegments) {
                    download.videoSegments.forEach(seg => seg.data = null);
                }
                this.downloads.delete(id);
                this.ui.updateDownloads();
            }
        }

        async downloadAndDecryptSegments(id, download, context) {
            const segments = download.videoSegments;
            const totalSegments = segments.length;
            let nextIndex = 0;
            let error = null;

            download.speedStats.queue = [{ time: performance.now(), bytes: 0 }];

            const worker = async () => {
                while (!context.isCancelled && !error && nextIndex < totalSegments) {
                    if (context.isPaused) {
                        await new Promise(resolve => {
                            context.pauseResolve = resolve;
                        });
                        if (context.isCancelled) break;
                    }

                    const currentIndex = nextIndex++;
                    if (currentIndex >= totalSegments) break;
                    const segment = segments[currentIndex];

                    if (segment.downloaded) continue;

                    try {
                        const response = await this.network.request({
                            url: segment.url,
                            responseType: 'arraybuffer'
                        }, context);

                        let segmentData = new Uint8Array(response.response);

                        if (segment.key && segment.key.method !== 'NONE' && segment.key.uri) {
                            if (segment.key.method !== 'AES-128') {
                                console.warn(`分片 ${currentIndex} 使用了非 AES-128 加密 (${segment.key.method}),无法解密,将保存原始数据`);
                            } else {
                                const key = await this.network.getDecryptKey(segment.key.uri, context);
                                segmentData = new Uint8Array(
                                    await Utils.aesDecrypt(segmentData, key, segment.key.iv, segment.index)
                                );
                            }
                        }

                        segment.data = segmentData;
                        segment.downloaded = true;

                        const segSize = segmentData.length;
                        download.segmentSizes.push(segSize);
                        download.size += segSize;
                        download.downloadedCount++;

                        const now = performance.now();
                        download.speedStats.queue.push({ time: now, bytes: download.size });
                        const cutoffTime = now - this.config.speedWindow;
                        download.speedStats.queue = download.speedStats.queue.filter(item => item.time >= cutoffTime);

                        if (download.downloadedCount % 10 === 0 && download.segmentSizes.length > 0) {
                            const avgSegSize = download.segmentSizes.reduce((a, b) => a + b, 0) / download.segmentSizes.length;
                            download.estimatedTotalSize = avgSegSize * totalSegments;
                        }
                    } catch (e) {
                        if (!context.isCancelled) {
                            console.error(`分片 ${currentIndex} 下载失败:`, e);
                            error = e;
                        }
                    }
                }
            };

            const config = Utils.getConfig();
            const workers = [];
            for (let i = 0; i < config.maxConcurrent; i++) {
                workers.push(worker());
            }

            await Promise.all(workers);
            if (error) throw error;
            if (context.isCancelled) throw new Error('已取消');
        }

        calculateProgressAndETA(download) {
            const now = performance.now();
            const { speedStats, estimatedTotalSize, size, totalSegments, downloadedCount } = download;

            if (speedStats.queue.length >= 2) {
                const first = speedStats.queue[0];
                const last = speedStats.queue[speedStats.queue.length - 1];
                const timeDiff = (last.time - first.time) / 1000;
                const bytesDiff = last.bytes - first.bytes;
                download.speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
            } else {
                download.speed = 0;
            }

            let finalTotalSize = estimatedTotalSize;
            if (download.segmentSizes.length > 0) {
                const avgSegSize = download.segmentSizes.reduce((a, b) => a + b, 0) / download.segmentSizes.length;
                finalTotalSize = avgSegSize * totalSegments;
            }
            download.estimatedTotalSize = finalTotalSize;

            let progress = 0;
            if (finalTotalSize > 0 && size > 0) {
                progress = Math.min((size / finalTotalSize) * 100, 99.9);
            } else {
                progress = (downloadedCount / totalSegments) * 100;
            }
            download.progress = Math.max(progress, download.lastProgress);
            download.lastProgress = download.progress;

            if (download.speed > 0 && finalTotalSize > 0) {
                const remainingBytes = Math.max(0, finalTotalSize - size);
                download.remainingTime = remainingBytes / download.speed;
            } else {
                const elapsed = (now - download.startTime) / 1000;
                if (downloadedCount > 0 && elapsed > 0) {
                    const avgTimePerSeg = elapsed / downloadedCount;
                    download.remainingTime = avgTimePerSeg * (totalSegments - downloadedCount);
                } else {
                    download.remainingTime = 0;
                }
            }
        }

        async mergeSegments(download) {
            const { videoSegments, title } = download;
            const sorted = videoSegments.sort((a, b) => a.index - b.index);

            // 使用 Blob 分段合并,避免一次性创建超大的 Uint8Array
            const chunkSize = 50; // 一次合并多少个分片
            const blobs = [];
            for (let i = 0; i < sorted.length; i += chunkSize) {
                const chunk = sorted.slice(i, i + chunkSize);
                let totalLen = 0;
                for (const s of chunk) if (s.data) totalLen += s.data.length;
                if (totalLen === 0) continue;
                const merged = new Uint8Array(totalLen);
                let offset = 0;
                for (const s of chunk) {
                    if (s.data) {
                        merged.set(s.data, offset);
                        offset += s.data.length;
                        s.data = null; // 释放内存
                    }
                }
                blobs.push(new Blob([merged], { type: 'video/MP2T' }));
            }
            const finalBlob = new Blob(blobs, { type: 'video/MP2T' });
            const fileName = `${title}.ts`;

            // 允许用户自定义文件名
            const customName = await this.ui.promptFileName(fileName);
            this.triggerBrowserDownload(finalBlob, customName || fileName);
        }

        triggerBrowserDownload(blob, fileName) {
            const config = Utils.getConfig();
            if (config.useGMdownload && typeof GM_download !== 'undefined') {
                try {
                    const blobUrl = URL.createObjectURL(blob);
                    GM_download({
                        url: blobUrl,
                        name: fileName,
                        saveAs: false,
                        onload: () => URL.revokeObjectURL(blobUrl),
                        onerror: (e) => {
                            console.warn('GM_download 失败,尝试普通下载:', e);
                            URL.revokeObjectURL(blobUrl);
                            this.nativeDownload(blob, fileName);
                        }
                    });
                } catch (e) {
                    console.warn('GM_download 初始化失败:', e);
                    this.nativeDownload(blob, fileName);
                }
            } else {
                this.nativeDownload(blob, fileName);
            }
        }

        nativeDownload(blob, fileName) {
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = fileName;
            a.style.display = 'none';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            setTimeout(() => URL.revokeObjectURL(url), 1000);
        }

        getDownloads() { return Array.from(this.downloads.values()); }
    }

    // ==================== UI层 ====================
    class UIManager {
        constructor() {
            this.downloadManager = null;
            this.container = null;
            this.panel = null;
            this.floatingButton = null;
            this.badge = null;
            this.isVisible = false;
            this.detectedVideos = new Map();
            this.concurrentDisplay = null;
            this.concurrentSlider = null;
            this.themeBtn = null;
            this.init();
        }

        setDownloadManager(manager) {
            this.downloadManager = manager;
            const currentConcurrent = Utils.getConfig().maxConcurrent;
            this.updateConcurrentDisplay(currentConcurrent);
        }

        init() {
            this.applyTheme();
            this.createContainer();
            this.createFloatingButton();
            this.createPanel();
            this.loadPosition();
            this.hideAll();
        }

        applyTheme() {
            const config = Utils.getConfig();
            const theme = config.theme;
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            const isDark = theme === 'dark' || (theme === 'auto' && prefersDark);
            document.documentElement.style.setProperty('--m3u8-bg', isDark ? '#1e1e1e' : '#ffffff');
            document.documentElement.style.setProperty('--m3u8-text', isDark ? '#e0e0e0' : '#333333');
            document.documentElement.style.setProperty('--m3u8-border', isDark ? '#333' : '#eee');
            document.documentElement.style.setProperty('--m3u8-card-bg', isDark ? '#2d2d2d' : '#f8f9fa');
        }

        createContainer() {
            this.container = document.createElement('div');
            this.container.id = 'm3u8-downloader-pro-container';
            Object.assign(this.container.style, {
                all: 'initial',
                position: 'fixed',
                zIndex: '2147483647',
                top: '20px',
                right: '20px',
                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
            });
            document.documentElement.appendChild(this.container);
        }

        createFloatingButton() {
            this.floatingButton = document.createElement('div');
            Object.assign(this.floatingButton.style, {
                all: 'initial',
                position: 'fixed',
                zIndex: '2147483647',
                top: '20px',
                right: '20px',
                width: '50px',
                height: '50px',
                background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
                borderRadius: '50%',
                boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
                cursor: 'pointer',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                transition: 'transform 0.2s'
            });

            this.floatingButton.innerHTML = `
                <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect>
                    <polygon points="10 8 16 12 10 16 10 8"></polygon>
                </svg>
            `;

            this.badge = document.createElement('div');
            Object.assign(this.badge.style, {
                position: 'absolute', top: '-2px', right: '-2px',
                background: '#ff4757', color: 'white',
                fontSize: '12px', fontWeight: 'bold',
                minWidth: '20px', height: '20px',
                borderRadius: '10px',
                display: 'none', alignItems: 'center', justifyContent: 'center',
                padding: '0 4px', border: '2px solid white', boxSizing: 'border-box'
            });
            this.floatingButton.appendChild(this.badge);

            this.floatingButton.addEventListener('click', () => this.showPanel());

            let isDragging = false, startX, startY, initX, initY;
            this.floatingButton.addEventListener('mousedown', (e) => {
                isDragging = true; startX = e.clientX; startY = e.clientY;
                const rect = this.floatingButton.getBoundingClientRect();
                initX = rect.left; initY = rect.top;
                e.preventDefault(); e.stopPropagation();
            });
            const moveHandler = (e) => {
                if (!isDragging) return;
                this.floatingButton.style.left = `${initX + e.clientX - startX}px`;
                this.floatingButton.style.top = `${initY + e.clientY - startY}px`;
                this.floatingButton.style.right = 'auto';
                this.floatingButton.style.bottom = 'auto';
            };
            const upHandler = () => { isDragging = false; };
            document.addEventListener('mousemove', moveHandler);
            document.addEventListener('mouseup', upHandler);

            document.documentElement.appendChild(this.floatingButton);
        }

        createPanel() {
            this.panel = document.createElement('div');
            Object.assign(this.panel.style, {
                background: 'var(--m3u8-bg, #ffffff)',
                color: 'var(--m3u8-text, #333)',
                backdropFilter: 'blur(10px)',
                borderRadius: '12px',
                boxShadow: '0 8px 32px rgba(0,0,0,0.15)',
                width: '380px',
                maxHeight: '650px',
                overflow: 'hidden',
                border: '1px solid var(--m3u8-border, #eee)'
            });

            this.panel.innerHTML = `
                <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 16px; color: white; cursor: move; user-select: none;">
                    <div style="display: flex; justify-content: space-between; align-items: center;">
                        <div style="display: flex; align-items: center; gap: 10px;">
                            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:none;"><rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect><polygon points="10 8 16 12 10 16 10 8"></polygon></svg>
                            <span style="font-weight: 600; font-size: 16px;">M3U8 下载器 v4.3</span>
                        </div>
                        <div style="display: flex; gap: 4px;">
                            <button id="m3u8-theme-btn" title="切换深色/浅色模式" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;">
                                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
                            </button>
                            <button id="m3u8-collapse-btn" title="最小化" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;">
                                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"></path></svg>
                            </button>
                            <button id="m3u8-close-btn" title="隐藏" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;">
                                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
                            </button>
                        </div>
                    </div>
                </div>
                <div id="m3u8-concurrent-control" style="padding: 12px 16px; border-bottom: 1px solid var(--m3u8-border); background: var(--m3u8-card-bg);">
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
                        <span style="font-size: 13px; font-weight: 500;">下载并发数</span>
                        <span id="m3u8-concurrent-value" style="font-size: 14px; color: #667eea; font-weight: 600;">6</span>
                    </div>
                    <input type="range" id="m3u8-concurrent-slider" min="1" max="16" value="6" step="1" style="width: 100%; height: 6px; background: #e9ecef; border-radius: 3px; outline: none; -webkit-appearance: none;">
                    <div style="display: flex; justify-content: space-between; font-size: 11px; color: #888; margin-top: 4px;">
                        <span>1 (稳定)</span>
                        <span>16 (高速)</span>
                    </div>
                </div>
                <div style="padding: 8px 16px; border-bottom: 1px solid var(--m3u8-border); display: flex; gap: 8px;">
                    <button id="m3u8-pause-all" style="background: #ff9800; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">全部暂停</button>
                    <button id="m3u8-resume-all" style="background: #28a745; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">全部恢复</button>
                    <button id="m3u8-cancel-all" style="background: #dc3545; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">取消所有</button>
                    <button id="m3u8-clear-finished" style="background: #6c757d; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">清除已完成</button>
                </div>
                <div id="m3u8-content" style="max-height: 400px; overflow-y: auto;">
                    <div id="m3u8-videos-section" style="padding: 16px; border-bottom: 1px solid var(--m3u8-border);">
                        <div style="font-weight: 600; margin-bottom: 12px;">检测到的视频 (<span id="m3u8-video-count">0</span>)</div>
                        <div id="m3u8-videos-list" style="display: flex; flex-direction: column; gap: 8px;"></div>
                    </div>
                    <div id="m3u8-downloads-section" style="padding: 16px;">
                        <div style="font-weight: 600; margin-bottom: 12px;">下载任务 (<span id="m3u8-download-count">0</span>)</div>
                        <div id="m3u8-downloads-list" style="display: flex; flex-direction: column; gap: 12px;"></div>
                    </div>
                </div>
            `;

            // 按钮事件
            this.panel.querySelector('#m3u8-close-btn').addEventListener('click', () => this.hideAll());
            this.panel.querySelector('#m3u8-collapse-btn').addEventListener('click', () => this.hidePanel());
            this.themeBtn = this.panel.querySelector('#m3u8-theme-btn');
            this.themeBtn.addEventListener('click', () => this.toggleTheme());

            this.concurrentDisplay = this.panel.querySelector('#m3u8-concurrent-value');
            this.concurrentSlider = this.panel.querySelector('#m3u8-concurrent-slider');
            const currentConcurrent = Utils.getConfig().maxConcurrent;
            this.concurrentSlider.value = currentConcurrent;
            this.concurrentDisplay.textContent = currentConcurrent;

            this.concurrentSlider.addEventListener('input', (e) => {
                this.concurrentDisplay.textContent = e.target.value;
            });
            this.concurrentSlider.addEventListener('change', (e) => {
                this.downloadManager?.updateConcurrent(parseInt(e.target.value));
            });

            // 批量操作
            this.panel.querySelector('#m3u8-pause-all').addEventListener('click', () => {
                this.downloadManager?.downloads.forEach((dl, id) => {
                    if (dl.status === 'downloading') this.downloadManager.pauseDownload(id);
                });
                this.showToast('已暂停所有下载');
            });
            this.panel.querySelector('#m3u8-resume-all').addEventListener('click', () => {
                this.downloadManager?.downloads.forEach((dl, id) => {
                    if (dl.status === 'paused') this.downloadManager.resumeDownload(id);
                });
                this.showToast('已恢复所有下载');
            });
            this.panel.querySelector('#m3u8-cancel-all').addEventListener('click', () => {
                this.downloadManager?.downloads.forEach((dl, id) => {
                    if (!['completed', 'cancelled', 'error'].includes(dl.status))
                        this.downloadManager.cancelDownload(id);
                });
                this.showToast('已取消所有进行中的下载');
            });
            this.panel.querySelector('#m3u8-clear-finished').addEventListener('click', () => {
                const toDelete = [];
                this.downloadManager?.downloads.forEach((dl, id) => {
                    if (['completed', 'cancelled', 'error'].includes(dl.status)) toDelete.push(id);
                });
                toDelete.forEach(id => this.downloadManager.deleteDownload(id));
                this.showToast(`已清除 ${toDelete.length} 个任务`);
            });

            // 滑块样式
            const style = document.createElement('style');
            style.textContent = `
                #m3u8-concurrent-slider::-webkit-slider-thumb {
                    -webkit-appearance: none;
                    width: 18px;
                    height: 18px;
                    border-radius: 50%;
                    background: #667eea;
                    cursor: pointer;
                    box-shadow: 0 2px 4px rgba(102,126,234,0.3);
                }
            `;
            this.panel.appendChild(style);

            // 拖拽
            const header = this.panel.querySelector('div[style*="background: linear-gradient"]');
            let isDragging = false, startX, startY, initX, initY;
            header.addEventListener('mousedown', (e) => {
                isDragging = true; startX = e.clientX; startY = e.clientY;
                const rect = this.container.getBoundingClientRect();
                initX = rect.left; initY = rect.top;
                e.preventDefault();
            });
            document.addEventListener('mousemove', (e) => {
                if (!isDragging) return;
                this.container.style.left = `${initX + e.clientX - startX}px`;
                this.container.style.top = `${initY + e.clientY - startY}px`;
                this.container.style.right = 'auto';
            });
            document.addEventListener('mouseup', () => isDragging = false);

            this.container.appendChild(this.panel);
        }

        toggleTheme() {
            const config = Utils.getConfig();
            const newTheme = config.theme === 'dark' ? 'light' : 'dark';
            Utils.saveConfig({ theme: newTheme });
            this.applyTheme();
            this.updatePanelColors();
        }

        updatePanelColors() {
            const panel = this.panel;
            if (panel) {
                panel.style.background = 'var(--m3u8-bg)';
                panel.style.color = 'var(--m3u8-text)';
                panel.style.border = '1px solid var(--m3u8-border)';
                // 可进一步调整内部元素颜色,此处省略细节
            }
        }

        updateConcurrentDisplay(count) {
            if (this.concurrentDisplay) this.concurrentDisplay.textContent = count;
            if (this.concurrentSlider) this.concurrentSlider.value = count;
        }

        loadPosition() {
            try {
                const pos = GM_getValue('m3u8-pos');
                if (pos) { this.container.style.left = pos.left; this.container.style.top = pos.top; this.container.style.right = 'auto'; }
            } catch(e) {}
        }

        hidePanel() {
            this.container.style.display = 'none';
            this.isVisible = false;
            if (this.detectedVideos.size > 0) {
                this.floatingButton.style.display = 'flex';
            }
        }

        showPanel() {
            this.container.style.display = 'block';
            this.floatingButton.style.display = 'none';
            this.isVisible = true;
        }

        hideAll() {
            this.container.style.display = 'none';
            this.floatingButton.style.display = 'none';
            this.isVisible = false;
        }

        updateBadge() {
            const count = this.detectedVideos.size;
            if (this.badge) {
                if (count > 0) {
                    this.badge.style.display = 'flex';
                    this.badge.textContent = count > 99 ? '99+' : count;
                } else {
                    this.badge.style.display = 'none';
                }
            }
            const panelCount = this.panel.querySelector('#m3u8-video-count');
            if (panelCount) panelCount.textContent = count;
            if (count === 0) {
                // 不强制隐藏浮动按钮,让用户自己决定
            } else {
                if (!this.isVisible) {
                    this.floatingButton.style.display = 'flex';
                }
            }
        }

        addDetectedVideo(url, type = 'm3u8', info = {}) {
            if (this.detectedVideos.has(url)) return;
            this.detectedVideos.set(url, {
                id: Utils.generateId(),
                url,
                type,
                status: 'parsing',
                ...info
            });
            this.updateVideosList();
            this.updateBadge();
        }

        updateDetectedVideoInfo(url, info) {
            if (!this.detectedVideos.has(url)) return;
            const video = this.detectedVideos.get(url);
            this.detectedVideos.set(url, { ...video, ...info });
            this.updateVideosList();
        }

        updateVideosList() {
            const list = this.panel.querySelector('#m3u8-videos-list');
            if (!list) return;
            list.innerHTML = '';

            this.detectedVideos.forEach((video, url) => {
                const item = document.createElement('div');
                Object.assign(item.style, {
                    background: 'var(--m3u8-card-bg)',
                    borderRadius: '8px',
                    padding: '12px',
                    display: 'flex',
                    justifyContent: 'space-between',
                    alignItems: 'center'
                });

                let statusTag = '';
                if (video.status === 'parsing') {
                    statusTag = `<span style="background: #ffc107; color: #333; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 4px;">解析中</span>`;
                } else if (video.status === 'parse_failed') {
                    statusTag = `<span style="background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 4px;">解析失败</span>`;
                }

                let metaText = '等待解析...';
                if (video.status === 'ready') {
                    const dur = video.totalDuration ? `时长: ${Utils.formatTime(video.totalDuration)}` : '';
                    const size = video.estimatedSize ? ` | 预估: ${Utils.formatSize(video.estimatedSize)}` : '';
                    metaText = video.isLive ? '直播流 (实时)' : (dur + size);
                } else if (video.status === 'parse_failed') {
                    metaText = '点击下载尝试强行解析';
                }

                item.innerHTML = `
                    <div style="flex: 1; min-width: 0; margin-right: 10px;">
                        <div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
                            <span style="background: #667eea; color: white; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;">${video.type.toUpperCase()}</span>
                            ${statusTag}
                        </div>
                        <div style="color: var(--m3u8-text); font-size: 13px; font-weight: 500; margin-top: 4px;">${metaText}</div>
                        <div style="color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px;" title="${url}">${url.substring(0, 50)}...</div>
                    </div>
                    <button class="m3u8-download-btn" data-url="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; flex-shrink: 0;">下载</button>
                `;
                list.appendChild(item);
            });

            list.querySelectorAll('.m3u8-download-btn').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const url = e.target.dataset.url;
                    this.downloadManager.addDownload(url);
                    this.showToast('已添加下载任务');
                });
            });
        }

        updateDownloads() {
            if (!this.downloadManager) return;
            const list = this.panel.querySelector('#m3u8-downloads-list');
            const count = this.panel.querySelector('#m3u8-download-count');
            if (!list || !count) return;

            const downloads = this.downloadManager.getDownloads();
            count.textContent = downloads.length;
            list.innerHTML = '';

            downloads.forEach(d => {
                const item = document.createElement('div');
                item.style.cssText = `background: var(--m3u8-card-bg); border-radius: 8px; padding: 12px; color: var(--m3u8-text);`;

                const statusMap = {
                    preparing: { color: '#ffc107', text: '准备中' },
                    downloading: { color: '#667eea', text: '下载中' },
                    paused: { color: '#ff9800', text: '已暂停' },
                    merging: { color: '#17a2b8', text: '合并中' },
                    completed: { color: '#28a745', text: '已完成' },
                    error: { color: '#dc3545', text: '失败' },
                    cancelled: { color: '#6c757d', text: '已取消' }
                };
                const status = statusMap[d.status] || statusMap.preparing;
                const isFinished = ['completed', 'error', 'cancelled'].includes(d.status);

                let infoLine = '';
                if (d.status === 'downloading') {
                    const totalSizeDisplay = d.estimatedTotalSize > 0 ? ` / 约${Utils.formatSize(d.estimatedTotalSize)}` : '';
                    const etaDisplay = d.remainingTime > 0 ? ` • 剩余${Utils.formatTime(d.remainingTime)}` : '';
                    infoLine = `<span style="color: #888; font-size: 12px;">${Utils.formatSize(d.size)}${totalSizeDisplay} • ${Utils.formatSize(d.speed)}/s${etaDisplay}</span>`;
                } else if (d.status === 'completed') {
                    infoLine = `<span style="color: #888; font-size: 12px;">总大小: ${Utils.formatSize(d.size)}</span>`;
                }

                item.innerHTML = `
                    <div style="display: flex; justify-content: space-between; align-items: start;">
                        <div style="flex: 1; min-width: 0;">
                            <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${d.title}">${d.title}</div>
                            <div style="margin-top: 4px;">${infoLine}</div>
                            ${d.error ? `<div style="color: #dc3545; font-size: 11px; margin-top: 4px; word-break: break-all;">错误: ${d.error}</div>` : ''}
                        </div>
                        <span style="background: ${status.color}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px; flex-shrink:0;">${status.text}</span>
                    </div>
                    ${!isFinished ? `
                        <div style="margin-top: 8px; background: #e9ecef; border-radius: 4px; height: 6px; overflow: hidden;">
                            <div style="width: ${d.progress}%; height: 100%; background: ${status.color}; transition: width 0.1s ease;"></div>
                        </div>
                    ` : ''}
                    <div style="display: flex; gap: 4px; margin-top: 8px; justify-content: flex-end;">
                        ${d.status === 'downloading' ? `
                            <button data-id="${d.id}" class="m3u8-pause-btn" style="background: #ff9800; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">暂停</button>
                        ` : d.status === 'paused' ? `
                            <button data-id="${d.id}" class="m3u8-resume-btn" style="background: #28a745; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">恢复</button>
                        ` : ''}
                        ${!isFinished ? `
                            <button data-id="${d.id}" class="m3u8-cancel-btn" style="background: #dc3545; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">取消</button>
                        ` : ''}
                        <button data-id="${d.id}" class="m3u8-delete-btn" style="background: #6c757d; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">删除</button>
                    </div>
                `;
                list.appendChild(item);
            });

            list.querySelectorAll('.m3u8-pause-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.pauseDownload(e.target.dataset.id));
            list.querySelectorAll('.m3u8-resume-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.resumeDownload(e.target.dataset.id));
            list.querySelectorAll('.m3u8-cancel-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.cancelDownload(e.target.dataset.id));
            list.querySelectorAll('.m3u8-delete-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.deleteDownload(e.target.dataset.id));
        }

        showVariantSelector(playlists) {
            return new Promise(resolve => {
                const modal = document.createElement('div');
                modal.style.cssText = 'position:fixed;z-index:2147483647;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
                const content = document.createElement('div');
                content.style.cssText = 'background:var(--m3u8-bg);padding:20px;border-radius:12px;max-width:400px;width:90%;color:var(--m3u8-text);';
                let html = '<h3 style="margin:0 0 12px">选择清晰度</h3>';
                playlists.forEach((pl, idx) => {
                    const res = pl.attributes.RESOLUTION || {};
                    const bw = pl.attributes.BANDWIDTH ? ` ${(pl.attributes.BANDWIDTH/1000).toFixed(0)}kbps` : '';
                    html += `<button data-index="${idx}" style="display:block;width:100%;padding:8px;margin-bottom:6px;background:var(--m3u8-card-bg);border:1px solid var(--m3u8-border);border-radius:6px;cursor:pointer;text-align:left;">${res.width||'?'}x${res.height||'?'}${bw}</button>`;
                });
                html += '<button id="m3u8-variant-cancel" style="margin-top:8px;background:#dc3545;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;">取消</button>';
                content.innerHTML = html;
                modal.appendChild(content);
                document.body.appendChild(modal);

                content.querySelectorAll('button[data-index]').forEach(btn => {
                    btn.onclick = (e) => {
                        const idx = parseInt(e.target.dataset.index);
                        document.body.removeChild(modal);
                        resolve(playlists[idx].uri);
                    };
                });
                content.querySelector('#m3u8-variant-cancel').onclick = () => {
                    document.body.removeChild(modal);
                    resolve(null);
                };
                modal.addEventListener('click', (e) => {
                    if (e.target === modal) {
                        document.body.removeChild(modal);
                        resolve(null);
                    }
                });
            });
        }

        promptFileName(defaultName) {
            return new Promise(resolve => {
                const newName = prompt('请输入文件名 (不包含扩展名 .ts):', defaultName.replace('.ts', ''));
                resolve(newName ? newName + '.ts' : defaultName);
            });
        }

        showToast(message, duration = 2000) {
            const toast = document.createElement('div');
            Object.assign(toast.style, {
                position: 'fixed', zIndex: '2147483647', bottom: '30px', left: '50%', transform: 'translateX(-50%)',
                background: 'rgba(0,0,0,0.8)', color: 'white', padding: '10px 20px', borderRadius: '6px',
                fontSize: '14px', opacity: '0', transition: 'opacity 0.3s ease'
            });
            toast.textContent = message;
            document.documentElement.appendChild(toast);
            requestAnimationFrame(() => toast.style.opacity = '1');
            setTimeout(() => {
                toast.style.opacity = '0';
                setTimeout(() => toast.remove(), 300);
            }, duration);
        }
    }

    // ==================== 启动逻辑 ====================
    const init = async () => {
        try {
            const parserLib = await waitForParser();
            console.log('【M3U8下载器】初始化完成 (v4.3)');

            const uiManager = new UIManager();
            const downloadManager = new DownloadManager();

            downloadManager.setParserLib(parserLib);
            downloadManager.setUI(uiManager);
            uiManager.setDownloadManager(downloadManager);

            const detectedSet = new Set();

            const processUrl = (url) => {
                if (!url || typeof url !== 'string') return;
                if (url.includes('.m3u8') || url.includes('mpegurl') || (url.includes('hls') && url.includes('manifest'))) {
                    let cleanUrl = url.trim().replace(/^['"]|['"]$/g, '');
                    if (!detectedSet.has(cleanUrl)) {
                        detectedSet.add(cleanUrl);
                        console.log('【M3U8下载器】检测到链接:', cleanUrl);
                        uiManager.addDetectedVideo(cleanUrl, 'm3u8');

                        const context = { isCancelled: false };
                        downloadManager.parser.getVideoInfo(cleanUrl, context, parserLib).then(info => {
                            if (info.success) {
                                uiManager.updateDetectedVideoInfo(cleanUrl, {
                                    status: 'ready',
                                    ...info
                                });
                            } else {
                                uiManager.updateDetectedVideoInfo(cleanUrl, { status: 'parse_failed' });
                            }
                        }).catch(() => {
                            uiManager.updateDetectedVideoInfo(cleanUrl, { status: 'parse_failed' });
                        });
                    }
                }
            };

            // 使用 MutationObserver 替代定时扫描
            const observer = new MutationObserver(() => {
                if (document.body) {
                    const html = document.documentElement.innerHTML;
                    const matches = html.match(/https?:\/\/[^\s<>"']+/g);
                    if (matches) matches.forEach(processUrl);
                }
            });
            if (document.body) {
                observer.observe(document.body, { childList: true, subtree: true });
            } else {
                // 等待 body 出现
                const waitBody = setInterval(() => {
                    if (document.body) {
                        clearInterval(waitBody);
                        observer.observe(document.body, { childList: true, subtree: true });
                    }
                }, 500);
            }

            const originalXHROpen = XMLHttpRequest.prototype.open;
            XMLHttpRequest.prototype.open = function(method, url) {
                processUrl(url);
                return originalXHROpen.apply(this, arguments);
            };

            const originalFetch = window.fetch;
            window.fetch = function(input, init) {
                const url = typeof input === 'string' ? input : input.url;
                processUrl(url);
                return originalFetch.apply(this, arguments);
            };

        } catch (error) {
            console.error('【M3U8下载器】启动失败:', error);
            if (typeof window !== 'undefined') {
                alert('M3U8下载器启动失败: ' + error.message + '\n请检查脚本是否正确安装,以及是否允许访问 cdn.jsdelivr.net');
            }
        }
    };

    init();
})();