// ==UserScript== // @name 百度网盘视频播放器 // @namespace https://scriptcat.org/ // @version 5.4 // @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'; /* ============================================================ * 配置 * ============================================================ */ 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: 5, hideDelayMs: 2000, saveDebounceMs: 2000, saveThrottleMs: 3000, progressMinSec: 5, progressTtlMs: 15 * 86400000, 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 = () => ({ headers: { 'User-Agent': CONFIG.userAgent } }); /* ============================================================ * 工具 * ============================================================ */ 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; return (...args) => { clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...args); }, delay); }; }; // 控制台仅在切换播放时输出一段播报;其它路径统一静默。 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}`); }, info: () => {}, warn: () => {}, error: () => {}, }; const safe = (fn, label = '') => { try { return fn(); } catch (e) { if (label) log.warn(`safe:${label}`, e); return undefined; } }; /* ============================================================ * 生命周期基类:Disposable * - 所有子系统继承,统一 dispose() 入口 * - 自带 _listeners 集中管理监听/定时器/轮询 * - 支持 _track(child) 注册子 disposable * ============================================================ */ 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(); } } /* ============================================================ * AutoNext 状态机枚举 * IDLE 未显示 * SHOWN 倒计时进行中 * CANCELLED 用户取消 / seek 取消 / 路由变更 * COMPLETED 倒计时结束 / 立即播放 * ============================================================ */ const AutoNextState = Object.freeze({ IDLE: 'idle', SHOWN: 'shown', CANCELLED: 'cancelled', COMPLETED: 'completed', }); const dumpCache = (player, label = '') => { // DEBUG: 注释掉下面一行以隐藏调试输出 return; try { const filelist = player?.filelist || []; const hlsCache = player?.hls?._cacheList || []; const qualityList = player?.quality?.list || []; const tag = label ? ` [${label}]` : ''; console.info('[PlayerCache]' + tag, { filelist, hls_cacheList: hlsCache, quality_list: qualityList, }); } catch (e) { console.warn('[PlayerCache] dump 失败:', e); } }; unsafeWindow.dumpPlayerCache = () => dumpCache(unsafeWindow.player); const gmFetch = (url) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': CONFIG.userAgent }, responseType: 'text', timeout: 10000, onload: (r) => resolve(r.responseText), onerror: (r) => reject(new Error(`HTTP ${r.status}`)), ontimeout: () => reject(new Error('timeout')), }); }); const parseM3u8Duration = (text) => { if (!text || typeof text !== 'string') return 0; const re = /#EXTINF:([0-9]+(?:\.[0-9]+)?)/g; let total = 0; let m; while ((m = re.exec(text))) { total += parseFloat(m[1]) || 0; } return total; }; async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout, label = 'waitFor' } = {}) { for (let i = 0; i < maxAttempts; i++) { try { const v = fn(); if (v) return v; } catch (e) { log.warn(`${label}.poll`, e); } await new Promise((r) => setTimeout(r, intervalMs)); } if (onTimeout) safe(onTimeout, `${label}.timeout`); 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; } }; /** ListenerBag 集中管理监听器 / 定时器 / 轮询,destroy 时一次性清理 */ class ListenerBag { constructor() { this._listeners = []; this._timers = new Set(); this._intervals = new Set(); } on(target, type, fn, options) { target.addEventListener(type, fn, options); this._listeners.push({ target, type, fn, options }); } once(target, type, fn, options) { const wrapped = (...args) => { this.off(target, type, wrapped); fn(...args); }; target.addEventListener(type, wrapped, { ...(options || {}), once: true }); this._listeners.push({ target, type, fn: wrapped, options: { ...(options || {}), once: true } }); return wrapped; } off(target, type, fn) { target.removeEventListener(type, fn); } trackArtListener(art, type, fn) { art.on(type, fn); this._listeners.push({ target: art, type, fn, options: undefined }); } untrackArtListener(art, type, fn) { try { art.off(type, fn); } catch (_) { /* target 已销毁 */ } } 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 }) => { try { if (target && typeof target.removeEventListener === 'function') { target.removeEventListener(type, fn, options); } else if (target && typeof target.off === 'function') { target.off(type, fn); } } catch (_) { /* target 已卸载 */ } }); 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) { log.error('SwitchController', e); } finally { this._running = false; } } } get isRunning() { return this._running; } } /* ============================================================ * 存储抽象:GM_* 优先,回落 localStorage * - 集中处理 QuotaExceededError 时的 ev_old_progress 回收 * - 暴露一次性 helpers 取代 listKeys 拉取 + JSON.parse 二次循环 * ============================================================ */ const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; const hasGMList = typeof GM_listValues === 'function'; const hasGMDel = typeof GM_deleteValue === '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) { if (!hasGMList) return; 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) { log.warn('evictOldProgress', e); } } const storage = { getItem(key) { try { if (hasGM) { const v = GM_getValue(key); return v == null ? null : v; } return localStorage.getItem(key); } catch (e) { log.warn('storage.get', key, 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) { log.warn('storage.set.retry', key, e2); } } else { log.warn('storage.set', key, e); } } }, removeItem(key) { try { if (hasGM) { if (hasGMDel) GM_deleteValue(key); else log.warn('storage.remove.unsupported', key); return; } localStorage.removeItem(key); } catch (e) { log.warn('storage.remove', key, e); } }, getRawItem(key) { if (hasGM) return storage.getItem(key); try { return localStorage.getItem(key); } catch (e) { log.warn('storage.getRaw', key, e); return null; } }, }; /* ============================================================ * 进度键与解析 * ============================================================ */ 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) { log.warn('parseStoredProgress.json', e); return null; } if (typeof obj?.currentTime !== 'number' || obj.currentTime < 0) return null; if (typeof obj?.timestamp !== 'number' || obj.timestamp <= 0) return null; const duration = typeof obj.duration === 'number' && obj.duration >= 0 ? obj.duration : 0; if (duration > 0 && 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 }; } /* ============================================================ * 选集元数据:时长 / 进度 单一来源 * 取代旧的 EpisodeCache + DurationCache 双类 * ============================================================ */ 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; // HLS EXTINF 累加总 ≤ 真实时长;用 Math.ceil 避免被 floor 砍掉 ~1s // 与 artplayer 显示的 art.duration (≈ seekable.end) 对齐 const dur = Math.ceil(sec); // verified=false 不能覆盖已 verified 的值(HLS 推断不能反向覆盖真实时长) if (entry.durationVerified && !verified) return; // 值未变且 verified 状态相同 → 跳过(避免 durationchange 重复 emit) 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 { entry.currentTime = Math.floor(currentTime); 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 raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null; if (raw != null && Number.isFinite(raw) && raw > 0) { entry.duration = Math.floor(raw); } } } 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) || {}; // storage 中 parsed.duration = 上次 progress.save() 写入的 art.duration(真实) // 若 entry 无 duration,才写入并标记 verified=true(避免后续 HLS 推断反覆盖) if (existing.duration == null) { existing.duration = parsed.duration; existing.durationVerified = true; } existing.currentTime = parsed.currentTime; existing.timestamp = parsed.timestamp; this._map.set(k, existing); } } *iterateKeys() { yield* this._map.keys(); } clear() { this._map.clear(); this.events.emit('clear'); } dispose() { this.clear(); super.dispose(); } } /* ============================================================ * 文件排序 / 文件名 / 时间格式化 * ============================================================ */ 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 || '未命名'; 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)); } /* ============================================================ * URL 构建 * ============================================================ */ 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}`; } /* ============================================================ * HLS 控制器 * 缓存:JSON 数组 [{ fileKey, qualities: [{ q, r }] }] * 同集候选清晰度并行;多集之间串行 * ============================================================ */ 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; } getCachedUrl(file, quality) { return this.getCachedUrlByKey(fileKeyOf(file), quality); } getCachedUrlByKey(key, quality) { if (!key) return null; const idx = this._findEpisodeIndex(key); if (idx < 0) return null; return this._findQualityUrl(this._cacheList[idx].qualities, quality); } 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; } log.info('resolve errno:', json.errno, json); if (json.errno === 133 && json.adToken) return `${url}&adToken=${encodeURIComponent(json.adToken)}`; return null; }).catch((e) => { log.warn('gmFetch', e?.message); return 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) { 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; } clearUrlCache() { this._cacheList = []; this._pendingRequests.clear(); this._retries = {}; } exportCacheAsJson() { return this._cacheList.map((entry) => ({ fileKey: entry.fileKey, qualities: [...entry.qualities].sort((a, b) => b.q - a.q), })); } importCacheFromJson(arr) { if (!Array.isArray(arr)) return; const next = []; for (const entry of arr) { if (!entry || !entry.fileKey || !Array.isArray(entry.qualities)) continue; const qualities = entry.qualities .filter((qe) => typeof qe.q === 'number' && typeof qe.r === 'string') .map((qe) => ({ q: qe.q, r: qe.r })) .sort((a, b) => b.q - a.q); next.push({ fileKey: entry.fileKey, qualities }); } this._cacheList = next; } _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')) { video.src = url; return null; } showTip('浏览器不支持视频播放'); return null; } this._fileKey = fileKey || 'default'; this._retries[this._fileKey] = 0; const hls = new Hls({ ...CONFIG.hls, fetchSetup: HLS_FETCH_SETUP }); hls.on(Hls.Events.ERROR, (_, data) => { if (this.instance !== hls || !data.fatal) return; if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { if (data.details === 'manifestParsingError') { 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) { hls.recoverMediaError(); } else { showTip('播放失败,请刷新重试'); } }); 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(); } } /* ============================================================ * 画质控制 * ============================================================ */ class QualityController extends Disposable { constructor(hlsController) { super(); this.hls = hlsController; this.list = []; this.getUrl = null; this._lastResolution = 0; this._switchState = null; this._art = null; this._qualityHandler = null; } async build(resolution, getUrl) { this.getUrl = getUrl; const match = resolution?.match?.(/width:(\d+),height:(\d+)/); let videoHeight = match ? +match[2] : 0; if (!videoHeight && this._lastResolution) videoHeight = this._lastResolution; const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a); // 根据视频实际分辨率过滤:只保留不超过视频高度的最高画质 let candidates = sortedDesc; if (videoHeight > 0) { this._lastResolution = videoHeight; const startIdx = sortedDesc.findIndex((q) => videoHeight >= q); if (startIdx >= 0) candidates = sortedDesc.slice(startIdx); } const fileKey = this.hls.fileKey; 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, })); } apply(art) { if (art && this.list?.length) art.quality = this.list; } bind(art) { if (!art || this._qualityHandler) return; 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 = { currentTime: art.currentTime, volume: art.video.volume, muted: art.video.muted, playing: !art.video.paused, }; 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('画质切换失败')); if (!newHls) { this._switchState = null; return; } newHls.once(Hls.Events.MANIFEST_PARSED, () => { const state = this._switchState; if (!state) return; this._switchState = null; const { currentTime, volume, muted, playing } = state; art.video.muted = muted; art.video.volume = volume; art.currentTime = currentTime; if (playing) art.video.play().catch(() => { }); }); }; 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(); } } /* ============================================================ * 进度存储 * ============================================================ */ 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?.duration || art.duration <= 0) return; const data = JSON.stringify({ currentTime: art.currentTime, duration: art.duration, timestamp: Date.now(), }); storage.setItem(key, data); this.episodeMeta.setProgress(file, art.currentTime, Date.now()); } load() { const file = this._file(); const art = this.playerRef()?.art; if (!file || !art) return; const obj = parseStoredProgress(file); if (!obj) return; art.currentTime = obj.currentTime; this.episodeMeta.setProgress(file, obj.currentTime, obj.timestamp); } 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); if (entry?.currentTime != null && entry.timestamp > 0) { return { currentTime: entry.currentTime, duration: entry.duration ?? 0 }; } return parseStoredProgress(file); } } /* ============================================================ * 选集面板 — ArtPlayer 插件 * 始终返回同一实例对象;状态由内部 _state 控制 * ============================================================ */ const DRAWER_WIDTH = 280; const DRAWER_WIDTH_NARROW = 220; const DRAWER_NARROW_BREAKPOINT = 480; const DRAWER_HEIGHT = 400; const DRAWER_TRANSITION_MS = 250; const TOP_Z = 2147483647; 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, 0.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 8px; 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: 40px; display: flex; align-items: center; padding: 0 16px; background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0)); font-family: system-ui, -apple-system, sans-serif; box-sizing: border-box; transition: opacity var(--art-transition-duration, 0.2s) ease; } .art-title-text { color: #fff; font-size: 14px; font-weight: 500; letter-spacing: .2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-shadow: 0 1px 3px rgba(0,0,0,.6); } .artplayer-autonext { position: absolute; left: 0; right: 0; display: flex; justify-content: center; pointer-events: none; padding: 0 16px 0; box-sizing: border-box; animation: art-autonext-rise .2s ease-out; } .art-layers:not(.art-show) .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); } } `; function ensureEpisodeMenuStyle() { if (document.getElementById('ep-menu-style')) return; const style = document.createElement('style'); style.id = 'ep-menu-style'; style.textContent = EPISODE_MENU_CSS; document.head.appendChild(style); } /* ============================================================ * 选集面板 — 正式 class(替代原 createEpisodeMenu 闭包) * 状态由实例内部维护;单例通过模块级 _episodeMenu 暴露 * ============================================================ */ class EpisodeMenu extends Disposable { constructor() { super(); this._el = null; this._resizeHandler = null; this._menuLeaveHandler = null; this._playerRef = null; this._hooks = { onOpen: null, onClose: null }; } _getPlayer() { return this._playerRef || null; } _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); }; 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'; } open(player) { const target = player || this._getPlayer(); if (!target) return; if (this._el) this.close(); 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:${TOP_Z};transform:translateX(100%);transition:transform ${DRAWER_TRANSITION_MS}ms cubic-bezier(.22,.61,.36,1);`; 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?.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._menuLeaveHandler = () => { if (this._el === menu) this.close(); }; menu.addEventListener('mouseleave', this._menuLeaveHandler); this._resizeHandler = debounce(() => this._applyPosition(), 100); window.addEventListener('resize', this._resizeHandler); target.episodeMeta.prefetch(target.filelist); if (typeof this._hooks.onOpen === 'function') this._hooks.onOpen(); } close() { const wasOpen = !!this._el; const el = this._el; if (!el) return; if (this._menuLeaveHandler) { safe(() => el.removeEventListener('mouseleave', this._menuLeaveHandler), 'menu.removeListener'); } const finalize = once(() => { el.removeEventListener('transitionend', finalize); el.remove(); }); el.addEventListener('transitionend', finalize); setTimeout(finalize, DRAWER_TRANSITION_MS + 50); el.style.transform = 'translateX(100%)'; this._el = null; this._menuLeaveHandler = null; if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = 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; } updateDuration(file, sec) { if (!this._el || !file) return; const key = fileKeyOf(file); const escapedKey = CSS.escape(String(key)); const el = this._el.querySelector(`[data-ep-key="${escapedKey}"] .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(); if (!player) return; const key = fileKeyOf(file); const escapedKey = CSS.escape(String(key)); const item = this._el.querySelector(`[data-ep-key="${escapedKey}"]`); if (!item) return; const saved = player.episodeMeta.get(file); const metaEl = item.querySelector('.ep-meta'); if (!metaEl) return; 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; /* ============================================================ * 选集面板 — 按钮注入插件 * 负责在 artplayer controls 注入"选集"按钮,调用 ensureEpisodeMenuStyle()。 * 面板本身由 _episodeMenu 单例管理。 * ============================================================ */ const EPISODE_MENU_PLUGIN_NAME = 'episodeMenu'; function episodeMenuPlugin(options = {}) { return function installEpisodeMenu(art) { ensureEpisodeMenuStyle(); const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null); art.controls.add({ name: EPISODE_MENU_PLUGIN_NAME, position: 'right', html: '选集', tooltip: '选集', style: { padding: '0 10px', fontSize: '14px' }, click: () => { if (_episodeMenu.isOpen()) _episodeMenu.close(); else _episodeMenu.open(getPlayer()); }, }); return { name: EPISODE_MENU_PLUGIN_NAME, open: () => _episodeMenu.open(getPlayer()), close: () => _episodeMenu.close(), toggle: () => (getPlayer() && _episodeMenu.toggle(getPlayer())), isOpen: () => _episodeMenu.isOpen(), setHooks: (hooks) => _episodeMenu.setHooks(hooks), updateDuration: (f, sec) => _episodeMenu.updateDuration(f, sec), updateActiveState: (i) => _episodeMenu.updateActiveState(i), updateItemProgress: (f) => _episodeMenu.updateItemProgress(f), destroy: () => _episodeMenu.destroy(), }; }; } /* ============================================================ * 选集上/下一集 Nav 插件 * ============================================================ */ function episodeNavPlugin(options = {}) { return function installEpisodeNav(art) { const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null); const prev = art.controls.add({ name: 'prev', position: 'left', index: 5, html: makeSvg(ICONS.prev), tooltip: '上一集', click: (_component, event) => { if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return; const player = getPlayer(); if (!player) return; const idx = player.getCurrentIndex(); if (idx > 0) { player.switchVideo(player.filelist[idx - 1]); } }, }); const next = art.controls.add({ name: 'next', position: 'left', index: 15, html: makeSvg(ICONS.next), tooltip: '下一集', click: (_component, event) => { if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return; const player = getPlayer(); if (!player) return; const idx = player.getCurrentIndex(); const len = player.filelist?.length || 0; if (idx >= 0 && idx < len - 1) { player.switchVideo(player.filelist[idx + 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(); }, destroy() { // artplayer 内部 on destroy 会自动清理 controls }, }; }; } /* ============================================================ * 自动切下一集控制器 * ============================================================ */ class AutoNextController extends Disposable { constructor(artRef) { super(); this.artRef = artRef; this.state = AutoNextState.IDLE; this._token = 0; this._ticker = null; this._visible = false; this._resizeObs = null; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; } get visible() { return this._visible; } get isTerminal() { return this.state === AutoNextState.CANCELLED || this.state === AutoNextState.COMPLETED; } _setState(s) { if (this.state === s) return; this.state = s; } _cancelTicker() { if (this._ticker) { clearInterval(this._ticker); this._ticker = null; } } _disconnectResize() { if (this._resizeObs) { safe(() => this._resizeObs.disconnect(), 'AutoNext.disconnect'); this._resizeObs = null; } } cancel() { this._cancelTicker(); } show({ name, seconds, onCancel, onComplete }) { if (this._disposed) return; const art = this.artRef(); if (!art) return; this.hideCountdown(); this._token++; const token = this._token; this._setState(AutoNextState.SHOWN); this._onCancelCb = onCancel; this._onCompleteCb = onComplete; const EMBED = 15; const $ctrl = art?.template?.$controls || art?.controls?.$controls; const ctrlH = Math.max(40, $ctrl?.getBoundingClientRect?.().height || 40); const bottomPx = Math.round(ctrlH + EMBED); this._visible = true; art.controls.show = true; art.layers.show = true; let remaining = seconds; const layerName = `autoNextCountdown_${token}`; this._layerName = layerName; const $layer = art.layers.add({ name: layerName, html: ` `, style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' }, mounted: ($el) => { if (token !== this._token) return; const $title = $el.querySelector('.artplayer-autonext-title'); const $sec = $el.querySelector('.artplayer-autonext-sec'); $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 = $el?.querySelector?.('.artplayer-autonext'); if (!wrap) return; const $ctrl2 = art?.template?.$controls || art?.controls?.$controls; const h = Math.max(40, $ctrl2?.getBoundingClientRect?.().height || 40); wrap.style.bottom = Math.round(h + EMBED) + 'px'; }); safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe'); $el.querySelector('.artplayer-autonext-cancel')?.addEventListener('click', (ev) => { ev.stopPropagation(); if (token !== this._token) return; if (this.state !== AutoNextState.SHOWN) return; this._setState(AutoNextState.CANCELLED); safe(() => this._onCancelCb?.(), 'AutoNext.onCancel'); }); $el.querySelector('.artplayer-autonext-play')?.addEventListener('click', (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'); }); }, }); 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(); this._disconnectResize(); const art = this.artRef(); const layerName = this._layerName; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; if (!art || !layerName || !art.layers?.[layerName]) { this._visible = false; return; } safe(() => art.layers.remove(layerName), 'AutoNext.removeLayer'); this._visible = false; } dispose() { this.hideCountdown(); super.dispose(); } } /* ============================================================ * UI 控制:控制栏隐藏 + 标题层 * 提供 hideVisor() / showVisor() 接口, * 由 Player 决定何时调用(避免与 AutoNext 双向耦合) * ============================================================ */ class UiController extends Disposable { constructor(artRef) { super(); this.artRef = artRef; 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; let hideTimer = null; const $controls = () => art?.template?.$controls; const clearHide = () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }; const showNow = () => { if (!art) return; if (art.controls) art.controls.show = true; art.layers.show = true; }; const isPinned = () => this._pinned(); const scheduleHide = () => { clearHide(); if (isPinned()) return; hideTimer = setTimeout(() => { hideTimer = null; if (!art?.controls || isPinned()) return; const el = $controls(); if (el && el.matches(':hover')) return; art.controls.show = false; art.layers.show = false; }, CONFIG.hideDelayMs); }; return { clearHide, showNow, scheduleHide }; } setup() { const art = this.artRef(); if (!art || this._setup) return; this._setup = true; this._hideCtl = this._makeHideCtl(); if (typeof art.on === 'function' && !this._layerSyncBound) { this._layerSyncBound = true; this._listeners.trackArtListener(art, 'control', (state) => { if (!art) return; if (this._pinned()) { if (art.layers) art.layers.show = true; return; } if (art.layers) art.layers.show = !!state; }); } this._listeners.on(document, 'mousemove', (e) => { const el = art?.template?.$controls; if (!el) return; if (el.contains(e.target)) { this._hideCtl?.clearHide(); this._hideCtl?.showNow(); } else if (!this._pinned()) { this._hideCtl?.scheduleHide(); } }, { passive: true }); this._listeners.on(document, 'mouseleave', () => { if (this._pinned()) { this._hideCtl?.clearHide(); return; } this._hideCtl?.scheduleHide(); }, { passive: true }); } addTitle(file) { const art = this.artRef(); if (!art || this._titleLayerAdded) return; this._titleLayerAdded = true; const initialName = file ? getFileName(file) : ''; art.layers.add({ name: 'episodeTitle', html: `
`, style: { position: 'absolute', top: '0', left: '0', right: '0', pointerEvents: 'none' }, mounted: ($el) => { this._titleEl = $el.querySelector('.art-title-text'); this._setTitleText(initialName); }, }); this._setTitleText(initialName); } updateTitle(file) { this._setTitleText(file ? getFileName(file) : ''); } _setTitleText(name) { const art = this.artRef(); const el = this._titleEl || art?.layers?.episodeTitle?.querySelector?.('.art-title-text'); if (el) el.textContent = name || ''; } showNow() { this._hideCtl?.showNow(); } scheduleHide() { this._hideCtl?.scheduleHide(); } reset() { this._titleLayerAdded = false; this._titleEl = null; this._setup = false; this._layerSyncBound = false; this._hideCtl = null; this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } /* ============================================================ * 快捷键 * ============================================================ */ class HotkeyController extends Disposable { constructor(playerRef) { super(); this.playerRef = playerRef; } setup() { const player = this.playerRef(); if (!player?.art) return; this._listeners.on(document, 'keydown', (e) => { const player2 = this.playerRef(); if (!player2?.art) 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 key = e.key.toLowerCase(); if (key === 'f') { player2.art.fullscreen = !player2.art.fullscreen; e.preventDefault(); e.stopPropagation(); player2._ui.scheduleHide(); } else if (key === 'w') { player2.art.fullscreenWeb = !player2.art.fullscreenWeb; e.preventDefault(); e.stopPropagation(); player2._ui.scheduleHide(); } else if (key === 'm') { player2.art.muted = !player2.art.muted; e.preventDefault(); e.stopPropagation(); player2._ui.scheduleHide(); } else if (key === 'p') { const idx = player2.getCurrentIndex(); if (idx > 0) player2.switchVideo(player2.filelist[idx - 1]); e.preventDefault(); e.stopPropagation(); player2._ui.scheduleHide(); } else if (key === 'n') { const idx = player2.getCurrentIndex(); if (idx >= 0 && idx < (player2.filelist?.length || 0) - 1) player2.switchVideo(player2.filelist[idx + 1]); e.preventDefault(); e.stopPropagation(); player2._ui.scheduleHide(); } }, true); } reset() { this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } /* ============================================================ * 触摸手势控制器:水平滑动调整播放进度(移动端) * - 单指水平滑动 → 在视频时长内按比例 seek * - 实时显示目标时间,touchend 时应用并阻止合成 click * ============================================================ */ class TouchGestureController extends Disposable { constructor(playerRef) { super(); this.playerRef = playerRef; } setup() { const player = this.playerRef(); const art = player?.art; const video = art?.video; if (!art || !video) return; const MOVE_THRESHOLD = 10; // 触发滑动的最小位移 const DIRECTION_RATIO = 1.5; // 水平分量需为垂直的 1.5 倍才判定为水平滑动 const SEEK_RATIO = 0.5; // 屏宽对应 50% 时长 let startX = 0, startY = 0; let startCurrent = 0, duration = 0; let seeking = false; let lastTarget = 0; let suppressClickUntil = 0; this._listeners.on(video, 'touchstart', (e) => { if (!e.touches || e.touches.length !== 1) return; const t = e.touches[0]; startX = t.clientX; startY = t.clientY; startCurrent = art.currentTime || 0; duration = art.duration || 0; seeking = false; lastTarget = startCurrent; }, { passive: true }); this._listeners.on(video, 'touchmove', (e) => { if (!e.touches || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; if (!seeking) { if (Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) return; if (Math.abs(dx) < Math.abs(dy) * DIRECTION_RATIO) return; seeking = true; } if (e.cancelable) e.preventDefault(); const rect = video.getBoundingClientRect(); const w = rect.width || video.clientWidth || 1; if (w <= 0) return; const ratio = Math.max(-1, Math.min(1, dx / w)); const delta = ratio * duration * SEEK_RATIO; let target = startCurrent + delta; if (target < 0) target = 0; if (duration > 0 && target > duration) target = duration; lastTarget = target; try { art.notice.show = `${formatTime(target)} / ${formatTime(duration)}`; } catch (_) {} }, { passive: false }); const endHandler = () => { if (!seeking) return; seeking = false; suppressClickUntil = Date.now() + 400; try { if (Number.isFinite(lastTarget) && Math.abs(lastTarget - (art.currentTime || 0)) > 0.5) { art.currentTime = lastTarget; } art.notice.show = ''; } catch (_) {} }; this._listeners.on(video, 'touchend', endHandler, { passive: false }); this._listeners.on(video, 'touchcancel', endHandler, { passive: false }); // 抑制滑动后合成的 click(避免被 Artplayer 当成播放/暂停) this._listeners.on(video, 'click', (e) => { if (Date.now() < suppressClickUntil) { e.preventDefault?.(); e.stopPropagation?.(); e.stopImmediatePropagation?.(); } }, { capture: true }); } reset() { this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } /* ============================================================ * AutoNext 调度器:监听 video:ended → 触发倒计时 * - 由 Player 持有,独立生命周期 * - 显式 token + 状态机防止取消混乱 * ============================================================ */ class AutoNextScheduler extends Disposable { constructor(player) { super(); this.player = player; this._artListenerToken = null; } attach(art) { if (this._disposed) return; const handler = (ev) => this._onEnded(ev); art.on('video:ended', handler); this._artListenerToken = { art, handler }; } _onEnded() { const player = this.player; if (!player || player._disposed) return; if (!player._autoNext || player._autoNext._disposed) { player._autoNext = player._track(new AutoNextController(() => player.art)); player._ui.setPinnedProbe(() => player._autoNext?.visible || getEpisodeMenu().isOpen()); } const art = player.art; if (!art) return; player.progress.clear(); const idx = player.getCurrentIndex(); const listLen = player.filelist?.length || 0; if (idx < 0 || idx >= listLen - 1) return; 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']; const clearAutoNextTimer = () => { if (player._autoNextTimer != null) { player._listeners.clearTimeout(player._autoNextTimer); player._autoNextTimer = null; } }; const cancelOnSeek = () => { if (art._disposed || player._disposed) return; player._cancelAutoNext(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); safe(() => art.play(), 'cancelOnSeek.play'); }; const switchNow = () => { if (player._disposed) return; player._autoNext?.hideCountdown(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); if (player._switchToken !== tokenAtEnd) return; player.switchVideo(next); }; player._autoNext.show({ name: nextName, seconds: CONFIG.countdownSec, onCancel: () => cancelOnSeek(), onComplete: () => switchNow(), }); player._autoNextTimer = player._listeners.setTimeout(() => switchNow(), CONFIG.countdownSec * 1000); cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek)); } dispose() { if (this._artListenerToken) { safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNextScheduler.detach'); this._artListenerToken = null; } super.dispose(); } } /* ============================================================ * 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._autoNext = null; this._autoNextTimer = null; this._autoNextScheduler = null; this._episodeControlsAdded = false; this._initialized = false; this._epMenuUnsubs = []; 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._ui = new UiController(() => this.art)); this._track(this._hotkey = new HotkeyController(() => this)); this._track(this._touchGesture = new TouchGestureController(() => this)); this._track(getEpisodeMenu()); this._epMenuUnsubs = []; this._epMenuUnsubs.push(this.episodeMeta.events.on('progress', ({ file }) => { if (!file) return; getEpisodeMenu().updateItemProgress(file); })); this._epMenuUnsubs.push(this.episodeMeta.events.on('duration', ({ file, duration }) => { if (!file || !Number.isFinite(duration)) return; getEpisodeMenu().updateDuration(file, Math.ceil(duration)); })); } getCurrentIndex() { const { file, filelist } = this; if (!file || !filelist?.length) return -1; return filelist.findIndex( (f) => (f.fs_id && file.fs_id && f.fs_id == file.fs_id) || (f.path && file.path && f.path === file.path), ); } _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) { log.warn('pickResumeIndex', e); } 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) { log.warn('_saveLastEpisode', e); } } switchVideo(file) { if (!file) return; this._switchCtrl.submit(() => this._performSwitch(file)); } async _performSwitch(file) { this._cancelAutoNext(); const prevFile = this.file; if (prevFile) this.progress.save(prevFile); if (!this.art) return; const wasFullscreen = !!this.art.fullscreen; const wasFullscreenWeb = !!this.art.fullscreenWeb; const volume = this.art.volume ?? 1; this.file = file; const token = ++this._switchToken; const cardNodes = document.getElementsByClassName('vp-video-page-card__video-detail'); const title = getFileName(file); const match = Array.from(cardNodes).find((n) => (n.textContent || '').includes(title)); if (match) match.click(); const newEpIdx = this.getCurrentIndex(); if (newEpIdx >= 0) this._saveLastEpisode(newEpIdx); this.getUrl = this.flag === 'sharevideo' ? buildShareUrl(file) : this.flag === 'mboxvideo' ? buildMboxUrl(file) : buildFileUrl(file); if (!this.getUrl) return; this.hls.setUrlBuilder(this.getUrl); await this.quality.build(file.resolution, this.getUrl); if (!this.quality.list?.length) { showTip('无法获取视频地址'); return; } if (token !== this._switchToken) return; const resolvedUrl = this.quality.list[0].url; if (!resolvedUrl) { showTip(`无法播放: ${getFileName(file)}`); return; } this.hls.destroy(); safe(() => { this.art.video.pause(); this.art.video.removeAttribute('src'); this.art.video.load(); }, 'switch.resetVideo'); const hls = this.hls.create(resolvedUrl, this.art.video, fileKeyOf(file), () => showTip('网络持续错误,请刷新页面')); 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); this._bindHlsDuration(hls, file); try { await this._waitForVideo(this.art.video, hls, token); } catch (e) { if (e?.message === 'stale') return; log.warn('视频加载超时,继续尝试播放'); } if (token !== this._switchToken) return; const video = this.art.video; video.muted = false; video.volume = volume; safe(() => { if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true; if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true; }, 'restoreFullscreen'); this.progress.load(); log.announce(file); video.play().catch(() => { }); } _bindHlsDuration(hls, file) { if (!hls || !file) return; const key = fileKeyOf(file); if (!key) return; const handler = (_, data) => { const dur = data?.details?.totalduration; // 仅作兜底:仅在真实 duration 尚未验证时写入 if (dur && Number.isFinite(dur) && dur > 0 && !this.episodeMeta.isDurationVerified(file)) { this.episodeMeta.setDuration(file, dur, false); } }; hls.once(Hls.Events.LEVEL_LOADED, handler); } _waitForVideo(video, hls, token) { return new Promise((resolve, reject) => { const finish = once((err) => { clearTimeout(timer); err ? reject(err) : resolve(); }); const timer = setTimeout(() => finish(new Error('timeout')), 8000); const onParsed = () => { if (token !== this._switchToken) { finish(new Error('stale')); return; } if (video?.readyState >= 1) finish(); else video?.addEventListener('loadedmetadata', () => finish(), { once: true }); }; hls.once(Hls.Events.MANIFEST_PARSED, onParsed); }); } _cancelAutoNext() { this._autoNext?.hideCountdown(); this._ui.scheduleHide(); } 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); } _setupAutoNext(art) { if (!art) return; if (!this._autoNext) { this._autoNext = this._track(new AutoNextController(() => this.art)); this._ui.setPinnedProbe(() => this._autoNext?.visible || getEpisodeMenu().isOpen()); } if (!this._autoNextScheduler || this._autoNextScheduler._disposed) { this._autoNextScheduler = this._track(new AutoNextScheduler(this)); } this._autoNextScheduler.attach(art); } _teardown() { if (this._disposed) return; this._cancelAutoNext(); this._autoNextTimer = null; this._episodeControlsAdded = false; this._initialized = false; if (this._epMenuUnsubs?.length) { this._epMenuUnsubs.forEach((un) => { try { un(); } catch (_) { } }); this._epMenuUnsubs = []; } getEpisodeMenu().close(); 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'); } // 一次性清理 artplayer 事件(timeupdate / pause / seeked / destroy / visibilitychange) // 子系统(Hls / Quality / Ui / Hotkey / EpisodeMeta / AutoNext / AutoNextScheduler) // 已通过 _track 注册,由 super.dispose() 链式释放,无需重复调用。 this._listeners.clearAll(); } dispose() { this._teardown(); super.dispose(); } async init(container) { if (!this.getUrl) { showTip('无法获取播放地址,请检查登录状态'); return; } this._teardown(); this._switchCtrl = new SwitchController(); this._switchToken = 0; this.hls.setUrlBuilder(this.getUrl); await this.quality.build(this.file?.resolution, this.getUrl); if (!this.quality.list?.length) { showTip('无法获取播放地址,请检查登录状态'); return; } const resolvedUrl = this.quality.list[0].url; if (!resolvedUrl) { showTip('无法获取播放地址,请检查登录状态'); return; } this.art = new Artplayer({ container, url: resolvedUrl, type: 'm3u8', customType: { m3u8: (video, url) => { this.hls.create(url, video, fileKeyOf(this.file), () => showTip('网络持续错误,请刷新页面')); }, }, poster: Object.values(this.file?.thumbs || {}) .pop() ?.replace(/size=c\d+_u\d+/, 'size=c850_u580') || '', 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: [ episodeMenuPlugin({ getPlayer: () => this }), episodeNavPlugin({ getPlayer: () => this }), ], }); getEpisodeMenu().setHooks({ onOpen: () => this._ui.showNow(), onClose: () => 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.setup(); this._hotkey.setup(); this._touchGesture.setup(); this._setupAutoNext(this.art); log.announce(this.file); this._initialized = true; }); const debouncedSave = debounce(() => this.progress.save(), CONFIG.saveDebounceMs); let lastSave = 0; this._listeners.trackArtListener(this.art, 'video:timeupdate', () => { if (!this.art?.currentTime || this.art.currentTime <= 0) return; if (Date.now() - lastSave > CONFIG.saveThrottleMs) { lastSave = Date.now(); debouncedSave(); } }); // 视频元数据加载完成 → 写入真实 duration,覆盖 HLS 推断值 const applyRealDuration = () => { const realDur = this.art?.duration; if (!Number.isFinite(realDur) || realDur <= 0) return; this.episodeMeta.setDuration(this.file, realDur, true); }; this._listeners.trackArtListener(this.art, 'video:loadedmetadata', applyRealDuration); this._listeners.trackArtListener(this.art, 'video:canplay', applyRealDuration); this._listeners.trackArtListener(this.art, 'video:durationchange', applyRealDuration, { once: true }); const flushSave = () => { if (!this.art?.currentTime || this.art.currentTime <= 0) return; this.progress.save(); }; this._listeners.trackArtListener(this.art, 'video:pause', flushSave); this._listeners.trackArtListener(this.art, 'video:seeked', flushSave); this._listeners.on(document, 'visibilitychange', () => { if (document.hidden) flushSave(); }, { passive: true }); this._listeners.trackArtListener(this.art, 'destroy', () => { this._teardown(); }); } 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 > 30) { this._listeners.clearInterval(id); } }, 300); }; if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) { setTimeout(() => { unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => { if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance); }); }, 1000); } if (this.flag === 'video' && this.nativeVideoNode) { setTimeout(() => { pollDestroy(() => this.nativeVideoNode?.firstChild); }, 1000); } } 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; } } /* ============================================================ * 单例与生命周期 * ============================================================ */ const player = new Player(); unsafeWindow.player = player; window.addEventListener('beforeunload', () => { if (player._initialized) player.progress.save(); }); /* ============================================================ * 入口路由 * ============================================================ */ function probeAsideTopOp() { const el = document.getElementsByClassName('vp-aside-box__top-operation')[0]; const hit = el?.textContent.includes('查看全部'); log.info(`vp-aside-box__top-operation ${hit ? '存在' : '找到'}`); if (hit) el.click(); } async function handleShare() { const localsReady = await waitFor(() => unsafeWindow.locals, { intervalMs: 500, maxAttempts: 40, label: 'handleShare.locals', onTimeout: () => log.warn('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; } player.filelist = sortByLocale(videoList); let file = videoList[0]; const resumeEp = player.pickResumeIndex(); if (resumeEp != null) file = videoList[resumeEp]; player.flag = 'sharevideo'; player.file = file; player.getUrl = buildShareUrl(file); if (!player.getUrl) { resolve(); return; } player.hls.setUrlBuilder(player.getUrl); (async () => { await player.quality.build(file.resolution, player.getUrl); dumpCache(player, 'handleShare.init'); const container = await player.replacePlayer(); if (container) await player.init(container); resolve(); })(); }, ); }); } async function handlePlay() { const jqReady = await waitFor(() => unsafeWindow.jQuery, { intervalMs: 500, maxAttempts: 40, label: 'handlePlay.jQuery', onTimeout: () => log.warn('jQuery等待超时,放弃初始化'), }); if (!jqReady) return; let hasInit = false; jqReady(document).ajaxComplete(async (event, xhr, options) => { const url = options.url || ''; if (url.includes('/api/categorylist')) { player.filelist = sortByLocale((xhr.responseJSON?.info || []).filter((f) => f.category === 1)); if (player.art) player.addEpisodeControls(); } else if (url.includes('/api/filemetas')) { if (hasInit) return; const info = xhr.responseJSON?.info?.[0]; if (!info) return; const resumeEp = player.pickResumeIndex(); const list = player.filelist || []; let file = null; if (resumeEp != null && list[resumeEp]) { file = list[resumeEp]; } else { file = list.find((f) => (info.fs_id && f.fs_id && f.fs_id == info.fs_id) || (info.path && f.path && f.path === info.path) ) || info; } hasInit = true; player.flag = 'playvideo'; player.file = file; player.getUrl = buildFileUrl(file); if (!player.getUrl) return; player.hls.setUrlBuilder(player.getUrl); player.quality.build(file.resolution, player.getUrl).then(() => dumpCache(player, 'handlePlay.init')); const container = await player.replacePlayer(); if (container) await player.init(container); } }); } async function handleVideo() { 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: 'handleVideo.pinia' }, ); if (!pinia) return; const file = pinia.state._rawValue.videoinfo.videoinfo; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; player.filelist = sortByLocale(list); const resumeEp = player.pickResumeIndex(); if (resumeEp != null && list.length > resumeEp) { player.file = list[resumeEp]; } else { player.file = file; } player.flag = 'video'; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) player.nativeVideoNode = videoNode; player.getUrl = buildFileUrl(player.file); if (!player.getUrl) return; player.hls.setUrlBuilder(player.getUrl); await player.quality.build(player.file.resolution, player.getUrl); dumpCache(player, 'handleVideo.init'); const container = await player.replacePlayer(); if (container) await player.init(container); } async function handleMboxVideo() { 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: 'handleMboxVideo.pinia' }, ); if (!pinia) return; const file = pinia.state._rawValue.videoinfo.videoinfo; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; player.filelist = sortByLocale(list); const resumeEp = player.pickResumeIndex(); if (resumeEp != null && list.length > resumeEp) { player.file = list[resumeEp]; } else { player.file = file; } player.flag = 'mboxvideo'; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) player.nativeVideoNode = videoNode; player.getUrl = buildMboxUrl(player.file); if (!player.getUrl) return; player.hls.setUrlBuilder(player.getUrl); await player.quality.build(player.file.resolution, player.getUrl); dumpCache(player, 'handleMboxVideo.init'); const container = await player.replacePlayer(); if (container) await player.init(container); } if (document.readyState === 'complete') probeAsideTopOp(); else window.addEventListener('load', probeAsideTopOp, { once: true }); const ROUTE_HANDLERS = { share: handleShare, play: handlePlay, video: handleVideo, mboxvideo: handleMboxVideo, }; const ROUTES = Object.freeze([ { pattern: CONFIG.urls.sharePattern, handler: ROUTE_HANDLERS.share }, { pattern: CONFIG.urls.playPattern, handler: ROUTE_HANDLERS.play }, { pattern: CONFIG.urls.videoPattern, handler: ROUTE_HANDLERS.video }, { pattern: CONFIG.urls.mboxvideoPattern, handler: ROUTE_HANDLERS.mboxvideo }, ]); (async () => { if (document.readyState !== 'complete') { await new Promise((r) => window.addEventListener('load', r, { once: true })); } const url = location.href; for (const route of ROUTES) { if (url.includes(route.pattern)) { await route.handler(); break; } } })(); })();