// ==UserScript== // @name 百度网盘视频播放器 // @namespace https://scriptcat.org/ // @version 5.8 // @description 基于ClaudeAI的百度网盘视频播放器,支持多清晰度切换、连续播放、进度记忆、键盘快捷键 // @match https://pan.baidu.com/s/* // @match https://pan.baidu.com/play/video* // @match https://pan.baidu.com/pfile/video* // @match https://pan.baidu.com/pfile/mboxvideo* // @require https://unpkg.com/hls.js@1.6.16/dist/hls.min.js // @require https://unpkg.com/artplayer@5.4.0/dist/artplayer.js // @icon https://nd-static.bdstatic.com/m-static/v20-main/home/img/icon-home-new.b4083345.png // @run-at document-start // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_xmlhttpRequest // @license MIT // ==/UserScript== (function () { 'use strict'; // 1 配置(外部勿改) const CONFIG = Object.freeze({ userAgent: 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts', hls: Object.freeze({ debug: false, enableWorker: true, lowLatencyMode: false, backBufferLength: 120, maxBufferLength: 60, maxMaxBufferLength: 600, maxBufferSize: 60 * 1000 * 1000, maxBufferHole: 0.5, startLevel: -1, autoStartLoad: true, abrEwmaDefaultEstimate: 5000000, }), qualityTemplates: Object.freeze({ 1080: '超清 1080P', 720: '高清 720P', 480: '流畅 480P', 360: '省流 360P', }), qualityLevels: Object.freeze([1080, 720, 480, 360]), countdownSec: 3, hideDelayMs: 2000, saveDebounceMs: 2000, saveThrottleMs: 3000, progressMinSec: 5, progressTtlMs: 15 * 86400000, ui: Object.freeze({ topZ: 2147483647, controlMinHeight: 40, controlEmbedPx: 15, menuDebounceMs: 100, videoPollingIntervalMs: 300, videoPollingMaxMs: 1000, videoPollingCount: 30, drawerTransitionMs: 700, drawerTransitionEasing: 'cubic-bezier(.16,1,.3,1)', autonextRiseMs: 600, }), network: Object.freeze({ timeoutMs: 8000, }), retry: Object.freeze({ baseMs: 500, maxMs: 10000, hlsNetworkBaseMs: 1000, hlsNetworkMaxMs: 10000, hlsNetworkMaxAttempts: 3, evictKeep: 15, evictTrigger: 20, }), urls: Object.freeze({ sharePattern: '/s/', playPattern: '/play/video', videoPattern: '/pfile/video', mboxvideoPattern: '/mboxvideo', }), logPrefix: '[BDPlayer]', }); const ICONS = Object.freeze({ prev: '', next: '', }); const HLS_FETCH_SETUP = (_context, _initParams) => ({ method: 'GET', credentials: 'include', mode: 'cors', headers: { 'User-Agent': CONFIG.userAgent }, }); // 2 通用工具(svg / escape / safe / debounce / tip / log / safeRun) const makeSvg = (inner) => `${inner}`; const once = (fn) => { let called = false; return (...args) => { if (called) return; called = true; fn(...args); }; }; const debounce = (fn, delay) => { let timer = null; const wrapped = (...args) => { clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...args); }, delay); }; wrapped.cancel = () => { if (timer != null) { clearTimeout(timer); timer = null; } }; wrapped.pending = () => timer != null; return wrapped; }; const ERR_NO_URL = '无法获取播放地址,请检查登录状态'; const log = { announce(file) { const title = file ? getFileName(file) : ''; const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; console.info(CONFIG.logPrefix, `当前时间:${stamp}\n当前播放:${title}`); }, }; const safe = (fn, _label = '') => { try { return fn(); } catch (_e) { return undefined; } }; const gmFetch = (url) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': CONFIG.userAgent }, responseType: 'text', timeout: CONFIG.network.timeoutMs, onload: (r) => resolve(r.responseText), onerror: (r) => reject(new Error(`HTTP ${r.status}`)), ontimeout: () => reject(new Error('timeout')), }); }); async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout } = {}) { for (let i = 0; i < maxAttempts; i++) { try { const v = fn(); if (v) return v; } catch (_e) { /* keep polling */ } await new Promise((r) => setTimeout(r, intervalMs)); } if (onTimeout) safe(onTimeout); return null; } const base64Encode = (str) => { try { return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)))); } catch (_) { return null; } }; // 3 生命周期基类(Disposable / ListenerBag / EventBus / SwitchController) class Disposable { constructor() { this._disposed = false; this._childDisposables = new Set(); this._listeners = new ListenerBag(); } _track(d) { if (!d) return d; this._childDisposables.add(d); return d; } dispose() { if (this._disposed) return; this._disposed = true; this._childDisposables.forEach((d) => safe(() => d.dispose?.(), `${this.constructor.name}.dispose`)); this._childDisposables.clear(); this._listeners.clearAll(); } } class ListenerBag { constructor() { this._listeners = []; this._timers = new Set(); this._intervals = new Set(); } on(target, type, fn, options) { 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) { /* swallow */ } finally { this._running = false; } } } } // 4 存储(GM_* 优先,QuotaExceeded 时回收旧数据) const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; const STORAGE_PREFIX = 'video_progress_'; function evictOldProgress() { try { const entries = []; const visit = (rawKey) => { if (!rawKey || !rawKey.startsWith(STORAGE_PREFIX)) return; const obj = safe(() => JSON.parse(storage.getRawItem(rawKey)), 'evict.parse'); entries.push({ key: rawKey, time: obj?.timestamp || 0 }); }; if (hasGM) { const all = safe(() => GM_listValues(), 'evict.list') || []; all.forEach((k) => visit(k)); } else { const keys = safe(() => Object.keys(localStorage), 'evict.keys') || []; keys.forEach((k) => visit(k)); } if (entries.length <= CONFIG.retry.evictTrigger) return; entries.sort((a, b) => a.time - b.time); const toRemove = entries.slice(0, entries.length - CONFIG.retry.evictKeep); toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove')); } catch (_e) { /* evict 失败可忽略 */ } } const storage = { getItem(key) { try { if (hasGM) { return GM_getValue(key); } return localStorage.getItem(key); } catch (_e) { return null; } }, setItem(key, value) { try { if (hasGM) { GM_setValue(key, value); return; } localStorage.setItem(key, value); } catch (e) { if (e?.name === 'QuotaExceededError') { evictOldProgress(); try { if (hasGM) GM_setValue(key, value); else localStorage.setItem(key, value); } catch (_e2) { /* quota 二次失败可忽略 */ } } } }, removeItem(key) { try { if (hasGM) { GM_deleteValue(key); } else { localStorage.removeItem(key); } } catch (_e) { /* remove 失败可忽略 */ } }, getRawItem(key) { if (hasGM) return storage.getItem(key); try { return localStorage.getItem(key); } catch (_e) { return null; } }, }; // 5 选集元数据 + 进度键(时长/进度的单一来源) const fileKeyOf = (file) => { if (!file) return null; if (file.fs_id) return String(file.fs_id); if (file.path) { const encoded = base64Encode(file.path); return encoded ? `path_${encoded}` : null; } return null; }; const progressKey = (file) => { const k = fileKeyOf(file); return k ? `${STORAGE_PREFIX}${k}` : null; }; function parseStoredProgress(file) { const key = progressKey(file); if (!key) return null; const raw = storage.getRawItem(key); if (!raw) return null; let obj; try { obj = JSON.parse(raw); } catch (_e) { return null; } if (typeof obj?.currentTime !== 'number' || obj.currentTime < 0) return null; if (typeof obj?.timestamp !== 'number' || obj.timestamp <= 0) return null; 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 }; } class EpisodeMeta extends Disposable { constructor() { super(); this._map = new Map(); this.events = new EventBus(); } _key(file) { return fileKeyOf(file); } _ensure(file) { const k = this._key(file); if (!k) return null; let entry = this._map.get(k); if (!entry) { entry = { duration: null, durationVerified: false, currentTime: null, timestamp: null }; this._map.set(k, entry); } return entry; } setDuration(file, sec, verified = false) { const entry = this._ensure(file); if (!entry || !Number.isFinite(sec) || sec <= 0) return; const dur = Math.ceil(sec); if (entry.durationVerified && !verified) return; if (entry.duration === dur && !!entry.durationVerified === !!verified) return; entry.duration = dur; entry.durationVerified = entry.durationVerified || !!verified; this.events.emit('duration', { file, duration: dur, verified: entry.durationVerified }); } isDurationVerified(file) { const entry = this._map.get(this._key(file)); return !!(entry && entry.duration != null && entry.durationVerified); } setProgress(file, currentTime, timestamp) { const entry = this._ensure(file); if (!entry) return; if (currentTime == null) { if (entry.currentTime == null && entry.timestamp == null) return; entry.currentTime = null; entry.timestamp = null; } else { 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) || {}; if (existing.duration == null) { existing.duration = parsed.duration; existing.durationVerified = true; } existing.currentTime = parsed.currentTime; existing.timestamp = parsed.timestamp; this._map.set(k, existing); } } clear() { this._map.clear(); this.events.emit('clear'); } dispose() { this.clear(); super.dispose(); } } // 6 文件排序 / 文件名 / 时间格式化 function sortByLocale(list) { return [...list].sort((a, b) => (a.server_filename || a.name || '').localeCompare(b.server_filename || b.name || '', undefined, { numeric: true }) ); } const getFileName = (f) => f?.server_filename || f?.name || '未命名'; function formatTime(sec) { if (!Number.isFinite(sec) || sec < 0) return '0:00'; sec = Math.floor(sec); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const pad = (n) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } function showTip(msg) { const fns = [ () => unsafeWindow.require('system-core:system/uiService/tip/tip.js').show({ mode: 'success', msg }), () => unsafeWindow.toast?.show({ type: 'svip', message: msg, duration: 3000 }), () => unsafeWindow.$bus?.$Toast?.addToast?.({ type: 'success', content: msg, durtime: 3000 }), ]; fns.some((fn) => safe(fn)); } // 7 URL 构建(share / file / mboxvideo 三种取流地址) const checkJsToken = () => { if (!unsafeWindow.jsToken) { showTip('登录状态异常,请刷新页面'); return false; } return true; }; function buildFileUrl(file) { if (!checkJsToken()) return () => null; return (type) => `/api/streaming?path=${encodeURIComponent(file.path)}&app_id=250528&clienttype=0&type=${type}&jsToken=${unsafeWindow.jsToken}`; } function buildMboxUrl(file) { if (!file || !file.to || !file.msg_id) return null; return (stream_type) => { const params = new URLSearchParams({ to: file.to, from_uk: file.from_uk ?? '', msg_id: file.msg_id, fs_id: file.fs_id ?? '', type: file.type ?? '', stream_type, trans: file.trans ?? '', ltime: file.ltime ?? '', }); return `/mbox/msg/streaming?${params.toString()}`; }; } function buildShareUrl(file) { if (!checkJsToken()) return null; const locals = unsafeWindow.locals; const get = (k) => { try { return typeof locals.get === 'function' ? locals.get(k) : locals[k]; } catch (_) { return null; } }; const [share_uk, shareid, sign, timestamp] = ['share_uk', 'shareid', 'sign', 'timestamp'].map(get); if (!share_uk || !sign || !timestamp) return null; const token = unsafeWindow.jsToken; return (type) => `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&jsToken=${token}`; } // 8 HLS 协议层(HlsController;缓存 + 指数退避重试) class HlsController extends Disposable { constructor() { super(); this.instance = null; this._fileKey = 'default'; this._getUrlForQuality = null; this._retries = {}; this._cacheList = []; this._pendingRequests = new Map(); this._backoffTimers = new Set(); } get fileKey() { return this._fileKey; } _findEpisodeIndex(fileKey) { if (!fileKey) return -1; for (let i = 0; i < this._cacheList.length; i++) { if (this._cacheList[i].fileKey === fileKey) return i; } return -1; } _getOrCreateQualities(fileKey) { const idx = this._findEpisodeIndex(fileKey); if (idx >= 0) return this._cacheList[idx].qualities; const entry = { fileKey, qualities: [] }; this._cacheList.push(entry); return entry.qualities; } _findQualityUrl(qualities, q) { for (let i = 0; i < qualities.length; i++) { if (qualities[i].q === q) return qualities[i].r; } return null; } setUrlBuilder(fn) { this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality); } resolveUrl(url) { return gmFetch(url).then((text) => { if (!text) return null; if (text.trim().startsWith('#EXTM3U')) return url; let json; try { json = JSON.parse(text); } catch (_) { return null; } if (json.errno === 133 && json.adToken) return `${url}&adToken=${encodeURIComponent(json.adToken)}`; return null; }).catch(() => null); } async resolveEpisodeQualities(fileKey, candidates, getUrl) { if (!fileKey || !Array.isArray(candidates) || !candidates.length) return []; const qualities = this._getOrCreateQualities(fileKey); const tasks = candidates.map(async (q) => { const cached = this._findQualityUrl(qualities, q); if (cached) return { q, r: cached }; const url = getUrl?.('M3U8_AUTO_' + q); if (!url) return null; if (this._pendingRequests.has(url)) { await this._pendingRequests.get(url); const cached2 = this._findQualityUrl(qualities, q); return cached2 ? { q, r: cached2 } : null; } const promise = this.resolveUrl(url); this._pendingRequests.set(url, promise); try { const r = await promise; if (r) { 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; } _scheduleBackoff(action, opt, ownerHls) { const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {}; if (attempts >= maxAttempts) { if (onGiveUp) safe(onGiveUp, 'backoff.giveUp'); return false; } const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs); const timerId = setTimeout(() => { this._backoffTimers.delete(timerId); if (ownerHls && ownerHls !== this.instance) return; safe(action, 'backoff.action'); }, delay); this._backoffTimers.add(timerId); return true; } _clearBackoffTimers() { this._backoffTimers.forEach((id) => clearTimeout(id)); this._backoffTimers.clear(); } create(url, video, fileKey, onGiveUpNetwork) { if (!Hls.isSupported()) { if (video?.canPlayType?.('application/vnd.apple.mpegurl')) { this._fileKey = fileKey || 'default'; this._retries[this._fileKey] = 0; video.src = url; this.instance = null; return null; } showTip('浏览器不支持视频播放'); return null; } this._fileKey = fileKey || 'default'; this._retries[this._fileKey] = 0; const hls = new Hls({ ...CONFIG.hls, fetchSetup: HLS_FETCH_SETUP }); 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(); } } // 9 切换快照 SwitchSnapshot class SwitchSnapshot { static capture(art) { if (!art?.video) return null; return { currentTime: art.currentTime || 0, volume: art.video.volume ?? 1, muted: !!art.video.muted, playing: !art.video.paused, }; } static apply(art, snap) { if (!art?.video || !snap) return; art.video.muted = snap.muted; art.video.volume = snap.volume; art.currentTime = snap.currentTime; if (snap.playing) art.video.play().catch(() => { }); } } // 10 画质控制 QualityController class QualityController extends Disposable { constructor(hlsController) { super(); this.hls = hlsController; this.list = []; this.getUrl = null; this._switchState = null; this._art = null; this._qualityHandler = null; } async build(resolution, getUrl, fileKey = this.hls.fileKey) { this.getUrl = getUrl; const match = resolution?.match?.(/width:(\d+),height:(\d+)/); const videoHeight = match ? +match[2] : 0; const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a); const candidates = videoHeight > 0 ? sortedDesc.filter((q) => q <= videoHeight) : sortedDesc; 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) return; if (this._qualityHandler && this._art === art) return; if (this._qualityHandler && this._art && this._art !== art) this.unbind(); this._art = art; this._qualityHandler = async (url) => { if (!url) return; const newUrl = await this.hls.resolveUrl(url); if (!newUrl) { showTip('画质切换失败'); return; } if (this.hls.instance && this.hls.instance.url === newUrl) return; this._switchState = SwitchSnapshot.capture(art); this.hls.destroy(); safe(() => { art.video.pause(); art.video.removeAttribute('src'); art.video.load(); }, 'quality.resetVideo'); const newHls = this.hls.create(newUrl, art.video, this.hls.fileKey, () => showTip('画质切换失败')); if (!newHls) { this._switchState = null; return; } newHls.once(Hls.Events.MANIFEST_PARSED, () => { const state = this._switchState; if (!state) return; this._switchState = null; SwitchSnapshot.apply(art, state); }); }; art.on('quality', this._qualityHandler); } unbind() { if (this._art && this._qualityHandler) { safe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind'); } this._art = null; this._qualityHandler = null; } dispose() { this.unbind(); super.dispose(); } } // 11 进度持久化 ProgressSaveAgent class ProgressSaveAgent extends Disposable { constructor(playerRef, episodeMeta) { super(); this.playerRef = playerRef; this.episodeMeta = episodeMeta; this._file = () => this.playerRef()?.file || null; } getKey(file = this._file()) { return progressKey(file); } save(file = this._file()) { const key = progressKey(file); const art = this.playerRef()?.art; if (!key || !art?.currentTime || art.currentTime < 0) return; const currentTime = art.currentTime; const duration = art.duration; if (Number.isFinite(duration) && duration > 0) { const data = JSON.stringify({ currentTime, duration, timestamp: Date.now(), }); storage.setItem(key, data); } this.episodeMeta.setProgress(file, currentTime, Date.now()); } load() { const file = this._file(); const art = this.playerRef()?.art; if (!file || !art) return; const obj = parseStoredProgress(file); if (!obj) return; this.episodeMeta.setProgress(file, obj.currentTime, obj.timestamp); } applyResume(saved) { const art = this.playerRef()?.art; if (!art || !saved) return; try { art.currentTime = saved.currentTime; } catch (_) { /* 未就绪容错 */ } const file = this._file(); this.episodeMeta.setProgress(file, saved.currentTime, saved.timestamp ?? Date.now()); } 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); } } // 12 选集面板 CSS const DRAWER_WIDTH = 280; const DRAWER_WIDTH_NARROW = 220; const DRAWER_NARROW_BREAKPOINT = 480; const DRAWER_HEIGHT = 400; const DRAWER_TRANSITION_MS = CONFIG.ui.drawerTransitionMs; const calcDrawerPosition = () => { const width = window.innerWidth <= DRAWER_NARROW_BREAKPOINT ? DRAWER_WIDTH_NARROW : DRAWER_WIDTH; return { right: 0, top: '50%', marginTop: -DRAWER_HEIGHT / 2, height: DRAWER_HEIGHT, width, }; }; const EPISODE_MENU_CSS = ` .ep-menu { background: rgba(18, 18, 22, 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: 48px; display: flex; align-items: center; padding: 0 16px 0 56px; background: linear-gradient(to bottom, rgba(0, 0, 0, .62) 0%, rgba(0, 0, 0, .32) 60%, rgba(0, 0, 0, 0) 100%), linear-gradient(to right, rgba(0, 0, 0, .25) 0%, rgba(0, 0, 0, 0) 30%); font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-sizing: border-box; transition: opacity var(--art-transition-duration, 0.25s) ease, transform var(--art-transition-duration, 0.25s) ease, visibility 0s linear 0s; z-index: 60; pointer-events: none; opacity: 0; visibility: hidden; transform: translateY(-6px); will-change: opacity, transform; } .artplayer-title.art-show { opacity: 1; visibility: visible; transform: translateY(0); transition: opacity var(--art-transition-duration, 0.25s) ease, transform var(--art-transition-duration, 0.25s) ease, visibility 0s linear 0s; } .art-title-text { color: #fff; font-size: 15px; font-weight: 500; letter-spacing: .15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-shadow: 0 1px 2px rgba(0, 0, 0, .75), 0 0 6px rgba(0, 0, 0, .35); line-height: 1.2; } @media (max-width: 640px) { .artplayer-title { height: 40px; padding: 0 12px 0 36px; } .art-title-text { font-size: 13px; } } .artplayer-autonext { position: absolute; left: 0; right: 0; display: flex; justify-content: center; pointer-events: none; padding: 0 16px 0; box-sizing: border-box; animation: art-autonext-rise ${CONFIG.ui.autonextRiseMs}ms ease-out; } .art-layers:not(.art-show) .artplayer-autonext { opacity: 1 !important; visibility: visible !important; } .art-layers:not(.art-show):has(.artplayer-autonext) { opacity: 1 !important; visibility: visible !important; } .artplayer-autonext-inner { display: flex; align-items: center; gap: 10px; padding: 7px 10px 7px 12px; background: #3a3d42; border: 1px solid rgba(0, 0, 0, .25); border-radius: 10px; box-shadow: 0 6px 24px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .06) inset; color: #fff; font-family: system-ui, -apple-system, sans-serif; max-width: calc(100% - 32px); pointer-events: auto; } .artplayer-autonext-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; } .artplayer-autonext-title { font-size: 12px; font-weight: 500; letter-spacing: .1px; max-width: 320px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(255, 255, 255, .92); } .artplayer-autonext-sub { font-size: 10.5px; opacity: .7; font-variant-numeric: tabular-nums; letter-spacing: .1px; color: rgba(255, 255, 255, .82); } .artplayer-autonext-sub b { font-weight: 600; color: rgba(255, 255, 255, .95); } .artplayer-autonext-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: 2px; } .artplayer-autonext-cancel { padding: 4px 10px; font-size: 11px; color: rgba(255, 255, 255, .82); background: rgba(255, 255, 255, .08); border: 1px solid rgba(255, 255, 255, .14); border-radius: 999px; cursor: pointer; transition: background .15s ease, transform .1s ease; font-family: inherit; line-height: 1.4; } .artplayer-autonext-cancel:hover { background: rgba(255, 255, 255, .16); } .artplayer-autonext-cancel:active { transform: scale(.96); } .artplayer-autonext-play { padding: 4px 10px; font-size: 11px; color: #1f2328; background: #fff; border: 0; border-radius: 999px; cursor: pointer; font-weight: 600; transition: background .15s ease, transform .1s ease; font-family: inherit; line-height: 1.4; } .artplayer-autonext-play:hover { background: #f0f0f0; } .artplayer-autonext-play:active { transform: scale(.96); } @keyframes art-autonext-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } /* AutoPlaybackPrompt CSS(与 artplayer 官方弹窗结构、配色一致) */ .artplayer-autoplayback { display: none; gap: 10px; align-items: center; position: absolute; border-radius: var(--art-border-radius); padding: var(--art-padding); line-height: 1; left: var(--art-padding); bottom: calc(var(--art-control-height) + var(--art-bottom-gap) + 10px); background-color: var(--art-widget-background); color: #fff; font-family: system-ui, -apple-system, sans-serif; z-index: 2147483647; pointer-events: auto; box-sizing: border-box; animation: art-autoplayback-rise 240ms ease-out; } .artplayer-autoplayback.is-shown { display: flex; } .artplayer-autoplayback-text { color: #fff; font-size: 13px; font-weight: 500; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; } .artplayer-autoplayback-actions { display: flex; align-items: center; flex-shrink: 0; margin-left: 4px; } .artplayer-autoplayback-btn { color: #fff; cursor: pointer; padding: 2px 6px; border-radius: var(--art-border-radius); font-size: 12px; line-height: 1.4; background: transparent; border: 0; font-family: inherit; transition: opacity .15s ease; } .artplayer-autoplayback-btn:hover { opacity: .75; } .artplayer-autoplayback-btn:active { transform: scale(.96); } .artplayer-autoplayback-btn--primary { color: #ff3b30; font-weight: 500; } @keyframes art-autoplayback-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } `; // 13 选集面板 DOM + 行为 EpisodeMenu 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); player.art?.plugins?.episodeNav?.setIndex(i); } }; item.addEventListener('click', handleClick); item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }); return item; } _applyPosition() { if (!this._el) return; const pos = calcDrawerPosition(); const { style } = this._el; style.right = pos.right + 'px'; style.top = pos.top; style.bottom = 'auto'; style.marginTop = pos.marginTop + 'px'; style.height = pos.height + 'px'; style.width = pos.width + 'px'; } open(player) { const target = player || this._getPlayer(); if (!target) return; EpisodeMenu.ensureStyle(); if (this._el) { const oldEl = this._el; this._el = null; safe(() => oldEl.remove(), 'menu.open.replaceOld'); if (this._menuLeaveHandler) { try { oldEl.removeEventListener('mouseleave', this._menuLeaveHandler); } catch (_) { /* el 已移除 */ } this._menuLeaveHandler = null; } if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler.cancel?.(); this._resizeHandler = null; } this._playerRef = null; if (typeof this._hooks.onClose === 'function') this._hooks.onClose(); } const idx = target.getCurrentIndex(); const pos = calcDrawerPosition(); const menu = document.createElement('div'); menu.className = 'ep-menu'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', '选集列表'); menu.style.cssText = `position:fixed;right:${pos.right}px;top:${pos.top};margin-top:${pos.marginTop}px;height:${pos.height}px;width:${pos.width}px;z-index:${CONFIG.ui.topZ};transform:translateX(100%);transition:transform ${DRAWER_TRANSITION_MS}ms ${CONFIG.ui.drawerTransitionEasing};`; menu.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); }, { passive: false }); const header = document.createElement('div'); header.className = 'ep-header'; header.innerHTML = `选集列表${target.filelist.length}集`; menu.appendChild(header); const list = document.createElement('div'); list.className = 'ep-list'; list.setAttribute('role', 'listbox'); const fragment = document.createDocumentFragment(); target.filelist.forEach((f, i) => fragment.appendChild(this._buildItem(target, f, i, idx))); list.appendChild(fragment); menu.appendChild(list); const host = (target?.art?.fullscreenWeb ? document.body : target?.art?.template?.$player) || document.querySelector('#artplayer') || document.body; host.appendChild(menu); menu.style.pointerEvents = 'auto'; this._el = menu; this._playerRef = target; requestAnimationFrame(() => { if (this._el !== menu) return; menu.style.transform = 'translateX(0)'; const active = list.querySelector('.ep-item--active'); if (active) { active.focus(); active.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }); this._menuLeaveHandler = () => { if (this._el === menu) this.close(); }; menu.addEventListener('mouseleave', this._menuLeaveHandler); this._resizeHandler = debounce(() => this._applyPosition(), CONFIG.ui.menuDebounceMs); window.addEventListener('resize', this._resizeHandler); target.episodeMeta.prefetch(target.filelist); if (typeof this._hooks.onOpen === 'function') this._hooks.onOpen(); } close() { const wasOpen = !!this._el; const el = this._el; if (!el) return; if (this._menuLeaveHandler) { safe(() => el.removeEventListener('mouseleave', this._menuLeaveHandler), 'menu.removeListener'); } el.style.pointerEvents = 'none'; const finalize = once(() => { el.removeEventListener('transitionend', finalize); el.remove(); }); el.addEventListener('transitionend', finalize); this._listeners.setTimeout(finalize, DRAWER_TRANSITION_MS + 50); el.style.transform = 'translateX(100%)'; this._el = null; this._menuLeaveHandler = null; if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler.cancel?.(); 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; // 14 ArtPlayer 插件(选集 / 上下集 / 标题栏 / 继续播放 / 自动连播 / 快捷键) EpisodeMenu.PLUGIN_NAME = 'episodeMenu'; EpisodeMenu.ensureStyle = function ensureStyle() { if (EpisodeMenu._styleInjected || document.getElementById('ep-menu-style')) { EpisodeMenu._styleInjected = true; return; } const style = document.createElement('style'); style.id = 'ep-menu-style'; style.textContent = EPISODE_MENU_CSS; document.head.appendChild(style); EpisodeMenu._styleInjected = true; }; EpisodeMenu.installPlugin = function installEpisodeMenuPlugin(options = {}) { const menu = getEpisodeMenu(); const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null); return function install(art) { EpisodeMenu.ensureStyle(); art.controls.add({ name: EpisodeMenu.PLUGIN_NAME, position: 'right', html: '选集', tooltip: '选集', style: { padding: '0 10px', fontSize: '14px' }, click: () => { const player = getPlayer(); if (!player) return; if (menu.isOpen()) menu.close(); else menu.open(player); }, }); return { name: EpisodeMenu.PLUGIN_NAME, open: () => menu.open(getPlayer()), close: () => menu.close(), toggle: () => { const p = getPlayer(); return p && menu.toggle(p); }, isOpen: () => menu.isOpen(), setHooks: (hooks) => menu.setHooks(hooks), updateDuration: (f, sec) => menu.updateDuration(f, sec), updateActiveState: (i) => menu.updateActiveState(i), updateItemProgress: (f) => menu.updateItemProgress(f), destroy: () => menu.destroy(), }; }; }; function episodeNavPlugin(options = {}) { return function installEpisodeNav(art) { const getPlayer = () => (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(); }, setIndex(idx) { const len = getPlayer()?.filelist?.length || 0; setEnabled(resolveEl('prev', prev), idx > 0); setEnabled(resolveEl('next', next), idx >= 0 && idx < len - 1); }, destroy() { setEnabled(resolveEl('prev', prev), false); setEnabled(resolveEl('next', next), false); }, }; }; } // 15 自动连播倒计时面板 AutoNextController(含调度逻辑) const AutoNextState = Object.freeze({ IDLE: 'idle', SHOWN: 'shown', CANCELLED: 'cancelled', COMPLETED: 'completed', }); class AutoNextController extends Disposable { constructor(artRef, player = null) { super(); this.artRef = artRef; this.player = player; this._visible = false; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; this._token = 0; this._ticker = null; this._resizeObs = null; this._artListenerToken = null; } get visible() { return this._visible; } _cancelTicker() { if (this._ticker) { clearInterval(this._ticker); this._ticker = null; } } _disconnectResize() { if (this._resizeObs) { try { this._resizeObs.disconnect(); } catch (_) { /* target 已卸载 */ } this._resizeObs = null; } } _setState(state) { this.state = state; } show({ name, seconds, onCancel, onComplete }) { const art = this.artRef(); if (!art) return; this.hideCountdown(); this._cancelListeners = null; const token = ++this._token; const layerName = `autoNext-${token}`; this._layerName = layerName; this._onCancelCb = onCancel; this._onCompleteCb = onComplete; this._setState(AutoNextState.SHOWN); this._visible = true; let remaining = seconds; const EMBED = CONFIG.ui.controlEmbedPx; const $ctrl = art?.template?.$controls; const playerRoot = art?.template?.$player || document.getElementById('artplayer') || document.body; const layerHtml = `
${name || '下一集'}
${seconds} 秒后自动播放
`; let host; try { art.layers.add({ name: layerName, html: layerHtml, style: { zIndex: CONFIG.ui.topZ }, }); try { art.layers.show = true; } catch (_) { /* 旧版兼容 */ } host = art.layers[layerName]; } catch (_) { /* 退化:直接挂到播放器根 */ } if (!host || !host.querySelector) { const wrap = document.createElement('div'); wrap.innerHTML = layerHtml; host = wrap.firstElementChild; if (!host) { this._visible = false; return; } wrap.remove(); Object.defineProperty(art.layers || {}, layerName, { value: host, configurable: true }); playerRoot.appendChild(host); } const $layer = host; const $sec = $layer.querySelector?.('.artplayer-autonext-sec'); const $title = $layer.querySelector?.('.artplayer-autonext-title'); if ($title) $title.textContent = name || '下一集'; const paint = () => { const v = String(Math.max(remaining, 0)); if ($sec) { $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } }; paint(); this._resizeObs = new ResizeObserver(() => { const wrap = $layer?.querySelector?.('.artplayer-autonext'); if (!wrap) return; const $ctrl2 = art?.template?.$controls || art?.controls?.$controls; const h = Math.max(CONFIG.ui.controlMinHeight, $ctrl2?.getBoundingClientRect?.().height || CONFIG.ui.controlMinHeight); wrap.style.bottom = Math.round(h + EMBED) + 'px'; }); safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe'); const onCancelClick = (ev) => { ev.stopPropagation(); if (token !== this._token) return; if (this.state !== AutoNextState.SHOWN) return; this._setState(AutoNextState.CANCELLED); safe(() => this._onCancelCb?.(), 'AutoNext.onCancel'); }; const onPlayClick = (ev) => { ev.stopPropagation(); if (token !== this._token) return; if (this.state !== AutoNextState.SHOWN) return; this._setState(AutoNextState.COMPLETED); safe(() => this._onCompleteCb?.({ immediate: true }), 'AutoNext.onComplete.immediate'); }; $layer.querySelector?.('.artplayer-autonext-cancel')?.addEventListener('click', onCancelClick); $layer.querySelector?.('.artplayer-autonext-play')?.addEventListener('click', onPlayClick); this._cancelListeners = () => { $layer.querySelector?.('.artplayer-autonext-cancel')?.removeEventListener('click', onCancelClick); $layer.querySelector?.('.artplayer-autonext-play')?.removeEventListener('click', onPlayClick); }; this._ticker = setInterval(() => { if (token !== this._token) { this._cancelTicker(); return; } remaining -= 1; const $sec = $layer?.querySelector?.('.artplayer-autonext-sec'); if ($sec) { const v = String(Math.max(remaining, 0)); $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } if (remaining <= 0) { this._cancelTicker(); if (this.state === AutoNextState.SHOWN) { this._setState(AutoNextState.COMPLETED); safe(() => this._onCompleteCb?.({ immediate: false }), 'AutoNext.onComplete.ticker'); } } }, 1000); } hideCountdown() { this._cancelTicker(); this._disconnectResize(); if (this._cancelListeners) { try { this._cancelListeners(); } catch (_) { /* target 已卸载 */ } this._cancelListeners = null; } const art = this.artRef(); const layerName = this._layerName; this._layerName = null; this._onCancelCb = null; this._onCompleteCb = null; if (!art || !layerName) { this._visible = false; return; } if (art.layers?.[layerName]) { try { art.layers.remove(layerName); } catch (_) { /* 旧版容错 */ } } else { const node = document.querySelector('.artplayer-autonext'); if (node?.parentNode) node.parentNode.removeChild(node); } this._visible = false; // 倒计时面板消失后,重新触发控件栏隐藏计时 if (this.player && !this.player._disposed) { safe(() => this.player._ui?.scheduleHide?.(), 'AutoNext.afterHide.scheduleHide'); } } dispose() { this.hideCountdown(); if (this._artListenerToken) { safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNext.detach'); this._artListenerToken = null; } super.dispose(); } attach(art) { if (this._disposed) return; if (this._artListenerToken) { safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNext.detachPrev'); this._artListenerToken = null; } const handler = () => this._onEnded(); art.on('video:ended', handler); this._artListenerToken = { art, handler }; } _onEnded() { const player = this.player; if (!player || player._disposed) return; const art = player.art; if (!art) return; const idx = player.getCurrentIndex(); const listLen = player.filelist?.length || 0; if (idx < 0 || idx >= listLen - 1) return; player.progress.clear(); const next = player.filelist[idx + 1]; const nextName = getFileName(next); const tokenAtEnd = player._switchToken; const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click']; this._timerHandle = null; const clearAutoNextTimer = () => { if (this._timerHandle != null) { clearTimeout(this._timerHandle); this._timerHandle = null; } }; const cancelOnSeek = () => { if (art._disposed || player._disposed) return; this.hideCountdown(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); }; const switchNow = () => { if (player._disposed) return; this.hideCountdown(); clearAutoNextTimer(); cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek)); if (player._switchToken !== tokenAtEnd) return; player.switchVideo(next); }; this.show({ name: nextName, seconds: CONFIG.countdownSec, onCancel: () => cancelOnSeek(), onComplete: () => switchNow(), }); this._timerHandle = setTimeout(() => switchNow(), CONFIG.countdownSec * 1000); cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek)); } } // 15b 记忆进度提示(自动跳转到记忆处,并提示「从头播放」) class AutoPlaybackPrompt { constructor(artRef) { this.artRef = artRef; this._token = 0; this._layerName = null; this._saved = null; this._autoHideTimer = null; this.progressApply = null; this.progressClear = null; } _esc(text) { return String(text ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } _playerRoot(art) { return art?.template?.$player || document.getElementById('artplayer') || document.body; } _addLayer(art, html, name) { try { art.layers.add({ name, html, style: { zIndex: CONFIG.ui.topZ } }); try { art.layers.show = true; } catch (_) { /* 旧版兼容 */ } const host = art.layers[name]; if (host?.querySelector) return host; } catch (_) { /* 退化:直接挂载 */ } const wrap = document.createElement('div'); wrap.innerHTML = html; const host = wrap.firstElementChild; wrap.remove(); if (!host) return null; this._playerRoot(art).appendChild(host); return host; } show({ saved }) { const art = this.artRef(); if (!art || !saved) return; EpisodeMenu.ensureStyle(); this.hide(); this._token++; const token = this._token; this._saved = saved; this._layerName = `autoPlayback-${token}`; const resumeText = formatTime(saved.currentTime); const html = `
已跳转至 ${this._esc(resumeText)}
`; const $layer = this._addLayer(art, html, this._layerName); if (!$layer) return; const restartBtn = $layer.querySelector('[data-act="restart"]'); restartBtn?.addEventListener('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); if (token !== this._token) return; this.hide(); try { art.currentTime = 0; } catch (_) { /* 未就绪容错 */ } safe(() => art.play?.(), 'AutoPlaybackPrompt.restart.play'); this.progressClear?.(); }); // 3s 无操作自动消失 clearTimeout(this._autoHideTimer); this._autoHideTimer = setTimeout(() => { if (token !== this._token) return; this.hide(); }, 3000); } maybeAsk({ getSavedFor }) { const art = this.artRef(); if (!art) return; const saved = getSavedFor?.(); if (!saved || saved.currentTime < CONFIG.progressMinSec) return; const apply = () => { if (!art?.duration || !Number.isFinite(art.duration)) return; const dur = art.duration; const ratio = saved.currentTime / dur; if (ratio >= 0.99 || saved.currentTime >= dur - 1) return; const tok = ++this._token; const alreadyThere = Math.abs((art.currentTime || 0) - saved.currentTime) < 0.5; const reveal = () => { if (tok !== this._token) return; try { art.off?.('seeked', onSeeked); } catch (_) { /* off 失败容错 */ } this.progressApply?.(saved); this.show({ saved }); }; const onSeeked = () => { reveal(); }; if (alreadyThere) { safe(() => art.play?.(), 'AutoPlaybackPrompt.autoJump.play'); reveal(); return; } try { art.on?.('seeked', onSeeked); } catch (_) { /* 监听失败容错 */ } // 兜底:800ms 内若 seeked 未触发,仍显示弹窗 setTimeout(() => { if (tok !== this._token) return; reveal(); }, 800); // 自动跳转到记忆进度 try { art.currentTime = saved.currentTime; } catch (_) { /* 未就绪容错 */ } safe(() => art.play?.(), 'AutoPlaybackPrompt.autoJump.play'); }; if (art?.duration && Number.isFinite(art.duration)) { apply(); return; } // 等元数据就绪(最多 5s) const tok = ++this._token; const cb = () => { art.off?.('loadedmetadata', cb); if (tok !== this._token) return; apply(); }; try { art.on?.('loadedmetadata', cb); } catch (_) { /* 监听失败 */ } setTimeout(() => { if (tok !== this._token) return; art.off?.('loadedmetadata', cb); }, 5000); } hide() { this._token++; this._saved = null; clearTimeout(this._autoHideTimer); this._autoHideTimer = null; const name = this._layerName; this._layerName = null; const art = this.artRef(); if (!art) return; if (art.layers?.[name]) { try { art.layers.remove(name); } catch (_) { /* 旧版容错 */ } } else { const node = document.querySelector('.artplayer-autoplayback'); if (node?.parentNode) node.parentNode.removeChild(node); } } } // 16 控制栏隐显 + 标题层 UiController 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(); const sync = () => { const layers = art?.layers; if (!layers || typeof layers.show === 'undefined') return; if (this._pinned()) { layers.show = true; } }; const titleLayer = () => this._titleHost; const onControlToggle = (visible) => { const el = titleLayer(); if (!el) return; el.classList.toggle('art-show', !!visible); }; if (typeof art.on === 'function' && !this._layerSyncBound) { art.on('layers:show', sync); art.on('layers:hide', sync); art.on('control', onControlToggle); this._layerSyncBound = true; } if (art.template?.$player) { const onMove = () => this._hideCtl?.scheduleHide(); this._listeners.on(art.template.$player, 'mousemove', onMove); this._listeners.on(art.template.$player, 'touchstart', onMove, { passive: true }); } this._hideCtl.scheduleHide(); } setTitleVisible(visible) { const el = this._titleHost; if (!el) return; el.classList.toggle('art-show', !!visible); } addTitle(file) { const art = this.artRef(); if (!art) return; EpisodeMenu.ensureStyle(); const initialName = file ? getFileName(file) : ''; if (this._titleLayerAdded) { this.updateTitle(file); return; } const host = art.template?.$player || art.layers?.episodeTitle?.parentElement; if (!host) return; const wrap = document.createElement('div'); wrap.className = 'artplayer-title'; wrap.innerHTML = `${initialName}`; host.appendChild(wrap); this._titleHost = wrap; this._titleEl = wrap.querySelector('.art-title-text'); this._titleLayerAdded = true; this._titlePlayer = art; this._setTitleText(initialName); } updateTitle(file) { this._setTitleText(file ? getFileName(file) : ''); } _setTitleText(name) { const el = this._titleEl; if (el) el.textContent = name || ''; } showNow() { this._hideCtl?.showNow(); } scheduleHide() { this._hideCtl?.scheduleHide(); } reset() { this._titleLayerAdded = false; this._titleEl = null; if (this._titleHost?.parentNode) { safe(() => this._titleHost.parentNode.removeChild(this._titleHost), 'UiController.removeTitle'); } this._titleHost = null; this._titlePlayer = null; this._setup = false; this._layerSyncBound = false; this._hideCtl = null; this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } // 17 快捷键 HotkeyController class HotkeyController extends Disposable { constructor(playerRef) { super(); this.playerRef = playerRef; } setup() { const handlers = { f: (p) => { p.art.fullscreen = !p.art.fullscreen; }, w: (p) => { p.art.fullscreenWeb = !p.art.fullscreenWeb; }, m: (p) => { p.art.muted = !p.art.muted; }, p: (p) => { const idx = p.getCurrentIndex(); if (idx > 0) p.switchVideo(p.filelist[idx - 1]); }, n: (p) => { const idx = p.getCurrentIndex(); if (idx >= 0 && idx < (p.filelist?.length || 0) - 1) p.switchVideo(p.filelist[idx + 1]); }, }; this._listeners.on(document, 'keydown', (e) => { const player = this.playerRef(); if (!player?.art) return; if (e.isComposing) return; const tag = document.activeElement?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return; if (e.ctrlKey || e.altKey || e.metaKey) return; if (e.repeat) return; const fn = handlers[e.key.toLowerCase()]; if (!fn) return; fn(player); e.preventDefault(); e.stopPropagation(); player._ui?.scheduleHide(); }, true); } reset() { this._listeners.clearAll(); } dispose() { this.reset(); super.dispose(); } } function titleUiPlugin() { return function installTitleUi(art) { EpisodeMenu.ensureStyle(); const ui = new UiController(() => art); const boot = () => ui.setup(); if (art.template?.$player) boot(); else art.on('ready', boot); return { name: 'titleUi', addTitle: (file) => ui.addTitle(file), updateTitle: (file) => ui.updateTitle(file), showNow: () => ui.showNow(), scheduleHide: () => ui.scheduleHide(), setPinnedProbe: (fn) => ui.setPinnedProbe(fn), setTitleVisible: (v) => ui.setTitleVisible(v), destroy: () => ui.dispose(), }; }; } function autoPlaybackPlugin(options = {}) { return function installAutoPlayback(art) { EpisodeMenu.ensureStyle(); const prompt = new AutoPlaybackPrompt(() => art); if (typeof options.progressApply === 'function') prompt.progressApply = options.progressApply; if (typeof options.progressClear === 'function') prompt.progressClear = options.progressClear; return { name: 'autoPlayback', maybeAsk: (opts) => prompt.maybeAsk(opts), hide: () => prompt.hide(), destroy: () => prompt.hide(), }; }; } function autoNextPlugin(options = {}) { return function installAutoNext(art) { EpisodeMenu.ensureStyle(); const ctl = new AutoNextController(() => art, options.getPlayer?.()); ctl.attach(art); return { name: 'autoNext', get visible() { return ctl.visible; }, show: (opts) => ctl.show(opts), hideCountdown: () => ctl.hideCountdown(), destroy: () => ctl.dispose(), }; }; } function extraHotkeyPlugin(options = {}) { return function installExtraHotkey() { const hotkey = new HotkeyController(() => options.getPlayer?.()); hotkey.setup(); return { name: 'extraHotkey', destroy: () => hotkey.dispose(), }; }; } // 18 切换编排 SwitchCoordinator(save → wait → restore) class SwitchCoordinator { constructor(player) { this.player = player; } async perform(file, { prevVolume, wasFullscreen, wasFullscreenWeb } = {}) { const player = this.player; if (!file) return; player._autoNext?.hideCountdown(); if (player.file) player.progress.save(player.file); if (!player.art) return; const token = ++player._switchToken; player.file = file; player._syncAsideUi(); const newEpIdx = player.getCurrentIndex(); if (newEpIdx >= 0) { safe(() => player._saveLastEpisode(newEpIdx)); } player.getUrl = player._resolveUrlBuilder(file); if (!player.getUrl) return; player.hls.setUrlBuilder(player.getUrl); await player.quality.build(file.resolution, player.getUrl, fileKeyOf(file)); if (!player.quality.list?.length) { showTip(ERR_NO_URL); return; } if (token !== player._switchToken) return; const resolvedUrl = player.quality.list[0].url; if (!resolvedUrl) { showTip(`无法播放: ${getFileName(file)}`); return; } player._applyHlsSource(resolvedUrl, file, token); if (typeof prevVolume === 'number') { player.art.video.muted = false; player.art.video.volume = prevVolume; } try { await player._waitForVideo(player.art.video, player.hls.instance, token); } catch (e) { if (e?.message === 'stale') return; } if (token !== player._switchToken) return; safe(() => { if (wasFullscreen && !player.art.fullscreen) player.art.fullscreen = true; if (wasFullscreenWeb && !player.art.fullscreenWeb) player.art.fullscreenWeb = true; }, 'restoreFullscreen'); player._skipResumePrompt = true; player.progress.load(); player._resumePrompt?.maybeAsk({ getSavedFor: () => player.progress.getSavedFor(player.file), }); log.announce(file); player.art.video.play().catch(() => { }); } } // 20 Player 协调器(持有子系统,统一通过 Disposable 链释放) class Player extends Disposable { constructor() { super(); this.art = null; this.file = null; this.filelist = []; this.flag = ''; this.nativeVideoNode = null; this.getUrl = null; this._switchCtrl = new SwitchController(); this._switchToken = 0; this._episodeControlsAdded = false; this._initialized = false; this._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._switch = new SwitchCoordinator(this); 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)); })); } get _ui() { return this.art?.plugins?.titleUi || null; } get _resumePrompt() { return this.art?.plugins?.autoPlayback || null; } get _autoNext() { return this.art?.plugins?.autoNext || null; } getCurrentIndex() { const { file, filelist } = this; if (!file || !filelist?.length) return -1; return filelist.findIndex( (f) => (f?.fs_id != null && file?.fs_id != null && 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) { /* JSON 损坏回退默认 */ } return null; } _saveLastEpisode(epIdx) { if (!Number.isInteger(epIdx) || epIdx < 0) return; const folderKey = this._getFolderKey(); if (!folderKey) return; try { const raw = storage.getRawItem('last_episode'); const obj = raw ? JSON.parse(raw) : {}; obj[folderKey] = epIdx; storage.setItem('last_episode', JSON.stringify(obj)); } catch (_e) { /* 写入失败下次再试 */ } } _resolveUrlBuilder(file) { return this.flag === 'sharevideo' ? buildShareUrl(file) : this.flag === 'mboxvideo' ? buildMboxUrl(file) : buildFileUrl(file); } _syncAsideUi() { if (!this.file) return; clickViewAll(); const nodes = safe(() => document.getElementsByClassName('vp-video-page-card__video-detail'), 'aside.sync.get') || []; if (!nodes.length) return; const target = getFileName(this.file); const list = Array.from(nodes); let match = list.find((n) => n.__bdActiveMatch === target); if (!match && target) { match = list.find((n) => (n.textContent || '').includes(target)); if (match) match.__bdActiveMatch = target; } if (!match) return; safe(() => match.click(), 'aside.sync.click'); } switchVideo(file) { if (!file) return; this._switchCtrl.submit(() => this._performSwitch(file)); } async _performSwitch(file) { const prevVolume = this.art?.volume; const wasFullscreen = !!this.art?.fullscreen; const wasFullscreenWeb = !!this.art?.fullscreenWeb; await this._switch.perform(file, { prevVolume, wasFullscreen, wasFullscreenWeb }); } _applyHlsSource(url, file, token) { this.hls.destroy(); safe(() => { this.art.video.pause(); this.art.video.removeAttribute('src'); this.art.video.load(); }, 'switch.resetVideo'); const hls = this.hls.create(url, 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); } _bindHlsDuration(hls, file) { if (!hls || !file) return; const key = fileKeyOf(file); if (!key) return; if (this._hlsDurationHandler && this._hlsDurationTarget === hls) { try { hls.off(Hls.Events.LEVEL_LOADED, this._hlsDurationHandler); } catch (_) {} } const handler = (_, data) => { const dur = data?.details?.totalduration; if (dur && Number.isFinite(dur) && dur > 0 && !this.episodeMeta.isDurationVerified(file)) { this.episodeMeta.setDuration(file, dur, false); } }; hls.once(Hls.Events.LEVEL_LOADED, handler); this._hlsDurationHandler = handler; this._hlsDurationTarget = hls; } _waitForVideo(video, hls, token) { const listeners = this._listeners; return new Promise((resolve, reject) => { const finish = once((err) => { listeners.clearTimeout(timer); err ? reject(err) : resolve(); }); const timer = listeners.setTimeout(() => finish(new Error('timeout')), CONFIG.network.timeoutMs); const onLoaded = () => finish(); const onParsed = () => { if (token !== this._switchToken) { finish(new Error('stale')); return; } if (video?.readyState >= 1) { finish(); } else if (video) { listeners.on(video, 'loadedmetadata', onLoaded, { once: true }); } }; hls.once(Hls.Events.MANIFEST_PARSED, onParsed); }); } refreshControlDisplay() { this.art?.plugins?.episodeNav?.refresh?.(); } addEpisodeControls() { if (this._episodeControlsAdded) return; this._episodeControlsAdded = true; this.episodeMeta.prefetch(this.filelist); this.refreshControlDisplay(); this._ui?.addTitle(this.file); } _teardown() { if (this._disposed) return; this._autoNext?.hideCountdown(); this._episodeControlsAdded = false; this._initialized = false; if (this._epMenuUnsubs?.length) { this._epMenuUnsubs.forEach((un) => { try { un(); } catch (_) { } }); this._epMenuUnsubs = []; } this.quality.unbind(); if (this.art?.video) { safe(() => { this.art.video.muted = true; this.art.video.pause(); }, 'teardown.mute'); } if (this.art) { const art = this.art; this.art = null; safe(() => { if (art.video) { art.video.pause(); art.video.src = ''; art.video.load(); } art.destroy(true); }, 'teardown.destroy'); } } dispose() { this._teardown(); super.dispose(); } async init(container) { if (!this.getUrl) { showTip(ERR_NO_URL); return; } this._teardown(); this._switchCtrl = new SwitchController(); this._switchToken = 0; this.hls.setUrlBuilder(this.getUrl); await this.quality.build(this.file?.resolution, this.getUrl, fileKeyOf(this.file)); if (!this.quality.list?.length) { showTip(ERR_NO_URL); return; } const resolvedUrl = this.quality.list[0].url; if (!resolvedUrl) { showTip(ERR_NO_URL); return; } this.art = new Artplayer({ container, url: resolvedUrl, type: 'm3u8', customType: { m3u8: (video, url) => { this.hls.create(url, video, fileKeyOf(this.file), () => showTip('网络持续错误,请刷新页面')); }, }, 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: [ EpisodeMenu.installPlugin({ getPlayer: () => this }), episodeNavPlugin({ getPlayer: () => this }), titleUiPlugin(), autoPlaybackPlugin({ getPlayer: () => this, progressApply: (saved) => { if (!saved) return; this.episodeMeta.setProgress(this.file, saved.currentTime, saved.timestamp ?? Date.now()); }, progressClear: () => { this.progress.clear(); }, }), autoNextPlugin({ getPlayer: () => this }), extraHotkeyPlugin({ getPlayer: () => this }), ], }); this._ui?.setPinnedProbe(() => this._autoNext?.visible || getEpisodeMenu().isOpen()); 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?.showNow(); const skip = this._skipResumePrompt; this._skipResumePrompt = false; if (!skip) { this._resumePrompt?.maybeAsk({ getSavedFor: () => this.progress.getSavedFor(this.file), }); } log.announce(this.file); this._initialized = true; }); 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(); } }); 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: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 > CONFIG.ui.videoPollingCount) { this._listeners.clearInterval(id); } }, CONFIG.ui.videoPollingIntervalMs); }; if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) { this._listeners.setTimeout(() => { safe(() => unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => { if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance); }), 'pollDestroy.require'); }, CONFIG.ui.videoPollingMaxMs); } if (this.flag === 'video' && this.nativeVideoNode) { this._listeners.setTimeout(() => { pollDestroy(() => this.nativeVideoNode?.firstChild); }, CONFIG.ui.videoPollingMaxMs); } } async replacePlayer() { const videoNode = await waitFor(() => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), { intervalMs: 500, maxAttempts: 20, label: 'replacePlayer', }); if (!videoNode) return null; let container = document.getElementById('artplayer'); if (!container) { container = document.createElement('div'); container.id = 'artplayer'; container.style.cssText = 'width:100%;height:100%'; videoNode.parentNode?.replaceChild(container, videoNode); } return container; } } // 21 路由管理(每个 handler 只产出 file/list/flag/urlBuilder) const clickViewAll = () => { const el = document.getElementsByClassName('vp-aside-box__top-operation')[0]; if (el?.textContent.includes('查看全部')) { safe(() => el.click(), 'aside.viewAll'); } }; class RouteManager { constructor(player) { this.player = player; } probeAsideTopOp() { clickViewAll(); } async _commit({ file, list, flag, urlBuilder }) { if (!file || !urlBuilder) return false; const p = this.player; p.filelist = sortByLocale(list || []); const resumeEp = p.pickResumeIndex(); p.file = resumeEp != null && p.filelist[resumeEp] ? p.filelist[resumeEp] : file; p.flag = flag; p.getUrl = urlBuilder(p.file); if (!p.getUrl) return false; p.hls.setUrlBuilder(p.getUrl); const buildPromise = p.quality.build(p.file.resolution, p.getUrl, fileKeyOf(p.file)); const container = await p.replacePlayer(); if (container) await p.init(container); await buildPromise; return true; } async handleShare() { const localsReady = await waitFor(() => unsafeWindow.locals, { intervalMs: 500, maxAttempts: 40, label: 'handleShare.locals', }); if (!localsReady) return; await new Promise((resolve) => { let done = false; localsReady.get( 'file_list', 'share_uk', 'shareid', 'sign', 'timestamp', (file_list, share_uk, shareid, sign, timestamp) => { if (done) return; done = true; if (!file_list?.length) { resolve(); return; } let videoList = []; try { const list = unsafeWindow .require('system-core:context/context.js') .instanceForSystem.list.getCurrentList(); videoList = list.filter((f) => f.category === 1); } catch (_) { videoList = file_list.filter((f) => f.category === 1); } if (!videoList.length) { resolve(); return; } const list2 = videoList; const file0 = videoList[0]; this._commit({ file: file0, list: list2, flag: 'sharevideo', urlBuilder: buildShareUrl, }).finally(resolve); }, ); }); } async handlePlay() { const jqReady = await waitFor(() => unsafeWindow.jQuery, { intervalMs: 500, maxAttempts: 40, label: 'handlePlay.jQuery', }); if (!jqReady) return; let hasInit = false; jqReady(document).ajaxComplete(async (event, xhr, options) => { const url = options.url || ''; if (url.includes('/api/categorylist')) { this.player.filelist = sortByLocale((xhr.responseJSON?.info || []).filter((f) => f.category === 1)); if (this.player.art) this.player.addEpisodeControls(); } else if (url.includes('/api/filemetas')) { if (hasInit) return; const info = xhr.responseJSON?.info?.[0]; if (!info) return; const list = this.player.filelist || []; let file = null; const resumeEp = this.player.pickResumeIndex(); if (resumeEp != null && list[resumeEp]) { file = list[resumeEp]; } else { file = list.find((f) => (info?.fs_id != null && f?.fs_id != null && f.fs_id === info.fs_id) || (info?.path && f?.path && f.path === info.path) ) || list[0] || info; } hasInit = true; await this._commit({ file, list, flag: 'playvideo', urlBuilder: buildFileUrl, }); } }); } async _handlePinia(flag, urlBuilder) { const app = document.querySelector('#app'); const pinia = await waitFor( () => app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo ? app.__vue_app__.config.globalProperties.$pinia : null, { intervalMs: 500, maxAttempts: 40, label: `${flag}.pinia` }, ); if (!pinia) return; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; const file = pinia.state._rawValue.videoinfo.videoinfo; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) this.player.nativeVideoNode = videoNode; await this._commit({ file, list, flag, urlBuilder }); } async handleVideo() { await this._handlePinia('video', buildFileUrl); } async handleMboxVideo() { await this._handlePinia('mboxvideo', buildMboxUrl); } async dispatch() { const ready = document.readyState === 'complete' ? Promise.resolve() : new Promise((r) => window.addEventListener('load', r, { once: true })); if (document.readyState !== 'complete') { ready.then(() => this.probeAsideTopOp()); } else { this.probeAsideTopOp(); } await ready; const url = location.href; for (const route of RouteManager.ROUTES) { if (url.includes(route.pattern)) { await route.handler.call(this); break; } } } } RouteManager.ROUTES = Object.freeze([ { pattern: CONFIG.urls.sharePattern, handler: RouteManager.prototype.handleShare }, { pattern: CONFIG.urls.playPattern, handler: RouteManager.prototype.handlePlay }, { pattern: CONFIG.urls.videoPattern, handler: RouteManager.prototype.handleVideo }, { pattern: CONFIG.urls.mboxvideoPattern, handler: RouteManager.prototype.handleMboxVideo }, ]); // 22 入口 const player = new Player(); unsafeWindow.player = player; window.addEventListener('beforeunload', () => { if (player._initialized) player.progress.save(); }); const routeManager = new RouteManager(player); routeManager.dispatch(); })();