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