Greasy Fork is available in English.
革命性升级:现代UI、深色主题、迷你模式、任务排序、断点续传、自动跳过失败片段、通知中心、历史记录,下载体验全面进化
当前为
// ==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(); })();