Greasy Fork

Greasy Fork is available in English.

M3U8 超级下载器 Pro v5.0

革命性升级:现代UI、深色主题、迷你模式、任务排序、断点续传、自动跳过失败片段、通知中心、历史记录,下载体验全面进化

当前为 2026-04-24 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         M3U8 超级下载器 Pro v5.0
// @namespace    https://github.com/yourusername/m3u8-downloader-pro
// @version      5.0.0
// @description  革命性升级:现代UI、深色主题、迷你模式、任务排序、断点续传、自动跳过失败片段、通知中心、历史记录,下载体验全面进化
// @author       Optimized
// @match        *://*/*
// @exclude      *://*.google.com/*
// @exclude      *://*.baidu.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_notification
// @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 = () => new Promise((resolve, reject) => {
        if (typeof m3u8Parser !== 'undefined') return resolve(m3u8Parser);
        let attempts = 0;
        const check = setInterval(() => {
            attempts++;
            if (typeof m3u8Parser !== 'undefined') {
                clearInterval(check);
                resolve(m3u8Parser);
            } else if (attempts > 50) {
                clearInterval(check);
                reject(new Error('m3u8-parser 库加载失败,请检查网络或脚本源'));
            }
        }, 100);
    });

    // ==================== 增强配置中心 ====================
    const DEFAULT_CONFIG = {
        maxConcurrent: 6,
        maxConcurrentMin: 1,
        maxConcurrentMax: 16,
        timeout: 60000,
        retryCount: 5,
        fileNamePrefix: '',
        useGMdownload: true,
        speedWindow: 2000,
        uiUpdateInterval: 100,
        theme: 'auto',               // 'light', 'dark', 'auto'
        allowSkipFailedSegment: false, // 是否自动跳过失败分片继续下载
        enableNotification: true,    // 浏览器通知
        enableSound: true,           // 完成提示音
        soundVolume: 0.5,
        historySize: 20,            // 历史记录保留条数
        miniMode: false,            // 默认不启用迷你模式,由用户切换
        cancelKeepData: true,       // 取消任务后保留已下载数据(缓存恢复)
    };

    // ==================== 工具函数库 ====================
    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));
            try {
                GM_setValue('m3u8_downloader_settings', merged);
            } catch (e) {
                console.warn('保存配置失败', e);
            }
            return merged;
        },
        extractTitle() {
            let title = document.title;
            const hostname = window.location.hostname;
            let rules = [ /^(.+?)\s*[-_|]\s*.*$/ ];
            for (const [domain, domainRules] of Object.entries({
                'youtube.com': [/^(.+?)\s*-\s*YouTube$/],
                'bilibili.com': [/^(.+?)\s*[-_|]\s*哔哩哔哩.*$/],
            })) {
                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_${Date.now()}`;
            return (this.getConfig().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.ceil(Math.max(1, 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; }
            }
        },
        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;
        },
        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);
        },
        playBeep(volume = 0.5) {
            try {
                const ctx = new (window.AudioContext || window.webkitAudioContext)();
                const oscillator = ctx.createOscillator();
                const gainNode = ctx.createGain();
                oscillator.type = 'sine';
                oscillator.frequency.setValueAtTime(800, ctx.currentTime);
                gainNode.gain.setValueAtTime(volume, ctx.currentTime);
                gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
                oscillator.connect(gainNode);
                gainNode.connect(ctx.destination);
                oscillator.start();
                oscillator.stop(ctx.currentTime + 0.3);
            } catch (e) { /* 静默 */ }
        },
        notify(title, body) {
            if (this.getConfig().enableNotification && 'Notification' in window && Notification.permission === 'granted') {
                new Notification(title, { body, icon: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="45" fill="%23667eea"/%3E%3Ctext x="50" y="55" text-anchor="middle" fill="white" font-size="40"%3E⏬%3C/text%3E%3C/svg%3E' });
            }
        }
    };

    // ==================== 网络请求层(无变化) ====================
    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;
        }
        request(options, context = {}) {
            this.refreshConfig();
            return new Promise((resolve, reject) => {
                const rid = Utils.generateId();
                const req = { id: rid, ...options, resolve, reject, retries: 0, context };
                if (context.isCancelled) return reject(new Error('Cancelled'));
                this.requestQueue.push(req);
                this.processQueue();
            });
        }
        processQueue() {
            this.refreshConfig();
            while (this.activeRequests.size < this.maxConcurrent && this.requestQueue.length > 0) {
                const req = this.requestQueue.shift();
                if (req.context.isCancelled) { req.reject(new Error('Cancelled')); continue; }
                this.executeRequest(req);
            }
        }
        executeRequest(req) {
            const xhr = {
                method: req.method || 'GET',
                url: req.url,
                headers: { 'Referer': window.location.href, 'Origin': window.location.origin, 'User-Agent': navigator.userAgent, ...req.headers },
                timeout: req.timeout || this.timeout,
                responseType: req.responseType || 'text',
                onload: (resp) => {
                    this.activeRequests.delete(req.id);
                    if (req.context.isCancelled) return req.reject(new Error('Cancelled'));
                    if (resp.status >= 200 && resp.status < 300) req.resolve(resp);
                    else this.handleError(req, new Error(`HTTP ${resp.status}`));
                    this.processQueue();
                },
                onerror: (err) => {
                    this.activeRequests.delete(req.id);
                    if (!req.context.isCancelled) this.handleError(req, err || new Error('Network Error'));
                    else req.reject(new Error('Cancelled'));
                    this.processQueue();
                },
                ontimeout: () => {
                    this.activeRequests.delete(req.id);
                    if (!req.context.isCancelled) this.handleError(req, new Error('Timeout'));
                    else req.reject(new Error('Cancelled'));
                    this.processQueue();
                }
            };
            try {
                GM_xmlhttpRequest(xhr);
                this.activeRequests.set(req.id, { request: req });
            } catch (e) { this.handleError(req, e); }
        }
        handleError(req, err) {
            req.retries++;
            if (req.retries < this.retryCount && !req.context.isCancelled) {
                const delay = Math.min(1000 * Math.pow(2, req.retries - 1), 10000);
                console.warn(`[M3U8] 重试 ${req.url} ${req.retries}/${this.retryCount}`, err);
                setTimeout(() => {
                    if (!req.context.isCancelled) {
                        this.requestQueue.unshift(req);
                        this.processQueue();
                    }
                }, delay);
            } else {
                req.reject(err);
            }
        }
        async getDecryptKey(url, context) {
            if (this.keyCache.has(url)) return this.keyCache.get(url);
            const resp = await this.request({ url, responseType: 'arraybuffer' }, context);
            const key = new Uint8Array(resp.response);
            this.keyCache.set(url, key);
            return key;
        }
    }

    // ==================== M3U8解析器(基本不变) ====================
    class M3U8Parser {
        constructor() { this.network = new NetworkManager(); }
        async parse(url, context, parserLib) {
            const resp = await this.network.request({ url, responseType: 'text' }, context);
            if (!resp?.responseText) throw new Error('空内容');
            const parser = new parserLib.Parser();
            parser.push(resp.responseText);
            parser.end();
            const manifest = parser.manifest;
            if (!manifest) throw new Error('解析失败');
            const baseUrl = Utils.getBaseUrl(url);
            if (manifest.playlists?.length) {
                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 };
        }
        async getVideoInfo(url, context, parserLib) {
            const parsed = await this.parse(url, context, parserLib);
            if (!parsed.success) return { success: false, error: parsed.error };
            if (parsed.isMaster) {
                return { success: true, isMaster: true, playlists: parsed.playlists, baseUrl: parsed.baseUrl };
            }
            const manifest = parsed.manifest;
            const isLive = !manifest.endList;
            const totalDuration = manifest.segments ? manifest.segments.reduce((sum, s) => sum + (s.duration || 0), 0) : 0;
            let estimatedSize = 0;
            if (!isLive && parsed.masterAttributes?.BANDWIDTH) {
                estimatedSize = (parsed.masterAttributes.BANDWIDTH * totalDuration) / 8;
            }
            return {
                success: true, isMaster: false,
                totalDuration, estimatedSize,
                segmentCount: manifest.segments ? manifest.segments.length : 0,
                isLive
            };
        }
        prepareSegments(manifest, baseUrl) {
            if (!manifest?.segments) return [];
            let currentKey = null;
            return manifest.segments.map((seg, index) => {
                if (seg.key) {
                    currentKey = {
                        uri: seg.key.uri ? Utils.resolveUrl(seg.key.uri, baseUrl) : null,
                        iv: seg.key.iv ? (typeof seg.key.iv === 'string' ? Utils.hexToBytes(seg.key.iv) : new Uint8Array(seg.key.iv.buffer)) : null,
                        method: seg.key.method || 'AES-128'
                    };
                }
                return {
                    index,
                    url: Utils.resolveUrl(seg.uri, baseUrl),
                    duration: seg.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.updateTimers = new Map();
            this.cachedData = new Map(); // 任务取消后保留数据的临时缓存
            this.taskOrder = [];          // 控制优先级顺序的队列
        }
        setParserLib(lib) { this.parserLib = lib; }
        setUI(ui) { this.ui = ui; }

        async addDownload(url, customTitle) {
            if (!this.parserLib) return this.ui?.showToast('解析库未就绪');
            const id = Utils.generateId();
            const context = { isPaused: false, isCancelled: false, pauseResolve: null };
            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,
                speedStats: { queue: [], maxLength: Math.ceil(Utils.getConfig().speedWindow / 100) },
                lastProgress: 0, variantUri: null
            };
            this.downloads.set(id, download);
            this.taskOrder.push(id); // 添加顺序
            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) throw new Error(parseResult.error);

                if (parseResult.isMaster) {
                    const selectedUri = await this.ui.showVariantSelector(parseResult.playlists);
                    if (!selectedUri) throw new Error('未选择清晰度');
                    download.variantUri = selectedUri;
                    download.url = selectedUri;
                    const sub = await this.parser.parse(selectedUri, context, this.parserLib);
                    if (!sub.success) throw new Error(sub.error);
                    download.videoSegments = this.parser.prepareSegments(sub.manifest, sub.baseUrl);
                } else {
                    download.videoSegments = this.parser.prepareSegments(parseResult.manifest, parseResult.baseUrl);
                }

                if (!download.videoSegments || download.videoSegments.length === 0) {
                    throw new Error('无有效视频分片');
                }
                download.totalSegments = download.videoSegments.length;
                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.addHistory(download);
                Utils.notify('下载完成', download.title);
                if (Utils.getConfig().enableSound) Utils.playBeep(Utils.getConfig().soundVolume);
                this.ui.showToast(`【${download.title}】完成!`);
            } catch (e) {
                this.stopUIAutoUpdate(id);
                if (context.isCancelled) {
                    download.status = 'cancelled';
                    download.error = '用户取消';
                } else {
                    download.status = 'error';
                    download.error = e.message;
                }
                this.ui.updateDownloads();
            } finally {
                if (download.videoSegments) {
                    download.videoSegments.forEach(s => s.data = null);
                }
            }
        }

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

            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 seg = segments[currentIndex];
                    if (seg.downloaded) continue;

                    try {
                        const resp = await this.network.request({ url: seg.url, responseType: 'arraybuffer' }, context);
                        let segData = new Uint8Array(resp.response);

                        if (seg.key && seg.key.method !== 'NONE' && seg.key.uri) {
                            const key = await this.network.getDecryptKey(seg.key.uri, context);
                            segData = new Uint8Array(await Utils.aesDecrypt(segData, key, seg.key.iv, seg.index));
                        }
                        seg.data = segData;
                        seg.downloaded = true;
                        const segSize = segData.length;
                        download.segmentSizes.push(segSize);
                        download.size += segSize;
                        download.downloadedCount++;
                        const now = performance.now();
                        download.speedStats.queue.push({ time: now, bytes: download.size });
                        const cutoff = now - Utils.getConfig().speedWindow;
                        download.speedStats.queue = download.speedStats.queue.filter(item => item.time >= cutoff);
                    } catch (e) {
                        if (!context.isCancelled) {
                            console.error(`分片 ${currentIndex} 失败`, e);
                            if (allowSkip) {
                                console.warn('已跳过失败分片,继续下载');
                                // 标记为已下载(跳过),但不增加size
                                seg.downloaded = true;
                                seg.data = new Uint8Array(0); // 空数据
                                download.segmentSizes.push(0); // 记录长度为0
                                download.downloadedCount++;
                                continue;
                            } else {
                                error = e;
                                break;
                            }
                        }
                    }
                }
            };

            const workers = [];
            for (let i = 0; i < Utils.getConfig().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 finalSize = estimatedTotalSize;
            if (download.segmentSizes.length > 0) {
                const avg = download.segmentSizes.reduce((a, b) => a + b, 0) / download.segmentSizes.length;
                finalSize = avg * totalSegments;
            }
            download.estimatedTotalSize = finalSize;

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

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

        async mergeSegments(download) {
            const segments = download.videoSegments.sort((a, b) => a.index - b.index);
            const chunkSize = 50;
            const blobs = [];
            for (let i = 0; i < segments.length; i += chunkSize) {
                const chunk = segments.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 config = Utils.getConfig();
            const fileName = await this.ui.promptFileName(`${download.title}.ts`);
            if (config.useGMdownload && typeof GM_download !== 'undefined') {
                const url = URL.createObjectURL(finalBlob);
                GM_download({
                    url,
                    name: fileName,
                    saveAs: false,
                    onload: () => URL.revokeObjectURL(url),
                    onerror: (e) => {
                        console.warn('GM_download失败', e);
                        URL.revokeObjectURL(url);
                        this.nativeDownload(finalBlob, fileName);
                    }
                });
            } else {
                this.nativeDownload(finalBlob, 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);
        }

        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();
            }, Utils.getConfig().uiUpdateInterval);
            this.updateTimers.set(id, timer);
        }

        stopUIAutoUpdate(id) {
            clearInterval(this.updateTimers.get(id));
            this.updateTimers.delete(id);
        }

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

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

        cancelDownload(id) {
            const dl = this.downloads.get(id);
            if (dl) {
                dl.context.isCancelled = true;
                dl.context.pauseResolve?.();
                dl.status = 'cancelled';
                this.stopUIAutoUpdate(id);
                // 保留数据在缓存中一段时间
                if (Utils.getConfig().cancelKeepData && dl.videoSegments) {
                    this.cachedData.set(id, { segments: dl.videoSegments.map(s => ({ ...s })), download: { ...dl } });
                    setTimeout(() => this.cachedData.delete(id), 60000); // 1分钟后清除
                }
                this.ui.updateDownloads();
            }
        }

        deleteDownload(id) {
            const dl = this.downloads.get(id);
            if (dl) {
                this.cancelDownload(id);
                dl.videoSegments?.forEach(s => s.data = null);
                this.downloads.delete(id);
                this.taskOrder = this.taskOrder.filter(oid => oid !== id);
                this.ui.updateDownloads();
            }
        }

        getDownloads() {
            // 根据 taskOrder 顺序输出
            return this.taskOrder.map(id => this.downloads.get(id)).filter(Boolean);
        }

        addHistory(download) {
            try {
                let history = GM_getValue('m3u8_history', []);
                history.unshift({ title: download.title, url: download.url, size: download.size, time: Date.now() });
                history = history.slice(0, Utils.getConfig().historySize);
                GM_setValue('m3u8_history', history);
                this.ui?.renderHistory();
            } catch (e) {}
        }

        getHistory() {
            try {
                return GM_getValue('m3u8_history', []);
            } catch (e) { return []; }
        }

        clearHistory() {
            GM_setValue('m3u8_history', []);
            this.ui?.renderHistory();
        }

        // 调整任务顺序(拖拽后调用)
        reorderTask(fromId, toIndex) {
            const fromIdx = this.taskOrder.indexOf(fromId);
            if (fromIdx === -1) return;
            this.taskOrder.splice(fromIdx, 1);
            this.taskOrder.splice(toIndex, 0, fromId);
            this.ui.updateDownloads();
        }
    }

    // ==================== 全新 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.activeTab = 'videos'; // videos | downloads | settings
            this.miniMode = false;
            this.init();
        }

        setDownloadManager(dm) { this.downloadManager = dm; }

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

        applyTheme() {
            const theme = Utils.getConfig().theme;
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            const isDark = theme === 'dark' || (theme === 'auto' && prefersDark);
            document.documentElement.style.setProperty('--m3u8-bg', isDark ? '#1e1e2e' : '#ffffff');
            document.documentElement.style.setProperty('--m3u8-text', isDark ? '#cdd6f4' : '#1e1e2e');
            document.documentElement.style.setProperty('--m3u8-card', isDark ? '#313244' : '#f5f5f5');
            document.documentElement.style.setProperty('--m3u8-border', isDark ? '#45475a' : '#e0e0e0');
        }

        createContainer() {
            this.container = document.createElement('div');
            this.container.id = 'm3u8-container';
            Object.assign(this.container.style, {
                position: 'fixed', zIndex: '2147483647', top: '20px', right: '20px',
                fontFamily: 'system-ui, sans-serif', fontSize: '14px', color: 'var(--m3u8-text)',
                transition: 'opacity 0.2s'
            });
            document.documentElement.appendChild(this.container);
        }

        createFloatingButton() {
            this.floatingButton = document.createElement('div');
            this.floatingButton.id = 'm3u8-float';
            Object.assign(this.floatingButton.style, {
                position: 'fixed', zIndex: '2147483647', top: '20px', right: '20px',
                width: '48px', height: '48px', borderRadius: '50%',
                background: 'linear-gradient(135deg, #c084fc, #a855f7)',
                boxShadow: '0 4px 12px rgba(168,85,247,0.4)', cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                transition: 'transform 0.2s'
            });
            this.floatingButton.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 8 16 12 10 16"/></svg>`;
            this.badge = document.createElement('div');
            Object.assign(this.badge.style, {
                position: 'absolute', top: '-4px', right: '-4px',
                background: '#f43f5e', color: 'white', fontSize: '11px', fontWeight: 'bold',
                minWidth: '18px', height: '18px', borderRadius: '9px',
                display: 'none', alignItems: 'center', justifyContent: 'center',
                border: '2px solid white'
            });
            this.floatingButton.appendChild(this.badge);
            this.floatingButton.addEventListener('click', () => this.togglePanel());
            this.floatingButton.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                this.showContextMenu(e.clientX, e.clientY);
            });
            // 拖拽
            let dragging = false, sx, sy, ix, iy;
            this.floatingButton.addEventListener('mousedown', (e) => {
                if (e.button !== 0) return;
                dragging = true; sx = e.clientX; sy = e.clientY;
                const rect = this.floatingButton.getBoundingClientRect();
                ix = rect.left; iy = rect.top;
                e.preventDefault();
            });
            document.addEventListener('mousemove', (e) => {
                if (!dragging) return;
                this.floatingButton.style.left = `${ix + e.clientX - sx}px`;
                this.floatingButton.style.top = `${iy + e.clientY - sy}px`;
                this.floatingButton.style.right = 'auto';
            });
            document.addEventListener('mouseup', () => { dragging = false; });
            document.documentElement.appendChild(this.floatingButton);
        }

        showContextMenu(x, y) {
            const menu = document.createElement('div');
            menu.style.cssText = `position:fixed;z-index:9999999;left:${x}px;top:${y}px;background:var(--m3u8-card);border:1px solid var(--m3u8-border);border-radius:8px;padding:6px 0;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,0.2);color:var(--m3u8-text);font-size:13px;`;
            const items = [
                { text: '📥 手动输入链接', action: () => this.promptManualUrl() },
                { text: '🧹 清空检测列表', action: () => { this.detectedVideos.clear(); this.updateVideosList(); } },
                { text: this.miniMode ? '🗂 切换完整模式' : '🔹 切换迷你模式', action: () => this.toggleMiniMode() },
                { text: '🎨 切换主题', action: () => this.toggleTheme() },
            ];
            items.forEach(item => {
                const el = document.createElement('div');
                el.textContent = item.text;
                el.style.cssText = 'padding:8px 16px;cursor:pointer;';
                el.onmouseenter = () => el.style.background = 'var(--m3u8-border)';
                el.onmouseleave = () => el.style.background = '';
                el.onclick = () => { item.action(); menu.remove(); };
                menu.appendChild(el);
            });
            document.body.appendChild(menu);
            const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
            setTimeout(() => document.addEventListener('click', close), 0);
        }

        toggleMiniMode() {
            this.miniMode = !this.miniMode;
            if (this.miniMode) {
                this.container.style.display = 'none';
                this.floatingButton.style.display = 'flex';
                this.floatingButton.innerHTML = ''; // 清空用于环形进度
                // 创建环形进度SVG
                const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svg.setAttribute('width', '48'); svg.setAttribute('height', '48'); svg.setAttribute('viewBox', '0 0 48 48');
                svg.innerHTML = `<circle cx="24" cy="24" r="20" fill="none" stroke="white" stroke-width="3" stroke-opacity="0.3"/><circle id="m3u8-mini-progress" cx="24" cy="24" r="20" fill="none" stroke="white" stroke-width="3" stroke-dasharray="125.6" stroke-dashoffset="125.6" stroke-linecap="round" transform="rotate(-90 24 24)"/>`;
                this.floatingButton.appendChild(svg);
                this.floatingButton.title = '迷你模式 - 显示总进度';
                this.isVisible = false;
            } else {
                this.floatingButton.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 8 16 12 10 16"/></svg>`;
                this.badge = document.createElement('div');
                Object.assign(this.badge.style, {
                    position: 'absolute', top: '-4px', right: '-4px',
                    background: '#f43f5e', color: 'white', fontSize: '11px', fontWeight: 'bold',
                    minWidth: '18px', height: '18px', borderRadius: '9px',
                    display: 'none', alignItems: 'center', justifyContent: 'center',
                    border: '2px solid white'
                });
                this.floatingButton.appendChild(this.badge);
                this.floatingButton.title = 'M3U8 下载器';
            }
            this.updateMiniProgress();
        }

        updateMiniProgress() {
            if (!this.miniMode || !this.downloadManager) return;
            const downloads = this.downloadManager.getDownloads();
            const active = downloads.filter(d => d.status === 'downloading');
            if (active.length === 0) {
                const circle = this.floatingButton.querySelector('#m3u8-mini-progress');
                if (circle) circle.setAttribute('stroke-dashoffset', '125.6');
                return;
            }
            const totalProgress = active.reduce((sum, d) => sum + d.progress, 0) / active.length;
            const circle = this.floatingButton.querySelector('#m3u8-mini-progress');
            if (circle) {
                const circumference = 125.6; // 2*pi*20
                const offset = circumference - (totalProgress / 100) * circumference;
                circle.setAttribute('stroke-dashoffset', offset);
            }
        }

        createPanel() {
            this.panel = document.createElement('div');
            this.panel.id = 'm3u8-panel';
            Object.assign(this.panel.style, {
                width: '420px', maxHeight: '700px', borderRadius: '16px',
                background: 'var(--m3u8-bg)', border: '1px solid var(--m3u8-border)',
                boxShadow: '0 16px 48px rgba(0,0,0,0.2)', overflow: 'hidden',
                display: 'flex', flexDirection: 'column',
                transition: 'opacity 0.2s, transform 0.2s'
            });

            // 头部
            const header = document.createElement('div');
            header.style.cssText = 'background:linear-gradient(135deg,#a855f7,#7c3aed);padding:16px 20px;color:white;cursor:move;display:flex;justify-content:space-between;align-items:center;';
            header.innerHTML = `
                <div style="display:flex;align-items:center;gap:8px;">
                    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 8 16 12 10 16"/></svg>
                    <span style="font-weight:700;font-size:16px;">M3U8 Pro v5.0</span>
                </div>
                <div style="display:flex;gap:6px;">
                    <button id="m3u8-mini-btn" title="迷你模式" style="background:none;border:none;color:white;cursor:pointer;padding:4px;border-radius:6px;">🔹</button>
                    <button id="m3u8-collapse-btn" title="最小化" style="background:none;border:none;color:white;cursor:pointer;padding:4px;border-radius:6px;">➖</button>
                    <button id="m3u8-close-btn" title="隐藏" style="background:none;border:none;color:white;cursor:pointer;padding:4px;border-radius:6px;">✖</button>
                </div>
            `;
            // 标签栏
            const tabs = document.createElement('div');
            tabs.style.cssText = 'display:flex;border-bottom:2px solid var(--m3u8-border);';
            ['videos', 'downloads', 'settings'].forEach(tab => {
                const btn = document.createElement('button');
                btn.dataset.tab = tab;
                btn.textContent = tab === 'videos' ? '🎬 视频检测' : tab === 'downloads' ? '📥 下载任务' : '⚙️ 设置';
                btn.style.cssText = 'flex:1;padding:10px;background:transparent;border:none;color:var(--m3u8-text);font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:0.2s;';
                btn.onclick = () => this.switchTab(tab);
                tabs.appendChild(btn);
            });
            // 内容区
            const content = document.createElement('div');
            content.id = 'm3u8-tab-content';
            content.style.cssText = 'flex:1;overflow-y:auto;padding:16px;max-height:500px;';

            this.panel.appendChild(header);
            this.panel.appendChild(tabs);
            this.panel.appendChild(content);
            this.container.appendChild(this.panel);

            // 事件绑定
            this.panel.querySelector('#m3u8-mini-btn').addEventListener('click', () => this.toggleMiniMode());
            this.panel.querySelector('#m3u8-collapse-btn').addEventListener('click', () => this.hidePanel());
            this.panel.querySelector('#m3u8-close-btn').addEventListener('click', () => this.hideAll());
            // 拖拽
            let drag = false, sx, sy, ix, iy;
            header.addEventListener('mousedown', (e) => {
                if (e.target.tagName === 'BUTTON') return;
                drag = true; sx = e.clientX; sy = e.clientY;
                const rect = this.container.getBoundingClientRect();
                ix = rect.left; iy = rect.top;
                e.preventDefault();
            });
            document.addEventListener('mousemove', (e) => {
                if (!drag) return;
                this.container.style.left = `${ix + e.clientX - sx}px`;
                this.container.style.top = `${iy + e.clientY - sy}px`;
                this.container.style.right = 'auto';
            });
            document.addEventListener('mouseup', () => { drag = false; });

            this.switchTab('videos');
        }

        switchTab(tab) {
            this.activeTab = tab;
            const content = this.panel.querySelector('#m3u8-tab-content');
            if (!content) return;
            content.innerHTML = '';
            const tabs = this.panel.querySelectorAll('button[data-tab]');
            tabs.forEach(t => {
                t.style.borderBottomColor = t.dataset.tab === tab ? '#a855f7' : 'transparent';
                t.style.color = t.dataset.tab === tab ? '#a855f7' : 'var(--m3u8-text)';
            });
            if (tab === 'videos') this.renderVideosTab(content);
            else if (tab === 'downloads') this.renderDownloadsTab(content);
            else if (tab === 'settings') this.renderSettingsTab(content);
        }

        renderVideosTab(container) {
            container.innerHTML = `
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
                    <span style="font-weight:600;">检测到的视频 (<span id="m3u8-video-count">${this.detectedVideos.size}</span>)</span>
                    <button id="m3u8-refresh-btn" style="background:transparent;border:1px solid var(--m3u8-border);color:var(--m3u8-text);padding:4px 8px;border-radius:6px;cursor:pointer;">🔄 刷新</button>
                </div>
                <div id="m3u8-videos-list" style="display:flex;flex-direction:column;gap:10px;"></div>
            `;
            container.querySelector('#m3u8-refresh-btn').onclick = () => this.scanDOM();
            this.updateVideosList();
        }

        renderDownloadsTab(container) {
            container.innerHTML = `
                <div style="margin-bottom:8px;display:flex;gap:6px;">
                    <button id="m3u8-pause-all" style="background:#f97316;border:none;color:white;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px;">⏸ 全部暂停</button>
                    <button id="m3u8-resume-all" style="background:#22c55e;border:none;color:white;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px;">▶ 全部恢复</button>
                    <button id="m3u8-cancel-all" style="background:#ef4444;border:none;color:white;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px;">✖ 取消所有</button>
                    <button id="m3u8-clear-finished" style="background:#6b7280;border:none;color:white;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px;">🗑 清除已完成</button>
                </div>
                <div id="m3u8-downloads-list" style="display:flex;flex-direction:column;gap:12px;"></div>
            `;
            container.querySelector('#m3u8-pause-all').onclick = () => { this.downloadManager?.downloads.forEach((d, id) => { if(d.status==='downloading') this.downloadManager.pauseDownload(id); }); this.showToast('已暂停所有'); };
            container.querySelector('#m3u8-resume-all').onclick = () => { this.downloadManager?.downloads.forEach((d, id) => { if(d.status==='paused') this.downloadManager.resumeDownload(id); }); this.showToast('已恢复所有'); };
            container.querySelector('#m3u8-cancel-all').onclick = () => { this.downloadManager?.downloads.forEach((d, id) => { if(!['completed','cancelled','error'].includes(d.status)) this.downloadManager.cancelDownload(id); }); this.showToast('已取消进行中的任务'); };
            container.querySelector('#m3u8-clear-finished').onclick = () => {
                const ids = [];
                this.downloadManager?.downloads.forEach((d, id) => { if(['completed','cancelled','error'].includes(d.status)) ids.push(id); });
                ids.forEach(id => this.downloadManager.deleteDownload(id));
                this.showToast(`清除了${ids.length}个任务`);
            };
            this.updateDownloads();
        }

        renderSettingsTab(container) {
            const config = Utils.getConfig();
            container.innerHTML = `
                <div style="display:flex;flex-direction:column;gap:16px;">
                    <div>
                        <label style="font-weight:600;">并发数 (<span id="settings-conc-val">${config.maxConcurrent}</span>)</label>
                        <input type="range" id="settings-conc" min="1" max="16" value="${config.maxConcurrent}" style="width:100%;">
                    </div>
                    <div>
                        <label>主题</label>
                        <select id="settings-theme" style="width:100%;padding:6px;border-radius:6px;background:var(--m3u8-card);color:var(--m3u8-text);border:1px solid var(--m3u8-border);">
                            <option value="auto" ${config.theme==='auto'?'selected':''}>跟随系统</option>
                            <option value="light" ${config.theme==='light'?'selected':''}>浅色</option>
                            <option value="dark" ${config.theme==='dark'?'selected':''}>深色</option>
                        </select>
                    </div>
                    <div style="display:flex;align-items:center;justify-content:space-between;">
                        <span>下载完成通知</span>
                        <input type="checkbox" id="settings-notif" ${config.enableNotification?'checked':''}>
                    </div>
                    <div style="display:flex;align-items:center;justify-content:space-between;">
                        <span>完成提示音</span>
                        <input type="checkbox" id="settings-sound" ${config.enableSound?'checked':''}>
                    </div>
                    <div style="display:flex;align-items:center;justify-content:space-between;">
                        <span>跳过失败分片</span>
                        <input type="checkbox" id="settings-skip" ${config.allowSkipFailedSegment?'checked':''}>
                    </div>
                    <div>
                        <label>重试次数</label>
                        <input type="number" id="settings-retry" value="${config.retryCount}" min="0" max="10" style="width:100%;padding:6px;border-radius:6px;background:var(--m3u8-card);color:var(--m3u8-text);border:1px solid var(--m3u8-border);">
                    </div>
                    <button id="m3u8-save-settings" style="background:#a855f7;border:none;color:white;padding:10px;border-radius:8px;cursor:pointer;font-weight:600;">💾 保存设置</button>
                    <hr style="border-color:var(--m3u8-border);">
                    <div id="m3u8-history-section"></div>
                    <button id="m3u8-clear-history" style="background:#ef4444;border:none;color:white;padding:8px;border-radius:8px;cursor:pointer;">🗑 清除历史记录</button>
                </div>
            `;
            const save = () => {
                const conc = parseInt(container.querySelector('#settings-conc').value);
                const theme = container.querySelector('#settings-theme').value;
                const notif = container.querySelector('#settings-notif').checked;
                const sound = container.querySelector('#settings-sound').checked;
                const skip = container.querySelector('#settings-skip').checked;
                const retry = parseInt(container.querySelector('#settings-retry').value);
                Utils.saveConfig({ maxConcurrent: conc, theme, enableNotification: notif, enableSound: sound, allowSkipFailedSegment: skip, retryCount: retry });
                this.applyTheme();
                this.showToast('设置已保存');
                this.switchTab('settings'); // 刷新设置页
            };
            container.querySelector('#m3u8-save-settings').onclick = save;
            container.querySelector('#m3u8-clear-history').onclick = () => this.downloadManager?.clearHistory();
            container.querySelector('#settings-conc').oninput = (e) => {
                container.querySelector('#settings-conc-val').textContent = e.target.value;
            };
            this.renderHistory(container.querySelector('#m3u8-history-section'));
        }

        renderHistory(container) {
            if (!container || !this.downloadManager) return;
            const history = this.downloadManager.getHistory();
            container.innerHTML = `<div style="font-weight:600;margin-bottom:8px;">📜 下载历史 (${history.length})</div>`;
            history.forEach((item, idx) => {
                const el = document.createElement('div');
                el.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--m3u8-border);font-size:12px;';
                el.innerHTML = `<div style="flex:1;min-width:0;"><div style="font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${item.title}</div><div style="color:#888;font-size:11px;">${Utils.formatSize(item.size)}</div></div>`;
                const btnGroup = document.createElement('div');
                btnGroup.style.cssText = 'display:flex;gap:4px;';
                const redl = document.createElement('button');
                redl.textContent = '再下载'; redl.style.cssText = 'background:#a855f7;border:none;color:white;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:11px;';
                redl.onclick = () => this.downloadManager.addDownload(item.url, item.title.split('.ts')[0]);
                const copy = document.createElement('button');
                copy.textContent = '复制'; copy.style.cssText = 'background:#6b7280;border:none;color:white;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:11px;';
                copy.onclick = () => { navigator.clipboard.writeText(item.url); this.showToast('链接已复制'); };
                btnGroup.appendChild(redl);
                btnGroup.appendChild(copy);
                el.appendChild(btnGroup);
                container.appendChild(el);
            });
        }

        updateVideosList() {
            const list = this.panel?.querySelector('#m3u8-videos-list');
            if (!list) return;
            list.innerHTML = '';
            this.detectedVideos.forEach((video, url) => {
                const item = document.createElement('div');
                item.style.cssText = 'background:var(--m3u8-card);border-radius:10px;padding:12px;display:flex;justify-content:space-between;align-items:center;';
                let info = '';
                if (video.status === 'ready') {
                    info = `${video.totalDuration ? Utils.formatTime(video.totalDuration) : ''} ${video.estimatedSize ? '| '+Utils.formatSize(video.estimatedSize) : ''}`;
                } else if (video.status === 'parsing') info = '解析中...';
                else info = '解析失败';
                item.innerHTML = `
                    <div style="flex:1;min-width:0;margin-right:8px;">
                        <div style="font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;">${url.substring(0,45)}...</div>
                        <div style="font-size:12px;color:#888;">${info}</div>
                    </div>
                    <div style="display:flex;gap:6px;">
                        <button class="m3u8-dl-btn" data-url="${url}" style="background:#a855f7;border:none;color:white;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px;">下载</button>
                        <button class="m3u8-copy-btn" data-url="${url}" style="background:#6b7280;border:none;color:white;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:12px;">复制</button>
                    </div>
                `;
                list.appendChild(item);
            });
            list.querySelectorAll('.m3u8-dl-btn').forEach(b => b.onclick = () => this.downloadManager.addDownload(b.dataset.url));
            list.querySelectorAll('.m3u8-copy-btn').forEach(b => b.onclick = () => { navigator.clipboard.writeText(b.dataset.url); this.showToast('链接已复制'); });
            const cnt = this.panel.querySelector('#m3u8-video-count');
            if (cnt) cnt.textContent = this.detectedVideos.size;
            this.updateBadge();
        }

        updateDownloads() {
            const list = this.panel?.querySelector('#m3u8-downloads-list');
            if (!list || !this.downloadManager) return;
            const downloads = this.downloadManager.getDownloads();
            list.innerHTML = '';
            downloads.forEach(d => {
                const item = document.createElement('div');
                item.setAttribute('draggable', 'true');
                item.dataset.id = d.id;
                item.style.cssText = 'background:var(--m3u8-card);border-radius:10px;padding:12px;cursor:grab;';
                const statusMap = {
                    preparing: { color: '#facc15', text: '准备' },
                    downloading: { color: '#a855f7', text: '下载中' },
                    paused: { color: '#f97316', text: '暂停' },
                    merging: { color: '#38bdf8', text: '合并' },
                    completed: { color: '#22c55e', text: '完成' },
                    error: { color: '#ef4444', text: '失败' },
                    cancelled: { color: '#9ca3af', text: '取消' }
                };
                const s = statusMap[d.status] || statusMap.preparing;
                item.innerHTML = `
                    <div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:6px;">
                        <div style="font-weight:500;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${d.title}">${d.title}</div>
                        <span style="background:${s.color};color:white;padding:2px 8px;border-radius:10px;font-size:11px;margin-left:8px;">${s.text}</span>
                    </div>
                    ${d.status==='downloading' ? `
                        <div style="font-size:12px;color:#888;margin-bottom:4px;">${Utils.formatSize(d.size)} / ${Utils.formatSize(d.estimatedTotalSize)} · ${Utils.formatSize(d.speed)}/s · 剩余${Utils.formatTime(d.remainingTime)}</div>
                        <div style="height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden;"><div style="width:${d.progress}%;height:100%;background:${s.color};transition:width 0.1s;"></div></div>
                    ` : d.status==='completed' ? `<div style="font-size:12px;color:#888;">${Utils.formatSize(d.size)}</div>` : ''}
                    <div style="margin-top:8px;display:flex;gap:4px;justify-content:flex-end;">
                        ${d.status==='downloading' ? `<button class="m3u8-pause" data-id="${d.id}">⏸</button>` : ''}
                        ${d.status==='paused' ? `<button class="m3u8-resume" data-id="${d.id}">▶</button>` : ''}
                        ${!['completed','cancelled','error'].includes(d.status) ? `<button class="m3u8-cancel" data-id="${d.id}">✖</button>` : ''}
                        <button class="m3u8-delete" data-id="${d.id}">🗑</button>
                    </div>
                `;
                // 按钮事件
                item.querySelector('.m3u8-pause')?.addEventListener('click', () => this.downloadManager.pauseDownload(d.id));
                item.querySelector('.m3u8-resume')?.addEventListener('click', () => this.downloadManager.resumeDownload(d.id));
                item.querySelector('.m3u8-cancel')?.addEventListener('click', () => this.downloadManager.cancelDownload(d.id));
                item.querySelector('.m3u8-delete')?.addEventListener('click', () => this.downloadManager.deleteDownload(d.id));
                // 拖拽排序
                item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', d.id); item.style.opacity = '0.5'; });
                item.addEventListener('dragend', () => { item.style.opacity = '1'; });
                item.addEventListener('dragover', (e) => e.preventDefault());
                item.addEventListener('drop', (e) => {
                    e.preventDefault();
                    const fromId = e.dataTransfer.getData('text/plain');
                    const toId = d.id;
                    if (fromId !== toId) {
                        const toIndex = this.downloadManager.taskOrder.indexOf(toId);
                        this.downloadManager.reorderTask(fromId, toIndex);
                    }
                });
                list.appendChild(item);
            });
            if (this.miniMode) this.updateMiniProgress();
        }

        showVariantSelector(playlists) {
            return new Promise(resolve => {
                const modal = document.createElement('div');
                modal.style.cssText = 'position:fixed;z-index:9999999;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
                const div = document.createElement('div');
                div.style.cssText = 'background:var(--m3u8-bg);padding:24px;border-radius:16px;max-width:400px;width:90%;color:var(--m3u8-text);';
                let html = '<h3 style="margin:0 0 16px;">选择清晰度</h3>';
                playlists.forEach((pl, i) => {
                    const res = pl.attributes.RESOLUTION || {};
                    const bw = pl.attributes.BANDWIDTH ? ` ${(pl.attributes.BANDWIDTH/1000).toFixed(0)}kbps` : '';
                    html += `<button data-index="${i}" style="display:block;width:100%;padding:10px;margin-bottom:8px;background:var(--m3u8-card);border:1px solid var(--m3u8-border);border-radius:8px;cursor:pointer;text-align:left;">${res.width||'?'}x${res.height||'?'}${bw}</button>`;
                });
                html += '<button id="m3u8-variant-cancel" style="margin-top:8px;background:#ef4444;color:white;border:none;padding:8px 16px;border-radius:8px;cursor:pointer;">取消</button>';
                div.innerHTML = html;
                modal.appendChild(div);
                document.body.appendChild(modal);
                div.querySelectorAll('button[data-index]').forEach(btn => {
                    btn.onclick = () => { document.body.removeChild(modal); resolve(playlists[btn.dataset.index].uri); };
                });
                div.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 name = prompt('保存文件名(不含.ts):', defaultName.replace('.ts',''));
                resolve(name ? name+'.ts' : defaultName);
            });
        }

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

        togglePanel() {
            if (this.miniMode) {
                this.toggleMiniMode(); // 退出迷你模式并显示
            }
            if (this.isVisible) this.hidePanel();
            else this.showPanel();
        }

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

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

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

        updateBadge() {
            if (this.miniMode) return;
            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';
                }
            }
        }

        promptManualUrl() {
            const url = prompt('输入 M3U8 链接:');
            if (url && url.trim()) {
                this.downloadManager.addDownload(url.trim());
            }
        }

        requestNotificationPermission() {
            if ('Notification' in window && Notification.permission === 'default') {
                Notification.requestPermission();
            }
        }

        scanDOM() {
            // 由启动逻辑调用
            const html = document.documentElement.innerHTML;
            const matches = html.match(/https?:\/\/[^\s<>"']+\.m3u8[^\s<>"']*/g) || [];
            matches.forEach(url => this.processUrl(url));
            // 也扫描可播放链接
            const alt = html.match(/https?:\/\/[^\s<>"']*mpegurl[^\s<>"']*/g) || [];
            alt.forEach(url => this.processUrl(url));
        }

        processUrl(url) {
            if (this.detectedVideos.has(url)) return;
            this.detectedVideos.set(url, { status: 'parsing' });
            this.updateVideosList();
            // 异步解析信息
            this.downloadManager.parser.getVideoInfo(url, { isCancelled: false }, this.downloadManager.parserLib).then(info => {
                if (info.success) {
                    this.detectedVideos.set(url, { status: 'ready', ...info });
                } else {
                    this.detectedVideos.set(url, { status: 'parse_failed' });
                }
                this.updateVideosList();
            }).catch(() => {
                this.detectedVideos.set(url, { status: 'parse_failed' });
                this.updateVideosList();
            });
        }
    }

    // ==================== 启动 ====================
    const init = async () => {
        try {
            const parserLib = await waitForParser();
            console.log('【M3U8 Pro】v5.0 启动');
            const dm = new DownloadManager();
            const ui = new UIManager();
            dm.setParserLib(parserLib);
            dm.setUI(ui);
            ui.setDownloadManager(dm);

            // 扫描当前页面
            ui.scanDOM();

            // 监视 DOM 变化
            const observer = new MutationObserver(() => ui.scanDOM());
            if (document.body) observer.observe(document.body, { childList: true, subtree: true });
            else {
                const interval = setInterval(() => {
                    if (document.body) { clearInterval(interval); observer.observe(document.body, { childList: true, subtree: true }); }
                }, 500);
            }

            // 拦截 XHR / fetch
            const origOpen = XMLHttpRequest.prototype.open;
            XMLHttpRequest.prototype.open = function(method, url) {
                ui.processUrl(url);
                return origOpen.apply(this, arguments);
            };
            const origFetch = window.fetch;
            window.fetch = function(input, init) {
                const url = typeof input === 'string' ? input : input.url;
                ui.processUrl(url);
                return origFetch.apply(this, arguments);
            };

        } catch (e) {
            console.error('启动失败', e);
            alert('M3U8 Pro 启动失败: ' + e.message);
        }
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();