// ==UserScript== // @name 百度网盘视频优化 // @namespace https://scriptcat.org/ // @version 4.0 // @description 基于Claude的百度网盘视频优化(免播放缓存),支持多清晰度切换、连续播放、进度记忆、键盘快捷键 // @author Claude // @match https://pan.baidu.com/s/* // @match https://pan.baidu.com/play/video* // @match https://pan.baidu.com/pfile/video* // @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_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: true, backBufferLength: 90, maxBufferLength: 30, maxMaxBufferLength: 600, }), qualityTemplates: Object.freeze({ 1080: '超清 1080P', 720: '高清 720P', 480: '流畅 480P', 360: '省流 360P', }), qualityLevels: [1080, 720, 480, 360], countdownSec: 5, hideDelayMs: 2000, saveDebounceMs: 2000, saveThrottleMs: 5000, progressMinSec: 5, progressTtlMs: 30 * 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', }), logPrefix: '[BDPlayer]', }); const HLS_FETCH_SETUP = () => ({ headers: { 'User-Agent': CONFIG.userAgent } }); const ICONS = Object.freeze({ prev: '', next: '', episodes: '', }); /* ============================================================ * 错误码定义 * ============================================================ */ const ERROR_CODES = Object.freeze({ PLAYER_INIT: { code: 1001, msg: '播放器初始化失败' }, PLAYER_NOT_READY: { code: 1002, msg: '播放器未就绪' }, NETWORK_FAILED: { code: 2001, msg: '网络请求失败' }, NETWORK_TIMEOUT: { code: 2002, msg: '网络请求超时' }, NETWORK_AUTH: { code: 2003, msg: '认证失败,请刷新页面重新登录' }, HLS_NOT_SUPPORTED: { code: 3001, msg: '浏览器不支持视频播放' }, HLS_MANIFEST: { code: 3002, msg: '视频地址无效' }, HLS_NETWORK: { code: 3003, msg: '网络错误,尝试重新加载...' }, HLS_MEDIA: { code: 3004, msg: '媒体错误,尝试恢复...' }, STORAGE_QUOTA: { code: 4001, msg: '存储空间已满,自动清理中...' }, }); const getErrorMsg = (code, fallback) => { const err = Object.values(ERROR_CODES).find(e => e.code === code); return err ? err.msg : fallback; }; /* ============================================================ * 工具函数 * ============================================================ */ 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 = { info: (...args) => console.info(CONFIG.logPrefix, ...args), warn: (...args) => console.warn(CONFIG.logPrefix, ...args), error: (...args) => console.error(CONFIG.logPrefix, ...args), }; const safe = (fn, label = '') => { try { return fn(); } catch (e) { if (label) log.warn(`safe:${label}`, e); return undefined; } }; const trySafe = (fn, label = '') => { try { return fn(); } catch (e) { if (label) log.warn(`safe:${label}`, e); return undefined; } }; 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) { log.warn('waitFor.poll', e); } await new Promise((r) => setTimeout(r, intervalMs)); } if (onTimeout) trySafe(onTimeout, 'waitFor.timeout'); return null; } /** 异步串行化控制器:丢弃过期请求,只执行最后一次 */ 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; } } /** 集中管理所有事件监听器与定时器,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, fn); fn(...args); }; target.addEventListener(type, wrapped, options); this._listeners.push({ target, type, fn: wrapped, options }); return wrapped; } off(target, type, fn) { target.removeEventListener(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) { 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 { target.removeEventListener(type, fn, options); } catch (e) { /* target 已卸载 */ } }); this._listeners = []; this._timers.forEach((id) => clearTimeout(id)); this._timers.clear(); this._intervals.forEach((id) => clearInterval(id)); this._intervals.clear(); } } const base64Encode = (str) => { try { return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)))); } catch (e) { return null; } }; /* ============================================================ * 存储抽象:GM_* 优先,回落 localStorage * ============================================================ */ const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; 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 { 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 (typeof GM_deleteValue === 'function') GM_deleteValue(key); else GM_setValue(key, undefined); 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; } }, listKeys(prefix) { if (hasGM) return []; try { return Object.keys(localStorage).filter((k) => k.startsWith(prefix)); } catch (e) { log.warn('storage.list', e); return []; } }, }; function evictOldProgress() { try { const keys = storage.listKeys('video_progress'); if (keys.length <= CONFIG.retry.evictTrigger) return; const sorted = keys .map((k) => { const obj = trySafe(() => JSON.parse(storage.getRawItem(k)), 'evict.parse'); return { key: k, time: obj?.timestamp || 0 }; }) .sort((a, b) => a.time - b.time); const toRemove = sorted.slice(0, sorted.length - CONFIG.retry.evictKeep); toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove')); } catch (e) { log.warn('evictOldProgress', e); } } /* ============================================================ * 进度管理 * ============================================================ */ const progressKey = (file) => { if (!file) return null; if (file.fs_id) return `video_progress_${file.fs_id}`; if (file.path) { const encoded = base64Encode(file.path); if (encoded) return `video_progress_path_${encoded}`; return null; } return null; }; const validateProgress = (obj) => { if (!obj || typeof obj !== 'object') return null; const { currentTime, duration, timestamp } = obj; if (!Number.isFinite(currentTime) || currentTime < 0) return null; if (!Number.isFinite(timestamp) || timestamp <= 0) return null; if (duration != null && (!Number.isFinite(duration) || duration < 0)) return null; return { currentTime, duration: duration || 0, timestamp }; }; function readResumeFromUrl() { try { const sp = new URLSearchParams(window.location.search); const epRaw = sp.get('ep'); const tRaw = sp.get('t'); const result = {}; if (epRaw != null && /^\d+$/.test(epRaw)) { const ep = parseInt(epRaw, 10); if (ep >= 0) result.ep = ep; } if (tRaw != null && /^\d+(\.\d+)?$/.test(tRaw)) { const t = parseFloat(tRaw); if (t >= 0) result.t = t; } return result; } catch (e) { return {}; } } function writeResumeToUrl(updates) { try { const sp = new URLSearchParams(window.location.search); if ('ep' in updates) { if (updates.ep == null) sp.delete('ep'); else sp.set('ep', String(updates.ep)); } if ('t' in updates) { if (updates.t == null) sp.delete('t'); else sp.set('t', String(Math.floor(updates.t))); } const qs = sp.toString(); const url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; window.history.replaceState(null, '', url); } catch (e) { log.warn('writeResumeToUrl', e); } } /* ============================================================ * 文件名排序:智能识别集数关键词 * ============================================================ */ const EPISODE_HINTS = [ /第\s*(\d+)\s*[集话話]/i, // 第1集、第5话 /[Ee][Pp]?\s*\.?\s*(\d+)/, // EP01、Ep.05、E10 /[Ss]\d{1,3}\s*[Ee]\s*(\d+)/, // S01E05 /[\[【((]\s*(\d+)\s*[\]】))]/, // [1]、(1)、【1】 /[\u4e00-\u9fa5]\s*(\d+)\s*[\]】))]/, // 番1】、剧2) ]; function extractEpisodeKeys(name) { const str = String(name || ''); for (const re of EPISODE_HINTS) { const m = str.match(re); if (m) { const num = parseInt(m[1], 10); if (Number.isFinite(num)) return [num]; } } const m = str.match(/\d+/); const num = m ? parseInt(m[0], 10) : NaN; return Number.isFinite(num) ? [num] : [Infinity]; } function sortByFileNameNumber(list) { return [...list].sort((a, b) => { const an = String(a?.server_filename || a?.name || ''); const bn = String(b?.server_filename || b?.name || ''); const aKeys = extractEpisodeKeys(an); const bKeys = extractEpisodeKeys(bn); const len = Math.max(aKeys.length, bKeys.length); for (let i = 0; i < len; i++) { const av = aKeys[i] ?? -1; const bv = bKeys[i] ?? -1; if (av !== bv) { if (av === Infinity) return 1; if (bv === Infinity) return -1; return av - bv; } } return an.localeCompare(bn, 'zh-Hans-CN', { numeric: true }); }); } /* ============================================================ * Tip 提示(兼容多种宿主 toast 接口) * ============================================================ */ 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)); } 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)}`; } const getFileName = (f) => f?.server_filename || f?.name || '未命名'; /* ============================================================ * 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 buildShareUrl(file) { const locals = unsafeWindow.locals; const get = (k) => { try { return typeof locals.get === 'function' ? locals.get(k) : locals[k]; } catch (e) { return null; } }; const [share_uk, shareid, sign, timestamp] = ['share_uk', 'shareid', 'sign', 'timestamp'].map(get); if (!share_uk || !sign || !timestamp) return null; return (type) => `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&jsToken=${unsafeWindow.jsToken}`; } /* ============================================================ * HLS 控制器:负责 hls 实例、错误重试 * ============================================================ */ class HlsController { constructor() { this.instance = null; this._fileKey = 'default'; this._retries = {}; } get fileKey() { return this._fileKey; } setUrlBuilder(fn) { this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality); this._qualityLevels = [360, 480, 720, 1080]; } async resolvePlayUrl(baseUrl, depth = 0) { if (depth > 3 || !baseUrl) { showTip('所有画质均无法播放,请刷新重试'); return null; } let res; try { res = await fetch(baseUrl, { credentials: 'include', headers: { 'User-Agent': CONFIG.userAgent }, }); } catch (e) { console.warn('[BDPlayer] fetch失败:', e.message); return null; } if (!res.ok) { console.warn(`[BDPlayer] HTTP ${res.status} ${res.statusText},URL: ${baseUrl}`); if (res.status === 401 || res.status === 403) { showTip('登录状态失效,请刷新页面重新登录'); } return null; } const text = await res.text(); if (text.trim().startsWith('#EXTM3U')) return baseUrl; let json; try { json = JSON.parse(text); } catch (e) { console.warn('[BDPlayer] 响应非JSON:', text.slice(0, 200)); return null; } console.info('[BDPlayer] resolve errno:', json.errno, 'depth:', depth, json); if (json.errno === 133 && json.adToken) { return `${baseUrl}&adToken=${encodeURIComponent(json.adToken)}`; } if (json.errno === 2 || json.errno === 9019 || json.errno === 9013) { // 从低到高尝试所有画质级别 if (depth < this._qualityLevels?.length) { const quality = this._qualityLevels[depth]; const fallbackUrl = this._getUrlForQuality?.(quality); console.info('[BDPlayer] 尝试 fallback 到', quality + 'P:', fallbackUrl); if (fallbackUrl && fallbackUrl !== baseUrl) { return this.resolvePlayUrl(fallbackUrl, depth + 1); } } } return null; } _scheduleBackoff(action, opt) { const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {}; if (attempts >= maxAttempts) { if (onGiveUp) trySafe(onGiveUp, 'backoff.giveUp'); return false; } const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs); setTimeout(() => trySafe(action, 'backoff.action'), delay); return true; } 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, } ); 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.instance) return; const hls = this.instance; this.instance = null; safe( () => { hls.stopLoad(); hls.detachMedia(); hls.destroy(); }, 'HlsController.destroy', ); } } /* ============================================================ * 画质控制:构建画质列表、监听 artplayer quality 切换 * (画质功能保持原样:基于实际分辨率过滤 + 切换时保留播放位置) * ============================================================ */ class QualityController { constructor(hlsController) { this.hls = hlsController; this.list = []; this.getUrl = null; this._lastResolution = 0; this._setupBound = false; this._switchState = null; this._art = null; this._qualityHandler = null; } build(resolution, getUrl) { this.getUrl = getUrl; const match = resolution?.match?.(/width:(\d+),height:(\d+)/); let height = match ? +match[2] : 0; if (!height && this._lastResolution) height = this._lastResolution; const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a); if (height > 0) { this._lastResolution = height; const startIdx = sortedDesc.findIndex((q) => height >= q); if (startIdx >= 0) { const available = sortedDesc.slice(startIdx); this.list = available.map((q, i) => ({ html: CONFIG.qualityTemplates[q], url: getUrl?.('M3U8_AUTO_' + q) || '', default: i === 0, })); return; } } this.list = sortedDesc.map((q, i) => ({ html: CONFIG.qualityTemplates[q], url: getUrl?.('M3U8_AUTO_' + q) || '', default: i === 0, })); } apply(art) { if (art && this.list?.length) art.quality = this.list; } bind(art) { if (!art || this._setupBound) return; this._setupBound = true; this._art = art; this._qualityHandler = async (url) => { if (!url) return; const newUrl = await this.hls.resolvePlayUrl(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) { trySafe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind'); } this._art = null; this._qualityHandler = null; this._setupBound = false; } } /* ============================================================ * 进度记忆:保存/读取/校验 * ============================================================ */ class ProgressStore { constructor(playerRef) { this.playerRef = playerRef; this.onSaved = null; } _art() { return this.playerRef()?.art || null; } _file() { return this.playerRef()?.file || null; } getKey(file = this._file()) { return progressKey(file); } save(file = this._file()) { const key = progressKey(file); const art = this._art(); if (!key || !art?.duration || art.duration <= 0) return; const data = JSON.stringify({ currentTime: art.currentTime, duration: art.duration, timestamp: Date.now(), }); try { storage.setItem(key, data); } catch (e) { if (e?.name === 'QuotaExceededError') { evictOldProgress(); safe(() => storage.setItem(key, data), 'ProgressStore.save.retry'); } else { log.warn('saveProgress', e); return; } } if (this.onSaved) trySafe(() => this.onSaved(file), 'ProgressStore.onSaved'); } load() { const key = this.getKey(); const art = this._art(); if (!key || !art) return; const urlResume = readResumeFromUrl(); if (urlResume.t != null) { if (urlResume.t < CONFIG.progressMinSec) return; if (art.duration && urlResume.t >= art.duration - 1) return; art.currentTime = urlResume.t; return; } try { const saved = storage.getRawItem(key); if (!saved) return; const obj = validateProgress(JSON.parse(saved)); if (!obj) return; if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) { storage.removeItem(key); return; } if (obj.currentTime < CONFIG.progressMinSec) return; art.currentTime = obj.currentTime; } catch (e) { log.warn('loadProgress', e); } } clear(file = this._file()) { const key = progressKey(file); if (!key) return; safe(() => storage.removeItem(key), 'clearProgress'); if (this.onSaved) trySafe(() => this.onSaved(file, null), 'ProgressStore.onCleared'); } getSavedFor(file) { if (!file) return null; const key = progressKey(file); if (!key) return null; try { const saved = storage.getRawItem(key); if (!saved) return null; const obj = validateProgress(JSON.parse(saved)); if (!obj) 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: obj.duration }; } catch (e) { log.warn('getSavedFor', e); return null; } } } /* ============================================================ * 时长缓存 * ============================================================ */ class DurationCache { constructor() { this._map = new Map(); } get(f) { if (!f) return null; const key = f.fs_id || f.path; if (!key) return null; if (this._map.has(key)) return this._map.get(key); const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null; if (raw != null && raw > 0) { const v = Math.floor(raw); this._map.set(key, v); return v; } return null; } set(f, sec) { if (!f) return; const key = f.fs_id || f.path; if (!key || !Number.isFinite(sec) || sec <= 0) return; this._map.set(key, Math.floor(sec)); } clear() { this._map.clear(); } prefetch(list) { if (!list?.length) return; list.forEach((f) => { if (!f) return; const key = f.fs_id || f.path; if (!key || this._map.has(key)) return; const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null; if (raw != null && raw > 0) { this._map.set(key, Math.floor(raw)); } }); } bindHlsEvents(hls, f) { if (!hls || !f) return; const key = f.fs_id || f.path; if (!key || this._map.has(key)) return; const handler = (_, data) => { const dur = data?.details?.totalduration; if (dur && Number.isFinite(dur) && dur > 0) { this.set(f, dur); episodeMenu.updateDuration(f, Math.floor(dur)); hls.off(Hls.Events.LEVEL_LOADED, handler); } }; hls.on(Hls.Events.LEVEL_LOADED, handler); } } /* ============================================================ * 选集菜单(DOM + 样式) * ============================================================ */ const episodeMenu = (() => { const state = { el: null, closeHandler: null, resizeHandler: null, menuLeaveHandler: null, playerRef: null, onOpen: null, onClose: null, styleInjected: false, }; const calcPosition = (btnEl) => { const rect = btnEl.getBoundingClientRect(); const menuW = 300; const menuH = Math.min(232, window.innerHeight * 0.6); const gap = -15; const bottom = window.innerHeight - (rect.top + gap); const left = Math.max(8, rect.right - menuW + 120); return { bottom, left, menuW, menuH }; }; const buildItem = (player, f, i, idx) => { const isCurrent = i === idx; const name = getFileName(f); const key = f.fs_id || f.path; const cachedDur = player.durations.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'; indexEl.innerHTML = isCurrent ? `` : `${i + 1}`; 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); const saved = player.progress.getSavedFor(f); if (saved) { 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 = () => { 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; }; function isOpen() { return !!state.el; } function open(player, btnEl) { close(); state.playerRef = player; const idx = player.getCurrentIndex(); const { bottom, left, menuW, menuH } = calcPosition(btnEl); const menu = document.createElement('div'); menu.className = 'ep-menu'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', '选集列表'); menu.style.cssText = `position:fixed;bottom:${bottom}px;left:${left}px;width:${menuW}px;max-height:${menuH}px;z-index:2147483647;`; const header = document.createElement('div'); header.className = 'ep-header'; header.innerHTML = `选集列表${player.filelist.length}集`; menu.appendChild(header); const list = document.createElement('div'); list.className = 'ep-list'; list.setAttribute('role', 'listbox'); const fragment = document.createDocumentFragment(); player.filelist.forEach((f, i) => fragment.appendChild(buildItem(player, f, i, idx))); list.appendChild(fragment); menu.appendChild(list); document.body.appendChild(menu); state.el = menu; requestAnimationFrame(() => { const active = list.querySelector('.ep-item--active'); if (active) { active.focus(); active.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }); state.closeHandler = (e) => { if (state.el && !state.el.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) { close(); } }; document.addEventListener('click', state.closeHandler); state.menuLeaveHandler = () => { if (state.el) close(); }; menu.addEventListener('mouseleave', state.menuLeaveHandler); state.resizeHandler = debounce(() => { if (!state.el) return; const pos = calcPosition(btnEl); state.el.style.bottom = pos.bottom + 'px'; state.el.style.left = pos.left + 'px'; state.el.style.maxHeight = pos.menuH + 'px'; }, 100); window.addEventListener('resize', state.resizeHandler); if (typeof state.onOpen === 'function') state.onOpen(); } function close() { const wasOpen = !!state.el; if (state.el) { if (state.menuLeaveHandler) { safe(() => state.el.removeEventListener('mouseleave', state.menuLeaveHandler), 'menu.removeListener'); } state.el.remove(); state.el = null; } state.menuLeaveHandler = null; if (state.closeHandler) { document.removeEventListener('click', state.closeHandler); state.closeHandler = null; } if (state.resizeHandler) { window.removeEventListener('resize', state.resizeHandler); state.resizeHandler = null; } const prevPlayer = state.playerRef; state.playerRef = null; if (wasOpen && typeof state.onClose === 'function') state.onClose(); } function updateDuration(f, sec) { if (!state.el || !state.playerRef) return; const key = f.fs_id || f.path; const escapedKey = CSS.escape(String(key)); const el = state.el.querySelector(`[data-ep-key="${escapedKey}"] .ep-duration`); if (el) el.textContent = formatTime(sec); } function updateActiveState(idx) { if (!state.el) return; const items = state.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 numEl = item.querySelector('.ep-num'); if (numEl) numEl.style.display = isActive ? 'none' : ''; }); } function updateItemProgress(file, saved) { if (!state.el || !file) return; const key = file.fs_id || file.path; const escapedKey = CSS.escape(String(key)); const item = state.el.querySelector(`[data-ep-key="${escapedKey}"]`); if (!item) return; const metaEl = item.querySelector('.ep-meta'); if (!metaEl) return; let progressEl = metaEl.querySelector('.ep-progress'); if (!saved) { if (progressEl) progressEl.remove(); return; } if (!progressEl) { progressEl = document.createElement('span'); progressEl.className = 'ep-progress'; metaEl.appendChild(progressEl); } const percent = saved.duration > 0 ? Math.floor((saved.currentTime / saved.duration) * 100) : 0; progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`; progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`; } function setHooks({ onOpen, onClose }) { state.onOpen = onOpen || null; state.onClose = onClose || null; } function injectStyle() { if (state.styleInjected) return; state.styleInjected = true; const style = document.createElement('style'); style.id = 'ep-menu-style'; style.textContent = EPISODE_MENU_CSS; document.head.appendChild(style); } return { isOpen, open, close, updateDuration, updateActiveState, updateItemProgress, injectStyle, setHooks }; })(); 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-radius: 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; animation: epFadeIn .18s ease-out; pointer-events: auto; } @keyframes epFadeIn { from { opacity:0; transform: translateY(6px) scale(.97); } to { opacity:1; transform: translateY(0) scale(1); } } .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; } .art-control-prev.art-ep-disabled, .art-control-next.art-ep-disabled { opacity: .35; filter: grayscale(1) brightness(.55); cursor: not-allowed; pointer-events: auto; } .art-control-prev.art-ep-disabled:hover, .art-control-next.art-ep-disabled:hover { opacity: .35; filter: grayscale(1) brightness(.55); } .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); } } `; /* ============================================================ * 自动切下一集控制器 * ============================================================ */ class AutoNextController { constructor(artRef) { this.artRef = artRef; this._ticker = null; this._dismissed = false; this._visible = false; this._resizeObs = null; } get visible() { return this._visible; } cancel() { if (this._ticker) { clearInterval(this._ticker); this._ticker = null; } } show({ name, seconds, onCancel, onComplete }) { const art = this.artRef(); if (!art) return; this.hideCountdown(); this._dismissed = false; 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; art.layers.add({ name: 'autoNextCountdown', html: ` `, style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' }, mounted: ($el) => { 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 $el2 = art?.layers?.autoNextCountdown; const wrap = $el2?.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 (this._dismissed) return; this._dismissed = true; safe(() => onCancel && onCancel(), 'AutoNext.onCancel'); }); $el.querySelector('.artplayer-autonext-play')?.addEventListener('click', (ev) => { ev.stopPropagation(); if (this._dismissed) return; this._dismissed = true; safe(() => onComplete && onComplete({ immediate: true }), 'AutoNext.onComplete.immediate'); }); }, }); this._ticker = setInterval(() => { remaining -= 1; const $el = art?.layers?.autoNextCountdown; const $sec = $el?.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.cancel(); if (!this._dismissed) { this._dismissed = true; safe(() => onComplete && onComplete({ immediate: false }), 'AutoNext.onComplete.ticker'); } } }, 1000); } hideCountdown() { this.cancel(); this._dismissed = true; if (this._resizeObs) { safe(() => this._resizeObs.disconnect(), 'AutoNext.disconnect'); this._resizeObs = null; } const art = this.artRef(); if (!art || !art.layers) return; safe(() => art.layers.remove('autoNextCountdown'), 'AutoNext.removeLayer'); this._visible = false; } } /* ============================================================ * UI 控制器:控制栏隐藏/标题层 * ============================================================ */ class UiController { constructor(artRef) { this.artRef = artRef; this._hideCtl = null; this._setup = false; this._listeners = new ListenerBag(); this._titleEl = null; this._titleLayerAdded = false; } _makeHideCtl() { const art = this.artRef(); if (!art) return null; let hideTimer = null; const controls = () => art?.controls; const $controls = () => art?.template?.$controls; const clearHide = () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }; const showNow = () => { if (!art) return; if (controls()) art.controls.show = true; art.layers.show = true; }; const isPinned = () => episodeMenu.isOpen() || this._autoNextPinned?.(); const scheduleHide = () => { clearHide(); if (isPinned()) return; hideTimer = setTimeout(() => { hideTimer = null; if (!controls() || episodeMenu.isOpen()) return; if (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 }; } setAutoNextPin(fn) { this._autoNextPinned = fn; } setup() { const art = this.artRef(); if (!art || this._setup) return; this._setup = true; this._hideCtl = this._makeHideCtl(); if (art && typeof art.on === 'function' && !this._layerSyncBound) { this._layerSyncBound = true; art.on('control', (state) => { if (!art) return; if (episodeMenu.isOpen() || this._autoNextPinned?.()) { if (art.layers) art.layers.show = true; return; } if (art.layers) art.layers.show = !!state; }); } const onDocMouseMove = (e) => { const art2 = this.artRef(); if (!art2) return; const el = art2?.template?.$controls; if (!el) return; const over = el.contains(e.target); if (over) { this._hideCtl?.clearHide(); this._hideCtl?.showNow(); } else if (!episodeMenu.isOpen() && !this._autoNextPinned?.()) { this._hideCtl?.scheduleHide(); } }; const onDocMouseLeave = () => { if (episodeMenu.isOpen() || this._autoNextPinned?.()) { this._hideCtl?.clearHide(); return; } this._hideCtl?.scheduleHide(); }; this._listeners.on(document, 'mousemove', onDocMouseMove, { passive: true }); this._listeners.on(document, 'mouseleave', onDocMouseLeave, { 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 || ''; } reset() { this._titleLayerAdded = false; this._titleEl = null; this._setup = false; this._layerSyncBound = false; this._listeners.clearAll(); this._hideCtl = null; } } /* ============================================================ * 快捷键 * ============================================================ */ class HotkeyController { constructor(playerRef) { this.playerRef = playerRef; this._setup = false; this._listeners = new ListenerBag(); } setup() { const player = this.playerRef(); if (!player || !player.art || this._setup) return; this._setup = true; const onKey = (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(); } else if (key === 'w') { player2.art.fullscreenWeb = !player2.art.fullscreenWeb; e.preventDefault(); e.stopPropagation(); } else if (key === 'm') { player2.art.muted = !player2.art.muted; e.preventDefault(); e.stopPropagation(); } else if (key === 'p') { const idx = player2.getCurrentIndex(); if (idx > 0) player2.switchVideo(player2.filelist[idx - 1]); e.preventDefault(); e.stopPropagation(); } 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(); } }; this._listeners.on(document, 'keydown', onKey, true); } reset() { this._listeners.clearAll(); this._setup = false; } } /* ============================================================ * Player 协调器:组合各控制器,对外暴露统一接口 * ============================================================ */ class Player { constructor() { this.art = null; this.file = {}; this.filelist = []; this.flag = ''; this.nativeVideoNode = null; this.getUrl = null; this.hls = new HlsController(); this.quality = new QualityController(this.hls); this.durations = new DurationCache(); this.progress = new ProgressStore(() => this); this.progress.onSaved = (file) => { if (!file) return; const saved = this.progress.getSavedFor(file); episodeMenu.updateItemProgress(file, saved); }; this._switchCtrl = new SwitchController(); this._switchToken = 0; this._autoNext = null; this._ui = new UiController(() => this.art); this._hotkey = new HotkeyController(() => this); this._episodeControlsAdded = false; this._initialized = false; this._autoNextTimer = null; this._autoNextCanceled = false; this._listeners = new ListenerBag(); this._onTimeUpdateSave = null; this._onVisibilityChange = null; this._onArtDestroy = null; } 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), ); } pickResumeIndex() { const resume = readResumeFromUrl(); const list = this.filelist; if (resume.ep != null && Array.isArray(list) && list.length > resume.ep) return resume.ep; return null; } switchVideo(file) { if (!file) return; this._switchCtrl.submit(() => this._performSwitch(file)); } async _performSwitch(file) { this._cancelAutoNext(); const prevFile = this.file; this.progress.save(); 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 newEpIdx = this.getCurrentIndex(); if (newEpIdx >= 0) writeResumeToUrl({ ep: newEpIdx, t: null }); if (this.flag === 'sharevideo') { const shareUrl = buildShareUrl(file); if (!shareUrl) return; this.getUrl = shareUrl; } else { this.getUrl = buildFileUrl(file); } this.hls.setUrlBuilder(this.getUrl); this.quality.build(file.resolution, this.getUrl); if (!this.quality.list?.length) { showTip('无法获取视频地址'); return; } if (!this.art?.video) { showTip('播放器未就绪'); return; } const resolvedUrl = await this.hls.resolvePlayUrl(this.quality.list[0].url); if (!resolvedUrl) { showTip(`无法播放: ${getFileName(file)}`); return; } if (token !== this._switchToken) 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, file.fs_id || file.path, () => showTip('网络持续错误,请刷新页面')); if (!hls || token !== this._switchToken) return; this.quality.apply(this.art); this.quality.bind(this.art); this.refreshControlDisplay(); episodeMenu.updateActiveState(this.getCurrentIndex()); this._ui.updateTitle(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.durations.bindHlsEvents(hls, file); this.progress.load(); video.play().catch(() => {}); } _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() { if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; } if (this._autoNext) { this._autoNext.cancel(); } this._autoNextCanceled = false; } refreshControlDisplay() { const idx = this.getCurrentIndex(); const len = this.filelist?.length || 0; const hasPrev = idx > 0; const hasNext = idx >= 0 && idx < len - 1; const $prev = this.art?.controls?.prev; const $next = this.art?.controls?.next; if ($prev) { $prev.classList.toggle('art-ep-disabled', !hasPrev); $prev.setAttribute('aria-disabled', String(!hasPrev)); } if ($next) { $next.classList.toggle('art-ep-disabled', !hasNext); $next.setAttribute('aria-disabled', String(!hasNext)); } } addEpisodeControls() { if (this._episodeControlsAdded) return; this._episodeControlsAdded = true; this.durations.prefetch(this.filelist); episodeMenu.injectStyle(); this.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 idx = this.getCurrentIndex(); if (idx > 0) this.switchVideo(this.filelist[idx - 1]); }, }); this.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 idx = this.getCurrentIndex(); if (idx >= 0 && idx < (this.filelist?.length || 0) - 1) this.switchVideo(this.filelist[idx + 1]); }, }); this.art.controls.add({ name: 'episodes', position: 'right', html: '选集', tooltip: '选集', style: { padding: '0 10px', fontSize: '14px' }, click: () => { const btnEl = document.querySelector('.art-control-episodes'); if (episodeMenu.isOpen()) episodeMenu.close(); else if (btnEl) episodeMenu.open(this, btnEl); }, }); this.refreshControlDisplay(); this._ui.addTitle(this.file); } refreshEpisodeControls() { if (!this.art || this._episodeControlsAdded) return; this.addEpisodeControls(); } setupAutoNextLayer() { if (this._autoNext) return; this._autoNext = new AutoNextController(() => this.art); this._ui.setAutoNextPin(() => this._autoNext?.visible); } _setupAutoNext() { const art = this.art; if (!art) return; this.setupAutoNextLayer(); art.on('video:ended', () => { this.progress.clear(); this._autoNextCanceled = false; const idx = this.getCurrentIndex(); const listLen = this.filelist?.length || 0; if (idx < 0 || idx >= listLen - 1) return; const next = this.filelist[idx + 1]; const nextName = getFileName(next); const tokenAtEnd = this._switchToken; let seekUnbound = false; const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click']; const bindCancel = () => cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek)); const unbindCancel = () => cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); const cancelOnSeek = () => { if (seekUnbound) return; seekUnbound = true; if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; } this._autoNext?.cancel(); this._autoNext?.hideCountdown(); this._autoNextCanceled = true; unbindCancel(); safe(() => art.play(), 'cancelOnSeek.play'); }; const switchNow = () => { if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; } if (this._autoNext) this._autoNext.hideCountdown(); unbindCancel(); if (this._switchToken !== tokenAtEnd) return; this.switchVideo(next); }; this._autoNext.show({ name: nextName, seconds: CONFIG.countdownSec, onCancel: () => cancelOnSeek(), onComplete: () => switchNow(), }); this._autoNextTimer = setTimeout(() => switchNow(), CONFIG.countdownSec * 1000); bindCancel(); }); } async init(container) { if (!this.quality.list?.length) return; this._initialized = false; this.destroy(); this._switchCtrl = new SwitchController(); this._switchToken = 0; this.durations.clear(); this.hls.setUrlBuilder(this.getUrl); const resolvedUrl = await this.hls.resolvePlayUrl(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, this.file?.fs_id || this.file?.path, () => 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' }, }); this.art.on('ready', () => { this.destroyNativePlayer(); this.art.video.muted = false; this.progress.load(); this.quality.bind(this.art); this.addEpisodeControls(); this.durations.bindHlsEvents(this.hls.instance, this.file); this._ui.setup(); this._hotkey.setup(); this._setupAutoNext(); this._initialized = true; }); const debouncedSave = debounce(() => this.progress.save(), CONFIG.saveDebounceMs); let lastSave = 0; const onTimeUpdateSave = () => { if (this.art.currentTime > 0 && Date.now() - lastSave > CONFIG.saveThrottleMs) { lastSave = Date.now(); debouncedSave(); const idx = this.getCurrentIndex(); if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime }); } }; this.art.on('video:timeupdate', onTimeUpdateSave); this._onTimeUpdateSave = onTimeUpdateSave; const flushSave = () => { if (this.art?.currentTime > 0) { this.progress.save(); const idx = this.getCurrentIndex(); if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime }); } }; this.art.on('video:pause', flushSave); this.art.on('video:seeked', flushSave); const onVisibilityChange = () => { if (document.hidden) flushSave(); }; document.addEventListener('visibilitychange', onVisibilityChange); this._onVisibilityChange = onVisibilityChange; this.art.on('video:play', () => { if (this._ui._hideCtl) this._ui._hideCtl.scheduleHide(); }); this._onArtDestroy = () => { this._cancelAutoNext(); this._listeners.clearAll(); this._ui.reset(); this._hotkey.reset(); this._episodeControlsAdded = false; episodeMenu.close(); this._initialized = false; }; this.art.on('destroy', this._onArtDestroy); } destroy() { this._switchToken++; this._cancelAutoNext(); if (this._onTimeUpdateSave) { this.art?.off('video:timeupdate', this._onTimeUpdateSave); this._onTimeUpdateSave = null; } if (this._onVisibilityChange) { document.removeEventListener('visibilitychange', this._onVisibilityChange); this._onVisibilityChange = null; } if (this._onArtDestroy) { this.art?.off('destroy', this._onArtDestroy); this._onArtDestroy = null; } episodeMenu.close(); if (this.art?.video) { safe(() => { this.art.video.muted = true; this.art.video.pause(); }, 'destroy.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); }, 'destroy.art'); } this.hls.destroy(); this.quality.unbind(); this._episodeControlsAdded = false; } 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 = setInterval(() => { count++; const t = getTarget(); if (t?.player) { clearInterval(id); safe(() => { t.player.dispose(); t.player = null; }, 'pollDestroy.dispose'); } else if (count > 30) { 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, }); 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(); episodeMenu.setHooks({ onOpen: () => player._ui._hideCtl?.showNow(), onClose: () => player._ui._hideCtl?.scheduleHide(), }); window.addEventListener('beforeunload', () => { if (player._initialized) player.progress.save(); }); /* ============================================================ * 入口路由 * ============================================================ */ async function handleShare() { const localsReady = await waitFor(() => unsafeWindow.locals, { intervalMs: 500, maxAttempts: 40, 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 (e) { videoList = file_list.filter((f) => f.category === 1); } if (!videoList.length) { resolve(); return; } videoList = sortByFileNameNumber(videoList); player.filelist = 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); player.hls.setUrlBuilder(player.getUrl); player.quality.build(file.resolution, player.getUrl); player.replacePlayer().then((container) => { if (container) player.init(container); resolve(); }); }, ); }); } async function handlePlay() { const jqReady = await waitFor(() => unsafeWindow.jQuery, { intervalMs: 500, maxAttempts: 40, 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 = sortByFileNameNumber( (xhr.responseJSON?.info || []).filter((f) => f.category === 1), ); player.refreshEpisodeControls(); } else if (url.includes('/api/filemetas')) { if (hasInit) return; let file = xhr.responseJSON?.info?.[0]; if (!file) return; const resumeEp = player.pickResumeIndex(); if (resumeEp != null && player.filelist?.[resumeEp]) file = player.filelist[resumeEp]; hasInit = true; player.flag = 'playvideo'; player.file = file; player.getUrl = buildFileUrl(file); player.hls.setUrlBuilder(player.getUrl); player.quality.build(file.resolution, player.getUrl); 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 }, ); if (!pinia) return; const file = pinia.state._rawValue.videoinfo.videoinfo; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; player.flag = 'video'; player.file = file; player.filelist = sortByFileNameNumber(list); let initFile = file; const resumeEp = player.pickResumeIndex(); if (resumeEp != null && list.length > resumeEp) initFile = list[resumeEp]; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) player.nativeVideoNode = videoNode; player.getUrl = buildFileUrl(initFile); player.hls.setUrlBuilder(player.getUrl); player.quality.build(initFile.resolution, player.getUrl); player.file = initFile; const container = await player.replacePlayer(); if (container) await player.init(container); } function ready() { return new Promise((resolve) => { if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(resolve, 0); else document.addEventListener('DOMContentLoaded', resolve); }); } ready().then(() => { const url = location.href; if (url.includes(CONFIG.urls.sharePattern)) handleShare(); else if (url.includes(CONFIG.urls.playPattern)) handlePlay(); else if (url.includes(CONFIG.urls.videoPattern)) handleVideo(); }); })();