// ==UserScript==
// @name 百度网盘视频播放器
// @namespace https://scriptcat.org/
// @version 5.4
// @description 基于ClaudeAI的百度网盘视频播放器,支持多清晰度切换、连续播放、进度记忆、键盘快捷键
// @match https://pan.baidu.com/s/*
// @match https://pan.baidu.com/play/video*
// @match https://pan.baidu.com/pfile/video*
// @match https://pan.baidu.com/pfile/mboxvideo*
// @require https://unpkg.com/hls.js@1.6.16/dist/hls.min.js
// @require https://unpkg.com/artplayer@5.4.0/dist/artplayer.js
// @icon https://nd-static.bdstatic.com/m-static/v20-main/home/img/icon-home-new.b4083345.png
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ============================================================
* 配置
* ============================================================ */
const CONFIG = Object.freeze({
userAgent: 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts',
hls: Object.freeze({
debug: false,
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 120,
maxBufferLength: 60,
maxMaxBufferLength: 600,
maxBufferSize: 60 * 1000 * 1000,
maxBufferHole: 0.5,
startLevel: -1,
autoStartLoad: true,
abrEwmaDefaultEstimate: 5000000,
}),
qualityTemplates: Object.freeze({
1080: '超清 1080P',
720: '高清 720P',
480: '流畅 480P',
360: '省流 360P',
}),
qualityLevels: Object.freeze([1080, 720, 480, 360]),
countdownSec: 5,
hideDelayMs: 2000,
saveDebounceMs: 2000,
saveThrottleMs: 3000,
progressMinSec: 5,
progressTtlMs: 15 * 86400000,
retry: Object.freeze({
baseMs: 500,
maxMs: 10000,
hlsNetworkBaseMs: 1000,
hlsNetworkMaxMs: 10000,
hlsNetworkMaxAttempts: 3,
evictKeep: 15,
evictTrigger: 20,
}),
urls: Object.freeze({
sharePattern: '/s/',
playPattern: '/play/video',
videoPattern: '/pfile/video',
mboxvideoPattern: '/mboxvideo',
}),
logPrefix: '[BDPlayer]',
});
const ICONS = Object.freeze({
prev: '',
next: '',
});
const HLS_FETCH_SETUP = () => ({ headers: { 'User-Agent': CONFIG.userAgent } });
/* ============================================================
* 工具
* ============================================================ */
const makeSvg = (inner) =>
``;
const once = (fn) => {
let called = false;
return (...args) => {
if (called) return;
called = true;
fn(...args);
};
};
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delay);
};
};
// 控制台仅在切换播放时输出一段播报;其它路径统一静默。
const log = {
announce(file) {
const title = file ? getFileName(file) : '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
console.info(CONFIG.logPrefix, `当前时间:${stamp}\n当前播放:${title}`);
},
info: () => {},
warn: () => {},
error: () => {},
};
const safe = (fn, label = '') => {
try {
return fn();
} catch (e) {
if (label) log.warn(`safe:${label}`, e);
return undefined;
}
};
/* ============================================================
* 生命周期基类:Disposable
* - 所有子系统继承,统一 dispose() 入口
* - 自带 _listeners 集中管理监听/定时器/轮询
* - 支持 _track(child) 注册子 disposable
* ============================================================ */
class Disposable {
constructor() {
this._disposed = false;
this._childDisposables = new Set();
this._listeners = new ListenerBag();
}
_track(d) {
if (!d) return d;
this._childDisposables.add(d);
return d;
}
dispose() {
if (this._disposed) return;
this._disposed = true;
this._childDisposables.forEach((d) => safe(() => d.dispose?.(), `${this.constructor.name}.dispose`));
this._childDisposables.clear();
this._listeners.clearAll();
}
}
/* ============================================================
* AutoNext 状态机枚举
* IDLE 未显示
* SHOWN 倒计时进行中
* CANCELLED 用户取消 / seek 取消 / 路由变更
* COMPLETED 倒计时结束 / 立即播放
* ============================================================ */
const AutoNextState = Object.freeze({
IDLE: 'idle',
SHOWN: 'shown',
CANCELLED: 'cancelled',
COMPLETED: 'completed',
});
const dumpCache = (player, label = '') => {
// DEBUG: 注释掉下面一行以隐藏调试输出
return;
try {
const filelist = player?.filelist || [];
const hlsCache = player?.hls?._cacheList || [];
const qualityList = player?.quality?.list || [];
const tag = label ? ` [${label}]` : '';
console.info('[PlayerCache]' + tag, {
filelist,
hls_cacheList: hlsCache,
quality_list: qualityList,
});
} catch (e) {
console.warn('[PlayerCache] dump 失败:', e);
}
};
unsafeWindow.dumpPlayerCache = () => dumpCache(unsafeWindow.player);
const gmFetch = (url) =>
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { 'User-Agent': CONFIG.userAgent },
responseType: 'text',
timeout: 10000,
onload: (r) => resolve(r.responseText),
onerror: (r) => reject(new Error(`HTTP ${r.status}`)),
ontimeout: () => reject(new Error('timeout')),
});
});
const parseM3u8Duration = (text) => {
if (!text || typeof text !== 'string') return 0;
const re = /#EXTINF:([0-9]+(?:\.[0-9]+)?)/g;
let total = 0;
let m;
while ((m = re.exec(text))) {
total += parseFloat(m[1]) || 0;
}
return total;
};
async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout, label = 'waitFor' } = {}) {
for (let i = 0; i < maxAttempts; i++) {
try {
const v = fn();
if (v) return v;
} catch (e) {
log.warn(`${label}.poll`, e);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
if (onTimeout) safe(onTimeout, `${label}.timeout`);
return null;
}
const base64Encode = (str) => {
try {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
String.fromCharCode(parseInt(p1, 16))));
} catch (_) {
return null;
}
};
/** ListenerBag 集中管理监听器 / 定时器 / 轮询,destroy 时一次性清理 */
class ListenerBag {
constructor() {
this._listeners = [];
this._timers = new Set();
this._intervals = new Set();
}
on(target, type, fn, options) {
target.addEventListener(type, fn, options);
this._listeners.push({ target, type, fn, options });
}
once(target, type, fn, options) {
const wrapped = (...args) => {
this.off(target, type, wrapped);
fn(...args);
};
target.addEventListener(type, wrapped, { ...(options || {}), once: true });
this._listeners.push({ target, type, fn: wrapped, options: { ...(options || {}), once: true } });
return wrapped;
}
off(target, type, fn) {
target.removeEventListener(type, fn);
}
trackArtListener(art, type, fn) {
art.on(type, fn);
this._listeners.push({ target: art, type, fn, options: undefined });
}
untrackArtListener(art, type, fn) {
try { art.off(type, fn); } catch (_) { /* target 已销毁 */ }
}
setTimeout(fn, delay) {
const id = setTimeout(() => {
this._timers.delete(id);
fn();
}, delay);
this._timers.add(id);
return id;
}
clearTimeout(id) {
if (id == null) return;
clearTimeout(id);
this._timers.delete(id);
}
setInterval(fn, delay) {
const id = setInterval(fn, delay);
this._intervals.add(id);
return id;
}
clearInterval(id) {
if (id != null && this._intervals.has(id)) {
clearInterval(id);
this._intervals.delete(id);
}
}
clearAll() {
this._listeners.forEach(({ target, type, fn, options }) => {
try {
if (target && typeof target.removeEventListener === 'function') {
target.removeEventListener(type, fn, options);
} else if (target && typeof target.off === 'function') {
target.off(type, fn);
}
} catch (_) { /* target 已卸载 */ }
});
this._listeners = [];
this._timers.forEach((id) => clearTimeout(id));
this._timers.clear();
this._intervals.forEach((id) => clearInterval(id));
this._intervals.clear();
}
}
/** 简易事件总线 */
class EventBus {
constructor() {
this._handlers = new Map();
}
on(event, handler) {
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
this._handlers.get(event).add(handler);
return () => this.off(event, handler);
}
off(event, handler) {
this._handlers.get(event)?.delete(handler);
}
emit(event, payload) {
this._handlers.get(event)?.forEach((fn) => safe(() => fn(payload), `EventBus.${event}`));
}
}
/** 异步串行化控制器:丢弃过期请求,只执行最后一次 */
class SwitchController {
constructor() {
this._running = false;
this._pending = null;
}
submit(fn) {
this._pending = fn;
this._drain();
}
async _drain() {
if (this._running) return;
while (this._pending) {
const fn = this._pending;
this._pending = null;
this._running = true;
try {
await fn();
} catch (e) {
log.error('SwitchController', e);
} finally {
this._running = false;
}
}
}
get isRunning() {
return this._running;
}
}
/* ============================================================
* 存储抽象:GM_* 优先,回落 localStorage
* - 集中处理 QuotaExceededError 时的 ev_old_progress 回收
* - 暴露一次性 helpers 取代 listKeys 拉取 + JSON.parse 二次循环
* ============================================================ */
const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const hasGMList = typeof GM_listValues === 'function';
const hasGMDel = typeof GM_deleteValue === 'function';
const STORAGE_PREFIX = 'video_progress_';
function evictOldProgress() {
try {
const entries = [];
const visit = (rawKey) => {
if (!rawKey || !rawKey.startsWith(STORAGE_PREFIX)) return;
const obj = safe(() => JSON.parse(storage.getRawItem(rawKey)), 'evict.parse');
entries.push({ key: rawKey, time: obj?.timestamp || 0 });
};
if (hasGM) {
if (!hasGMList) return;
const all = safe(() => GM_listValues(), 'evict.list') || [];
all.forEach((k) => visit(k));
} else {
const keys = safe(() => Object.keys(localStorage), 'evict.keys') || [];
keys.forEach((k) => visit(k));
}
if (entries.length <= CONFIG.retry.evictTrigger) return;
entries.sort((a, b) => a.time - b.time);
const toRemove = entries.slice(0, entries.length - CONFIG.retry.evictKeep);
toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove'));
} catch (e) {
log.warn('evictOldProgress', e);
}
}
const storage = {
getItem(key) {
try {
if (hasGM) {
const v = GM_getValue(key);
return v == null ? null : v;
}
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.get', key, e);
return null;
}
},
setItem(key, value) {
try {
if (hasGM) {
GM_setValue(key, value);
return;
}
localStorage.setItem(key, value);
} catch (e) {
if (e?.name === 'QuotaExceededError') {
evictOldProgress();
try {
if (hasGM) GM_setValue(key, value);
else localStorage.setItem(key, value);
} catch (e2) {
log.warn('storage.set.retry', key, e2);
}
} else {
log.warn('storage.set', key, e);
}
}
},
removeItem(key) {
try {
if (hasGM) {
if (hasGMDel) GM_deleteValue(key);
else log.warn('storage.remove.unsupported', key);
return;
}
localStorage.removeItem(key);
} catch (e) {
log.warn('storage.remove', key, e);
}
},
getRawItem(key) {
if (hasGM) return storage.getItem(key);
try {
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.getRaw', key, e);
return null;
}
},
};
/* ============================================================
* 进度键与解析
* ============================================================ */
const fileKeyOf = (file) => {
if (!file) return null;
if (file.fs_id) return String(file.fs_id);
if (file.path) {
const encoded = base64Encode(file.path);
return encoded ? `path_${encoded}` : null;
}
return null;
};
const progressKey = (file) => {
const k = fileKeyOf(file);
return k ? `${STORAGE_PREFIX}${k}` : null;
};
function parseStoredProgress(file) {
const key = progressKey(file);
if (!key) return null;
const raw = storage.getRawItem(key);
if (!raw) return null;
let obj;
try {
obj = JSON.parse(raw);
} catch (e) {
log.warn('parseStoredProgress.json', e);
return null;
}
if (typeof obj?.currentTime !== 'number' || obj.currentTime < 0) return null;
if (typeof obj?.timestamp !== 'number' || obj.timestamp <= 0) return null;
const duration = typeof obj.duration === 'number' && obj.duration >= 0 ? obj.duration : 0;
if (duration > 0 && obj.currentTime > duration + 1) return null;
if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) {
storage.removeItem(key);
return null;
}
if (obj.currentTime < CONFIG.progressMinSec) return null;
return { currentTime: obj.currentTime, duration, timestamp: obj.timestamp };
}
/* ============================================================
* 选集元数据:时长 / 进度 单一来源
* 取代旧的 EpisodeCache + DurationCache 双类
* ============================================================ */
class EpisodeMeta extends Disposable {
constructor() {
super();
this._map = new Map();
this.events = new EventBus();
}
_key(file) {
return fileKeyOf(file);
}
_ensure(file) {
const k = this._key(file);
if (!k) return null;
let entry = this._map.get(k);
if (!entry) {
entry = { duration: null, durationVerified: false, currentTime: null, timestamp: null };
this._map.set(k, entry);
}
return entry;
}
setDuration(file, sec, verified = false) {
const entry = this._ensure(file);
if (!entry || !Number.isFinite(sec) || sec <= 0) return;
// HLS EXTINF 累加总 ≤ 真实时长;用 Math.ceil 避免被 floor 砍掉 ~1s
// 与 artplayer 显示的 art.duration (≈ seekable.end) 对齐
const dur = Math.ceil(sec);
// verified=false 不能覆盖已 verified 的值(HLS 推断不能反向覆盖真实时长)
if (entry.durationVerified && !verified) return;
// 值未变且 verified 状态相同 → 跳过(避免 durationchange 重复 emit)
if (entry.duration === dur && !!entry.durationVerified === !!verified) return;
entry.duration = dur;
entry.durationVerified = entry.durationVerified || !!verified;
this.events.emit('duration', { file, duration: dur, verified: entry.durationVerified });
}
isDurationVerified(file) {
const entry = this._map.get(this._key(file));
return !!(entry && entry.duration != null && entry.durationVerified);
}
setProgress(file, currentTime, timestamp) {
const entry = this._ensure(file);
if (!entry) return;
if (currentTime == null) {
if (entry.currentTime == null && entry.timestamp == null) return;
entry.currentTime = null;
entry.timestamp = null;
} else {
entry.currentTime = Math.floor(currentTime);
entry.timestamp = timestamp ?? Date.now();
}
this.events.emit('progress', { file, currentTime: entry.currentTime, timestamp: entry.timestamp });
}
get(file) {
const k = this._key(file);
if (!k) return null;
const entry = this._map.get(k);
return entry ? { ...entry } : null;
}
getDuration(file) {
const entry = this._map.get(this._key(file));
return entry?.duration ?? null;
}
prefetch(list) {
if (!list?.length) return;
for (const f of list) {
if (!f) continue;
const entry = this._ensure(f);
if (!entry || entry.duration != null) continue;
const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null;
if (raw != null && Number.isFinite(raw) && raw > 0) {
entry.duration = Math.floor(raw);
}
}
}
preloadFromStorage(filelist) {
if (!filelist?.length) return;
for (const f of filelist) {
if (!f) continue;
const parsed = parseStoredProgress(f);
if (!parsed) continue;
const k = this._key(f);
const existing = this._map.get(k) || {};
// storage 中 parsed.duration = 上次 progress.save() 写入的 art.duration(真实)
// 若 entry 无 duration,才写入并标记 verified=true(避免后续 HLS 推断反覆盖)
if (existing.duration == null) {
existing.duration = parsed.duration;
existing.durationVerified = true;
}
existing.currentTime = parsed.currentTime;
existing.timestamp = parsed.timestamp;
this._map.set(k, existing);
}
}
*iterateKeys() {
yield* this._map.keys();
}
clear() {
this._map.clear();
this.events.emit('clear');
}
dispose() {
this.clear();
super.dispose();
}
}
/* ============================================================
* 文件排序 / 文件名 / 时间格式化
* ============================================================ */
function sortByLocale(list) {
return [...list].sort((a, b) =>
(a.server_filename || a.name || '').localeCompare(b.server_filename || b.name || '', undefined, { numeric: true })
);
}
const getFileName = (f) => f?.server_filename || f?.name || '未命名';
function formatTime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
sec = Math.floor(sec);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
function showTip(msg) {
const fns = [
() => unsafeWindow.require('system-core:system/uiService/tip/tip.js').show({ mode: 'success', msg }),
() => unsafeWindow.toast?.show({ type: 'svip', message: msg, duration: 3000 }),
() => unsafeWindow.$bus?.$Toast?.addToast?.({ type: 'success', content: msg, durtime: 3000 }),
];
fns.some((fn) => safe(fn));
}
/* ============================================================
* URL 构建
* ============================================================ */
const checkJsToken = () => {
if (!unsafeWindow.jsToken) {
showTip('登录状态异常,请刷新页面');
return false;
}
return true;
};
function buildFileUrl(file) {
if (!checkJsToken()) return () => null;
return (type) =>
`/api/streaming?path=${encodeURIComponent(file.path)}&app_id=250528&clienttype=0&type=${type}&jsToken=${unsafeWindow.jsToken}`;
}
function buildMboxUrl(file) {
if (!file || !file.to || !file.msg_id) return null;
return (stream_type) => {
const params = new URLSearchParams({
to: file.to,
from_uk: file.from_uk ?? '',
msg_id: file.msg_id,
fs_id: file.fs_id ?? '',
type: file.type ?? '',
stream_type,
trans: file.trans ?? '',
ltime: file.ltime ?? '',
});
return `/mbox/msg/streaming?${params.toString()}`;
};
}
function buildShareUrl(file) {
if (!checkJsToken()) return null;
const locals = unsafeWindow.locals;
const get = (k) => {
try {
return typeof locals.get === 'function' ? locals.get(k) : locals[k];
} catch (_) {
return null;
}
};
const [share_uk, shareid, sign, timestamp] = ['share_uk', 'shareid', 'sign', 'timestamp'].map(get);
if (!share_uk || !sign || !timestamp) return null;
const token = unsafeWindow.jsToken;
return (type) =>
`/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&jsToken=${token}`;
}
/* ============================================================
* HLS 控制器
* 缓存:JSON 数组 [{ fileKey, qualities: [{ q, r }] }]
* 同集候选清晰度并行;多集之间串行
* ============================================================ */
class HlsController extends Disposable {
constructor() {
super();
this.instance = null;
this._fileKey = 'default';
this._getUrlForQuality = null;
this._retries = {};
this._cacheList = [];
this._pendingRequests = new Map();
this._backoffTimers = new Set();
}
get fileKey() {
return this._fileKey;
}
_findEpisodeIndex(fileKey) {
if (!fileKey) return -1;
for (let i = 0; i < this._cacheList.length; i++) {
if (this._cacheList[i].fileKey === fileKey) return i;
}
return -1;
}
_getOrCreateQualities(fileKey) {
const idx = this._findEpisodeIndex(fileKey);
if (idx >= 0) return this._cacheList[idx].qualities;
const entry = { fileKey, qualities: [] };
this._cacheList.push(entry);
return entry.qualities;
}
_findQualityUrl(qualities, q) {
for (let i = 0; i < qualities.length; i++) {
if (qualities[i].q === q) return qualities[i].r;
}
return null;
}
getCachedUrl(file, quality) {
return this.getCachedUrlByKey(fileKeyOf(file), quality);
}
getCachedUrlByKey(key, quality) {
if (!key) return null;
const idx = this._findEpisodeIndex(key);
if (idx < 0) return null;
return this._findQualityUrl(this._cacheList[idx].qualities, quality);
}
setUrlBuilder(fn) {
this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality);
}
resolveUrl(url) {
return gmFetch(url).then((text) => {
if (!text) return null;
if (text.trim().startsWith('#EXTM3U')) return url;
let json;
try { json = JSON.parse(text); } catch (_) { return null; }
log.info('resolve errno:', json.errno, json);
if (json.errno === 133 && json.adToken) return `${url}&adToken=${encodeURIComponent(json.adToken)}`;
return null;
}).catch((e) => {
log.warn('gmFetch', e?.message);
return null;
});
}
async resolveEpisodeQualities(fileKey, candidates, getUrl) {
if (!fileKey || !Array.isArray(candidates) || !candidates.length) return [];
const qualities = this._getOrCreateQualities(fileKey);
const tasks = candidates.map(async (q) => {
const cached = this._findQualityUrl(qualities, q);
if (cached) return { q, r: cached };
const url = getUrl?.('M3U8_AUTO_' + q);
if (!url) return null;
if (this._pendingRequests.has(url)) {
await this._pendingRequests.get(url);
const cached2 = this._findQualityUrl(qualities, q);
return cached2 ? { q, r: cached2 } : null;
}
const promise = this.resolveUrl(url);
this._pendingRequests.set(url, promise);
try {
const r = await promise;
if (r) {
qualities.push({ q, r });
return { q, r };
}
return null;
} finally {
this._pendingRequests.delete(url);
}
});
const results = (await Promise.all(tasks)).filter(Boolean);
results.sort((a, b) => b.q - a.q);
return results;
}
clearUrlCache() {
this._cacheList = [];
this._pendingRequests.clear();
this._retries = {};
}
exportCacheAsJson() {
return this._cacheList.map((entry) => ({
fileKey: entry.fileKey,
qualities: [...entry.qualities].sort((a, b) => b.q - a.q),
}));
}
importCacheFromJson(arr) {
if (!Array.isArray(arr)) return;
const next = [];
for (const entry of arr) {
if (!entry || !entry.fileKey || !Array.isArray(entry.qualities)) continue;
const qualities = entry.qualities
.filter((qe) => typeof qe.q === 'number' && typeof qe.r === 'string')
.map((qe) => ({ q: qe.q, r: qe.r }))
.sort((a, b) => b.q - a.q);
next.push({ fileKey: entry.fileKey, qualities });
}
this._cacheList = next;
}
_scheduleBackoff(action, opt, ownerHls) {
const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {};
if (attempts >= maxAttempts) {
if (onGiveUp) safe(onGiveUp, 'backoff.giveUp');
return false;
}
const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs);
const timerId = setTimeout(() => {
this._backoffTimers.delete(timerId);
if (ownerHls && ownerHls !== this.instance) return;
safe(action, 'backoff.action');
}, delay);
this._backoffTimers.add(timerId);
return true;
}
_clearBackoffTimers() {
this._backoffTimers.forEach((id) => clearTimeout(id));
this._backoffTimers.clear();
}
create(url, video, fileKey, onGiveUpNetwork) {
if (!Hls.isSupported()) {
if (video?.canPlayType?.('application/vnd.apple.mpegurl')) {
video.src = url;
return null;
}
showTip('浏览器不支持视频播放');
return null;
}
this._fileKey = fileKey || 'default';
this._retries[this._fileKey] = 0;
const hls = new Hls({ ...CONFIG.hls, fetchSetup: HLS_FETCH_SETUP });
hls.on(Hls.Events.ERROR, (_, data) => {
if (this.instance !== hls || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === 'manifestParsingError') {
showTip('视频地址无效');
return;
}
const retries = this._retries[this._fileKey] || 0;
this._retries[this._fileKey] = retries + 1;
const MAX = CONFIG.retry.hlsNetworkMaxAttempts;
this._scheduleBackoff(
() => { if (this.instance === hls) hls.startLoad(); },
{
attempts: retries,
maxAttempts: MAX,
baseMs: CONFIG.retry.hlsNetworkBaseMs,
maxMs: CONFIG.retry.hlsNetworkMaxMs,
onGiveUp: onGiveUpNetwork,
},
hls,
);
showTip(`网络错误,指数重试 ${retries + 1}/${MAX}…`);
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
showTip('播放失败,请刷新重试');
}
});
hls.loadSource(url);
hls.attachMedia(video);
this.instance = hls;
return hls;
}
destroy() {
if (this._disposed) return;
this._clearBackoffTimers();
if (!this.instance) return;
const hls = this.instance;
this.instance = null;
safe(() => {
hls.stopLoad();
hls.detachMedia();
hls.destroy();
}, 'HlsController.destroy');
}
dispose() {
this.destroy();
super.dispose();
}
}
/* ============================================================
* 画质控制
* ============================================================ */
class QualityController extends Disposable {
constructor(hlsController) {
super();
this.hls = hlsController;
this.list = [];
this.getUrl = null;
this._lastResolution = 0;
this._switchState = null;
this._art = null;
this._qualityHandler = null;
}
async build(resolution, getUrl) {
this.getUrl = getUrl;
const match = resolution?.match?.(/width:(\d+),height:(\d+)/);
let videoHeight = match ? +match[2] : 0;
if (!videoHeight && this._lastResolution) videoHeight = this._lastResolution;
const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a);
// 根据视频实际分辨率过滤:只保留不超过视频高度的最高画质
let candidates = sortedDesc;
if (videoHeight > 0) {
this._lastResolution = videoHeight;
const startIdx = sortedDesc.findIndex((q) => videoHeight >= q);
if (startIdx >= 0) candidates = sortedDesc.slice(startIdx);
}
const fileKey = this.hls.fileKey;
const resolvedList = await this.hls.resolveEpisodeQualities(fileKey, candidates, getUrl);
if (!resolvedList.length) {
this.list = [];
return;
}
this.list = resolvedList.map((item, i) => ({
html: CONFIG.qualityTemplates[item.q],
url: item.r,
default: i === 0,
}));
}
apply(art) {
if (art && this.list?.length) art.quality = this.list;
}
bind(art) {
if (!art || this._qualityHandler) return;
this._art = art;
this._qualityHandler = async (url) => {
if (!url) return;
const newUrl = await this.hls.resolveUrl(url);
if (!newUrl) {
showTip('画质切换失败');
return;
}
if (this.hls.instance && this.hls.instance.url === newUrl) return;
this._switchState = {
currentTime: art.currentTime,
volume: art.video.volume,
muted: art.video.muted,
playing: !art.video.paused,
};
this.hls.destroy();
safe(() => {
art.video.pause();
art.video.removeAttribute('src');
art.video.load();
}, 'quality.resetVideo');
const newHls = this.hls.create(newUrl, art.video, this.hls.fileKey, () => showTip('画质切换失败'));
if (!newHls) {
this._switchState = null;
return;
}
newHls.once(Hls.Events.MANIFEST_PARSED, () => {
const state = this._switchState;
if (!state) return;
this._switchState = null;
const { currentTime, volume, muted, playing } = state;
art.video.muted = muted;
art.video.volume = volume;
art.currentTime = currentTime;
if (playing) art.video.play().catch(() => { });
});
};
art.on('quality', this._qualityHandler);
}
unbind() {
if (this._art && this._qualityHandler) {
safe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind');
}
this._art = null;
this._qualityHandler = null;
}
dispose() {
this.unbind();
super.dispose();
}
}
/* ============================================================
* 进度存储
* ============================================================ */
class ProgressSaveAgent extends Disposable {
constructor(playerRef, episodeMeta) {
super();
this.playerRef = playerRef;
this.episodeMeta = episodeMeta;
this._file = () => this.playerRef()?.file || null;
}
getKey(file = this._file()) {
return progressKey(file);
}
save(file = this._file()) {
const key = progressKey(file);
const art = this.playerRef()?.art;
if (!key || !art?.duration || art.duration <= 0) return;
const data = JSON.stringify({
currentTime: art.currentTime,
duration: art.duration,
timestamp: Date.now(),
});
storage.setItem(key, data);
this.episodeMeta.setProgress(file, art.currentTime, Date.now());
}
load() {
const file = this._file();
const art = this.playerRef()?.art;
if (!file || !art) return;
const obj = parseStoredProgress(file);
if (!obj) return;
art.currentTime = obj.currentTime;
this.episodeMeta.setProgress(file, obj.currentTime, obj.timestamp);
}
clear(file = this._file()) {
const key = progressKey(file);
if (!key) return;
safe(() => storage.removeItem(key), 'clearProgress');
this.episodeMeta.setProgress(file, null, null);
}
getSavedFor(file) {
if (!file) return null;
const entry = this.episodeMeta.get(file);
if (entry?.currentTime != null && entry.timestamp > 0) {
return { currentTime: entry.currentTime, duration: entry.duration ?? 0 };
}
return parseStoredProgress(file);
}
}
/* ============================================================
* 选集面板 — ArtPlayer 插件
* 始终返回同一实例对象;状态由内部 _state 控制
* ============================================================ */
const DRAWER_WIDTH = 280;
const DRAWER_WIDTH_NARROW = 220;
const DRAWER_NARROW_BREAKPOINT = 480;
const DRAWER_HEIGHT = 400;
const DRAWER_TRANSITION_MS = 250;
const TOP_Z = 2147483647;
const calcDrawerPosition = () => {
const width = window.innerWidth <= DRAWER_NARROW_BREAKPOINT ? DRAWER_WIDTH_NARROW : DRAWER_WIDTH;
return {
right: 0,
top: '50%',
marginTop: -DRAWER_HEIGHT / 2,
height: DRAWER_HEIGHT,
width,
};
};
const EPISODE_MENU_CSS = `
.ep-menu {
background: rgba(18, 18, 22, 0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.08);
border-right: none;
border-radius: 12px 0 0 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.65), 0 1px 0 rgba(255,255,255,.04) inset;
pointer-events: auto;
will-change: transform;
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 8px;
border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.ep-header-title {
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: .3px;
font-family: system-ui, sans-serif;
}
.ep-header-count {
color: rgba(255,255,255,.38);
font-size: 11px;
font-family: system-ui, sans-serif;
}
.ep-list {
overflow-y: auto;
padding: 6px 8px 8px;
flex: 1;
min-height: 0;
}
.ep-list::-webkit-scrollbar { width: 4px; }
.ep-list::-webkit-scrollbar-track { background: transparent; }
.ep-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.14);
border-radius: 2px;
}
.ep-list::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.28); }
.ep-item {
display: flex;
align-items: center;
min-height: 44px;
padding: 5px 10px;
gap: 8px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
box-sizing: border-box;
user-select: none;
outline: none;
}
.ep-item:focus-visible {
box-shadow: 0 0 0 2px rgba(30,144,255,.5);
background: rgba(255,255,255,.07);
}
.ep-item:hover{ background: rgba(255,255,255,.07); }
.ep-item--active { background: rgba(30,144,255,.14); }
.ep-item--active:hover { background: rgba(30,144,255,.20); }
.ep-index {
width: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.ep-num {
color: rgba(255,255,255,.3);
font-size: 12px;
font-variant-numeric: tabular-nums;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-num { color: rgba(30,144,255,.8); }
.ep-playing-bar {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.ep-playing-bar span {
display: block;
width: 3px;
border-radius: 2px;
background: #1e90ff;
animation: epBar .9s ease-in-out infinite alternate;
}
.ep-playing-bar span:nth-child(1) { height: 5px; animation-delay: 0s;}
.ep-playing-bar span:nth-child(2) { height: 12px; animation-delay: .2s; }
.ep-playing-bar span:nth-child(3) { height: 7px; animation-delay: .38s; }
@keyframes epBar {
from { transform: scaleY(.35); }
to { transform: scaleY(1); }
}
.ep-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-name {
color: rgba(255,255,255,.82);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-name {
color: #fff;
font-weight: 500;
}
.ep-meta {
display: flex;
align-items: center;
gap: 8px;
min-height: 14px;
}
.ep-duration {
color: rgba(255,255,255,.28);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-duration { color: rgba(30,144,255,.6); }
.ep-progress {
color: rgba(255,185,60,.75);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.ep-item--active .ep-progress { color: rgba(255,185,60,1); }
.art-control-prev,
.art-control-next,
.art-control-episodes {
opacity: .8;
cursor: pointer;
transition: opacity .2s;
}
.art-control-prev:hover,
.art-control-next:hover,
.art-control-episodes:hover { opacity: 1; }
div.art-control-prev.art-ep-disabled,
div.art-control-next.art-ep-disabled {
opacity: .5;
}
.artplayer-title {
position: absolute;
top: 0; left: 0; right: 0;
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0));
font-family: system-ui, -apple-system, sans-serif;
box-sizing: border-box;
transition: opacity var(--art-transition-duration, 0.2s) ease;
}
.art-title-text {
color: #fff;
font-size: 14px;
font-weight: 500;
letter-spacing: .2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
.artplayer-autonext {
position: absolute;
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
padding: 0 16px 0;
box-sizing: border-box;
animation: art-autonext-rise .2s ease-out;
}
.art-layers:not(.art-show) .artplayer-autonext {
opacity: 1 !important;
visibility: visible !important;
}
.artplayer-autonext-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px 7px 12px;
background: #3a3d42;
border: 1px solid rgba(0, 0, 0, .25);
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .06) inset;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
max-width: calc(100% - 32px);
pointer-events: auto;
}
.artplayer-autonext-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
.artplayer-autonext-title {
font-size: 12px;
font-weight: 500;
letter-spacing: .1px;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, .92);
}
.artplayer-autonext-sub {
font-size: 10.5px;
opacity: .7;
font-variant-numeric: tabular-nums;
letter-spacing: .1px;
color: rgba(255, 255, 255, .82);
}
.artplayer-autonext-sub b {
font-weight: 600;
color: rgba(255, 255, 255, .95);
}
.artplayer-autonext-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: 2px;
}
.artplayer-autonext-cancel {
padding: 4px 10px;
font-size: 11px;
color: rgba(255, 255, 255, .82);
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 999px;
cursor: pointer;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-cancel:hover {
background: rgba(255, 255, 255, .16);
}
.artplayer-autonext-cancel:active {
transform: scale(.96);
}
.artplayer-autonext-play {
padding: 4px 10px;
font-size: 11px;
color: #1f2328;
background: #fff;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-play:hover {
background: #f0f0f0;
}
.artplayer-autonext-play:active {
transform: scale(.96);
}
@keyframes art-autonext-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`;
function ensureEpisodeMenuStyle() {
if (document.getElementById('ep-menu-style')) return;
const style = document.createElement('style');
style.id = 'ep-menu-style';
style.textContent = EPISODE_MENU_CSS;
document.head.appendChild(style);
}
/* ============================================================
* 选集面板 — 正式 class(替代原 createEpisodeMenu 闭包)
* 状态由实例内部维护;单例通过模块级 _episodeMenu 暴露
* ============================================================ */
class EpisodeMenu extends Disposable {
constructor() {
super();
this._el = null;
this._resizeHandler = null;
this._menuLeaveHandler = null;
this._playerRef = null;
this._hooks = { onOpen: null, onClose: null };
}
_getPlayer() {
return this._playerRef || null;
}
_buildItem(player, f, i, idx) {
const isCurrent = i === idx;
const name = getFileName(f);
const key = fileKeyOf(f);
const cachedDur = player.episodeMeta.getDuration(f);
const saved = player.episodeMeta.get(f);
const item = document.createElement('div');
item.className = `ep-item${isCurrent ? ' ep-item--active' : ''}`;
item.dataset.epKey = String(key);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(isCurrent));
item.setAttribute('tabindex', '0');
const indexEl = document.createElement('div');
indexEl.className = 'ep-index';
const indexInner = document.createElement('span');
indexInner.className = isCurrent ? 'ep-playing-bar' : 'ep-num';
if (isCurrent) {
indexInner.innerHTML = '';
} else {
indexInner.textContent = String(i + 1);
}
indexEl.appendChild(indexInner);
const textEl = document.createElement('div');
textEl.className = 'ep-text';
const nameEl = document.createElement('div');
nameEl.className = 'ep-name';
nameEl.textContent = name;
nameEl.title = name;
const metaEl = document.createElement('div');
metaEl.className = 'ep-meta';
const durEl = document.createElement('span');
durEl.className = 'ep-duration';
durEl.textContent = cachedDur != null ? formatTime(cachedDur) : '';
metaEl.appendChild(durEl);
if (saved && saved.currentTime != null && saved.duration > 0) {
const progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
metaEl.appendChild(progressEl);
}
textEl.appendChild(nameEl);
textEl.appendChild(metaEl);
item.appendChild(indexEl);
item.appendChild(textEl);
const handleClick = () => {
this.close();
if (i !== idx) player.switchVideo(f);
};
item.addEventListener('click', handleClick);
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
});
return item;
}
_applyPosition() {
if (!this._el) return;
const pos = calcDrawerPosition();
const { style } = this._el;
style.right = pos.right + 'px';
style.top = pos.top;
style.bottom = 'auto';
style.marginTop = pos.marginTop + 'px';
style.height = pos.height + 'px';
style.width = pos.width + 'px';
}
open(player) {
const target = player || this._getPlayer();
if (!target) return;
if (this._el) this.close();
const idx = target.getCurrentIndex();
const pos = calcDrawerPosition();
const menu = document.createElement('div');
menu.className = 'ep-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', '选集列表');
menu.style.cssText = `position:fixed;right:${pos.right}px;top:${pos.top};margin-top:${pos.marginTop}px;height:${pos.height}px;width:${pos.width}px;z-index:${TOP_Z};transform:translateX(100%);transition:transform ${DRAWER_TRANSITION_MS}ms cubic-bezier(.22,.61,.36,1);`;
menu.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
}, { passive: false });
const header = document.createElement('div');
header.className = 'ep-header';
header.innerHTML = ``;
menu.appendChild(header);
const list = document.createElement('div');
list.className = 'ep-list';
list.setAttribute('role', 'listbox');
const fragment = document.createDocumentFragment();
target.filelist.forEach((f, i) => fragment.appendChild(this._buildItem(target, f, i, idx)));
list.appendChild(fragment);
menu.appendChild(list);
const host =
target?.art?.template?.$player ||
document.querySelector('#artplayer') ||
document.body;
host.appendChild(menu);
menu.style.pointerEvents = 'auto';
this._el = menu;
this._playerRef = target;
requestAnimationFrame(() => {
if (this._el !== menu) return;
menu.style.transform = 'translateX(0)';
const active = list.querySelector('.ep-item--active');
if (active) {
active.focus();
active.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
this._menuLeaveHandler = () => {
if (this._el === menu) this.close();
};
menu.addEventListener('mouseleave', this._menuLeaveHandler);
this._resizeHandler = debounce(() => this._applyPosition(), 100);
window.addEventListener('resize', this._resizeHandler);
target.episodeMeta.prefetch(target.filelist);
if (typeof this._hooks.onOpen === 'function') this._hooks.onOpen();
}
close() {
const wasOpen = !!this._el;
const el = this._el;
if (!el) return;
if (this._menuLeaveHandler) {
safe(() => el.removeEventListener('mouseleave', this._menuLeaveHandler), 'menu.removeListener');
}
const finalize = once(() => {
el.removeEventListener('transitionend', finalize);
el.remove();
});
el.addEventListener('transitionend', finalize);
setTimeout(finalize, DRAWER_TRANSITION_MS + 50);
el.style.transform = 'translateX(100%)';
this._el = null;
this._menuLeaveHandler = null;
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
this._playerRef = null;
if (wasOpen && typeof this._hooks.onClose === 'function') this._hooks.onClose();
}
toggle(player) {
if (this._el) this.close();
else this.open(player);
}
isOpen() {
return !!this._el;
}
updateDuration(file, sec) {
if (!this._el || !file) return;
const key = fileKeyOf(file);
const escapedKey = CSS.escape(String(key));
const el = this._el.querySelector(`[data-ep-key="${escapedKey}"] .ep-duration`);
if (el) el.textContent = formatTime(sec);
}
updateActiveState(idx) {
if (!this._el) return;
const items = this._el.querySelectorAll('.ep-item');
items.forEach((item, i) => {
const isActive = i === idx;
item.classList.toggle('ep-item--active', isActive);
item.setAttribute('aria-selected', String(isActive));
const indexWrap = item.querySelector('.ep-index');
if (!indexWrap) return;
const existing = indexWrap.firstElementChild;
if (isActive) {
if (!existing || !existing.classList.contains('ep-playing-bar')) {
if (existing) existing.remove();
const bar = document.createElement('span');
bar.className = 'ep-playing-bar';
bar.innerHTML = '';
indexWrap.appendChild(bar);
}
} else {
if (!existing || !existing.classList.contains('ep-num')) {
if (existing) existing.remove();
const num = document.createElement('span');
num.className = 'ep-num';
num.textContent = String(i + 1);
indexWrap.appendChild(num);
}
}
});
}
updateItemProgress(file) {
if (!this._el || !file) return;
const player = this._getPlayer();
if (!player) return;
const key = fileKeyOf(file);
const escapedKey = CSS.escape(String(key));
const item = this._el.querySelector(`[data-ep-key="${escapedKey}"]`);
if (!item) return;
const saved = player.episodeMeta.get(file);
const metaEl = item.querySelector('.ep-meta');
if (!metaEl) return;
let progressEl = metaEl.querySelector('.ep-progress');
if (!saved || saved.currentTime == null || !saved.duration || saved.duration <= 0) {
if (progressEl) progressEl.remove();
return;
}
if (!progressEl) {
progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
metaEl.appendChild(progressEl);
}
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
}
setHooks({ onOpen, onClose } = {}) {
this._hooks.onOpen = onOpen || null;
this._hooks.onClose = onClose || null;
}
destroy() {
this.close();
}
dispose() {
this.destroy();
super.dispose();
}
}
const _episodeMenu = new EpisodeMenu();
const getEpisodeMenu = () => _episodeMenu;
/* ============================================================
* 选集面板 — 按钮注入插件
* 负责在 artplayer controls 注入"选集"按钮,调用 ensureEpisodeMenuStyle()。
* 面板本身由 _episodeMenu 单例管理。
* ============================================================ */
const EPISODE_MENU_PLUGIN_NAME = 'episodeMenu';
function episodeMenuPlugin(options = {}) {
return function installEpisodeMenu(art) {
ensureEpisodeMenuStyle();
const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null);
art.controls.add({
name: EPISODE_MENU_PLUGIN_NAME,
position: 'right',
html: '选集',
tooltip: '选集',
style: { padding: '0 10px', fontSize: '14px' },
click: () => {
if (_episodeMenu.isOpen()) _episodeMenu.close();
else _episodeMenu.open(getPlayer());
},
});
return {
name: EPISODE_MENU_PLUGIN_NAME,
open: () => _episodeMenu.open(getPlayer()),
close: () => _episodeMenu.close(),
toggle: () => (getPlayer() && _episodeMenu.toggle(getPlayer())),
isOpen: () => _episodeMenu.isOpen(),
setHooks: (hooks) => _episodeMenu.setHooks(hooks),
updateDuration: (f, sec) => _episodeMenu.updateDuration(f, sec),
updateActiveState: (i) => _episodeMenu.updateActiveState(i),
updateItemProgress: (f) => _episodeMenu.updateItemProgress(f),
destroy: () => _episodeMenu.destroy(),
};
};
}
/* ============================================================
* 选集上/下一集 Nav 插件
* ============================================================ */
function episodeNavPlugin(options = {}) {
return function installEpisodeNav(art) {
const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null);
const prev = art.controls.add({
name: 'prev',
position: 'left',
index: 5,
html: makeSvg(ICONS.prev),
tooltip: '上一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const player = getPlayer();
if (!player) return;
const idx = player.getCurrentIndex();
if (idx > 0) {
player.switchVideo(player.filelist[idx - 1]);
}
},
});
const next = art.controls.add({
name: 'next',
position: 'left',
index: 15,
html: makeSvg(ICONS.next),
tooltip: '下一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const player = getPlayer();
if (!player) return;
const idx = player.getCurrentIndex();
const len = player.filelist?.length || 0;
if (idx >= 0 && idx < len - 1) {
player.switchVideo(player.filelist[idx + 1]);
}
},
});
function setEnabled(el, enabled) {
if (!el) return;
if (enabled) {
el.classList.remove('art-ep-disabled');
el.removeAttribute('aria-disabled');
el.style.opacity = '';
} else {
el.classList.add('art-ep-disabled');
el.setAttribute('aria-disabled', 'true');
el.style.opacity = '0.5';
}
}
const resolveEl = (name, captured) => {
if (captured && captured instanceof HTMLElement && captured.isConnected) return captured;
const fromArt = art.controls && art.controls[name];
if (fromArt instanceof HTMLElement && fromArt.isConnected) return fromArt;
const root = art.template && art.template.$controls;
if (!root) return null;
const found = root.querySelector(`div.art-control-${name}`);
return found instanceof HTMLElement ? found : null;
};
const refreshFromPlayer = (player) => {
const idx = player.getCurrentIndex();
const len = player.filelist?.length || 0;
setEnabled(resolveEl('prev', prev), idx > 0);
setEnabled(resolveEl('next', next), idx >= 0 && idx < len - 1);
};
const scheduleRefresh = () => {
const player = getPlayer();
if (!player) return;
refreshFromPlayer(player);
requestAnimationFrame(() => refreshFromPlayer(player));
setTimeout(() => refreshFromPlayer(player), 400);
};
return {
name: 'episodeNav',
refresh() {
scheduleRefresh();
},
destroy() {
// artplayer 内部 on destroy 会自动清理 controls
},
};
};
}
/* ============================================================
* 自动切下一集控制器
* ============================================================ */
class AutoNextController extends Disposable {
constructor(artRef) {
super();
this.artRef = artRef;
this.state = AutoNextState.IDLE;
this._token = 0;
this._ticker = null;
this._visible = false;
this._resizeObs = null;
this._layerName = null;
this._onCancelCb = null;
this._onCompleteCb = null;
}
get visible() {
return this._visible;
}
get isTerminal() {
return this.state === AutoNextState.CANCELLED || this.state === AutoNextState.COMPLETED;
}
_setState(s) {
if (this.state === s) return;
this.state = s;
}
_cancelTicker() {
if (this._ticker) {
clearInterval(this._ticker);
this._ticker = null;
}
}
_disconnectResize() {
if (this._resizeObs) {
safe(() => this._resizeObs.disconnect(), 'AutoNext.disconnect');
this._resizeObs = null;
}
}
cancel() {
this._cancelTicker();
}
show({ name, seconds, onCancel, onComplete }) {
if (this._disposed) return;
const art = this.artRef();
if (!art) return;
this.hideCountdown();
this._token++;
const token = this._token;
this._setState(AutoNextState.SHOWN);
this._onCancelCb = onCancel;
this._onCompleteCb = onComplete;
const EMBED = 15;
const $ctrl = art?.template?.$controls || art?.controls?.$controls;
const ctrlH = Math.max(40, $ctrl?.getBoundingClientRect?.().height || 40);
const bottomPx = Math.round(ctrlH + EMBED);
this._visible = true;
art.controls.show = true;
art.layers.show = true;
let remaining = seconds;
const layerName = `autoNextCountdown_${token}`;
this._layerName = layerName;
const $layer = art.layers.add({
name: layerName,
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' },
mounted: ($el) => {
if (token !== this._token) return;
const $title = $el.querySelector('.artplayer-autonext-title');
const $sec = $el.querySelector('.artplayer-autonext-sec');
$title.textContent = name || '下一集';
const paint = () => {
const v = String(Math.max(remaining, 0));
if ($sec) {
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
};
paint();
this._resizeObs = new ResizeObserver(() => {
const wrap = $el?.querySelector?.('.artplayer-autonext');
if (!wrap) return;
const $ctrl2 = art?.template?.$controls || art?.controls?.$controls;
const h = Math.max(40, $ctrl2?.getBoundingClientRect?.().height || 40);
wrap.style.bottom = Math.round(h + EMBED) + 'px';
});
safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe');
$el.querySelector('.artplayer-autonext-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (token !== this._token) return;
if (this.state !== AutoNextState.SHOWN) return;
this._setState(AutoNextState.CANCELLED);
safe(() => this._onCancelCb?.(), 'AutoNext.onCancel');
});
$el.querySelector('.artplayer-autonext-play')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (token !== this._token) return;
if (this.state !== AutoNextState.SHOWN) return;
this._setState(AutoNextState.COMPLETED);
safe(() => this._onCompleteCb?.({ immediate: true }), 'AutoNext.onComplete.immediate');
});
},
});
this._ticker = setInterval(() => {
if (token !== this._token) {
this._cancelTicker();
return;
}
remaining -= 1;
const $sec = $layer?.querySelector?.('.artplayer-autonext-sec');
if ($sec) {
const v = String(Math.max(remaining, 0));
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
if (remaining <= 0) {
this._cancelTicker();
if (this.state === AutoNextState.SHOWN) {
this._setState(AutoNextState.COMPLETED);
safe(() => this._onCompleteCb?.({ immediate: false }), 'AutoNext.onComplete.ticker');
}
}
}, 1000);
}
hideCountdown() {
this._cancelTicker();
this._disconnectResize();
const art = this.artRef();
const layerName = this._layerName;
this._layerName = null;
this._onCancelCb = null;
this._onCompleteCb = null;
if (!art || !layerName || !art.layers?.[layerName]) {
this._visible = false;
return;
}
safe(() => art.layers.remove(layerName), 'AutoNext.removeLayer');
this._visible = false;
}
dispose() {
this.hideCountdown();
super.dispose();
}
}
/* ============================================================
* UI 控制:控制栏隐藏 + 标题层
* 提供 hideVisor() / showVisor() 接口,
* 由 Player 决定何时调用(避免与 AutoNext 双向耦合)
* ============================================================ */
class UiController extends Disposable {
constructor(artRef) {
super();
this.artRef = artRef;
this._hideCtl = null;
this._setup = false;
this._titleEl = null;
this._titleLayerAdded = false;
this._layerSyncBound = false;
this._pinned = () => false;
}
setPinnedProbe(fn) {
this._pinned = typeof fn === 'function' ? fn : () => false;
}
_makeHideCtl() {
const art = this.artRef();
if (!art) return null;
let hideTimer = null;
const $controls = () => art?.template?.$controls;
const clearHide = () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const showNow = () => {
if (!art) return;
if (art.controls) art.controls.show = true;
art.layers.show = true;
};
const isPinned = () => this._pinned();
const scheduleHide = () => {
clearHide();
if (isPinned()) return;
hideTimer = setTimeout(() => {
hideTimer = null;
if (!art?.controls || isPinned()) return;
const el = $controls();
if (el && el.matches(':hover')) return;
art.controls.show = false;
art.layers.show = false;
}, CONFIG.hideDelayMs);
};
return { clearHide, showNow, scheduleHide };
}
setup() {
const art = this.artRef();
if (!art || this._setup) return;
this._setup = true;
this._hideCtl = this._makeHideCtl();
if (typeof art.on === 'function' && !this._layerSyncBound) {
this._layerSyncBound = true;
this._listeners.trackArtListener(art, 'control', (state) => {
if (!art) return;
if (this._pinned()) {
if (art.layers) art.layers.show = true;
return;
}
if (art.layers) art.layers.show = !!state;
});
}
this._listeners.on(document, 'mousemove', (e) => {
const el = art?.template?.$controls;
if (!el) return;
if (el.contains(e.target)) {
this._hideCtl?.clearHide();
this._hideCtl?.showNow();
} else if (!this._pinned()) {
this._hideCtl?.scheduleHide();
}
}, { passive: true });
this._listeners.on(document, 'mouseleave', () => {
if (this._pinned()) {
this._hideCtl?.clearHide();
return;
}
this._hideCtl?.scheduleHide();
}, { passive: true });
}
addTitle(file) {
const art = this.artRef();
if (!art || this._titleLayerAdded) return;
this._titleLayerAdded = true;
const initialName = file ? getFileName(file) : '';
art.layers.add({
name: 'episodeTitle',
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', pointerEvents: 'none' },
mounted: ($el) => {
this._titleEl = $el.querySelector('.art-title-text');
this._setTitleText(initialName);
},
});
this._setTitleText(initialName);
}
updateTitle(file) {
this._setTitleText(file ? getFileName(file) : '');
}
_setTitleText(name) {
const art = this.artRef();
const el = this._titleEl || art?.layers?.episodeTitle?.querySelector?.('.art-title-text');
if (el) el.textContent = name || '';
}
showNow() {
this._hideCtl?.showNow();
}
scheduleHide() {
this._hideCtl?.scheduleHide();
}
reset() {
this._titleLayerAdded = false;
this._titleEl = null;
this._setup = false;
this._layerSyncBound = false;
this._hideCtl = null;
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* 快捷键
* ============================================================ */
class HotkeyController extends Disposable {
constructor(playerRef) {
super();
this.playerRef = playerRef;
}
setup() {
const player = this.playerRef();
if (!player?.art) return;
this._listeners.on(document, 'keydown', (e) => {
const player2 = this.playerRef();
if (!player2?.art) return;
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.repeat) return;
const key = e.key.toLowerCase();
if (key === 'f') {
player2.art.fullscreen = !player2.art.fullscreen;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'w') {
player2.art.fullscreenWeb = !player2.art.fullscreenWeb;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'm') {
player2.art.muted = !player2.art.muted;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'p') {
const idx = player2.getCurrentIndex();
if (idx > 0) player2.switchVideo(player2.filelist[idx - 1]);
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'n') {
const idx = player2.getCurrentIndex();
if (idx >= 0 && idx < (player2.filelist?.length || 0) - 1) player2.switchVideo(player2.filelist[idx + 1]);
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
}
}, true);
}
reset() {
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* 触摸手势控制器:水平滑动调整播放进度(移动端)
* - 单指水平滑动 → 在视频时长内按比例 seek
* - 实时显示目标时间,touchend 时应用并阻止合成 click
* ============================================================ */
class TouchGestureController extends Disposable {
constructor(playerRef) {
super();
this.playerRef = playerRef;
}
setup() {
const player = this.playerRef();
const art = player?.art;
const video = art?.video;
if (!art || !video) return;
const MOVE_THRESHOLD = 10; // 触发滑动的最小位移
const DIRECTION_RATIO = 1.5; // 水平分量需为垂直的 1.5 倍才判定为水平滑动
const SEEK_RATIO = 0.5; // 屏宽对应 50% 时长
let startX = 0, startY = 0;
let startCurrent = 0, duration = 0;
let seeking = false;
let lastTarget = 0;
let suppressClickUntil = 0;
this._listeners.on(video, 'touchstart', (e) => {
if (!e.touches || e.touches.length !== 1) return;
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
startCurrent = art.currentTime || 0;
duration = art.duration || 0;
seeking = false;
lastTarget = startCurrent;
}, { passive: true });
this._listeners.on(video, 'touchmove', (e) => {
if (!e.touches || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!seeking) {
if (Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) return;
if (Math.abs(dx) < Math.abs(dy) * DIRECTION_RATIO) return;
seeking = true;
}
if (e.cancelable) e.preventDefault();
const rect = video.getBoundingClientRect();
const w = rect.width || video.clientWidth || 1;
if (w <= 0) return;
const ratio = Math.max(-1, Math.min(1, dx / w));
const delta = ratio * duration * SEEK_RATIO;
let target = startCurrent + delta;
if (target < 0) target = 0;
if (duration > 0 && target > duration) target = duration;
lastTarget = target;
try { art.notice.show = `${formatTime(target)} / ${formatTime(duration)}`; } catch (_) {}
}, { passive: false });
const endHandler = () => {
if (!seeking) return;
seeking = false;
suppressClickUntil = Date.now() + 400;
try {
if (Number.isFinite(lastTarget) && Math.abs(lastTarget - (art.currentTime || 0)) > 0.5) {
art.currentTime = lastTarget;
}
art.notice.show = '';
} catch (_) {}
};
this._listeners.on(video, 'touchend', endHandler, { passive: false });
this._listeners.on(video, 'touchcancel', endHandler, { passive: false });
// 抑制滑动后合成的 click(避免被 Artplayer 当成播放/暂停)
this._listeners.on(video, 'click', (e) => {
if (Date.now() < suppressClickUntil) {
e.preventDefault?.();
e.stopPropagation?.();
e.stopImmediatePropagation?.();
}
}, { capture: true });
}
reset() {
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* AutoNext 调度器:监听 video:ended → 触发倒计时
* - 由 Player 持有,独立生命周期
* - 显式 token + 状态机防止取消混乱
* ============================================================ */
class AutoNextScheduler extends Disposable {
constructor(player) {
super();
this.player = player;
this._artListenerToken = null;
}
attach(art) {
if (this._disposed) return;
const handler = (ev) => this._onEnded(ev);
art.on('video:ended', handler);
this._artListenerToken = { art, handler };
}
_onEnded() {
const player = this.player;
if (!player || player._disposed) return;
if (!player._autoNext || player._autoNext._disposed) {
player._autoNext = player._track(new AutoNextController(() => player.art));
player._ui.setPinnedProbe(() => player._autoNext?.visible || getEpisodeMenu().isOpen());
}
const art = player.art;
if (!art) return;
player.progress.clear();
const idx = player.getCurrentIndex();
const listLen = player.filelist?.length || 0;
if (idx < 0 || idx >= listLen - 1) return;
const next = player.filelist[idx + 1];
const nextName = getFileName(next);
const tokenAtEnd = player._switchToken;
const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click'];
const clearAutoNextTimer = () => {
if (player._autoNextTimer != null) {
player._listeners.clearTimeout(player._autoNextTimer);
player._autoNextTimer = null;
}
};
const cancelOnSeek = () => {
if (art._disposed || player._disposed) return;
player._cancelAutoNext();
clearAutoNextTimer();
cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek));
safe(() => art.play(), 'cancelOnSeek.play');
};
const switchNow = () => {
if (player._disposed) return;
player._autoNext?.hideCountdown();
clearAutoNextTimer();
cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek));
if (player._switchToken !== tokenAtEnd) return;
player.switchVideo(next);
};
player._autoNext.show({
name: nextName,
seconds: CONFIG.countdownSec,
onCancel: () => cancelOnSeek(),
onComplete: () => switchNow(),
});
player._autoNextTimer = player._listeners.setTimeout(() => switchNow(), CONFIG.countdownSec * 1000);
cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek));
}
dispose() {
if (this._artListenerToken) {
safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNextScheduler.detach');
this._artListenerToken = null;
}
super.dispose();
}
}
/* ============================================================
* Player 协调器
* - 持有所有子系统,统一通过 Disposable 链释放
* - 调度:路由 / 切换 / 进度 / 倒计时
* ============================================================ */
class Player extends Disposable {
constructor() {
super();
this.art = null;
this.file = null;
this.filelist = [];
this.flag = '';
this.nativeVideoNode = null;
this.getUrl = null;
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this._autoNext = null;
this._autoNextTimer = null;
this._autoNextScheduler = null;
this._episodeControlsAdded = false;
this._initialized = false;
this._epMenuUnsubs = [];
this._track(this.hls = new HlsController());
this._track(this.quality = new QualityController(this.hls));
this._track(this.episodeMeta = new EpisodeMeta());
this._track(this.progress = new ProgressSaveAgent(() => this, this.episodeMeta));
this._track(this._ui = new UiController(() => this.art));
this._track(this._hotkey = new HotkeyController(() => this));
this._track(this._touchGesture = new TouchGestureController(() => this));
this._track(getEpisodeMenu());
this._epMenuUnsubs = [];
this._epMenuUnsubs.push(this.episodeMeta.events.on('progress', ({ file }) => {
if (!file) return;
getEpisodeMenu().updateItemProgress(file);
}));
this._epMenuUnsubs.push(this.episodeMeta.events.on('duration', ({ file, duration }) => {
if (!file || !Number.isFinite(duration)) return;
getEpisodeMenu().updateDuration(file, Math.ceil(duration));
}));
}
getCurrentIndex() {
const { file, filelist } = this;
if (!file || !filelist?.length) return -1;
return filelist.findIndex(
(f) =>
(f.fs_id && file.fs_id && f.fs_id == file.fs_id) ||
(f.path && file.path && f.path === file.path),
);
}
_getFolderKey() {
if (this.flag === 'sharevideo') {
const locals = unsafeWindow.locals;
const shareid = safe(() => typeof locals.get === 'function' ? locals.get('shareid') : locals?.shareid);
if (shareid) return `share_${shareid}`;
}
if (this.flag === 'video' || this.flag === 'playvideo') {
try {
const sp = new URLSearchParams(window.location.search);
const p = sp.get('path');
if (p) return `path_${p}`;
} catch (_) { /* URL parse fail */ }
}
return null;
}
pickResumeIndex() {
const list = this.filelist;
const folderKey = this._getFolderKey();
if (!folderKey || !Array.isArray(list) || !list.length) return null;
try {
const raw = storage.getRawItem('last_episode');
if (!raw) return null;
const obj = JSON.parse(raw);
const savedEp = obj?.[folderKey];
if (Number.isInteger(savedEp) && savedEp >= 0 && list.length > savedEp) return savedEp;
} catch (e) {
log.warn('pickResumeIndex', e);
}
return null;
}
_saveLastEpisode(epIdx) {
if (!Number.isInteger(epIdx) || epIdx < 0) return;
const folderKey = this._getFolderKey();
if (!folderKey) return;
try {
const raw = storage.getRawItem('last_episode');
const obj = raw ? JSON.parse(raw) : {};
obj[folderKey] = epIdx;
storage.setItem('last_episode', JSON.stringify(obj));
} catch (e) {
log.warn('_saveLastEpisode', e);
}
}
switchVideo(file) {
if (!file) return;
this._switchCtrl.submit(() => this._performSwitch(file));
}
async _performSwitch(file) {
this._cancelAutoNext();
const prevFile = this.file;
if (prevFile) this.progress.save(prevFile);
if (!this.art) return;
const wasFullscreen = !!this.art.fullscreen;
const wasFullscreenWeb = !!this.art.fullscreenWeb;
const volume = this.art.volume ?? 1;
this.file = file;
const token = ++this._switchToken;
const cardNodes = document.getElementsByClassName('vp-video-page-card__video-detail');
const title = getFileName(file);
const match = Array.from(cardNodes).find((n) => (n.textContent || '').includes(title));
if (match) match.click();
const newEpIdx = this.getCurrentIndex();
if (newEpIdx >= 0) this._saveLastEpisode(newEpIdx);
this.getUrl = this.flag === 'sharevideo'
? buildShareUrl(file)
: this.flag === 'mboxvideo'
? buildMboxUrl(file)
: buildFileUrl(file);
if (!this.getUrl) return;
this.hls.setUrlBuilder(this.getUrl);
await this.quality.build(file.resolution, this.getUrl);
if (!this.quality.list?.length) {
showTip('无法获取视频地址');
return;
}
if (token !== this._switchToken) return;
const resolvedUrl = this.quality.list[0].url;
if (!resolvedUrl) {
showTip(`无法播放: ${getFileName(file)}`);
return;
}
this.hls.destroy();
safe(() => {
this.art.video.pause();
this.art.video.removeAttribute('src');
this.art.video.load();
}, 'switch.resetVideo');
const hls = this.hls.create(resolvedUrl, this.art.video, fileKeyOf(file), () => showTip('网络持续错误,请刷新页面'));
if (!hls || token !== this._switchToken) return;
this.quality.apply(this.art);
this.quality.bind(this.art);
this.refreshControlDisplay();
getEpisodeMenu().updateActiveState(this.getCurrentIndex());
this._ui.updateTitle(file);
this._bindHlsDuration(hls, file);
try {
await this._waitForVideo(this.art.video, hls, token);
} catch (e) {
if (e?.message === 'stale') return;
log.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
const video = this.art.video;
video.muted = false;
video.volume = volume;
safe(() => {
if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true;
if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true;
}, 'restoreFullscreen');
this.progress.load();
log.announce(file);
video.play().catch(() => { });
}
_bindHlsDuration(hls, file) {
if (!hls || !file) return;
const key = fileKeyOf(file);
if (!key) return;
const handler = (_, data) => {
const dur = data?.details?.totalduration;
// 仅作兜底:仅在真实 duration 尚未验证时写入
if (dur && Number.isFinite(dur) && dur > 0
&& !this.episodeMeta.isDurationVerified(file)) {
this.episodeMeta.setDuration(file, dur, false);
}
};
hls.once(Hls.Events.LEVEL_LOADED, handler);
}
_waitForVideo(video, hls, token) {
return new Promise((resolve, reject) => {
const finish = once((err) => {
clearTimeout(timer);
err ? reject(err) : resolve();
});
const timer = setTimeout(() => finish(new Error('timeout')), 8000);
const onParsed = () => {
if (token !== this._switchToken) {
finish(new Error('stale'));
return;
}
if (video?.readyState >= 1) finish();
else video?.addEventListener('loadedmetadata', () => finish(), { once: true });
};
hls.once(Hls.Events.MANIFEST_PARSED, onParsed);
});
}
_cancelAutoNext() {
this._autoNext?.hideCountdown();
this._ui.scheduleHide();
}
refreshControlDisplay() {
this.art?.plugins?.episodeNav?.refresh?.();
}
addEpisodeControls() {
if (this._episodeControlsAdded) return;
this._episodeControlsAdded = true;
this.episodeMeta.prefetch(this.filelist);
this.refreshControlDisplay();
this._ui.addTitle(this.file);
}
_setupAutoNext(art) {
if (!art) return;
if (!this._autoNext) {
this._autoNext = this._track(new AutoNextController(() => this.art));
this._ui.setPinnedProbe(() => this._autoNext?.visible || getEpisodeMenu().isOpen());
}
if (!this._autoNextScheduler || this._autoNextScheduler._disposed) {
this._autoNextScheduler = this._track(new AutoNextScheduler(this));
}
this._autoNextScheduler.attach(art);
}
_teardown() {
if (this._disposed) return;
this._cancelAutoNext();
this._autoNextTimer = null;
this._episodeControlsAdded = false;
this._initialized = false;
if (this._epMenuUnsubs?.length) {
this._epMenuUnsubs.forEach((un) => { try { un(); } catch (_) { } });
this._epMenuUnsubs = [];
}
getEpisodeMenu().close();
if (this.art?.video) {
safe(() => {
this.art.video.muted = true;
this.art.video.pause();
}, 'teardown.mute');
}
if (this.art) {
const art = this.art;
this.art = null;
safe(() => {
if (art.video) {
art.video.pause();
art.video.src = '';
art.video.load();
}
art.destroy(true);
}, 'teardown.destroy');
}
// 一次性清理 artplayer 事件(timeupdate / pause / seeked / destroy / visibilitychange)
// 子系统(Hls / Quality / Ui / Hotkey / EpisodeMeta / AutoNext / AutoNextScheduler)
// 已通过 _track 注册,由 super.dispose() 链式释放,无需重复调用。
this._listeners.clearAll();
}
dispose() {
this._teardown();
super.dispose();
}
async init(container) {
if (!this.getUrl) {
showTip('无法获取播放地址,请检查登录状态');
return;
}
this._teardown();
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this.hls.setUrlBuilder(this.getUrl);
await this.quality.build(this.file?.resolution, this.getUrl);
if (!this.quality.list?.length) {
showTip('无法获取播放地址,请检查登录状态');
return;
}
const resolvedUrl = this.quality.list[0].url;
if (!resolvedUrl) {
showTip('无法获取播放地址,请检查登录状态');
return;
}
this.art = new Artplayer({
container,
url: resolvedUrl,
type: 'm3u8',
customType: {
m3u8: (video, url) => {
this.hls.create(url, video, fileKeyOf(this.file), () => showTip('网络持续错误,请刷新页面'));
},
},
poster:
Object.values(this.file?.thumbs || {})
.pop()
?.replace(/size=c\d+_u\d+/, 'size=c850_u580') || '',
autoplay: true,
pip: true,
fullscreen: true,
fullscreenWeb: true,
setting: true,
quality: this.quality.list,
playbackRate: true,
aspectRatio: true,
muted: false,
volume: 1,
hotkey: true,
icons: {
loading: '
',
state: '
',
indicator: '
',
},
moreVideoAttr: { crossOrigin: 'anonymous', preload: 'auto' },
plugins: [
episodeMenuPlugin({ getPlayer: () => this }),
episodeNavPlugin({ getPlayer: () => this }),
],
});
getEpisodeMenu().setHooks({
onOpen: () => this._ui.showNow(),
onClose: () => this._ui.scheduleHide(),
});
this.art.on('ready', () => {
this.destroyNativePlayer();
this.art.video.muted = false;
this.progress.load();
this.episodeMeta.preloadFromStorage(this.filelist);
this.quality.bind(this.art);
this.addEpisodeControls();
this._bindHlsDuration(this.hls.instance, this.file);
this._ui.setup();
this._hotkey.setup();
this._touchGesture.setup();
this._setupAutoNext(this.art);
log.announce(this.file);
this._initialized = true;
});
const debouncedSave = debounce(() => this.progress.save(), CONFIG.saveDebounceMs);
let lastSave = 0;
this._listeners.trackArtListener(this.art, 'video:timeupdate', () => {
if (!this.art?.currentTime || this.art.currentTime <= 0) return;
if (Date.now() - lastSave > CONFIG.saveThrottleMs) {
lastSave = Date.now();
debouncedSave();
}
});
// 视频元数据加载完成 → 写入真实 duration,覆盖 HLS 推断值
const applyRealDuration = () => {
const realDur = this.art?.duration;
if (!Number.isFinite(realDur) || realDur <= 0) return;
this.episodeMeta.setDuration(this.file, realDur, true);
};
this._listeners.trackArtListener(this.art, 'video:loadedmetadata', applyRealDuration);
this._listeners.trackArtListener(this.art, 'video:canplay', applyRealDuration);
this._listeners.trackArtListener(this.art, 'video:durationchange', applyRealDuration, { once: true });
const flushSave = () => {
if (!this.art?.currentTime || this.art.currentTime <= 0) return;
this.progress.save();
};
this._listeners.trackArtListener(this.art, 'video:pause', flushSave);
this._listeners.trackArtListener(this.art, 'video:seeked', flushSave);
this._listeners.on(document, 'visibilitychange', () => {
if (document.hidden) flushSave();
}, { passive: true });
this._listeners.trackArtListener(this.art, 'destroy', () => {
this._teardown();
});
}
destroy() {
this._teardown();
}
destroyNativePlayer() {
document.querySelectorAll('video').forEach((v) => {
if (!v.closest('#artplayer')) {
safe(() => {
v.pause();
v.muted = true;
v.src = '';
v.load();
}, 'destroyNativePlayer.video');
}
});
const pollDestroy = (getTarget) => {
let count = 0;
const id = this._listeners.setInterval(() => {
count++;
const t = getTarget();
if (t?.player) {
this._listeners.clearInterval(id);
safe(() => {
t.player.dispose();
t.player = null;
}, 'pollDestroy.dispose');
} else if (count > 30) {
this._listeners.clearInterval(id);
}
}, 300);
};
if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) {
setTimeout(() => {
unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => {
if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance);
});
}, 1000);
}
if (this.flag === 'video' && this.nativeVideoNode) {
setTimeout(() => {
pollDestroy(() => this.nativeVideoNode?.firstChild);
}, 1000);
}
}
async replacePlayer() {
const videoNode = await waitFor(() => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), {
intervalMs: 500,
maxAttempts: 20,
label: 'replacePlayer',
});
if (!videoNode) return null;
let container = document.getElementById('artplayer');
if (!container) {
container = document.createElement('div');
container.id = 'artplayer';
container.style.cssText = 'width:100%;height:100%';
videoNode.parentNode?.replaceChild(container, videoNode);
}
return container;
}
}
/* ============================================================
* 单例与生命周期
* ============================================================ */
const player = new Player();
unsafeWindow.player = player;
window.addEventListener('beforeunload', () => {
if (player._initialized) player.progress.save();
});
/* ============================================================
* 入口路由
* ============================================================ */
function probeAsideTopOp() {
const el = document.getElementsByClassName('vp-aside-box__top-operation')[0];
const hit = el?.textContent.includes('查看全部');
log.info(`vp-aside-box__top-operation ${hit ? '存在' : '找到'}`);
if (hit) el.click();
}
async function handleShare() {
const localsReady = await waitFor(() => unsafeWindow.locals, {
intervalMs: 500,
maxAttempts: 40,
label: 'handleShare.locals',
onTimeout: () => log.warn('locals等待超时,放弃初始化'),
});
if (!localsReady) return;
await new Promise((resolve) => {
let done = false;
localsReady.get(
'file_list',
'share_uk',
'shareid',
'sign',
'timestamp',
(file_list, share_uk, shareid, sign, timestamp) => {
if (done) return;
done = true;
if (!file_list?.length) { resolve(); return; }
let videoList = [];
try {
const list = unsafeWindow
.require('system-core:context/context.js')
.instanceForSystem.list.getCurrentList();
videoList = list.filter((f) => f.category === 1);
} catch (_) {
videoList = file_list.filter((f) => f.category === 1);
}
if (!videoList.length) { resolve(); return; }
player.filelist = sortByLocale(videoList);
let file = videoList[0];
const resumeEp = player.pickResumeIndex();
if (resumeEp != null) file = videoList[resumeEp];
player.flag = 'sharevideo';
player.file = file;
player.getUrl = buildShareUrl(file);
if (!player.getUrl) { resolve(); return; }
player.hls.setUrlBuilder(player.getUrl);
(async () => {
await player.quality.build(file.resolution, player.getUrl);
dumpCache(player, 'handleShare.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
resolve();
})();
},
);
});
}
async function handlePlay() {
const jqReady = await waitFor(() => unsafeWindow.jQuery, {
intervalMs: 500,
maxAttempts: 40,
label: 'handlePlay.jQuery',
onTimeout: () => log.warn('jQuery等待超时,放弃初始化'),
});
if (!jqReady) return;
let hasInit = false;
jqReady(document).ajaxComplete(async (event, xhr, options) => {
const url = options.url || '';
if (url.includes('/api/categorylist')) {
player.filelist = sortByLocale((xhr.responseJSON?.info || []).filter((f) => f.category === 1));
if (player.art) player.addEpisodeControls();
} else if (url.includes('/api/filemetas')) {
if (hasInit) return;
const info = xhr.responseJSON?.info?.[0];
if (!info) return;
const resumeEp = player.pickResumeIndex();
const list = player.filelist || [];
let file = null;
if (resumeEp != null && list[resumeEp]) {
file = list[resumeEp];
} else {
file = list.find((f) =>
(info.fs_id && f.fs_id && f.fs_id == info.fs_id) ||
(info.path && f.path && f.path === info.path)
) || info;
}
hasInit = true;
player.flag = 'playvideo';
player.file = file;
player.getUrl = buildFileUrl(file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
player.quality.build(file.resolution, player.getUrl).then(() => dumpCache(player, 'handlePlay.init'));
const container = await player.replacePlayer();
if (container) await player.init(container);
}
});
}
async function handleVideo() {
const app = document.querySelector('#app');
const pinia = await waitFor(
() =>
app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo
? app.__vue_app__.config.globalProperties.$pinia
: null,
{ intervalMs: 500, maxAttempts: 40, label: 'handleVideo.pinia' },
);
if (!pinia) return;
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.filelist = sortByLocale(list);
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && list.length > resumeEp) {
player.file = list[resumeEp];
} else {
player.file = file;
}
player.flag = 'video';
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildFileUrl(player.file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
await player.quality.build(player.file.resolution, player.getUrl);
dumpCache(player, 'handleVideo.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
}
async function handleMboxVideo() {
const app = document.querySelector('#app');
const pinia = await waitFor(
() =>
app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo
? app.__vue_app__.config.globalProperties.$pinia
: null,
{ intervalMs: 500, maxAttempts: 40, label: 'handleMboxVideo.pinia' },
);
if (!pinia) return;
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.filelist = sortByLocale(list);
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && list.length > resumeEp) {
player.file = list[resumeEp];
} else {
player.file = file;
}
player.flag = 'mboxvideo';
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildMboxUrl(player.file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
await player.quality.build(player.file.resolution, player.getUrl);
dumpCache(player, 'handleMboxVideo.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
}
if (document.readyState === 'complete') probeAsideTopOp();
else window.addEventListener('load', probeAsideTopOp, { once: true });
const ROUTE_HANDLERS = {
share: handleShare,
play: handlePlay,
video: handleVideo,
mboxvideo: handleMboxVideo,
};
const ROUTES = Object.freeze([
{ pattern: CONFIG.urls.sharePattern, handler: ROUTE_HANDLERS.share },
{ pattern: CONFIG.urls.playPattern, handler: ROUTE_HANDLERS.play },
{ pattern: CONFIG.urls.videoPattern, handler: ROUTE_HANDLERS.video },
{ pattern: CONFIG.urls.mboxvideoPattern, handler: ROUTE_HANDLERS.mboxvideo },
]);
(async () => {
if (document.readyState !== 'complete') {
await new Promise((r) => window.addEventListener('load', r, { once: true }));
}
const url = location.href;
for (const route of ROUTES) {
if (url.includes(route.pattern)) {
await route.handler();
break;
}
}
})();
})();