// ==UserScript== // @name 百度网盘视频播放器 // @namespace https://scriptcat.org/ // @version 6.0 // @description 基于ClaudeAI的百度网盘视频播放器,支持多清晰度切换、连续播放、进度记忆、键盘快捷键 // @match https://pan.baidu.com/s/* // @match https://pan.baidu.com/play/video* // @match https://pan.baidu.com/pfile/video* // @match https://pan.baidu.com/pfile/mboxvideo* // @require https://unpkg.com/hls.js@1.6.16/dist/hls.min.js // @require https://unpkg.com/artplayer@5.4.0/dist/artplayer.js // @icon https://nd-static.bdstatic.com/m-static/v20-main/home/img/icon-home-new.b4083345.png // @run-at document-start // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_xmlhttpRequest // @license MIT // ==/UserScript== (function () { 'use strict'; // 1 配置(外部勿改) const CONFIG = Object.freeze({ userAgent: 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts', hls: Object.freeze({ debug: false, enableWorker: true, lowLatencyMode: false, backBufferLength: 120, maxBufferLength: 60, maxMaxBufferLength: 600, maxBufferSize: 60 * 1000 * 1000, maxBufferHole: 0.5, startLevel: -1, autoStartLoad: true, abrEwmaDefaultEstimate: 5000000, }), qualityTemplates: Object.freeze({ 1080: '超清 1080P', 720: '高清 720P', 480: '流畅 480P', 360: '省流 360P', }), qualityLevels: Object.freeze([1080, 720, 480, 360]), countdownSec: 3, hideDelayMs: 2000, saveDebounceMs: 2000, saveThrottleMs: 3000, progressMinSec: 5, progressTtlMs: 15 * 86400000, ui: Object.freeze({ topZ: 2147483647, controlMinHeight: 40, controlEmbedPx: 15, menuDebounceMs: 100, videoPollingIntervalMs: 300, videoPollingMaxMs: 1000, videoPollingCount: 30, drawerTransitionMs: 700, drawerTransitionEasing: 'cubic-bezier(.16,1,.3,1)', autonextRiseMs: 600, }), network: Object.freeze({ timeoutMs: 8000, }), // 下一集预取:提前解析 m3u8(+ 预热首分片连接),让切集零等待 prefetch: Object.freeze({ enabled: true, delayMs: 1500, // 切集后延迟触发,避开当前集缓冲抢带宽 minRemainingSec: 45, // 剩余不足该秒数时立即预取(临近片尾) maxAhead: 1, // 预取集数(当前集之后) warmSegment: true, // 额外用 Range 请求预热首分片连接 warmRangeBytes: 0, // bytes=0-N,仅握手不拉流;带宽可忽略 warmTimeoutMs: 4000, }), // 播放稳定性:起播超时看门狗 / 卡顿自愈 / 缓冲健康阈值 play: Object.freeze({ readyTimeoutMs: 20000, // 等待 loadedmetadata 的上限(超过视为起播失败) watchdogMs: 12000, // readyState 长时间不前进视为卡顿 stallRetries: 2, // 连续卡顿自愈次数 minBufferSec: 4, // 起播所需最小缓冲前向秒数 maxStartLagSec: 3, // 起播后允许的最大落后(超出则跳到缓冲前沿) }), retry: Object.freeze({ baseMs: 500, maxMs: 10000, hlsNetworkBaseMs: 1000, hlsNetworkMaxMs: 10000, hlsNetworkMaxAttempts: 3, evictKeep: 15, evictTrigger: 20, }), urls: Object.freeze({ sharePattern: '/s/', playPattern: '/play/video', videoPattern: '/pfile/video', mboxvideoPattern: '/mboxvideo', }), logPrefix: '[BDPlayer]', }); const ICONS = Object.freeze({ prev: '', next: '', }); const HLS_FETCH_SETUP = (_context, _initParams) => ({ method: 'GET', credentials: 'include', mode: 'cors', headers: { 'User-Agent': CONFIG.userAgent }, }); // hls.js ErrorDetails 常量:优先取库内枚举,取不到退回字面量(跨版本兼容) const HLS_DETAILS = Object.freeze({ get BUFFER_STALLED_ERROR() { return safe(() => Hls.ErrorDetails?.BUFFER_STALLED_ERROR) ?? 'bufferStalledError'; }, get MANIFEST_PARSING_ERROR() { return safe(() => Hls.ErrorDetails?.MANIFEST_PARSING_ERROR) ?? 'manifestParsingError'; }, }); // 2 通用工具(svg / escape / safe / debounce / tip / log / safeRun) const makeSvg = (inner) => `${inner}`; const once = (fn) => { let called = false; return (...args) => { if (called) return; called = true; fn(...args); }; }; const debounce = (fn, delay) => { let timer = null; const wrapped = (...args) => { clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...args); }, delay); }; wrapped.cancel = () => { if (timer != null) { clearTimeout(timer); timer = null; } }; wrapped.flush = () => { if (timer != null) { clearTimeout(timer); timer = null; fn(); } }; wrapped.pending = () => timer != null; return wrapped; }; // 解析 plugin 选项中的 getPlayer,统一 null 兜底 const resolveGetPlayer = (options) => () => (typeof options?.getPlayer === 'function' ? options.getPlayer() : null); const ERR_NO_URL = '无法获取播放地址,请检查登录状态'; const log = { announce(file) { const title = file ? getFileName(file) : ''; const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; console.info(CONFIG.logPrefix, `当前时间:${stamp}\n当前播放:${title}`); }, }; // 统一 toast 文案常量 const TIPS = { hlsNetworkDown: '网络持续错误,请刷新页面', qualitySwitchFail: '画质切换失败', playStuck: '播放异常,正在尝试恢复…', startTimeout: '起播超时,请重试或切换集数', }; const safe = (fn, _label = '') => { try { return fn(); } catch (_e) { return undefined; } }; const gmFetch = (url) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': CONFIG.userAgent }, responseType: 'text', timeout: CONFIG.network.timeoutMs, onload: (r) => resolve(r.responseText), onerror: (r) => reject(new Error(`HTTP ${r.status}`)), ontimeout: () => reject(new Error('timeout')), }); }); // 预热首连接:Range 只握手不拉流;部分 CDN 忽略 Range 返回 200, // 因此用 blob 接收并立即 abort,避免把整段 m3u8 拉进内存 const gmFetchWarm = (url) => new Promise((resolve) => { const req = GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': CONFIG.userAgent, Range: `bytes=0-${Math.max(0, CONFIG.prefetch.warmRangeBytes)}`, }, responseType: 'blob', timeout: CONFIG.prefetch.warmTimeoutMs, onload: () => { safe(() => req.abort?.(), 'warm.abort'); resolve(true); }, onerror: () => resolve(false), ontimeout: () => { safe(() => req.abort?.(), 'warm.abort'); resolve(false); }, onabort: () => resolve(false), }); }); async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout } = {}) { for (let i = 0; i < maxAttempts; i++) { try { const v = fn(); if (v) return v; } catch (_e) { /* keep polling */ } await new Promise((r) => setTimeout(r, intervalMs)); } if (onTimeout) safe(onTimeout); return null; } const base64Encode = (str) => { try { return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)))); } catch (_) { return null; } }; // 3 生命周期基类(Disposable / ListenerBag / EventBus / SwitchController) class Disposable { constructor() { this._disposed = false; this._childDisposables = new Set(); this._listeners = new ListenerBag(); } _track(d) { if (!d) return d; this._childDisposables.add(d); return d; } dispose() { if (this._disposed) return; this._disposed = true; this._childDisposables.forEach((d) => safe(() => d.dispose?.(), `${this.constructor.name}.dispose`)); this._childDisposables.clear(); this._listeners.clearAll(); } } class ListenerBag { constructor() { this._listeners = []; this._timers = new Set(); this._intervals = new Set(); } on(target, type, fn, options) { if (target?.addEventListener) { target.addEventListener(type, fn, options); this._listeners.push({ target, type, fn, options }); } else if (target?.on) { target.on(type, fn); this._listeners.push({ target, type, fn, options: undefined }); } } once(target, type, fn, options) { const wrapped = (...args) => { this.off(target, type, wrapped); fn(...args); }; this.on(target, type, wrapped, { ...(options || {}), once: true }); return wrapped; } off(target, type, fn, options) { if (target?.removeEventListener) { target.removeEventListener(type, fn, options); } else if (target?.off) { target.off(type, fn); } } setTimeout(fn, delay) { const id = setTimeout(() => { this._timers.delete(id); fn(); }, delay); this._timers.add(id); return id; } clearTimeout(id) { if (id == null) return; clearTimeout(id); this._timers.delete(id); } setInterval(fn, delay) { const id = setInterval(fn, delay); this._intervals.add(id); return id; } clearInterval(id) { if (id != null && this._intervals.has(id)) { clearInterval(id); this._intervals.delete(id); } } clearAll() { this._listeners.forEach(({ target, type, fn, options }) => { safe(() => this.off(target, type, fn, options), 'ListenerBag.clearAll'); }); this._listeners = []; this._timers.forEach((id) => clearTimeout(id)); this._timers.clear(); this._intervals.forEach((id) => clearInterval(id)); this._intervals.clear(); } } class EventBus { constructor() { this._handlers = new Map(); } on(event, handler) { if (!this._handlers.has(event)) this._handlers.set(event, new Set()); this._handlers.get(event).add(handler); return () => this.off(event, handler); } off(event, handler) { this._handlers.get(event)?.delete(handler); } emit(event, payload) { this._handlers.get(event)?.forEach((fn) => safe(() => fn(payload), `EventBus.${event}`)); } } class SwitchController { constructor() { this._running = false; this._pending = null; } submit(fn) { this._pending = fn; this._drain(); } async _drain() { if (this._running) return; while (this._pending) { const fn = this._pending; this._pending = null; this._running = true; try { await fn(); } catch (_e) { /* swallow */ } finally { this._running = false; } } } } // 4 存储(GM_* 优先,QuotaExceeded 时回收旧数据) const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; const STORAGE_PREFIX = 'video_progress_'; function evictOldProgress() { try { const entries = []; const visit = (rawKey) => { if (!rawKey || !rawKey.startsWith(STORAGE_PREFIX)) return; const obj = safe(() => JSON.parse(storage.getRawItem(rawKey)), 'evict.parse'); entries.push({ key: rawKey, time: obj?.timestamp || 0 }); }; if (hasGM) { const all = safe(() => GM_listValues(), 'evict.list') || []; all.forEach((k) => visit(k)); } else { const keys = safe(() => Object.keys(localStorage), 'evict.keys') || []; keys.forEach((k) => visit(k)); } if (entries.length <= CONFIG.retry.evictTrigger) return; entries.sort((a, b) => a.time - b.time); const toRemove = entries.slice(0, entries.length - CONFIG.retry.evictKeep); toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove')); } catch (_e) { /* evict 失败可忽略 */ } } const storage = { getItem(key) { try { if (hasGM) { return GM_getValue(key); } return localStorage.getItem(key); } catch (_e) { return null; } }, setItem(key, value) { try { if (hasGM) { GM_setValue(key, value); return; } localStorage.setItem(key, value); } catch (e) { if (e?.name === 'QuotaExceededError') { evictOldProgress(); try { if (hasGM) GM_setValue(key, value); else localStorage.setItem(key, value); } catch (_e2) { /* quota 二次失败可忽略 */ } } } }, removeItem(key) { try { if (hasGM) { GM_deleteValue(key); } else { localStorage.removeItem(key); } } catch (_e) { /* remove 失败可忽略 */ } }, getRawItem(key) { if (hasGM) return storage.getItem(key); try { return localStorage.getItem(key); } catch (_e) { return null; } }, }; // 5 选集元数据 + 进度键(时长/进度的单一来源) const fileKeyOf = (file) => { if (!file) return null; if (file.fs_id) return String(file.fs_id); if (file.path) { const encoded = base64Encode(file.path); return encoded ? `path_${encoded}` : null; } return null; }; const progressKey = (file) => { const k = fileKeyOf(file); return k ? `${STORAGE_PREFIX}${k}` : null; }; function parseStoredProgress(file) { const key = progressKey(file); if (!key) return null; const raw = storage.getRawItem(key); if (!raw) return null; let obj; try { obj = JSON.parse(raw); } catch (_e) { return null; } if (typeof obj?.currentTime !== 'number' || obj.currentTime < 0) return null; if (typeof obj?.timestamp !== 'number' || obj.timestamp <= 0) return null; // duration 必须 > 0(无 duration 的旧数据视为无效,避免下游百分比 / 边界计算异常) const duration = typeof obj.duration === 'number' && obj.duration > 0 ? obj.duration : 0; if (duration <= 0) return null; if (obj.currentTime > duration + 1) return null; if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) { storage.removeItem(key); return null; } if (obj.currentTime < CONFIG.progressMinSec) return null; return { currentTime: obj.currentTime, duration, timestamp: obj.timestamp }; } class EpisodeMeta extends Disposable { constructor() { super(); this._map = new Map(); this.events = new EventBus(); } _key(file) { return fileKeyOf(file); } _ensure(file) { const k = this._key(file); if (!k) return null; let entry = this._map.get(k); if (!entry) { entry = { duration: null, durationVerified: false, currentTime: null, timestamp: null }; this._map.set(k, entry); } return entry; } setDuration(file, sec, verified = false) { const entry = this._ensure(file); if (!entry || !Number.isFinite(sec) || sec <= 0) return; const dur = Math.ceil(sec); if (entry.durationVerified && !verified) return; if (entry.duration === dur && !!entry.durationVerified === !!verified) return; entry.duration = dur; entry.durationVerified = entry.durationVerified || !!verified; this.events.emit('duration', { file, duration: dur, verified: entry.durationVerified }); } isDurationVerified(file) { const entry = this._map.get(this._key(file)); return !!(entry && entry.duration != null && entry.durationVerified); } setProgress(file, currentTime, timestamp) { const entry = this._ensure(file); if (!entry) return; if (currentTime == null) { if (entry.currentTime == null && entry.timestamp == null) return; entry.currentTime = null; entry.timestamp = null; } else { // 边界保护:不让 currentTime 超过 known duration + 1s(防止下游百分比 / 时长计算异常) let capped = Math.floor(currentTime); if (entry.duration && entry.duration > 0 && capped > entry.duration + 1) { capped = Math.floor(entry.duration + 1); } // 脏检查:相同值不触发事件,避免选集菜单每帧更新 DOM if (entry.currentTime === capped) return; entry.currentTime = capped; entry.timestamp = timestamp ?? Date.now(); } this.events.emit('progress', { file, currentTime: entry.currentTime, timestamp: entry.timestamp }); } get(file) { const k = this._key(file); if (!k) return null; const entry = this._map.get(k); return entry ? { ...entry } : null; } getDuration(file) { const entry = this._map.get(this._key(file)); return entry?.duration ?? null; } prefetch(list) { if (!list?.length) return; for (const f of list) { if (!f) continue; const entry = this._ensure(f); if (!entry || entry.duration != null) continue; const sec = resolveFileDurationSec(f); if (sec != null) this.setDuration(f, sec, false); // 走 setDuration 保持 verified 语义 + emit 一致 } } preloadFromStorage(filelist) { if (!filelist?.length) return; for (const f of filelist) { if (!f) continue; const parsed = parseStoredProgress(f); if (!parsed) continue; const k = this._key(f); const existing = this._map.get(k) || {}; if (existing.duration == null) { existing.duration = parsed.duration; existing.durationVerified = true; } existing.currentTime = parsed.currentTime; existing.timestamp = parsed.timestamp; this._map.set(k, existing); this.events.emit('progress', { file: f, currentTime: existing.currentTime, timestamp: existing.timestamp }); } } clear() { this._map.clear(); this.events.emit('clear'); } dispose() { this.clear(); super.dispose(); } } // 6 文件排序 / 文件名 / 时间格式化 function sortByLocale(list) { return [...list].sort((a, b) => (a.server_filename || a.name || '').localeCompare(b.server_filename || b.name || '', undefined, { numeric: true }) ); } const getFileName = (f) => f?.server_filename || f?.name || '未命名'; // 提取视频时长(秒),兼容多种字段名且接受秒或毫秒 // 百度网盘 API 经验: // - 顶层 f.duration:秒(来自 categorylist 元数据) // - f.video_info.duration:秒 // - f.media_info.duration:毫秒 const resolveFileDurationSec = (f) => { if (!f || typeof f !== 'object') return null; // media_info 优先判定为毫秒 if (Number.isFinite(f.media_info?.duration) && f.media_info.duration > 0) { const ms = f.media_info.duration; // 兜底:超过 1e7(≈115 天毫秒)几乎不可能,取整数 const sec = ms > 1e7 ? Math.round(ms / 1000) : Math.floor(ms / 1000); return sec > 0 ? sec : null; } const raw = f.duration ?? f.video_info?.duration ?? null; if (!Number.isFinite(raw) || raw <= 0) return null; // 顶层字段默认是秒;兜底阈值 86400(24 小时)超过此值视为毫秒(异常情况) const sec = raw > 86400 ? Math.floor(raw / 1000) : Math.floor(raw); return sec > 0 ? sec : null; }; function formatTime(sec) { if (!Number.isFinite(sec) || sec < 0) return '0:00'; sec = Math.floor(sec); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const pad = (n) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } function showTip(msg) { const fns = [ () => unsafeWindow.require('system-core:system/uiService/tip/tip.js').show({ mode: 'success', msg }), () => unsafeWindow.toast?.show({ type: 'svip', message: msg, duration: 3000 }), () => unsafeWindow.$bus?.$Toast?.addToast?.({ type: 'success', content: msg, durtime: 3000 }), ]; fns.some((fn) => safe(fn)); } // 7 URL 构建(share / file / mboxvideo 三种取流地址) const checkJsToken = () => { if (!unsafeWindow.jsToken) { showTip('登录状态异常,请刷新页面'); return false; } return true; }; function buildFileUrl(file) { if (!checkJsToken()) return () => null; return (type) => `/api/streaming?path=${encodeURIComponent(file.path)}&app_id=250528&clienttype=0&type=${type}&jsToken=${unsafeWindow.jsToken}`; } function buildMboxUrl(file) { if (!file || !file.to || !file.msg_id) return null; return (stream_type) => { const params = new URLSearchParams({ to: file.to, from_uk: file.from_uk ?? '', msg_id: file.msg_id, fs_id: file.fs_id ?? '', type: file.type ?? '', stream_type, trans: file.trans ?? '', ltime: file.ltime ?? '', }); return `/mbox/msg/streaming?${params.toString()}`; }; } function buildShareUrl(file) { if (!checkJsToken()) return null; const locals = unsafeWindow.locals; const get = (k) => { try { return typeof locals.get === 'function' ? locals.get(k) : locals[k]; } catch (_) { return null; } }; const [share_uk, shareid, sign, timestamp] = ['share_uk', 'shareid', 'sign', 'timestamp'].map(get); if (!share_uk || !sign || !timestamp) return null; const token = unsafeWindow.jsToken; return (type) => `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&jsToken=${token}`; } // 8 HLS 协议层(HlsController;缓存 + 指数退避重试) class HlsController extends Disposable { constructor() { super(); this.instance = null; this._fileKey = 'default'; this._getUrlForQuality = null; this._retries = {}; this._cacheList = []; this._pendingRequests = new Map(); this._backoffTimers = new Set(); } get fileKey() { return this._fileKey; } _findEpisodeIndex(fileKey) { if (!fileKey) return -1; for (let i = 0; i < this._cacheList.length; i++) { if (this._cacheList[i].fileKey === fileKey) return i; } return -1; } _getOrCreateQualities(fileKey) { const idx = this._findEpisodeIndex(fileKey); if (idx >= 0) return this._cacheList[idx].qualities; const entry = { fileKey, qualities: [] }; this._cacheList.push(entry); return entry.qualities; } _findQualityUrl(qualities, q) { for (let i = 0; i < qualities.length; i++) { if (qualities[i].q === q) return qualities[i].r; } return null; } setUrlBuilder(fn) { this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality); } resolveUrl(url) { return gmFetch(url).then((text) => { if (!text) return null; if (text.trim().startsWith('#EXTM3U')) return url; let json; try { json = JSON.parse(text); } catch (_) { return null; } if (json.errno === 133 && json.adToken) return `${url}&adToken=${encodeURIComponent(json.adToken)}`; return null; }).catch(() => null); } async resolveEpisodeQualities(fileKey, candidates, getUrl) { if (!fileKey || !Array.isArray(candidates) || !candidates.length) return []; const qualities = this._getOrCreateQualities(fileKey); const tasks = candidates.map(async (q) => { const cached = this._findQualityUrl(qualities, q); if (cached) return { q, r: cached }; const url = getUrl?.('M3U8_AUTO_' + q); if (!url) return null; if (this._pendingRequests.has(url)) { await this._pendingRequests.get(url); const cached2 = this._findQualityUrl(qualities, q); return cached2 ? { q, r: cached2 } : null; } const promise = this.resolveUrl(url); this._pendingRequests.set(url, promise); try { const r = await promise; if (r) { // 并发下同一 q 可能被两条路径同时写入,去重后再 push if (!this._findQualityUrl(qualities, q)) qualities.push({ q, r }); return { q, r }; } return null; } finally { this._pendingRequests.delete(url); } }); const results = (await Promise.all(tasks)).filter(Boolean); results.sort((a, b) => b.q - a.q); return results; } // 该集是否已有可用解析结果(至少一个清晰度命中即可,切集只需要最佳档) hasResolvedQuality(fileKey) { const idx = this._findEpisodeIndex(fileKey); return idx >= 0 && this._cacheList[idx].qualities.some((it) => it?.r); } // 取已缓存的解析结果(不触发网络);用于预取后读取最佳清晰度 URL peekEpisodeQualities(fileKey) { const idx = this._findEpisodeIndex(fileKey); if (idx < 0) return []; return this._cacheList[idx].qualities .filter((it) => it?.q != null && it?.r) .map((it) => ({ q: it.q, r: it.r })) .sort((a, b) => b.q - a.q); } // 预热主播放地址的连接:Range 握手,避免切集时 DNS/TCP/TLS 冷启动 warmConnection(url) { if (!url || !CONFIG.prefetch.warmSegment) return Promise.resolve(false); // 已在解析中的 URL 无需再预热(同一连接会被复用) if (this._pendingRequests.has(url)) return Promise.resolve(false); return gmFetchWarm(url).then(() => true).catch(() => false); } // 组合预取:解析清晰度 + 预热连接。失败静默(预取是纯优化,不影响播放) async prefetchEpisode(fileKey, candidates, getUrl) { if (!CONFIG.prefetch.enabled || !fileKey) return null; // 已有可用结果 → 只补一次连接预热,不重复解析 if (this.hasResolvedQuality(fileKey)) { const best = this.peekEpisodeQualities(fileKey)[0]?.r; if (best) this.warmConnection(best); return this.peekEpisodeQualities(fileKey); } let resolved = null; try { resolved = await this.resolveEpisodeQualities(fileKey, candidates, getUrl); } catch (_e) { return null; // 预取失败不影响正常切集流程 } const best = resolved?.[0]?.r; if (best) this.warmConnection(best); return resolved || []; } _scheduleBackoff(action, opt, ownerHls) { const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {}; if (attempts >= maxAttempts) { if (onGiveUp) safe(onGiveUp, 'backoff.giveUp'); return false; } const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs); const timerId = setTimeout(() => { this._backoffTimers.delete(timerId); if (ownerHls && ownerHls !== this.instance) return; safe(action, 'backoff.action'); }, delay); this._backoffTimers.add(timerId); return true; } _clearBackoffTimers() { this._backoffTimers.forEach((id) => clearTimeout(id)); this._backoffTimers.clear(); } create(url, video, fileKey, onGiveUpNetwork) { if (!Hls.isSupported()) { if (video?.canPlayType?.('application/vnd.apple.mpegurl')) { this._fileKey = fileKey || 'default'; this._retries[this._fileKey] = 0; video.src = url; this.instance = null; return null; } showTip('浏览器不支持视频播放'); return null; } this._fileKey = fileKey || 'default'; this._retries[this._fileKey] = 0; const hls = new Hls({ ...CONFIG.hls, fetchSetup: HLS_FETCH_SETUP }); // 卡顿自愈计数:同一集内 MEDIA_ERROR 连续恢复失败超过上限就停手,避免 recoverMediaError 死循环 const mediaRetries = { n: 0 }; hls.on(Hls.Events.ERROR, (_, data) => { if (this.instance !== hls) return; if (!data.fatal) { // 非致命卡顿(缓冲停摆):hls 不会自动重试,主动 nudge 一下 if (data.details === HLS_DETAILS.BUFFER_STALLED_ERROR) { safe(() => hls.startLoad(), 'hls.nudgeStalled'); } return; } if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.details === HLS_DETAILS.MANIFEST_PARSING_ERROR) { showTip('视频地址无效'); return; } const retries = this._retries[this._fileKey] || 0; this._retries[this._fileKey] = retries + 1; const MAX = CONFIG.retry.hlsNetworkMaxAttempts; this._scheduleBackoff( () => { if (this.instance === hls) hls.startLoad(); }, { attempts: retries, maxAttempts: MAX, baseMs: CONFIG.retry.hlsNetworkBaseMs, maxMs: CONFIG.retry.hlsNetworkMaxMs, onGiveUp: onGiveUpNetwork, }, hls, ); showTip(`网络错误,指数重试 ${retries + 1}/${MAX}…`); } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { // recoverMediaError 会 swap/codec 重来,无限调用会把播放彻底卡死 → 限次 if (mediaRetries.n >= CONFIG.play.stallRetries) { showTip(TIPS.playStuck); safe(() => hls.recoverMediaError(), 'hls.recoverFinal'); return; } mediaRetries.n += 1; safe(() => hls.recoverMediaError(), 'hls.recoverMedia'); } else { showTip('播放失败,请刷新重试'); } }); // 播放恢复(FRAG_BUFFERED)后重置卡顿计数:说明问题已自愈 hls.on(Hls.Events.FRAG_BUFFERED, () => { mediaRetries.n = 0; }); hls.loadSource(url); hls.attachMedia(video); this.instance = hls; return hls; } destroy() { if (this._disposed) return; this._clearBackoffTimers(); if (!this.instance) return; const hls = this.instance; this.instance = null; safe(() => { hls.stopLoad(); hls.detachMedia(); hls.destroy(); }, 'HlsController.destroy'); } dispose() { this.destroy(); super.dispose(); } } // 9 切换快照 SwitchSnapshot class SwitchSnapshot { static capture(art) { if (!art?.video) return null; return { currentTime: art.currentTime || 0, volume: art.video.volume ?? 1, muted: !!art.video.muted, playing: !art.video.paused, }; } static apply(art, snap) { if (!art?.video || !snap) return; art.video.muted = snap.muted; art.video.volume = snap.volume; art.currentTime = snap.currentTime; if (snap.playing) art.video.play().catch(() => { }); } } // 画质候选推导:从 resolution 字符串取高度,过滤出不超过源高度的候选(降序) const qualityCandidatesOf = (resolution) => { const match = typeof resolution === 'string' ? resolution.match(/width:(\d+),height:(\d+)/) : null; const videoHeight = match ? +match[2] : 0; const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a); return videoHeight > 0 ? sortedDesc.filter((q) => q <= videoHeight) : sortedDesc; }; // 10 画质控制 QualityController class QualityController extends Disposable { constructor(hlsController) { super(); this.hls = hlsController; this.list = []; this.getUrl = null; this._switchState = null; this._art = null; this._qualityHandler = null; } async build(resolution, getUrl, fileKey = this.hls.fileKey) { this.getUrl = getUrl; const candidates = qualityCandidatesOf(resolution); const resolvedList = await this.hls.resolveEpisodeQualities(fileKey, candidates, getUrl); if (!resolvedList.length) { this.list = []; return; } this.list = resolvedList.map((item, i) => ({ html: CONFIG.qualityTemplates[item.q], url: item.r, default: i === 0, })); } // 预取入口:只填 HLS 解析缓存,不动 this.list(避免预取污染当前清晰度菜单) prefetch(resolution, getUrl, fileKey) { if (!fileKey) return Promise.resolve(null); const candidates = qualityCandidatesOf(resolution); return this.hls.prefetchEpisode(fileKey, candidates, getUrl); } apply(art) { if (art && this.list?.length) art.quality = this.list; } bind(art) { if (!art) return; if (this._qualityHandler && this._art === art) return; if (this._qualityHandler && this._art && this._art !== art) this.unbind(); this._art = art; this._qualityHandler = async (url) => { if (!url) return; const newUrl = await this.hls.resolveUrl(url); if (!newUrl) { showTip('画质切换失败'); return; } if (this.hls.instance && this.hls.instance.url === newUrl) return; this._switchState = SwitchSnapshot.capture(art); this.hls.destroy(); safe(() => { art.video.pause(); art.video.removeAttribute('src'); art.video.load(); }, 'quality.resetVideo'); const newHls = this.hls.create(newUrl, art.video, this.hls.fileKey, () => showTip(TIPS.qualitySwitchFail)); if (!newHls) { this._switchState = null; return; } newHls.once(Hls.Events.MANIFEST_PARSED, () => { const state = this._switchState; if (!state) return; this._switchState = null; SwitchSnapshot.apply(art, state); }); }; art.on('quality', this._qualityHandler); } unbind() { if (this._art && this._qualityHandler) { safe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind'); } this._art = null; this._qualityHandler = null; } dispose() { this.unbind(); super.dispose(); } } // 11 进度持久化 ProgressSaveAgent class ProgressSaveAgent extends Disposable { constructor(playerRef, episodeMeta) { super(); this.playerRef = playerRef; this.episodeMeta = episodeMeta; this._file = () => this.playerRef()?.file || null; } getKey(file = this._file()) { return progressKey(file); } save(file = this._file()) { const key = progressKey(file); const art = this.playerRef()?.art; if (!key || !art?.currentTime || art.currentTime < 0) return; // 归属校验:显式传入的 file 必须是当前集,否则拒绝写入。 // 防止切集竞态下把 A 集的播放位置写进 B 集的 key。 const current = this._file(); if (file && current && fileKeyOf(file) !== fileKeyOf(current)) return; let currentTime = art.currentTime; const duration = art.duration; // 防御性 sanity check:仅当 episodeMeta 里有 verified duration 时才做严格比对, // 避免 prefetch 阶段(durationVerified=false)的脏值误杀首次写入 const metaEntry = file ? this.episodeMeta.get(file) : null; const liveDur = Number.isFinite(duration) && duration > 0 ? Math.ceil(duration) : null; const metaDur = metaEntry?.durationVerified && Number.isFinite(metaEntry.duration) && metaEntry.duration > 0 ? Math.ceil(metaEntry.duration) : null; if (metaDur != null && liveDur != null && Math.abs(metaDur - liveDur) > 5) return; // 边界保护:currentTime 不能超过 duration + 1s,否则下次 parseStoredProgress 会拒绝并丢失 if (liveDur != null && currentTime > liveDur + 1) currentTime = liveDur + 1; if (liveDur != null) { const data = JSON.stringify({ currentTime, duration: liveDur, timestamp: Date.now(), }); storage.setItem(key, data); } this.episodeMeta.setProgress(file, currentTime, Date.now()); } cancelPending() { this._pendingSave?.cancel?.(); } flush() { // 立即执行一次 save(绕过 debounce),用于切集前等关键路径 this.cancelPending(); this.save(); } dispose() { this.cancelPending(); this._pendingSave = null; super.dispose(); } load() { const file = this._file(); const art = this.playerRef()?.art; if (!file || !art) return; const obj = parseStoredProgress(file); if (!obj) return; this.episodeMeta.setProgress(file, obj.currentTime, obj.timestamp); } applyResume(saved) { const art = this.playerRef()?.art; if (!art || !saved) return; // 先取消正在排队的 debounce:seek 完成后立刻持久化 saved(不再让旧 currentTime 覆盖) this.cancelPending(); try { art.currentTime = saved.currentTime; } catch (_) { /* 未就绪容错 */ } const file = this._file(); this.episodeMeta.setProgress(file, saved.currentTime, saved.timestamp ?? Date.now()); // 同步写入 storage,确保下次启动仍能拿到正确进度 const key = progressKey(file); if (key) { const dur = Number.isFinite(art.duration) && art.duration > 0 ? Math.ceil(art.duration) : (saved.duration ?? 0); const payload = JSON.stringify({ currentTime: saved.currentTime, duration: dur, timestamp: saved.timestamp ?? Date.now(), }); safe(() => storage.setItem(key, payload), 'applyResume.persist'); } } clear(file = this._file()) { const key = progressKey(file); if (!key) return; safe(() => storage.removeItem(key), 'clearProgress'); this.episodeMeta.setProgress(file, null, null); } getSavedFor(file) { if (!file) return null; const entry = this.episodeMeta.get(file); // entry 需同时有 currentTime 与 duration > 0 才可信;否则回退到 storage if (entry?.currentTime != null && entry.timestamp > 0 && entry.duration > 0) { return { currentTime: entry.currentTime, duration: entry.duration }; } return parseStoredProgress(file); } } // 12 选集面板 CSS const DRAWER_WIDTH = 280; const DRAWER_WIDTH_NARROW = 220; const DRAWER_NARROW_BREAKPOINT = 480; const DRAWER_HEIGHT = 400; const DRAWER_TRANSITION_MS = CONFIG.ui.drawerTransitionMs; const calcDrawerPosition = () => { const width = window.innerWidth <= DRAWER_NARROW_BREAKPOINT ? DRAWER_WIDTH_NARROW : DRAWER_WIDTH; return { right: 0, top: '50%', marginTop: -DRAWER_HEIGHT / 2, height: DRAWER_HEIGHT, width, }; }; const EPISODE_MENU_CSS = ` .ep-menu{background:rgba(18,18,22,.96);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.08);border-right:none;border-radius:12px 0 0 12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,.65),0 1px 0 rgba(255,255,255,.04) inset;pointer-events:auto;will-change:transform} .ep-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,.07);flex-shrink:0} .ep-header-title{color:#fff;font-size:12px;font-weight:600;letter-spacing:.3px;font-family:system-ui,sans-serif} .ep-header-count{color:rgba(255,255,255,.38);font-size:11px;font-family:system-ui,sans-serif} .ep-list{overflow-y:auto;padding:6px 8px 8px;flex:1;min-height:0} .ep-list::-webkit-scrollbar{width:4px} .ep-list::-webkit-scrollbar-track{background:transparent} .ep-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:2px} .ep-list::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.28)} .ep-item{display:flex;align-items:center;min-height:44px;padding:5px 10px;gap:8px;border-radius:8px;cursor:pointer;transition:background .15s;box-sizing:border-box;user-select:none;outline:none} .ep-item:focus-visible{box-shadow:0 0 0 2px rgba(30,144,255,.5);background:rgba(255,255,255,.07)} .ep-item:hover{background:rgba(255,255,255,.07)} .ep-item--active{background:rgba(30,144,255,.14)} .ep-item--active:hover{background:rgba(30,144,255,.20)} .ep-index{width:26px;flex-shrink:0;display:flex;align-items:center;justify-content:center} .ep-num{color:rgba(255,255,255,.3);font-size:12px;font-variant-numeric:tabular-nums;font-family:system-ui,sans-serif} .ep-item--active .ep-num{color:rgba(30,144,255,.8)} .ep-playing-bar{display:flex;align-items:flex-end;gap:2px;height:14px} .ep-playing-bar span{display:block;width:3px;border-radius:2px;background:#1e90ff;animation:epBar .9s ease-in-out infinite alternate} .ep-playing-bar span:nth-child(1){height:5px;animation-delay:0s} .ep-playing-bar span:nth-child(2){height:12px;animation-delay:.2s} .ep-playing-bar span:nth-child(3){height:7px;animation-delay:.38s} @keyframes epBar{from{transform:scaleY(.35)}to{transform:scaleY(1)}} .ep-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} .ep-name{color:rgba(255,255,255,.82);font-size:11px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:system-ui,sans-serif} .ep-item--active .ep-name{color:#fff;font-weight:500} .ep-meta{display:flex;align-items:center;gap:8px;min-height:14px} .ep-duration{color:rgba(255,255,255,.28);font-size:10px;font-variant-numeric:tabular-nums;letter-spacing:.2px;font-family:system-ui,sans-serif} .ep-item--active .ep-duration{color:rgba(30,144,255,.6)} .ep-progress{color:rgba(255,185,60,.75);font-size:10px;font-variant-numeric:tabular-nums;letter-spacing:.2px;font-family:system-ui,sans-serif;flex-shrink:0;max-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block} .ep-item--active .ep-progress{color:rgba(255,185,60,1)} .art-control-prev,.art-control-next,.art-control-episodes{opacity:.8;cursor:pointer;transition:opacity .2s} .art-control-prev:hover,.art-control-next:hover,.art-control-episodes:hover{opacity:1} div.art-control-prev.art-ep-disabled,div.art-control-next.art-ep-disabled{opacity:.5} .artplayer-title{position:absolute;top:0;left:0;right:0;height:48px;display:flex;align-items:center;padding:0 16px 0 56px;background:linear-gradient(to bottom,rgba(0,0,0,.62) 0%,rgba(0,0,0,.32) 60%,rgba(0,0,0,0) 100%),linear-gradient(to right,rgba(0,0,0,.25) 0%,rgba(0,0,0,0) 30%);font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;box-sizing:border-box;transition:opacity var(--art-transition-duration,.25s) ease,transform var(--art-transition-duration,.25s) ease,visibility 0s linear 0s;z-index:60;pointer-events:none;opacity:0;visibility:hidden;transform:translateY(-6px);will-change:opacity,transform} .artplayer-title.art-show{opacity:1;visibility:visible;transform:translateY(0);transition:opacity var(--art-transition-duration,.25s) ease,transform var(--art-transition-duration,.25s) ease,visibility 0s linear 0s} .art-title-text{color:#fff;font-size:15px;font-weight:500;letter-spacing:.15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-shadow:0 1px 2px rgba(0,0,0,.75),0 0 6px rgba(0,0,0,.35);line-height:1.2} @media (max-width:640px){.artplayer-title{height:40px;padding:0 12px 0 36px}.art-title-text{font-size:13px}} .artplayer-autonext{position:absolute;left:0;right:0;display:flex;justify-content:center;pointer-events:none;padding:0 16px;box-sizing:border-box;animation:art-autonext-rise ${CONFIG.ui.autonextRiseMs}ms ease-out} .art-layers:not(.art-show) .artplayer-autonext{opacity:1!important;visibility:visible!important} .art-layers:not(.art-show):has(.artplayer-autonext){opacity:1!important;visibility:visible!important} .artplayer-autonext-inner{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 12px;background:#3a3d42;border:1px solid rgba(0,0,0,.25);border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.35),0 1px 0 rgba(255,255,255,.06) inset;color:#fff;font-family:system-ui,-apple-system,sans-serif;max-width:calc(100% - 32px);pointer-events:auto} .artplayer-autonext-text{display:flex;flex-direction:column;min-width:0;gap:1px} .artplayer-autonext-title{font-size:12px;font-weight:500;letter-spacing:.1px;max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:rgba(255,255,255,.92)} .artplayer-autonext-sub{font-size:10.5px;opacity:.7;font-variant-numeric:tabular-nums;letter-spacing:.1px;color:rgba(255,255,255,.82)} .artplayer-autonext-sub b{font-weight:600;color:rgba(255,255,255,.95)} .artplayer-autonext-actions{display:flex;align-items:center;gap:6px;flex-shrink:0;margin-left:2px} .artplayer-autonext-cancel{padding:4px 10px;font-size:11px;color:rgba(255,255,255,.82);background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:999px;cursor:pointer;transition:background .15s ease,transform .1s ease;font-family:inherit;line-height:1.4} .artplayer-autonext-cancel:hover{background:rgba(255,255,255,.16)} .artplayer-autonext-cancel:active{transform:scale(.96)} .artplayer-autonext-play{padding:4px 10px;font-size:11px;color:#1f2328;background:#fff;border:0;border-radius:999px;cursor:pointer;font-weight:600;transition:background .15s ease,transform .1s ease;font-family:inherit;line-height:1.4} .artplayer-autonext-play:hover{background:#f0f0f0} .artplayer-autonext-play:active{transform:scale(.96)} @keyframes art-autonext-rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} .artplayer-autoplayback{display:none;gap:10px;align-items:center;position:absolute;border-radius:var(--art-border-radius);padding:var(--art-padding);line-height:1;left:var(--art-padding);bottom:calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);background-color:var(--art-widget-background);color:#fff;font-family:system-ui,-apple-system,sans-serif;z-index:2147483647;pointer-events:auto;box-sizing:border-box;animation:art-autoplayback-rise 240ms ease-out} .artplayer-autoplayback.is-shown{display:flex} .artplayer-autoplayback-text{color:#fff;font-size:13px;font-weight:500;line-height:1.2;font-variant-numeric:tabular-nums;white-space:nowrap} .artplayer-autoplayback-actions{display:flex;align-items:center;flex-shrink:0;margin-left:4px} .artplayer-autoplayback-btn{color:#fff;cursor:pointer;padding:2px 6px;border-radius:var(--art-border-radius);font-size:12px;line-height:1.4;background:transparent;border:0;font-family:inherit;transition:opacity .15s ease} .artplayer-autoplayback-btn:hover{opacity:.75} .artplayer-autoplayback-btn:active{transform:scale(.96)} .artplayer-autoplayback-btn--primary{color:#ff3b30;font-weight:500} @keyframes art-autoplayback-rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} `; // 13 选集面板 DOM + 行为 EpisodeMenu // 状态机:open → 绑定"光标/指针在面板内"信号 → setActive(true) // mouseleave → 启动 200ms grace 二次确认 → 无人活动 → close() // close() → 集中解绑所有 listener → 清理 timer → DOM 移除 // 关闭触发源:① 用户主动 close ② mouseleave grace 超时 ③ 外部 scheduleHide 检测到光标不在面板内 // "光标在面板内":mouseenter / pointerdown / touchstart 任一维持 _menuActive=true // 注意:focusin 不算(面板打开时自动 focus 不代表光标在面板内) const GRACE_MS = 200; class EpisodeMenu extends Disposable { constructor() { super(); this._el = null; this._resizeHandler = null; this._menuListenerOff = null; this._playerRef = null; this._menuActive = false; this._pendingCloseTimer = null; this._hooks = { onOpen: null, onClose: null }; } _getPlayer() { return this._playerRef || null; } _findItem(file) { if (!this._el || !file) return null; const key = fileKeyOf(file); if (!key) return null; return this._el.querySelector(`[data-ep-key="${CSS.escape(String(key))}"]`); } _buildItem(player, f, i, idx) { const isCurrent = i === idx; const name = getFileName(f); const key = fileKeyOf(f); const cachedDur = player.episodeMeta.getDuration(f); const saved = player.episodeMeta.get(f); const item = document.createElement('div'); item.className = `ep-item${isCurrent ? ' ep-item--active' : ''}`; item.dataset.epKey = String(key); item.setAttribute('role', 'option'); item.setAttribute('aria-selected', String(isCurrent)); item.setAttribute('tabindex', '0'); const indexEl = document.createElement('div'); indexEl.className = 'ep-index'; const indexInner = document.createElement('span'); indexInner.className = isCurrent ? 'ep-playing-bar' : 'ep-num'; if (isCurrent) indexInner.innerHTML = ''; else indexInner.textContent = String(i + 1); indexEl.appendChild(indexInner); const textEl = document.createElement('div'); textEl.className = 'ep-text'; const nameEl = document.createElement('div'); nameEl.className = 'ep-name'; nameEl.textContent = name; nameEl.title = name; const metaEl = document.createElement('div'); metaEl.className = 'ep-meta'; const durEl = document.createElement('span'); durEl.className = 'ep-duration'; durEl.textContent = cachedDur != null ? formatTime(cachedDur) : ''; metaEl.appendChild(durEl); if (saved && saved.currentTime != null && saved.duration > 0) { const progressEl = document.createElement('span'); progressEl.className = 'ep-progress'; const percent = Math.floor((saved.currentTime / saved.duration) * 100); progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`; progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`; metaEl.appendChild(progressEl); } textEl.appendChild(nameEl); textEl.appendChild(metaEl); item.appendChild(indexEl); item.appendChild(textEl); const handleClick = () => { this.close(); if (i !== idx) { player.switchVideo(f); player.art?.plugins?.episodeNav?.setIndex(i); } }; item.addEventListener('click', handleClick); item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }); return item; } _applyPosition() { if (!this._el) return; const pos = calcDrawerPosition(); const { style } = this._el; style.right = pos.right + 'px'; style.top = pos.top; style.bottom = 'auto'; style.marginTop = pos.marginTop + 'px'; style.height = pos.height + 'px'; style.width = pos.width + 'px'; } // 集中绑定/解绑"光标在面板内活动"信号,避免 close 后遗留 listener _bindMenuActivity(el) { const setActive = (active) => { const next = !!active; if (this._menuActive === next && this._pendingCloseTimer == null) return; this._menuActive = next; if (this._pendingCloseTimer != null) { clearTimeout(this._pendingCloseTimer); this._pendingCloseTimer = null; } if (!next && this._el === el) { this._pendingCloseTimer = setTimeout(() => { this._pendingCloseTimer = null; if (this._menuActive || this._el !== el) return; this.close(); }, GRACE_MS); } }; const handlers = [ ['mouseenter', () => setActive(true)], ['pointerdown', () => setActive(true), { capture: true }], ['touchstart', () => setActive(true), { passive: true }], ['mouseleave', () => setActive(false)], ]; handlers.forEach(([type, fn, opts]) => el.addEventListener(type, fn, opts)); this._menuListenerOff = () => { handlers.forEach(([type, fn, opts]) => { try { el.removeEventListener(type, fn, opts); } catch (_) { /* el 已移除 */ } }); }; } _resetState() { if (this._pendingCloseTimer != null) { clearTimeout(this._pendingCloseTimer); this._pendingCloseTimer = null; } this._menuActive = false; if (this._menuListenerOff) { try { this._menuListenerOff(); } catch (_) { /* 容错 */ } this._menuListenerOff = null; } if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler.cancel?.(); this._resizeHandler = null; } } open(player) { const target = player || this._getPlayer(); if (!target) return; EpisodeMenu.ensureStyle(); if (this._el) { // 重开:先彻底清理旧实例 const oldEl = this._el; this._el = null; this._resetState(); if (typeof this._hooks.onClose === 'function') this._hooks.onClose(); try { oldEl.remove(); } catch (_) { /* 容错 */ } } const idx = target.getCurrentIndex(); const pos = calcDrawerPosition(); const menu = document.createElement('div'); menu.className = 'ep-menu'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', '选集列表'); menu.style.cssText = `position:fixed;right:${pos.right}px;top:${pos.top};margin-top:${pos.marginTop}px;height:${pos.height}px;width:${pos.width}px;z-index:${CONFIG.ui.topZ};transform:translateX(100%);transition:transform ${DRAWER_TRANSITION_MS}ms ${CONFIG.ui.drawerTransitionEasing};`; menu.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); }, { passive: false }); const header = document.createElement('div'); header.className = 'ep-header'; header.innerHTML = `选集列表${target.filelist.length}集`; menu.appendChild(header); const list = document.createElement('div'); list.className = 'ep-list'; list.setAttribute('role', 'listbox'); const fragment = document.createDocumentFragment(); target.filelist.forEach((f, i) => fragment.appendChild(this._buildItem(target, f, i, idx))); list.appendChild(fragment); menu.appendChild(list); const host = (target?.art?.fullscreenWeb ? document.body : target?.art?.template?.$player) || document.querySelector('#artplayer') || document.body; host.appendChild(menu); menu.style.pointerEvents = 'auto'; this._el = menu; this._playerRef = target; requestAnimationFrame(() => { if (this._el !== menu) return; menu.style.transform = 'translateX(0)'; const active = list.querySelector('.ep-item--active'); if (active) { active.focus(); active.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }); this._bindMenuActivity(menu); this._resizeHandler = debounce(() => this._applyPosition(), CONFIG.ui.menuDebounceMs); window.addEventListener('resize', this._resizeHandler); target.episodeMeta.prefetch(target.filelist); if (typeof this._hooks.onOpen === 'function') this._hooks.onOpen(); } close() { const el = this._el; const wasOpen = !!el; this._resetState(); if (!el) return; el.style.pointerEvents = 'none'; const finalize = once(() => { el.removeEventListener('transitionend', finalize); el.remove(); }); el.addEventListener('transitionend', finalize); this._listeners.setTimeout(finalize, DRAWER_TRANSITION_MS + 50); el.style.transform = 'translateX(100%)'; this._el = null; this._playerRef = null; if (wasOpen && typeof this._hooks.onClose === 'function') this._hooks.onClose(); } toggle(player) { if (this._el) this.close(); else this.open(player); } isOpen() { return !!this._el; } isMenuActive() { return !!this._el && this._menuActive === true; } updateDuration(file, sec) { const item = this._findItem(file); if (!item) return; const el = item.querySelector('.ep-duration'); if (el) el.textContent = formatTime(sec); } updateActiveState(idx) { if (!this._el) return; const items = this._el.querySelectorAll('.ep-item'); items.forEach((item, i) => { const isActive = i === idx; item.classList.toggle('ep-item--active', isActive); item.setAttribute('aria-selected', String(isActive)); const indexWrap = item.querySelector('.ep-index'); if (!indexWrap) return; const existing = indexWrap.firstElementChild; if (isActive) { if (!existing || !existing.classList.contains('ep-playing-bar')) { if (existing) existing.remove(); const bar = document.createElement('span'); bar.className = 'ep-playing-bar'; bar.innerHTML = ''; indexWrap.appendChild(bar); } } else { if (!existing || !existing.classList.contains('ep-num')) { if (existing) existing.remove(); const num = document.createElement('span'); num.className = 'ep-num'; num.textContent = String(i + 1); indexWrap.appendChild(num); } } }); } updateItemProgress(file) { if (!this._el || !file) return; const player = this._getPlayer(); const item = this._findItem(file); const metaEl = item?.querySelector('.ep-meta'); if (!metaEl) return; const saved = player?.episodeMeta.get(file); let progressEl = metaEl.querySelector('.ep-progress'); if (!saved || saved.currentTime == null || !saved.duration || saved.duration <= 0) { if (progressEl) progressEl.remove(); return; } if (!progressEl) { progressEl = document.createElement('span'); progressEl.className = 'ep-progress'; metaEl.appendChild(progressEl); } const percent = Math.floor((saved.currentTime / saved.duration) * 100); progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`; progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`; } setHooks({ onOpen, onClose } = {}) { this._hooks.onOpen = onOpen || null; this._hooks.onClose = onClose || null; } destroy() { this.close(); } dispose() { this.destroy(); super.dispose(); } } const _episodeMenu = new EpisodeMenu(); const getEpisodeMenu = () => _episodeMenu; // 14 ArtPlayer 插件(选集 / 上下集 / 标题栏 / 继续播放 / 自动连播 / 快捷键) EpisodeMenu.PLUGIN_NAME = 'episodeMenu'; EpisodeMenu.ensureStyle = function ensureStyle() { if (EpisodeMenu._styleInjected || document.getElementById('ep-menu-style')) { EpisodeMenu._styleInjected = true; return; } const style = document.createElement('style'); style.id = 'ep-menu-style'; style.textContent = EPISODE_MENU_CSS; document.head.appendChild(style); EpisodeMenu._styleInjected = true; }; EpisodeMenu.installPlugin = function installEpisodeMenuPlugin(options = {}) { const menu = getEpisodeMenu(); const getPlayer = resolveGetPlayer(options); return function install(art) { EpisodeMenu.ensureStyle(); art.controls.add({ name: EpisodeMenu.PLUGIN_NAME, position: 'right', html: '选集', tooltip: '选集', style: { padding: '0 10px', fontSize: '14px' }, click: () => { const player = getPlayer(); if (!player) return; if (menu.isOpen()) menu.close(); else menu.open(player); }, }); return { name: EpisodeMenu.PLUGIN_NAME, open: () => menu.open(getPlayer()), close: () => menu.close(), toggle: () => { const p = getPlayer(); return p && menu.toggle(p); }, isOpen: () => menu.isOpen(), setHooks: (hooks) => menu.setHooks(hooks), updateDuration: (f, sec) => menu.updateDuration(f, sec), updateActiveState: (i) => menu.updateActiveState(i), updateItemProgress: (f) => menu.updateItemProgress(f), destroy: () => menu.destroy(), }; }; }; function episodeNavPlugin(options = {}) { return function installEpisodeNav(art) { const getPlayer = resolveGetPlayer(options); // 共用 helper:构造「上一集 / 下一集」按钮的 click 回调 const makeClick = (delta) => (_c, event) => { if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return; const p = getPlayer(); if (!p) return; const idx = p.getCurrentIndex(); const len = p.filelist?.length || 0; if (delta < 0) { if (idx > 0) p.switchVideo(p.filelist[idx - 1]); } else if (delta > 0) { if (idx >= 0 && idx < len - 1) p.switchVideo(p.filelist[idx + 1]); } }; const addEpControl = (cfg) => art.controls.add(cfg); const prev = addEpControl({ name: 'prev', position: 'left', index: 5, html: makeSvg(ICONS.prev), tooltip: '上一集', click: makeClick(-1) }); const next = addEpControl({ name: 'next', position: 'left', index: 15, html: makeSvg(ICONS.next), tooltip: '下一集', click: makeClick(1) }); function setEnabled(el, enabled) { if (!el) return; if (enabled) { el.classList.remove('art-ep-disabled'); el.removeAttribute('aria-disabled'); el.style.opacity = ''; } else { el.classList.add('art-ep-disabled'); el.setAttribute('aria-disabled', 'true'); el.style.opacity = '0.5'; } } const resolveEl = (name, captured) => { if (captured && captured instanceof HTMLElement && captured.isConnected) return captured; const fromArt = art.controls && art.controls[name]; if (fromArt instanceof HTMLElement && fromArt.isConnected) return fromArt; const root = art.template && art.template.$controls; if (!root) return null; const found = root.querySelector(`div.art-control-${name}`); return found instanceof HTMLElement ? found : null; }; const refreshFromPlayer = (player) => { const idx = player.getCurrentIndex(); const len = player.filelist?.length || 0; setEnabled(resolveEl('prev', prev), idx > 0); setEnabled(resolveEl('next', next), idx >= 0 && idx < len - 1); }; const scheduleRefresh = () => { const player = getPlayer(); if (!player) return; refreshFromPlayer(player); requestAnimationFrame(() => refreshFromPlayer(player)); setTimeout(() => refreshFromPlayer(player), 400); }; return { name: 'episodeNav', refresh() { scheduleRefresh(); }, setIndex(idx) { const len = getPlayer()?.filelist?.length || 0; setEnabled(resolveEl('prev', prev), idx > 0); setEnabled(resolveEl('next', next), idx >= 0 && idx < len - 1); }, destroy() { setEnabled(resolveEl('prev', prev), false); setEnabled(resolveEl('next', next), false); }, }; }; } // 15 自动连播倒计时面板 AutoNextController(含调度逻辑) const AutoNextState = Object.freeze({ IDLE: 'idle', SHOWN: 'shown', CANCELLED: 'cancelled', COMPLETED: 'completed', }); class AutoNextController extends Disposable { constructor(artRef, player = null) { super(); this.artRef = artRef; this.player = player; this._visible = false; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; this._token = 0; this._ticker = null; this._resizeObs = null; this._artListenerToken = null; } get visible() { return this._visible; } _cancelTicker() { if (this._ticker) { clearInterval(this._ticker); this._ticker = null; } } _disconnectResize() { if (this._resizeObs) { try { this._resizeObs.disconnect(); } catch (_) { /* target 已卸载 */ } this._resizeObs = null; } } _setState(state) { this.state = state; } show({ name, seconds, onCancel, onComplete }) { const art = this.artRef(); if (!art) return; this.hideCountdown(); this._cancelListeners = null; const token = ++this._token; const layerName = `autoNext-${token}`; this._layerName = layerName; this._onCancelCb = onCancel; this._onCompleteCb = onComplete; this._setState(AutoNextState.SHOWN); this._visible = true; let remaining = seconds; const EMBED = CONFIG.ui.controlEmbedPx; const $ctrl = art?.template?.$controls; const playerRoot = art?.template?.$player || document.getElementById('artplayer') || document.body; const layerHtml = `
${name || '下一集'}
${seconds} 秒后自动播放
`; let host; try { art.layers.add({ name: layerName, html: layerHtml, style: { zIndex: CONFIG.ui.topZ }, }); try { art.layers.show = true; } catch (_) { /* 旧版兼容 */ } host = art.layers[layerName]; } catch (_) { /* 退化:直接挂到播放器根 */ } if (!host || !host.querySelector) { const wrap = document.createElement('div'); wrap.innerHTML = layerHtml; host = wrap.firstElementChild; if (!host) { this._visible = false; return; } wrap.remove(); Object.defineProperty(art.layers || {}, layerName, { value: host, configurable: true }); playerRoot.appendChild(host); } const $layer = host; const $sec = $layer.querySelector?.('.artplayer-autonext-sec'); const $title = $layer.querySelector?.('.artplayer-autonext-title'); if ($title) $title.textContent = name || '下一集'; const paint = () => { const v = String(Math.max(remaining, 0)); if ($sec) { $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } }; paint(); this._resizeObs = new ResizeObserver(() => { const wrap = $layer?.querySelector?.('.artplayer-autonext'); if (!wrap) return; const $ctrl2 = art?.template?.$controls || art?.controls?.$controls; const h = Math.max(CONFIG.ui.controlMinHeight, $ctrl2?.getBoundingClientRect?.().height || CONFIG.ui.controlMinHeight); wrap.style.bottom = Math.round(h + EMBED) + 'px'; }); safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe'); const onCancelClick = (ev) => { ev.stopPropagation(); if (token !== this._token) return; if (this.state !== AutoNextState.SHOWN) return; this._setState(AutoNextState.CANCELLED); safe(() => this._onCancelCb?.(), 'AutoNext.onCancel'); }; const onPlayClick = (ev) => { ev.stopPropagation(); if (token !== this._token) return; if (this.state !== AutoNextState.SHOWN) return; this._setState(AutoNextState.COMPLETED); safe(() => this._onCompleteCb?.({ immediate: true }), 'AutoNext.onComplete.immediate'); }; $layer.querySelector?.('.artplayer-autonext-cancel')?.addEventListener('click', onCancelClick); $layer.querySelector?.('.artplayer-autonext-play')?.addEventListener('click', onPlayClick); this._cancelListeners = () => { $layer.querySelector?.('.artplayer-autonext-cancel')?.removeEventListener('click', onCancelClick); $layer.querySelector?.('.artplayer-autonext-play')?.removeEventListener('click', onPlayClick); }; this._ticker = setInterval(() => { if (token !== this._token) { this._cancelTicker(); return; } remaining -= 1; const $sec = $layer?.querySelector?.('.artplayer-autonext-sec'); if ($sec) { const v = String(Math.max(remaining, 0)); $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } if (remaining <= 0) { this._cancelTicker(); if (this.state === AutoNextState.SHOWN) { this._setState(AutoNextState.COMPLETED); safe(() => this._onCompleteCb?.({ immediate: false }), 'AutoNext.onComplete.ticker'); } } }, 1000); } hideCountdown() { this._cancelTicker(); // 清 _timerHandle(switchNow 的 setTimeout),避免切集时泄漏 if (this._timerHandle != null) { clearTimeout(this._timerHandle); this._timerHandle = null; } this._disconnectResize(); if (this._cancelListeners) { try { this._cancelListeners(); } catch (_) { /* target 已卸载 */ } this._cancelListeners = null; } const art = this.artRef(); const layerName = this._layerName; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; if (!art || !layerName) { this._visible = false; return; } if (art.layers?.[layerName]) { try { art.layers.remove(layerName); } catch (_) { /* 旧版容错 */ } } else { const node = document.querySelector('.artplayer-autonext'); if (node?.parentNode) node.parentNode.removeChild(node); } this._visible = false; // 倒计时面板消失后,重新触发控件栏隐藏计时 if (this.player && !this.player._disposed) { safe(() => this.player._ui?.scheduleHide?.(), 'AutoNext.afterHide.scheduleHide'); } } dispose() { this.hideCountdown(); // hideCountdown 已含 clearTimeout if (this._artListenerToken) { safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNext.detach'); this._artListenerToken = null; } super.dispose(); } attach(art) { if (this._disposed) return; if (this._artListenerToken) { safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNext.detachPrev'); this._artListenerToken = null; } const handler = () => this._onEnded(); art.on('video:ended', handler); this._artListenerToken = { art, handler }; } _onEnded() { const player = this.player; if (!player || player._disposed) return; const art = player.art; if (!art) return; const idx = player.getCurrentIndex(); const listLen = player.filelist?.length || 0; if (idx < 0 || idx >= listLen - 1) return; player.progress.clear(); const next = player.filelist[idx + 1]; const nextName = getFileName(next); const tokenAtEnd = player._switchToken; const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click']; this._timerHandle = null; const clearAutoNextTimer = () => { if (this._timerHandle != null) { clearTimeout(this._timerHandle); this._timerHandle = null; } }; const cancelOnSeek = () => { if (art._disposed || player._disposed) return; this.hideCountdown(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); }; const switchNow = () => { if (player._disposed) return; this.hideCountdown(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); if (player._switchToken !== tokenAtEnd) return; player.switchVideo(next); }; this.show({ name: nextName, seconds: CONFIG.countdownSec, onCancel: () => cancelOnSeek(), onComplete: () => switchNow(), }); this._timerHandle = setTimeout(() => switchNow(), CONFIG.countdownSec * 1000); cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek)); } } // 15b 记忆进度提示(自动跳转到记忆处,并提示「从头播放」) class AutoPlaybackPrompt { constructor(artRef) { this.artRef = artRef; this._token = 0; this._layerName = null; this._saved = null; this._autoHideTimer = null; this.progressApply = null; this.progressClear = null; } _esc(text) { return String(text ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } _playerRoot(art) { return art?.template?.$player || document.getElementById('artplayer') || document.body; } _addLayer(art, html, name) { try { art.layers.add({ name, html, style: { zIndex: CONFIG.ui.topZ } }); try { art.layers.show = true; } catch (_) { /* 旧版兼容 */ } const host = art.layers[name]; if (host?.querySelector) return host; } catch (_) { /* 退化:直接挂载 */ } const wrap = document.createElement('div'); wrap.innerHTML = html; const host = wrap.firstElementChild; wrap.remove(); if (!host) return null; this._playerRoot(art).appendChild(host); return host; } show({ saved }) { const art = this.artRef(); if (!art || !saved) return; EpisodeMenu.ensureStyle(); this.hide(); this._token++; const token = this._token; this._saved = saved; this._layerName = `autoPlayback-${token}`; const resumeText = formatTime(saved.currentTime); const html = `
已跳转至 ${this._esc(resumeText)}
`; const $layer = this._addLayer(art, html, this._layerName); if (!$layer) return; const restartBtn = $layer.querySelector('[data-act="restart"]'); restartBtn?.addEventListener('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); if (token !== this._token) return; this.hide(); try { art.currentTime = 0; } catch (_) { /* 未就绪容错 */ } safe(() => art.play?.(), 'AutoPlaybackPrompt.restart.play'); this.progressClear?.(); }); // 3s 无操作自动消失 clearTimeout(this._autoHideTimer); this._autoHideTimer = setTimeout(() => { if (token !== this._token) return; this.hide(); }, 3000); } // 把目标进度夹到当前可 seek 区间内。 // 越界 seek(HLS 只缓冲了前几片时会这样)会让播放器反复空拉分片 → 永久缓冲等待。 _clampSeekable(art, target, duration) { const want = Number.isFinite(target) && target > 0 ? target : 0; let safeTarget = Math.min(want, Math.max(0, (Number.isFinite(duration) ? duration : want) - 1)); const v = art?.video; const b = v?.buffered; if (!b || !b.length) return safeTarget; try { // 找到包含 want 的区间;不在任何区间内则取最后一个已缓冲区间起点 for (let i = 0; i < b.length; i++) { if (want >= b.start(i) && want <= b.end(i)) return safeTarget; } const last = b.length - 1; const end = b.end(last); const start = b.start(last); if (end - start > 1) safeTarget = Math.max(start, Math.min(safeTarget, end - 1)); } catch (_e) { /* buffered 读取失败则用未夹取的值 */ } return safeTarget; } maybeAsk({ getSavedFor }) { const art = this.artRef(); if (!art) return; const saved = getSavedFor?.(); if (!saved || saved.currentTime < CONFIG.progressMinSec) return; const apply = () => { if (!art?.duration || !Number.isFinite(art.duration)) return; const dur = art.duration; // 元数据一致性校验:art.duration 与存档 duration 明显不符说明拿到的是旧集/脏数据, // 此时 seek 会把播放位置落到错误的时间轴上(表现为"跳到上一集进度")→ 直接放弃续播 const savedDur = Number.isFinite(saved.duration) && saved.duration > 0 ? saved.duration : null; if (savedDur && Math.abs(savedDur - dur) > Math.max(5, dur * 0.05)) return; const ratio = saved.currentTime / dur; if (ratio >= 0.99 || saved.currentTime >= dur - 1) return; const tok = ++this._token; const alreadyThere = Math.abs((art.currentTime || 0) - saved.currentTime) < 0.5; const reveal = () => { if (tok !== this._token) return; try { art.off?.('seeked', onSeeked); } catch (_) { /* off 失败容错 */ } this.progressApply?.(saved); this.show({ saved }); }; const onSeeked = () => { reveal(); }; if (alreadyThere) { safe(() => art.play?.(), 'AutoPlaybackPrompt.autoJump.play'); reveal(); return; } try { art.on?.('seeked', onSeeked); } catch (_) { /* 监听失败容错 */ } // 兜底:800ms 内若 seeked 未触发,仍显示弹窗 setTimeout(() => { if (tok !== this._token) return; reveal(); }, 800); // 自动跳转到记忆进度:先夹到当前集可 seek 区间内。 // 越界 seek 会让 hls 反复空拉分片,表现为"一直缓冲/进度条等待"。 const target = this._clampSeekable(art, saved.currentTime, dur); try { art.currentTime = target; } catch (_) { /* 未就绪容错 */ } safe(() => art.play?.(), 'AutoPlaybackPrompt.autoJump.play'); }; if (art?.duration && Number.isFinite(art.duration)) { apply(); return; } // 等元数据就绪(最多 5s) const tok = ++this._token; const cb = () => { art.off?.('loadedmetadata', cb); if (tok !== this._token) return; apply(); }; try { art.on?.('loadedmetadata', cb); } catch (_) { /* 监听失败 */ } setTimeout(() => { if (tok !== this._token) return; art.off?.('loadedmetadata', cb); }, 5000); } hide() { this._token++; this._saved = null; clearTimeout(this._autoHideTimer); this._autoHideTimer = null; const name = this._layerName; this._layerName = null; const art = this.artRef(); if (!art) return; if (art.layers?.[name]) { try { art.layers.remove(name); } catch (_) { /* 旧版容错 */ } } else { const node = document.querySelector('.artplayer-autoplayback'); if (node?.parentNode) node.parentNode.removeChild(node); } } dispose() { clearTimeout(this._autoHideTimer); this._autoHideTimer = null; this._token++; this._saved = null; } } // 16 控制栏隐显 + 标题层 UiController class UiController extends Disposable { constructor(artRef) { super(); this.artRef = artRef; this._hideTimer = null; this._hideCtl = null; this._setup = false; this._titleEl = null; this._titleLayerAdded = false; this._layerSyncBound = false; this._pinned = () => false; } setPinnedProbe(fn) { this._pinned = typeof fn === 'function' ? fn : () => false; } _makeHideCtl() { const art = this.artRef(); if (!art) return null; const $controls = () => art?.template?.$controls; const clearHide = () => { if (this._hideTimer != null) { clearTimeout(this._hideTimer); this._hideTimer = null; } }; const showNow = () => { if (!art) return; if (art.controls) art.controls.show = true; art.layers.show = true; }; const isPinned = () => this._pinned(); const onHideTimer = () => { this._hideTimer = null; if (!art?.controls) return; const $c = $controls(); const menu = this._episodeMenuProbe?.(); const menuEl = menu?.isOpen?.() ? menu._el : null; // 光标在控制栏 或 面板处于活动状态(光标/指针/触摸在面板内)→ 全部保持显示 const menuActive = menuEl && (menuEl.matches(':hover') || menu.isMenuActive?.()); if ($c?.matches(':hover') || menuActive) { return void (this._hideTimer = setTimeout(onHideTimer, CONFIG.hideDelayMs)); } // 光标不在面板内 → 面板跟随控制栏消失 if (menuEl) menu.close?.(); art.controls.show = false; if (!isPinned()) art.layers.show = false; }; const scheduleHide = () => { clearHide(); // pinned 不再 early-return:pinned 期间也计时,到点统一处理 this._hideTimer = setTimeout(onHideTimer, CONFIG.hideDelayMs); }; return { clearHide, showNow, scheduleHide }; } setEpisodeMenuProbe(fn) { this._episodeMenuProbe = typeof fn === 'function' ? fn : null; } setup() { const art = this.artRef(); if (!art || this._setup) return; this._setup = true; this._hideCtl = this._makeHideCtl(); const sync = (eventName) => { const layers = art?.layers; if (!layers || typeof layers.show === 'undefined') return; // 只在 layers:show 时强制保持显示; // layers:hide 不再强制拉回,避免和 scheduleHide 拉锯导致标题栏残留 if (eventName === 'layers:show' && this._pinned()) { layers.show = true; } }; const titleLayer = () => this._titleHost; const onControlToggle = (visible) => { const el = titleLayer(); if (!el) return; el.classList.toggle('art-show', !!visible); }; if (typeof art.on === 'function' && !this._layerSyncBound) { art.on('layers:show', () => sync('layers:show')); art.on('layers:hide', () => sync('layers:hide')); art.on('control', onControlToggle); this._layerSyncBound = true; } if (art.template?.$player) { const onMove = () => this._hideCtl?.scheduleHide(); this._listeners.on(art.template.$player, 'mousemove', onMove); this._listeners.on(art.template.$player, 'touchstart', onMove, { passive: true }); } this._hideCtl.scheduleHide(); } setTitleVisible(visible) { const el = this._titleHost; if (!el) return; el.classList.toggle('art-show', !!visible); } addTitle(file) { const art = this.artRef(); if (!art) return; EpisodeMenu.ensureStyle(); const initialName = file ? getFileName(file) : ''; if (this._titleLayerAdded) { this.updateTitle(file); return; } const host = art.template?.$player || art.layers?.episodeTitle?.parentElement; if (!host) return; const wrap = document.createElement('div'); wrap.className = 'artplayer-title'; wrap.innerHTML = `${initialName}`; host.appendChild(wrap); this._titleHost = wrap; this._titleEl = wrap.querySelector('.art-title-text'); this._titleLayerAdded = true; this._titlePlayer = art; this._setTitleText(initialName); } updateTitle(file) { this._setTitleText(file ? getFileName(file) : ''); } _setTitleText(name) { const el = this._titleEl; if (el) el.textContent = name || ''; } showNow() { this._hideCtl?.showNow(); } scheduleHide() { this._hideCtl?.scheduleHide(); } reset() { if (this._hideTimer != null) { clearTimeout(this._hideTimer); this._hideTimer = null; } this._titleLayerAdded = false; this._titleEl = null; if (this._titleHost?.parentNode) { safe(() => this._titleHost.parentNode.removeChild(this._titleHost), 'UiController.removeTitle'); } this._titleHost = null; this._titlePlayer = null; this._setup = false; this._layerSyncBound = false; this._hideCtl = null; this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } // 17 快捷键 HotkeyController class HotkeyController extends Disposable { constructor(playerRef) { super(); this.playerRef = playerRef; } setup() { const handlers = { f: (p) => { p.art.fullscreen = !p.art.fullscreen; }, w: (p) => { p.art.fullscreenWeb = !p.art.fullscreenWeb; }, m: (p) => { p.art.muted = !p.art.muted; }, p: (p) => { const idx = p.getCurrentIndex(); if (idx > 0) p.switchVideo(p.filelist[idx - 1]); }, n: (p) => { const idx = p.getCurrentIndex(); if (idx >= 0 && idx < (p.filelist?.length || 0) - 1) p.switchVideo(p.filelist[idx + 1]); }, }; this._listeners.on(document, 'keydown', (e) => { const player = this.playerRef(); if (!player?.art) return; if (e.isComposing) return; const tag = document.activeElement?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return; if (e.ctrlKey || e.altKey || e.metaKey) return; if (e.repeat) return; const fn = handlers[e.key.toLowerCase()]; if (!fn) return; fn(player); e.preventDefault(); e.stopPropagation(); player._ui?.scheduleHide(); }, true); } reset() { this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } function titleUiPlugin() { return function installTitleUi(art) { EpisodeMenu.ensureStyle(); const ui = new UiController(() => art); const boot = () => ui.setup(); if (art.template?.$player) boot(); else art.on('ready', boot); return { name: 'titleUi', addTitle: (file) => ui.addTitle(file), updateTitle: (file) => ui.updateTitle(file), showNow: () => ui.showNow(), scheduleHide: () => ui.scheduleHide(), setPinnedProbe: (fn) => ui.setPinnedProbe(fn), setEpisodeMenuProbe: (fn) => ui.setEpisodeMenuProbe(fn), setTitleVisible: (v) => ui.setTitleVisible(v), destroy: () => ui.dispose(), }; }; } function autoPlaybackPlugin(options = {}) { return function installAutoPlayback(art) { EpisodeMenu.ensureStyle(); const prompt = new AutoPlaybackPrompt(() => art); if (typeof options.progressApply === 'function') prompt.progressApply = options.progressApply; if (typeof options.progressClear === 'function') prompt.progressClear = options.progressClear; return { name: 'autoPlayback', maybeAsk: (opts) => prompt.maybeAsk(opts), hide: () => prompt.hide(), destroy: () => prompt.dispose(), }; }; } function autoNextPlugin(options = {}) { return function installAutoNext(art) { EpisodeMenu.ensureStyle(); const ctl = new AutoNextController(() => art, options.getPlayer?.()); ctl.attach(art); return { name: 'autoNext', get visible() { return ctl.visible; }, show: (opts) => ctl.show(opts), hideCountdown: () => ctl.hideCountdown(), destroy: () => ctl.dispose(), }; }; } function extraHotkeyPlugin(options = {}) { return function installExtraHotkey() { const hotkey = new HotkeyController(() => options.getPlayer?.()); hotkey.setup(); return { name: 'extraHotkey', destroy: () => hotkey.dispose(), }; }; } // 17b 下一集预取 NextPrefetcher // 目的:切集时 quality.build 命中解析缓存 + 连接已预热 → 切集零等待 // 触发:① 切集/ready 后延迟 delayMs 触发 ② 片尾临近(remaining < minRemainingSec)立即触发 // 幂等:HlsController 按 fileKey 缓存 + pendingRequests 去重,重复调用不产生额外请求 // 纯优化:所有异常静默吞掉,绝不影响正常播放/切集 class NextPrefetcher extends Disposable { constructor(playerRef) { super(); this._player = playerRef; this._timer = null; this._inflightKey = null; } _cancelTimer() { if (this._timer != null) { clearTimeout(this._timer); this._timer = null; } } // 解析预取目标:下一集(idx + maxAhead)的 { file, fileKey, getUrl } _resolveTarget() { const player = this._player(); if (!player || player._disposed || !player.art) return null; const list = player.filelist; if (!Array.isArray(list) || !list.length) return null; const idx = player.getCurrentIndex(); if (idx < 0) return null; const next = list[idx + Math.max(1, CONFIG.prefetch.maxAhead)]; if (!next) return null; const fileKey = fileKeyOf(next); if (!fileKey) return null; // URL 构建器可能返回 null(缺 token / 缺参数),此时不预取 const getUrl = player._resolveUrlBuilder?.(next); if (typeof getUrl !== 'function') return null; return { file: next, fileKey, getUrl }; } _remainingSec() { const art = this._player()?.art; const dur = art?.duration; const cur = art?.currentTime; if (!Number.isFinite(dur) || dur <= 0) return null; if (!Number.isFinite(cur) || cur < 0) return null; return Math.max(0, dur - cur); } // 已在解析中 → 跳过(同一集并发只发一次) _isBusy(fileKey) { return this._inflightKey === fileKey; } // 缓冲健康度:视频正在吃紧时不预取,避免和当前集抢带宽把播放饿死 // 返回 true=带宽健康可预取 _bandwidthOk() { const v = this._player()?.art?.video; if (!v) return false; // readyState 2 以下说明正卡在缓冲,优先保证播放 if (v.readyState < 2) return false; // 缓冲余量不足 minBufferSec 时不预取 try { const ahead = v.buffered.length ? v.buffered.end(v.buffered.length - 1) - v.currentTime : 0; if (Number.isFinite(ahead) && ahead < CONFIG.play.minBufferSec) return false; } catch (_e) { /* buffered 不可读时不拦截 */ } return true; } // 核心预取:解析清晰度 URL + 预热首连接。返回是否实际发起了预取 async run({ force = false } = {}) { if (!CONFIG.prefetch.enabled) return false; const player = this._player(); if (!player || player._disposed) return false; const target = this._resolveTarget(); if (!target) return false; // 已解析出可用地址 → 缓存已命中,无需再请求 if (player.hls.hasResolvedQuality(target.fileKey)) return false; if (this._isBusy(target.fileKey)) return false; // 片尾临近时豁免带宽检查(那时当前集已不需要抢带宽了) if (!force && !this._bandwidthOk()) return false; this._inflightKey = target.fileKey; try { await player.quality.prefetch(target.file.resolution, target.getUrl, target.fileKey); return true; } catch (_e) { return false; // 失败不重试标记,下轮 schedule/maybeRun 会自然重试 } finally { if (this._inflightKey === target.fileKey) this._inflightKey = null; } } // 切集/ready 后:延迟预取。延迟目的是避开当前集起播缓冲抢带宽,也给用户留出"立刻切走"的余地 schedule(delayMs = CONFIG.prefetch.delayMs) { this._cancelTimer(); if (!CONFIG.prefetch.enabled) return; const player = this._player(); if (!player || player._disposed) return; this._timer = setTimeout(() => { this._timer = null; safe(() => this.run(), 'prefetch.timer'); }, Math.max(0, delayMs)); } // 片尾临近:取消待触发定时器并立即预取(把剩余时间用作预热窗口) maybeRunNearEnd() { if (!CONFIG.prefetch.enabled) return; const remaining = this._remainingSec(); if (remaining == null || remaining > CONFIG.prefetch.minRemainingSec) return; this._cancelTimer(); // 还没到 delay 就已片尾 → 立即跑 safe(() => this.run({ force: true }), 'prefetch.nearEnd'); } // 缓冲恢复健康后补跑一次(带宽检查拦下时的重试入口) retryWhenIdle() { if (!CONFIG.prefetch.enabled || this._timer != null) return; if (this._isBusy(this._resolveTarget()?.fileKey)) return; if (!this._bandwidthOk()) return; const target = this._resolveTarget(); if (!target || this._player()?.hls?.hasResolvedQuality(target.fileKey)) return; safe(() => this.run(), 'prefetch.retryIdle'); } cancel() { this._cancelTimer(); this._inflightKey = null; } dispose() { this.cancel(); super.dispose(); } } // 18 切换编排 SwitchCoordinator(save → wait → restore) class SwitchCoordinator { constructor(player) { this.player = player; } async perform(file, { prevVolume, wasFullscreen, wasFullscreenWeb } = {}) { const player = this.player; if (!file) return; player._autoNext?.hideCountdown(); // flush 待写的进度:取消 debounce + 立即同步 save 一次(用旧 file) player.progress.flush?.(); if (player.file) player.progress.save(player.file); if (!player.art) return; const token = ++player._switchToken; const prevFile = player.file; // 失败回滚用:player.file 已改但画面还没换 player.file = file; player._syncAsideUi(); const newEpIdx = player.getCurrentIndex(); if (newEpIdx >= 0) { safe(() => player._saveLastEpisode(newEpIdx)); } player.getUrl = player._resolveUrlBuilder(file); const failSwitch = (msg) => { // 回滚 player.file:否则 file 与实际播放内容不一致, // 后续所有 save 都会写进错误集的 key(下次续播跳到别的集) player.file = prevFile; player.getUrl = prevFile ? player._resolveUrlBuilder(prevFile) : null; if (player.getUrl) player.hls.setUrlBuilder(player.getUrl); if (msg) showTip(msg); }; if (!player.getUrl) { failSwitch(ERR_NO_URL); return; } player.hls.setUrlBuilder(player.getUrl); await player.quality.build(file.resolution, player.getUrl, fileKeyOf(file)); if (token !== player._switchToken) return; // 已被更新的切换取代,交给新流程 if (!player.quality.list?.length) { failSwitch(ERR_NO_URL); return; } const resolvedUrl = player.quality.list[0].url; if (!resolvedUrl) { failSwitch(`无法播放: ${getFileName(file)}`); return; } player._applyHlsSource(resolvedUrl, file, token); player._armStartWatchdog(token); if (typeof prevVolume === 'number') { player.art.video.muted = false; player.art.video.volume = prevVolume; } // 等待新集媒体就绪;ready=false 表示超时未拿到 metadata, // 此时 art.duration 仍是旧集的值,绝不能走依赖 duration 的续播逻辑(否则跳到上一集进度) const ready = await player._waitForVideo(player.art.video, player.hls.instance, token); if (token !== player._switchToken) return; safe(() => { if (wasFullscreen && !player.art.fullscreen) player.art.fullscreen = true; if (wasFullscreenWeb && !player.art.fullscreenWeb) player.art.fullscreenWeb = true; }, 'restoreFullscreen'); // metadata 未就绪也照常续播流程,但跳过续播判断(等 ready 后由 art 事件兜底触发) if (ready) { player._skipResumePrompt = true; player.progress.load(); player._resumePrompt?.maybeAsk({ getSavedFor: () => player.progress.getSavedFor(player.file), }); } else { // 保留原集续播标记,等真正就绪后再判断(见 _bindResumeOnReady) player._skipResumePrompt = false; player._bindResumeOnReady(token); } log.announce(file); // 切集后强制重新启动一次自动隐藏计时,避免控制栏/标题栏在切集后残留 safe(() => player._ui?.scheduleHide?.(), 'switch.after.scheduleHide'); // 切集稳定后预取下一集(延迟触发,避开当前集缓冲抢带宽) safe(() => player._prefetcher?.schedule?.(), 'switch.after.prefetch'); player.art.video.play().catch(() => { }); } } // 20 Player 协调器(持有子系统,统一通过 Disposable 链释放) class Player extends Disposable { constructor() { super(); this.art = null; this.file = null; this.filelist = []; this.flag = ''; this.nativeVideoNode = null; this.getUrl = null; this._switchCtrl = new SwitchController(); this._switchToken = 0; this._episodeControlsAdded = false; this._initialized = false; this._track(this.hls = new HlsController()); this._track(this.quality = new QualityController(this.hls)); this._track(this.episodeMeta = new EpisodeMeta()); this._track(this.progress = new ProgressSaveAgent(() => this, this.episodeMeta)); this._track(this._prefetcher = new NextPrefetcher(() => this)); this._switch = new SwitchCoordinator(this); // episodeMeta 事件订阅贯穿 Player 整个生命周期(不解除),确保 EpisodeMenu 始终响应 this.episodeMeta.events.on('progress', ({ file }) => { if (!file) return; getEpisodeMenu().updateItemProgress(file); }); this.episodeMeta.events.on('duration', ({ file, duration }) => { if (!file || !Number.isFinite(duration)) return; getEpisodeMenu().updateDuration(file, Math.ceil(duration)); }); } get _ui() { return this.art?.plugins?.titleUi || null; } get _resumePrompt() { return this.art?.plugins?.autoPlayback || null; } get _autoNext() { return this.art?.plugins?.autoNext || null; } getCurrentIndex() { const { file, filelist } = this; if (!file || !filelist?.length) return -1; const targetKey = fileKeyOf(file); if (!targetKey) return -1; return filelist.findIndex((f) => fileKeyOf(f) === targetKey); } _getFolderKey() { if (this.flag === 'sharevideo') { const locals = unsafeWindow.locals; const shareid = safe(() => typeof locals.get === 'function' ? locals.get('shareid') : locals?.shareid); if (shareid) return `share_${shareid}`; } if (this.flag === 'video' || this.flag === 'playvideo') { try { const sp = new URLSearchParams(window.location.search); const p = sp.get('path'); if (p) return `path_${p}`; } catch (_) { /* URL parse fail */ } } return null; } pickResumeIndex() { const list = this.filelist; const folderKey = this._getFolderKey(); if (!folderKey || !Array.isArray(list) || !list.length) return null; try { const raw = storage.getRawItem('last_episode'); if (!raw) return null; const obj = JSON.parse(raw); const savedEp = obj?.[folderKey]; if (Number.isInteger(savedEp) && savedEp >= 0 && list.length > savedEp) return savedEp; } catch (_e) { /* JSON 损坏回退默认 */ } return null; } _saveLastEpisode(epIdx) { if (!Number.isInteger(epIdx) || epIdx < 0) return; const folderKey = this._getFolderKey(); if (!folderKey) return; try { const raw = storage.getRawItem('last_episode'); const obj = raw ? JSON.parse(raw) : {}; obj[folderKey] = epIdx; storage.setItem('last_episode', JSON.stringify(obj)); } catch (_e) { /* 写入失败下次再试 */ } } _resolveUrlBuilder(file) { return this.flag === 'sharevideo' ? buildShareUrl(file) : this.flag === 'mboxvideo' ? buildMboxUrl(file) : buildFileUrl(file); } _syncAsideUi() { if (!this.file) return; clickViewAll(); const nodes = safe(() => document.getElementsByClassName('vp-video-page-card__video-detail'), 'aside.sync.get') || []; if (!nodes.length) return; const target = getFileName(this.file); const list = Array.from(nodes); let match = list.find((n) => n.__bdActiveMatch === target); if (!match && target) { match = list.find((n) => (n.textContent || '').includes(target)); if (match) match.__bdActiveMatch = target; } if (!match) return; safe(() => match.click(), 'aside.sync.click'); } switchVideo(file) { if (!file) return; this._switchCtrl.submit(() => this._performSwitch(file)); } async _performSwitch(file) { const prevVolume = this.art?.volume; const wasFullscreen = !!this.art?.fullscreen; const wasFullscreenWeb = !!this.art?.fullscreenWeb; await this._switch.perform(file, { prevVolume, wasFullscreen, wasFullscreenWeb }); } _applyHlsSource(url, file, token) { this.hls.destroy(); safe(() => { const v = this.art.video; v.pause(); // 彻底重置媒体元素:load() 不会把 currentTime 归零, // 残留的 seekable 区间会让新集起播在上一集的位置(表现为"跳到上一集进度") v.removeAttribute('src'); v.load(); // 强制归零,且必须在 load() 之后 —— 否则浏览器会忽略对已失效 seekable 的 seek try { v.currentTime = 0; } catch (_) { /* 尚未可 seek,HLS 附着后自然为 0 */ } }, 'switch.resetVideo'); const hls = this.hls.create(url, this.art.video, fileKeyOf(file), () => showTip(TIPS.hlsNetworkDown)); if (!hls || token !== this._switchToken) return; this.quality.apply(this.art); this.quality.bind(this.art); this.refreshControlDisplay(); getEpisodeMenu().updateActiveState(this.getCurrentIndex()); this._ui?.updateTitle(file); // 兜底:新 source 就绪后再归零一次,覆盖 load() 早期 seek 被忽略的情况。 // 用 settle 竞态守卫:loadedmetadata 与 duration 变化谁先到算谁,且只执行一次。 let settled = false; const settleReset = () => { if (settled) return; settled = true; if (token !== this._switchToken) return; const v = this.art?.video; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; // 已有有效进度(记忆续播刚设过)则不覆盖 if (v.currentTime > 0.5) return; try { v.currentTime = 0; } catch (_) { /* 未可 seek */ } }; this._listeners.once(this.art.video, 'loadedmetadata', settleReset); this._listeners.setTimeout(settleReset, 1200); } _bindHlsDuration(hls, file) { if (!hls || !file) return; const key = fileKeyOf(file); if (!key) return; const handler = (_, data) => { const dur = data?.details?.totalduration; if (dur && Number.isFinite(dur) && dur > 0 && !this.episodeMeta.isDurationVerified(file)) { this.episodeMeta.setDuration(file, dur, false); } }; // 用 on 而非 once:hls.once 会包装 handler,off 原函数无效导致监听泄漏 safe(() => hls.on(Hls.Events.LEVEL_LOADED, handler), 'bindDur.on'); this._listeners.setTimeout(() => { safe(() => hls.off(Hls.Events.LEVEL_LOADED, handler), 'bindDur.off'); }, CONFIG.play.readyTimeoutMs); } // 切集时 metadata 超时未就绪的兜底:等真正 loadedmetadata 后再执行续播判断。 // 必须等就绪,否则 art.duration 仍是旧集时长,会把续播点算到错误的进度上。 _bindResumeOnReady(token) { const video = this.art?.video; if (!video) return; const run = () => { if (token !== this._switchToken || !this.art) return; if (this._skipResumePrompt) return; this._skipResumePrompt = true; safe(() => this.progress.load(), 'resumeOnReady.load'); safe(() => this._resumePrompt?.maybeAsk({ getSavedFor: () => this.progress.getSavedFor(this.file), }), 'resumeOnReady.maybeAsk'); }; this._listeners.once(video, 'loadedmetadata', run); // HLS 场景下 metadata 可能由 hls 内部触发,补一层 duration 变化兜底 this._listeners.once(video, 'durationchange', run); this._listeners.setTimeout(run, CONFIG.play.readyTimeoutMs); } // 等待新集媒体就绪:MANIFEST_PARSED + readyState>=1(HAVE_METADATA)。 // resolve(true)=就绪 resolve(false)=超时未就绪(调用方据此跳过依赖 duration 的逻辑) _waitForVideo(video, hls, token) { const listeners = this._listeners; return new Promise((resolve) => { let done = false; // hls.once 内部会包装 handler,后续 off 原函数无效 → 用 on + 手动 off const onParsed = () => { if (token !== this._switchToken) { finish(false); return; } if (video?.readyState >= 1) finish(true); // readyState 未跟上就继续等 loadedmetadata,由总超时兜底 }; const onLoaded = () => finish(true); const finish = once((ok) => { if (done) return; done = true; listeners.clearTimeout(timer); safe(() => hls?.off?.(Hls.Events.MANIFEST_PARSED, onParsed), 'waitFor.offParsed'); listeners.off(video, 'loadedmetadata', onLoaded); resolve(ok); }); const timer = listeners.setTimeout(() => finish(false), CONFIG.play.readyTimeoutMs); if (hls?.on) hls.on(Hls.Events.MANIFEST_PARSED, onParsed); // 已经就绪(例如原生 src 直读路径)→ 立即返回,避免无谓等待 if (video?.readyState >= 1) { finish(true); return; } listeners.on(video, 'loadedmetadata', onLoaded); }); } // 起播看门狗:切集后若迟迟没有可播放帧(一直转圈/等待进度), // 逐级升级处理:nudge → 降清晰度 → 最后放弃并提示,绝不让用户无限等待。 _armStartWatchdog(token) { const start = Date.now(); const attempts = { n: 0 }; const check = () => { if (token !== this._switchToken || this._disposed || !this.art) return; const v = this.art.video; if (!v) return; // 已能播放(有 currentTime 且未处于等待状态)→ 解除看门狗 if (v.readyState >= 3 || (v.currentTime > 0 && !v.paused)) return; if (Date.now() - start < CONFIG.play.readyTimeoutMs) { this._listeners.setTimeout(check, 1000); return; } // 超时仍未起播 → 升级处理 attempts.n += 1; const hls = this.hls.instance; if (attempts.n === 1 && hls) { // 一级:让 hls 重新拉流 safe(() => { hls.stopLoad(); hls.startLoad(); }, 'watchdog.reload'); } else if (attempts.n === 2) { // 二级:降级到较低清晰度重试(多半是高清流拉不动) safe(() => this._fallbackQuality(), 'watchdog.fallback'); } else { // 三级:放弃,提示用户 showTip(TIPS.startTimeout); return; } this._listeners.setTimeout(check, CONFIG.play.readyTimeoutMs); }; this._listeners.setTimeout(check, 2000); } // 降到当前可用列表里的最低清晰度并重载(起播超时自愈) _fallbackQuality() { const list = this.quality?.list; if (!Array.isArray(list) || list.length < 2) return false; const lowest = list[list.length - 1]; if (!lowest?.url) return false; const cur = this.art?.video?.currentSrc || this.hls?.instance?.url; if (cur === lowest.url) return false; showTip('正在切换到较低画质以恢复播放'); this.hls.destroy(); const hls = this.hls.create(lowest.url, this.art.video, fileKeyOf(this.file), () => showTip(TIPS.hlsNetworkDown)); if (!hls) return false; this._bindHlsDuration(hls, this.file); safe(() => this.art?.video?.play?.(), 'fallback.play'); return true; } refreshControlDisplay() { this.art?.plugins?.episodeNav?.refresh?.(); } addEpisodeControls() { if (this._episodeControlsAdded) return; this._episodeControlsAdded = true; this.episodeMeta.prefetch(this.filelist); this.refreshControlDisplay(); this._ui?.addTitle(this.file); } _teardown() { if (this._disposed) return; this._autoNext?.hideCountdown(); this._episodeControlsAdded = false; this._initialized = false; // 取消待执行的预取,避免销毁/重初始化后仍发请求 safe(() => this._prefetcher?.cancel?.(), 'teardown.prefetch.cancel'); // 不解绑 episodeMeta.events —— episodeMeta 实例由 Player 持有贯穿整个生命周期, // EpisodeMenu 的 progress / duration 更新需要持续有效。teardown 仅重置业务状态。 // 切集/重置前先 flush 待写进度,避免最后一段 currentTime 丢失 safe(() => this.progress.flush?.(), 'teardown.progress.flush'); this.quality.unbind(); if (this.art?.video) { safe(() => { this.art.video.muted = true; this.art.video.pause(); }, 'teardown.mute'); } if (this.art) { const art = this.art; this.art = null; safe(() => { if (art.video) { art.video.pause(); art.video.src = ''; art.video.load(); } art.destroy(true); }, 'teardown.destroy'); } } dispose() { this._teardown(); super.dispose(); } async init(container) { if (!this.getUrl) { showTip(ERR_NO_URL); return; } this._teardown(); this._switchCtrl = new SwitchController(); this._switchToken = 0; this.hls.setUrlBuilder(this.getUrl); await this.quality.build(this.file?.resolution, this.getUrl, fileKeyOf(this.file)); if (!this.quality.list?.length) { showTip(ERR_NO_URL); return; } const resolvedUrl = this.quality.list[0].url; if (!resolvedUrl) { showTip(ERR_NO_URL); return; } this.art = new Artplayer({ container, url: resolvedUrl, type: 'm3u8', customType: { m3u8: (video, url) => { this.hls.create(url, video, fileKeyOf(this.file), () => showTip(TIPS.hlsNetworkDown)); }, }, poster: '', autoplay: true, pip: true, fullscreen: true, fullscreenWeb: true, setting: true, quality: this.quality.list, playbackRate: true, aspectRatio: true, muted: false, volume: 1, hotkey: true, icons: { loading: '', state: '', indicator: '', }, moreVideoAttr: { crossOrigin: 'anonymous', preload: 'auto' }, plugins: [ EpisodeMenu.installPlugin({ getPlayer: () => this }), episodeNavPlugin({ getPlayer: () => this }), titleUiPlugin(), autoPlaybackPlugin({ getPlayer: () => this, // applyResume 内部已做 if (!saved) return 的兜底,直接传引用 progressApply: (saved) => this.progress.applyResume(saved), progressClear: () => this.progress.clear(), }), autoNextPlugin({ getPlayer: () => this }), extraHotkeyPlugin({ getPlayer: () => this }), ], }); this._ui?.setPinnedProbe(() => this._autoNext?.visible || getEpisodeMenu().isOpen()); this._ui?.setEpisodeMenuProbe?.(() => getEpisodeMenu()); // 面板内 mousemove → 重置 hide 计时器;闭包捕获 el,避免 close 后 listener 无法移除 let unbindEpMenuMove = null; getEpisodeMenu().setHooks({ onOpen: () => { this._ui?.scheduleHide?.(); const el = getEpisodeMenu()?._el; if (el && !unbindEpMenuMove) { const onMove = () => this._ui?.scheduleHide?.(); el.addEventListener('mousemove', onMove); el.addEventListener('touchstart', onMove, { passive: true }); unbindEpMenuMove = () => { el.removeEventListener('mousemove', onMove); el.removeEventListener('touchstart', onMove); unbindEpMenuMove = null; }; } }, onClose: () => { unbindEpMenuMove?.(); this._ui?.scheduleHide?.(); }, }); this.art.on('ready', () => { this.destroyNativePlayer(); this.art.video.muted = false; this.progress.load(); this.episodeMeta.preloadFromStorage(this.filelist); this.quality.bind(this.art); this.addEpisodeControls(); this._bindHlsDuration(this.hls.instance, this.file); this._ui?.showNow(); // 首次进入也挂起播看门狗:初始流拉不动时同样能自愈 this._armStartWatchdog(this._switchToken); const skip = this._skipResumePrompt; this._skipResumePrompt = false; if (!skip) { this._resumePrompt?.maybeAsk({ getSavedFor: () => this.progress.getSavedFor(this.file), }); } log.announce(this.file); this._initialized = true; // 首次就绪后即预取下一集 safe(() => this._prefetcher?.schedule?.(), 'ready.prefetch'); }); // debounce 只延迟"写",不延迟"取 file":执行时才读 this.file, // 否则切集后仍会写到初始集 key(曾导致 A 集进度被 B 集覆盖) const debouncedSave = debounce(() => this.progress.save(this.file), CONFIG.saveDebounceMs); this.progress._pendingSave = debouncedSave; let lastSave = 0; let lastMetaSync = 0; const META_SYNC_INTERVAL = CONFIG.saveThrottleMs; // 与 save 同频,避免频繁触发 UI 更新 this._listeners.on(this.art, 'video:timeupdate', () => { if (!this.art?.currentTime || this.art.currentTime <= 0) return; const now = Date.now(); // 实时同步到 episodeMeta:选集菜单进度条需要当前播放的实时进度 if (now - lastMetaSync >= META_SYNC_INTERVAL) { lastMetaSync = now; this.episodeMeta.setProgress(this.file, this.art.currentTime, now); } if (now - lastSave > CONFIG.saveThrottleMs) { lastSave = now; debouncedSave(); } // 片尾临近时立即预取下一集(内部按 fileKey 节流,不会每次 timeupdate 都发请求) safe(() => this._prefetcher?.maybeRunNearEnd?.(), 'timeupdate.prefetch'); }); const applyRealDuration = () => { const realDur = this.art?.duration; if (!Number.isFinite(realDur) || realDur <= 0) return; // 用 this.file(当前集)而非闭包捕获的 selfFile —— 切集后监听会重新挂载, // 写错集会让 durationVerified 落到错误的集上,进而污染续播判断 this.episodeMeta.setDuration(this.file, realDur, true); }; this._listeners.on(this.art, 'video:durationchange', applyRealDuration); // 缓冲补上来后补跑被带宽检查拦下的预取 this._listeners.on(this.art.video, 'progress', () => { safe(() => this._prefetcher?.retryWhenIdle?.(), 'progress.prefetchRetry'); }); // 从卡顿恢复:paused 变 false 时也补跑一次 this._listeners.on(this.art.video, 'playing', () => { safe(() => this._prefetcher?.retryWhenIdle?.(), 'playing.prefetchRetry'); }); const flushSave = () => { if (!this.art?.currentTime || this.art.currentTime <= 0) return; // 显式传 this.file,避免 race 时写到错集(sanity check 已兜底,这里更稳) this.progress.save(this.file); }; this._listeners.on(this.art, 'video:pause', flushSave); this._listeners.on(this.art, 'video:seeked', flushSave); this._listeners.on(document, 'visibilitychange', () => { if (document.hidden) flushSave(); }, { passive: true }); this._listeners.on(this.art, 'destroy', () => { this._teardown(); }); } destroyNativePlayer() { document.querySelectorAll('video').forEach((v) => { if (!v.closest('#artplayer')) { safe(() => { v.pause(); v.muted = true; v.src = ''; v.load(); }, 'destroyNativePlayer.video'); } }); const pollDestroy = (getTarget) => { let count = 0; const id = this._listeners.setInterval(() => { count++; const t = getTarget(); if (t?.player) { this._listeners.clearInterval(id); safe(() => { t.player.dispose(); t.player = null; }, 'pollDestroy.dispose'); } else if (count > CONFIG.ui.videoPollingCount) { this._listeners.clearInterval(id); } }, CONFIG.ui.videoPollingIntervalMs); }; if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) { this._listeners.setTimeout(() => { safe(() => unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => { if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance); }), 'pollDestroy.require'); }, CONFIG.ui.videoPollingMaxMs); } if (this.flag === 'video' && this.nativeVideoNode) { this._listeners.setTimeout(() => { pollDestroy(() => this.nativeVideoNode?.firstChild); }, CONFIG.ui.videoPollingMaxMs); } } async replacePlayer() { const videoNode = await waitFor(() => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), { intervalMs: 500, maxAttempts: 20, label: 'replacePlayer', }); if (!videoNode) return null; let container = document.getElementById('artplayer'); if (!container) { container = document.createElement('div'); container.id = 'artplayer'; container.style.cssText = 'width:100%;height:100%'; videoNode.parentNode?.replaceChild(container, videoNode); } return container; } } // 21 路由管理(每个 handler 只产出 file/list/flag/urlBuilder) const clickViewAll = () => { const el = document.getElementsByClassName('vp-aside-box__top-operation')[0]; if (el?.textContent.includes('查看全部')) { safe(() => el.click(), 'aside.viewAll'); } }; class RouteManager { constructor(player) { this.player = player; } probeAsideTopOp() { clickViewAll(); } async _commit({ file, list, flag, urlBuilder }) { if (!file || !urlBuilder) return false; const p = this.player; p.filelist = sortByLocale(list || []); const resumeEp = p.pickResumeIndex(); p.file = resumeEp != null && p.filelist[resumeEp] ? p.filelist[resumeEp] : file; p.flag = flag; p.getUrl = urlBuilder(p.file); if (!p.getUrl) return false; p.hls.setUrlBuilder(p.getUrl); const buildPromise = p.quality.build(p.file.resolution, p.getUrl, fileKeyOf(p.file)); const container = await p.replacePlayer(); if (container) await p.init(container); await buildPromise; return true; } async handleShare() { const localsReady = await waitFor(() => unsafeWindow.locals, { intervalMs: 500, maxAttempts: 40, label: 'handleShare.locals', }); if (!localsReady) return; await new Promise((resolve) => { let done = false; localsReady.get( 'file_list', 'share_uk', 'shareid', 'sign', 'timestamp', (file_list, share_uk, shareid, sign, timestamp) => { if (done) return; done = true; if (!file_list?.length) { resolve(); return; } let videoList = []; try { const list = unsafeWindow .require('system-core:context/context.js') .instanceForSystem.list.getCurrentList(); videoList = list.filter((f) => f.category === 1); } catch (_) { videoList = file_list.filter((f) => f.category === 1); } if (!videoList.length) { resolve(); return; } const list2 = videoList; const file0 = videoList[0]; this._commit({ file: file0, list: list2, flag: 'sharevideo', urlBuilder: buildShareUrl, }).finally(resolve); }, ); }); } async handlePlay() { const jqReady = await waitFor(() => unsafeWindow.jQuery, { intervalMs: 500, maxAttempts: 40, label: 'handlePlay.jQuery', }); if (!jqReady) return; let hasInit = false; jqReady(document).ajaxComplete(async (event, xhr, options) => { const url = options.url || ''; if (url.includes('/api/categorylist')) { this.player.filelist = sortByLocale((xhr.responseJSON?.info || []).filter((f) => f.category === 1)); if (this.player.art) this.player.addEpisodeControls(); } else if (url.includes('/api/filemetas')) { if (hasInit) return; const info = xhr.responseJSON?.info?.[0]; if (!info) return; const list = this.player.filelist || []; let file = null; const resumeEp = this.player.pickResumeIndex(); if (resumeEp != null && list[resumeEp]) { file = list[resumeEp]; } else { file = list.find((f) => (info?.fs_id != null && f?.fs_id != null && f.fs_id === info.fs_id) || (info?.path && f?.path && f.path === info.path) ) || list[0] || info; } hasInit = true; await this._commit({ file, list, flag: 'playvideo', urlBuilder: buildFileUrl, }); } }); } async _handlePinia(flag, urlBuilder) { const app = document.querySelector('#app'); const pinia = await waitFor( () => app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo ? app.__vue_app__.config.globalProperties.$pinia : null, { intervalMs: 500, maxAttempts: 40, label: `${flag}.pinia` }, ); if (!pinia) return; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; const file = pinia.state._rawValue.videoinfo.videoinfo; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) this.player.nativeVideoNode = videoNode; await this._commit({ file, list, flag, urlBuilder }); } async handleVideo() { await this._handlePinia('video', buildFileUrl); } async handleMboxVideo() { await this._handlePinia('mboxvideo', buildMboxUrl); } async dispatch() { const ready = document.readyState === 'complete' ? Promise.resolve() : new Promise((r) => window.addEventListener('load', r, { once: true })); if (document.readyState !== 'complete') { ready.then(() => this.probeAsideTopOp()); } else { this.probeAsideTopOp(); } await ready; const url = location.href; for (const route of RouteManager.ROUTES) { if (url.includes(route.pattern)) { await route.handler.call(this); break; } } } } RouteManager.ROUTES = Object.freeze([ { pattern: CONFIG.urls.sharePattern, handler: RouteManager.prototype.handleShare }, { pattern: CONFIG.urls.playPattern, handler: RouteManager.prototype.handlePlay }, { pattern: CONFIG.urls.videoPattern, handler: RouteManager.prototype.handleVideo }, { pattern: CONFIG.urls.mboxvideoPattern, handler: RouteManager.prototype.handleMboxVideo }, ]); // 22 入口 const player = new Player(); unsafeWindow.player = player; window.addEventListener('beforeunload', () => { if (player._initialized) player.progress.save(player.file); }); const routeManager = new RouteManager(player); routeManager.dispatch(); })();