// ==UserScript==
// @name 百度网盘视频优化
// @namespace https://scriptcat.org/
// @version 4.0
// @description 基于Claude的百度网盘视频优化(免播放缓存),支持多清晰度切换、连续播放、进度记忆、键盘快捷键
// @author Claude
// @match https://pan.baidu.com/s/*
// @match https://pan.baidu.com/play/video*
// @match https://pan.baidu.com/pfile/video*
// @require https://unpkg.com/hls.js@1.6.16/dist/hls.min.js
// @require https://unpkg.com/artplayer@5.4.0/dist/artplayer.js
// @icon https://nd-static.bdstatic.com/m-static/v20-main/home/img/icon-home-new.b4083345.png
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ============================================================
* 配置常量(统一来源,便于调优)
* ============================================================ */
const CONFIG = Object.freeze({
userAgent: 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts',
hls: Object.freeze({
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 600,
}),
qualityTemplates: Object.freeze({
1080: '超清 1080P',
720: '高清 720P',
480: '流畅 480P',
360: '省流 360P',
}),
qualityLevels: [1080, 720, 480, 360],
countdownSec: 5,
hideDelayMs: 2000,
saveDebounceMs: 2000,
saveThrottleMs: 5000,
progressMinSec: 5,
progressTtlMs: 30 * 86400000,
retry: Object.freeze({
baseMs: 500,
maxMs: 10000,
hlsNetworkBaseMs: 1000,
hlsNetworkMaxMs: 10000,
hlsNetworkMaxAttempts: 3,
evictKeep: 15,
evictTrigger: 20,
}),
urls: Object.freeze({
sharePattern: '/s/',
playPattern: '/play/video',
videoPattern: '/pfile/video',
}),
logPrefix: '[BDPlayer]',
});
const HLS_FETCH_SETUP = () => ({ headers: { 'User-Agent': CONFIG.userAgent } });
const ICONS = Object.freeze({
prev: '',
next: '',
episodes: '',
});
/* ============================================================
* 错误码定义
* ============================================================ */
const ERROR_CODES = Object.freeze({
PLAYER_INIT: { code: 1001, msg: '播放器初始化失败' },
PLAYER_NOT_READY: { code: 1002, msg: '播放器未就绪' },
NETWORK_FAILED: { code: 2001, msg: '网络请求失败' },
NETWORK_TIMEOUT: { code: 2002, msg: '网络请求超时' },
NETWORK_AUTH: { code: 2003, msg: '认证失败,请刷新页面重新登录' },
HLS_NOT_SUPPORTED: { code: 3001, msg: '浏览器不支持视频播放' },
HLS_MANIFEST: { code: 3002, msg: '视频地址无效' },
HLS_NETWORK: { code: 3003, msg: '网络错误,尝试重新加载...' },
HLS_MEDIA: { code: 3004, msg: '媒体错误,尝试恢复...' },
STORAGE_QUOTA: { code: 4001, msg: '存储空间已满,自动清理中...' },
});
const getErrorMsg = (code, fallback) => {
const err = Object.values(ERROR_CODES).find(e => e.code === code);
return err ? err.msg : fallback;
};
/* ============================================================
* 工具函数
* ============================================================ */
const makeSvg = (inner) =>
``;
const once = (fn) => {
let called = false;
return (...args) => {
if (called) return;
called = true;
fn(...args);
};
};
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delay);
};
};
const log = {
info: (...args) => console.info(CONFIG.logPrefix, ...args),
warn: (...args) => console.warn(CONFIG.logPrefix, ...args),
error: (...args) => console.error(CONFIG.logPrefix, ...args),
};
const safe = (fn, label = '') => {
try {
return fn();
} catch (e) {
if (label) log.warn(`safe:${label}`, e);
return undefined;
}
};
const trySafe = (fn, label = '') => {
try {
return fn();
} catch (e) {
if (label) log.warn(`safe:${label}`, e);
return undefined;
}
};
async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout } = {}) {
for (let i = 0; i < maxAttempts; i++) {
try {
const v = fn();
if (v) return v;
} catch (e) {
log.warn('waitFor.poll', e);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
if (onTimeout) trySafe(onTimeout, 'waitFor.timeout');
return null;
}
/** 异步串行化控制器:丢弃过期请求,只执行最后一次 */
class SwitchController {
constructor() {
this._running = false;
this._pending = null;
}
submit(fn) {
this._pending = fn;
this._drain();
}
async _drain() {
if (this._running) return;
while (this._pending) {
const fn = this._pending;
this._pending = null;
this._running = true;
try {
await fn();
} catch (e) {
log.error('SwitchController', e);
} finally {
this._running = false;
}
}
}
get isRunning() {
return this._running;
}
}
/** 集中管理所有事件监听器与定时器,destroy 时一次性清理,避免泄漏 */
class ListenerBag {
constructor() {
this._listeners = [];
this._timers = new Set();
this._intervals = new Set();
}
on(target, type, fn, options) {
target.addEventListener(type, fn, options);
this._listeners.push({ target, type, fn, options });
}
once(target, type, fn, options) {
const wrapped = (...args) => {
this.off(target, type, fn);
fn(...args);
};
target.addEventListener(type, wrapped, options);
this._listeners.push({ target, type, fn: wrapped, options });
return wrapped;
}
off(target, type, fn) {
target.removeEventListener(type, fn);
}
setTimeout(fn, delay) {
const id = setTimeout(() => {
this._timers.delete(id);
fn();
}, delay);
this._timers.add(id);
return id;
}
clearTimeout(id) {
if (id != null) {
clearTimeout(id);
this._timers.delete(id);
}
}
setInterval(fn, delay) {
const id = setInterval(fn, delay);
this._intervals.add(id);
return id;
}
clearInterval(id) {
if (id != null && this._intervals.has(id)) {
clearInterval(id);
this._intervals.delete(id);
}
}
clearAll() {
this._listeners.forEach(({ target, type, fn, options }) => {
try {
target.removeEventListener(type, fn, options);
} catch (e) {
/* target 已卸载 */
}
});
this._listeners = [];
this._timers.forEach((id) => clearTimeout(id));
this._timers.clear();
this._intervals.forEach((id) => clearInterval(id));
this._intervals.clear();
}
}
const base64Encode = (str) => {
try {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
String.fromCharCode(parseInt(p1, 16))));
} catch (e) {
return null;
}
};
/* ============================================================
* 存储抽象:GM_* 优先,回落 localStorage
* ============================================================ */
const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const storage = {
getItem(key) {
try {
if (hasGM) {
const v = GM_getValue(key);
return v == null ? null : v;
}
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.get', key, e);
return null;
}
},
setItem(key, value) {
try {
if (hasGM) {
GM_setValue(key, value);
return;
}
localStorage.setItem(key, value);
} catch (e) {
if (e?.name === 'QuotaExceededError') {
evictOldProgress();
try {
localStorage.setItem(key, value);
} catch (e2) {
log.warn('storage.set.retry', key, e2);
}
} else {
log.warn('storage.set', key, e);
}
}
},
removeItem(key) {
try {
if (hasGM) {
if (typeof GM_deleteValue === 'function') GM_deleteValue(key);
else GM_setValue(key, undefined);
return;
}
localStorage.removeItem(key);
} catch (e) {
log.warn('storage.remove', key, e);
}
},
getRawItem(key) {
if (hasGM) return storage.getItem(key);
try {
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.getRaw', key, e);
return null;
}
},
listKeys(prefix) {
if (hasGM) return [];
try {
return Object.keys(localStorage).filter((k) => k.startsWith(prefix));
} catch (e) {
log.warn('storage.list', e);
return [];
}
},
};
function evictOldProgress() {
try {
const keys = storage.listKeys('video_progress');
if (keys.length <= CONFIG.retry.evictTrigger) return;
const sorted = keys
.map((k) => {
const obj = trySafe(() => JSON.parse(storage.getRawItem(k)), 'evict.parse');
return { key: k, time: obj?.timestamp || 0 };
})
.sort((a, b) => a.time - b.time);
const toRemove = sorted.slice(0, sorted.length - CONFIG.retry.evictKeep);
toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove'));
} catch (e) {
log.warn('evictOldProgress', e);
}
}
/* ============================================================
* 进度管理
* ============================================================ */
const progressKey = (file) => {
if (!file) return null;
if (file.fs_id) return `video_progress_${file.fs_id}`;
if (file.path) {
const encoded = base64Encode(file.path);
if (encoded) return `video_progress_path_${encoded}`;
return null;
}
return null;
};
const validateProgress = (obj) => {
if (!obj || typeof obj !== 'object') return null;
const { currentTime, duration, timestamp } = obj;
if (!Number.isFinite(currentTime) || currentTime < 0) return null;
if (!Number.isFinite(timestamp) || timestamp <= 0) return null;
if (duration != null && (!Number.isFinite(duration) || duration < 0)) return null;
return { currentTime, duration: duration || 0, timestamp };
};
function readResumeFromUrl() {
try {
const sp = new URLSearchParams(window.location.search);
const epRaw = sp.get('ep');
const tRaw = sp.get('t');
const result = {};
if (epRaw != null && /^\d+$/.test(epRaw)) {
const ep = parseInt(epRaw, 10);
if (ep >= 0) result.ep = ep;
}
if (tRaw != null && /^\d+(\.\d+)?$/.test(tRaw)) {
const t = parseFloat(tRaw);
if (t >= 0) result.t = t;
}
return result;
} catch (e) {
return {};
}
}
function writeResumeToUrl(updates) {
try {
const sp = new URLSearchParams(window.location.search);
if ('ep' in updates) {
if (updates.ep == null) sp.delete('ep');
else sp.set('ep', String(updates.ep));
}
if ('t' in updates) {
if (updates.t == null) sp.delete('t');
else sp.set('t', String(Math.floor(updates.t)));
}
const qs = sp.toString();
const url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
window.history.replaceState(null, '', url);
} catch (e) {
log.warn('writeResumeToUrl', e);
}
}
/* ============================================================
* 文件名排序:智能识别集数关键词
* ============================================================ */
const EPISODE_HINTS = [
/第\s*(\d+)\s*[集话話]/i, // 第1集、第5话
/[Ee][Pp]?\s*\.?\s*(\d+)/, // EP01、Ep.05、E10
/[Ss]\d{1,3}\s*[Ee]\s*(\d+)/, // S01E05
/[\[【((]\s*(\d+)\s*[\]】))]/, // [1]、(1)、【1】
/[\u4e00-\u9fa5]\s*(\d+)\s*[\]】))]/, // 番1】、剧2)
];
function extractEpisodeKeys(name) {
const str = String(name || '');
for (const re of EPISODE_HINTS) {
const m = str.match(re);
if (m) {
const num = parseInt(m[1], 10);
if (Number.isFinite(num)) return [num];
}
}
const m = str.match(/\d+/);
const num = m ? parseInt(m[0], 10) : NaN;
return Number.isFinite(num) ? [num] : [Infinity];
}
function sortByFileNameNumber(list) {
return [...list].sort((a, b) => {
const an = String(a?.server_filename || a?.name || '');
const bn = String(b?.server_filename || b?.name || '');
const aKeys = extractEpisodeKeys(an);
const bKeys = extractEpisodeKeys(bn);
const len = Math.max(aKeys.length, bKeys.length);
for (let i = 0; i < len; i++) {
const av = aKeys[i] ?? -1;
const bv = bKeys[i] ?? -1;
if (av !== bv) {
if (av === Infinity) return 1;
if (bv === Infinity) return -1;
return av - bv;
}
}
return an.localeCompare(bn, 'zh-Hans-CN', { numeric: true });
});
}
/* ============================================================
* Tip 提示(兼容多种宿主 toast 接口)
* ============================================================ */
function showTip(msg) {
const fns = [
() => unsafeWindow.require('system-core:system/uiService/tip/tip.js').show({ mode: 'success', msg }),
() => unsafeWindow.toast?.show({ type: 'svip', message: msg, duration: 3000 }),
() => unsafeWindow.$bus?.$Toast?.addToast?.({ type: 'success', content: msg, durtime: 3000 }),
];
fns.some((fn) => safe(fn));
}
function formatTime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
sec = Math.floor(sec);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
const getFileName = (f) => f?.server_filename || f?.name || '未命名';
/* ============================================================
* URL 构建
* ============================================================ */
const checkJsToken = () => {
if (!unsafeWindow.jsToken) {
showTip('登录状态异常,请刷新页面');
return false;
}
return true;
};
function buildFileUrl(file) {
if (!checkJsToken()) return () => null;
return (type) =>
`/api/streaming?path=${encodeURIComponent(file.path)}&app_id=250528&clienttype=0&type=${type}&jsToken=${unsafeWindow.jsToken}`;
}
function buildShareUrl(file) {
const locals = unsafeWindow.locals;
const get = (k) => {
try {
return typeof locals.get === 'function' ? locals.get(k) : locals[k];
} catch (e) {
return null;
}
};
const [share_uk, shareid, sign, timestamp] = ['share_uk', 'shareid', 'sign', 'timestamp'].map(get);
if (!share_uk || !sign || !timestamp) return null;
return (type) =>
`/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&jsToken=${unsafeWindow.jsToken}`;
}
/* ============================================================
* HLS 控制器:负责 hls 实例、错误重试
* ============================================================ */
class HlsController {
constructor() {
this.instance = null;
this._fileKey = 'default';
this._retries = {};
}
get fileKey() {
return this._fileKey;
}
setUrlBuilder(fn) {
this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality);
this._qualityLevels = [360, 480, 720, 1080];
}
async resolvePlayUrl(baseUrl, depth = 0) {
if (depth > 3 || !baseUrl) {
showTip('所有画质均无法播放,请刷新重试');
return null;
}
let res;
try {
res = await fetch(baseUrl, {
credentials: 'include',
headers: { 'User-Agent': CONFIG.userAgent },
});
} catch (e) {
console.warn('[BDPlayer] fetch失败:', e.message);
return null;
}
if (!res.ok) {
console.warn(`[BDPlayer] HTTP ${res.status} ${res.statusText},URL: ${baseUrl}`);
if (res.status === 401 || res.status === 403) {
showTip('登录状态失效,请刷新页面重新登录');
}
return null;
}
const text = await res.text();
if (text.trim().startsWith('#EXTM3U')) return baseUrl;
let json;
try {
json = JSON.parse(text);
} catch (e) {
console.warn('[BDPlayer] 响应非JSON:', text.slice(0, 200));
return null;
}
console.info('[BDPlayer] resolve errno:', json.errno, 'depth:', depth, json);
if (json.errno === 133 && json.adToken) {
return `${baseUrl}&adToken=${encodeURIComponent(json.adToken)}`;
}
if (json.errno === 2 || json.errno === 9019 || json.errno === 9013) {
// 从低到高尝试所有画质级别
if (depth < this._qualityLevels?.length) {
const quality = this._qualityLevels[depth];
const fallbackUrl = this._getUrlForQuality?.(quality);
console.info('[BDPlayer] 尝试 fallback 到', quality + 'P:', fallbackUrl);
if (fallbackUrl && fallbackUrl !== baseUrl) {
return this.resolvePlayUrl(fallbackUrl, depth + 1);
}
}
}
return null;
}
_scheduleBackoff(action, opt) {
const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {};
if (attempts >= maxAttempts) {
if (onGiveUp) trySafe(onGiveUp, 'backoff.giveUp');
return false;
}
const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs);
setTimeout(() => trySafe(action, 'backoff.action'), delay);
return true;
}
create(url, video, fileKey, onGiveUpNetwork) {
if (!Hls.isSupported()) {
if (video?.canPlayType?.('application/vnd.apple.mpegurl')) {
video.src = url;
return null;
}
showTip('浏览器不支持视频播放');
return null;
}
this._fileKey = fileKey || 'default';
this._retries[this._fileKey] = 0;
const hls = new Hls({
...CONFIG.hls,
fetchSetup: HLS_FETCH_SETUP,
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (this.instance !== hls || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === 'manifestParsingError') {
showTip('视频地址无效');
return;
}
const retries = this._retries[this._fileKey] || 0;
this._retries[this._fileKey] = retries + 1;
const MAX = CONFIG.retry.hlsNetworkMaxAttempts;
this._scheduleBackoff(
() => {
if (this.instance === hls) hls.startLoad();
},
{
attempts: retries,
maxAttempts: MAX,
baseMs: CONFIG.retry.hlsNetworkBaseMs,
maxMs: CONFIG.retry.hlsNetworkMaxMs,
onGiveUp,
}
);
showTip(`网络错误,指数重试 ${retries + 1}/${MAX}…`);
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
showTip('播放失败,请刷新重试');
}
});
hls.loadSource(url);
hls.attachMedia(video);
this.instance = hls;
return hls;
}
destroy() {
if (!this.instance) return;
const hls = this.instance;
this.instance = null;
safe(
() => {
hls.stopLoad();
hls.detachMedia();
hls.destroy();
},
'HlsController.destroy',
);
}
}
/* ============================================================
* 画质控制:构建画质列表、监听 artplayer quality 切换
* (画质功能保持原样:基于实际分辨率过滤 + 切换时保留播放位置)
* ============================================================ */
class QualityController {
constructor(hlsController) {
this.hls = hlsController;
this.list = [];
this.getUrl = null;
this._lastResolution = 0;
this._setupBound = false;
this._switchState = null;
this._art = null;
this._qualityHandler = null;
}
build(resolution, getUrl) {
this.getUrl = getUrl;
const match = resolution?.match?.(/width:(\d+),height:(\d+)/);
let height = match ? +match[2] : 0;
if (!height && this._lastResolution) height = this._lastResolution;
const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a);
if (height > 0) {
this._lastResolution = height;
const startIdx = sortedDesc.findIndex((q) => height >= q);
if (startIdx >= 0) {
const available = sortedDesc.slice(startIdx);
this.list = available.map((q, i) => ({
html: CONFIG.qualityTemplates[q],
url: getUrl?.('M3U8_AUTO_' + q) || '',
default: i === 0,
}));
return;
}
}
this.list = sortedDesc.map((q, i) => ({
html: CONFIG.qualityTemplates[q],
url: getUrl?.('M3U8_AUTO_' + q) || '',
default: i === 0,
}));
}
apply(art) {
if (art && this.list?.length) art.quality = this.list;
}
bind(art) {
if (!art || this._setupBound) return;
this._setupBound = true;
this._art = art;
this._qualityHandler = async (url) => {
if (!url) return;
const newUrl = await this.hls.resolvePlayUrl(url);
if (!newUrl) {
showTip('画质切换失败');
return;
}
if (this.hls.instance && this.hls.instance.url === newUrl) return;
this._switchState = {
currentTime: art.currentTime,
volume: art.video.volume,
muted: art.video.muted,
playing: !art.video.paused,
};
this.hls.destroy();
safe(() => {
art.video.pause();
art.video.removeAttribute('src');
art.video.load();
}, 'quality.resetVideo');
const newHls = this.hls.create(newUrl, art.video, this.hls.fileKey, () => showTip('画质切换失败'));
if (!newHls) {
this._switchState = null;
return;
}
newHls.once(Hls.Events.MANIFEST_PARSED, () => {
const state = this._switchState;
if (!state) return;
this._switchState = null;
const { currentTime, volume, muted, playing } = state;
art.video.muted = muted;
art.video.volume = volume;
art.currentTime = currentTime;
if (playing) art.video.play().catch(() => {});
});
};
art.on('quality', this._qualityHandler);
}
unbind() {
if (this._art && this._qualityHandler) {
trySafe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind');
}
this._art = null;
this._qualityHandler = null;
this._setupBound = false;
}
}
/* ============================================================
* 进度记忆:保存/读取/校验
* ============================================================ */
class ProgressStore {
constructor(playerRef) {
this.playerRef = playerRef;
this.onSaved = null;
}
_art() {
return this.playerRef()?.art || null;
}
_file() {
return this.playerRef()?.file || null;
}
getKey(file = this._file()) {
return progressKey(file);
}
save(file = this._file()) {
const key = progressKey(file);
const art = this._art();
if (!key || !art?.duration || art.duration <= 0) return;
const data = JSON.stringify({
currentTime: art.currentTime,
duration: art.duration,
timestamp: Date.now(),
});
try {
storage.setItem(key, data);
} catch (e) {
if (e?.name === 'QuotaExceededError') {
evictOldProgress();
safe(() => storage.setItem(key, data), 'ProgressStore.save.retry');
} else {
log.warn('saveProgress', e);
return;
}
}
if (this.onSaved) trySafe(() => this.onSaved(file), 'ProgressStore.onSaved');
}
load() {
const key = this.getKey();
const art = this._art();
if (!key || !art) return;
const urlResume = readResumeFromUrl();
if (urlResume.t != null) {
if (urlResume.t < CONFIG.progressMinSec) return;
if (art.duration && urlResume.t >= art.duration - 1) return;
art.currentTime = urlResume.t;
return;
}
try {
const saved = storage.getRawItem(key);
if (!saved) return;
const obj = validateProgress(JSON.parse(saved));
if (!obj) return;
if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) {
storage.removeItem(key);
return;
}
if (obj.currentTime < CONFIG.progressMinSec) return;
art.currentTime = obj.currentTime;
} catch (e) {
log.warn('loadProgress', e);
}
}
clear(file = this._file()) {
const key = progressKey(file);
if (!key) return;
safe(() => storage.removeItem(key), 'clearProgress');
if (this.onSaved) trySafe(() => this.onSaved(file, null), 'ProgressStore.onCleared');
}
getSavedFor(file) {
if (!file) return null;
const key = progressKey(file);
if (!key) return null;
try {
const saved = storage.getRawItem(key);
if (!saved) return null;
const obj = validateProgress(JSON.parse(saved));
if (!obj) return null;
if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) {
storage.removeItem(key);
return null;
}
if (obj.currentTime < CONFIG.progressMinSec) return null;
return { currentTime: obj.currentTime, duration: obj.duration };
} catch (e) {
log.warn('getSavedFor', e);
return null;
}
}
}
/* ============================================================
* 时长缓存
* ============================================================ */
class DurationCache {
constructor() {
this._map = new Map();
}
get(f) {
if (!f) return null;
const key = f.fs_id || f.path;
if (!key) return null;
if (this._map.has(key)) return this._map.get(key);
const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null;
if (raw != null && raw > 0) {
const v = Math.floor(raw);
this._map.set(key, v);
return v;
}
return null;
}
set(f, sec) {
if (!f) return;
const key = f.fs_id || f.path;
if (!key || !Number.isFinite(sec) || sec <= 0) return;
this._map.set(key, Math.floor(sec));
}
clear() {
this._map.clear();
}
prefetch(list) {
if (!list?.length) return;
list.forEach((f) => {
if (!f) return;
const key = f.fs_id || f.path;
if (!key || this._map.has(key)) return;
const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null;
if (raw != null && raw > 0) {
this._map.set(key, Math.floor(raw));
}
});
}
bindHlsEvents(hls, f) {
if (!hls || !f) return;
const key = f.fs_id || f.path;
if (!key || this._map.has(key)) return;
const handler = (_, data) => {
const dur = data?.details?.totalduration;
if (dur && Number.isFinite(dur) && dur > 0) {
this.set(f, dur);
episodeMenu.updateDuration(f, Math.floor(dur));
hls.off(Hls.Events.LEVEL_LOADED, handler);
}
};
hls.on(Hls.Events.LEVEL_LOADED, handler);
}
}
/* ============================================================
* 选集菜单(DOM + 样式)
* ============================================================ */
const episodeMenu = (() => {
const state = {
el: null,
closeHandler: null,
resizeHandler: null,
menuLeaveHandler: null,
playerRef: null,
onOpen: null,
onClose: null,
styleInjected: false,
};
const calcPosition = (btnEl) => {
const rect = btnEl.getBoundingClientRect();
const menuW = 300;
const menuH = Math.min(232, window.innerHeight * 0.6);
const gap = -15;
const bottom = window.innerHeight - (rect.top + gap);
const left = Math.max(8, rect.right - menuW + 120);
return { bottom, left, menuW, menuH };
};
const buildItem = (player, f, i, idx) => {
const isCurrent = i === idx;
const name = getFileName(f);
const key = f.fs_id || f.path;
const cachedDur = player.durations.get(f);
const item = document.createElement('div');
item.className = `ep-item${isCurrent ? ' ep-item--active' : ''}`;
item.dataset.epKey = String(key);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(isCurrent));
item.setAttribute('tabindex', '0');
const indexEl = document.createElement('div');
indexEl.className = 'ep-index';
indexEl.innerHTML = isCurrent
? ``
: `${i + 1}`;
const textEl = document.createElement('div');
textEl.className = 'ep-text';
const nameEl = document.createElement('div');
nameEl.className = 'ep-name';
nameEl.textContent = name;
nameEl.title = name;
const metaEl = document.createElement('div');
metaEl.className = 'ep-meta';
const durEl = document.createElement('span');
durEl.className = 'ep-duration';
durEl.textContent = cachedDur != null ? formatTime(cachedDur) : '';
metaEl.appendChild(durEl);
const saved = player.progress.getSavedFor(f);
if (saved) {
const progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
metaEl.appendChild(progressEl);
}
textEl.appendChild(nameEl);
textEl.appendChild(metaEl);
item.appendChild(indexEl);
item.appendChild(textEl);
const handleClick = () => {
close();
if (i !== idx) player.switchVideo(f);
};
item.addEventListener('click', handleClick);
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
});
return item;
};
function isOpen() {
return !!state.el;
}
function open(player, btnEl) {
close();
state.playerRef = player;
const idx = player.getCurrentIndex();
const { bottom, left, menuW, menuH } = calcPosition(btnEl);
const menu = document.createElement('div');
menu.className = 'ep-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', '选集列表');
menu.style.cssText = `position:fixed;bottom:${bottom}px;left:${left}px;width:${menuW}px;max-height:${menuH}px;z-index:2147483647;`;
const header = document.createElement('div');
header.className = 'ep-header';
header.innerHTML = ``;
menu.appendChild(header);
const list = document.createElement('div');
list.className = 'ep-list';
list.setAttribute('role', 'listbox');
const fragment = document.createDocumentFragment();
player.filelist.forEach((f, i) => fragment.appendChild(buildItem(player, f, i, idx)));
list.appendChild(fragment);
menu.appendChild(list);
document.body.appendChild(menu);
state.el = menu;
requestAnimationFrame(() => {
const active = list.querySelector('.ep-item--active');
if (active) {
active.focus();
active.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
state.closeHandler = (e) => {
if (state.el && !state.el.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) {
close();
}
};
document.addEventListener('click', state.closeHandler);
state.menuLeaveHandler = () => {
if (state.el) close();
};
menu.addEventListener('mouseleave', state.menuLeaveHandler);
state.resizeHandler = debounce(() => {
if (!state.el) return;
const pos = calcPosition(btnEl);
state.el.style.bottom = pos.bottom + 'px';
state.el.style.left = pos.left + 'px';
state.el.style.maxHeight = pos.menuH + 'px';
}, 100);
window.addEventListener('resize', state.resizeHandler);
if (typeof state.onOpen === 'function') state.onOpen();
}
function close() {
const wasOpen = !!state.el;
if (state.el) {
if (state.menuLeaveHandler) {
safe(() => state.el.removeEventListener('mouseleave', state.menuLeaveHandler), 'menu.removeListener');
}
state.el.remove();
state.el = null;
}
state.menuLeaveHandler = null;
if (state.closeHandler) {
document.removeEventListener('click', state.closeHandler);
state.closeHandler = null;
}
if (state.resizeHandler) {
window.removeEventListener('resize', state.resizeHandler);
state.resizeHandler = null;
}
const prevPlayer = state.playerRef;
state.playerRef = null;
if (wasOpen && typeof state.onClose === 'function') state.onClose();
}
function updateDuration(f, sec) {
if (!state.el || !state.playerRef) return;
const key = f.fs_id || f.path;
const escapedKey = CSS.escape(String(key));
const el = state.el.querySelector(`[data-ep-key="${escapedKey}"] .ep-duration`);
if (el) el.textContent = formatTime(sec);
}
function updateActiveState(idx) {
if (!state.el) return;
const items = state.el.querySelectorAll('.ep-item');
items.forEach((item, i) => {
const isActive = i === idx;
item.classList.toggle('ep-item--active', isActive);
item.setAttribute('aria-selected', String(isActive));
const numEl = item.querySelector('.ep-num');
if (numEl) numEl.style.display = isActive ? 'none' : '';
});
}
function updateItemProgress(file, saved) {
if (!state.el || !file) return;
const key = file.fs_id || file.path;
const escapedKey = CSS.escape(String(key));
const item = state.el.querySelector(`[data-ep-key="${escapedKey}"]`);
if (!item) return;
const metaEl = item.querySelector('.ep-meta');
if (!metaEl) return;
let progressEl = metaEl.querySelector('.ep-progress');
if (!saved) {
if (progressEl) progressEl.remove();
return;
}
if (!progressEl) {
progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
metaEl.appendChild(progressEl);
}
const percent = saved.duration > 0
? Math.floor((saved.currentTime / saved.duration) * 100)
: 0;
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
}
function setHooks({ onOpen, onClose }) {
state.onOpen = onOpen || null;
state.onClose = onClose || null;
}
function injectStyle() {
if (state.styleInjected) return;
state.styleInjected = true;
const style = document.createElement('style');
style.id = 'ep-menu-style';
style.textContent = EPISODE_MENU_CSS;
document.head.appendChild(style);
}
return { isOpen, open, close, updateDuration, updateActiveState, updateItemProgress, injectStyle, setHooks };
})();
const EPISODE_MENU_CSS = `
.ep-menu {
background: rgba(18, 18, 22, 0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.65), 0 1px 0 rgba(255,255,255,.04) inset;
animation: epFadeIn .18s ease-out;
pointer-events: auto;
}
@keyframes epFadeIn {
from { opacity:0; transform: translateY(6px) scale(.97); }
to { opacity:1; transform: translateY(0) scale(1); }
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 8px;
border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.ep-header-title {
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: .3px;
font-family: system-ui, sans-serif;
}
.ep-header-count {
color: rgba(255,255,255,.38);
font-size: 11px;
font-family: system-ui, sans-serif;
}
.ep-list {
overflow-y: auto;
padding: 6px 8px 8px;
flex: 1;
min-height: 0;
}
.ep-list::-webkit-scrollbar { width: 4px; }
.ep-list::-webkit-scrollbar-track { background: transparent; }
.ep-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.14);
border-radius: 2px;
}
.ep-list::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.28); }
.ep-item {
display: flex;
align-items: center;
min-height: 44px;
padding: 5px 10px;
gap: 8px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
box-sizing: border-box;
user-select: none;
outline: none;
}
.ep-item:focus-visible {
box-shadow: 0 0 0 2px rgba(30,144,255,.5);
background: rgba(255,255,255,.07);
}
.ep-item:hover{ background: rgba(255,255,255,.07); }
.ep-item--active { background: rgba(30,144,255,.14); }
.ep-item--active:hover { background: rgba(30,144,255,.20); }
.ep-index {
width: 26px;
flex-shrink: 0;display: flex;
align-items: center;
justify-content: center;
}
.ep-num {
color: rgba(255,255,255,.3);
font-size: 12px;
font-variant-numeric: tabular-nums;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-num { color: rgba(30,144,255,.8); }
.ep-playing-bar {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.ep-playing-bar span {
display: block;
width: 3px;
border-radius: 2px;
background: #1e90ff;
animation: epBar .9s ease-in-out infinite alternate;
}
.ep-playing-bar span:nth-child(1) { height: 5px; animation-delay: 0s;}
.ep-playing-bar span:nth-child(2) { height: 12px; animation-delay: .2s; }
.ep-playing-bar span:nth-child(3) { height: 7px; animation-delay: .38s; }
@keyframes epBar {
from { transform: scaleY(.35); }
to { transform: scaleY(1); }
}
.ep-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-name {
color: rgba(255,255,255,.82);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-name {
color: #fff;
font-weight: 500;
}
.ep-meta {
display: flex;
align-items: center;
gap: 8px;
min-height: 14px;
}
.ep-duration {
color: rgba(255,255,255,.28);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-duration { color: rgba(30,144,255,.6); }
.ep-progress {
color: rgba(255,185,60,.75);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.ep-item--active .ep-progress { color: rgba(255,185,60,1); }
.art-control-prev,
.art-control-next,
.art-control-episodes {
opacity: .8;
cursor: pointer;
transition: opacity .2s;
}
.art-control-prev:hover,
.art-control-next:hover,
.art-control-episodes:hover { opacity: 1; }
.art-control-prev.art-ep-disabled,
.art-control-next.art-ep-disabled {
opacity: .35;
filter: grayscale(1) brightness(.55);
cursor: not-allowed;
pointer-events: auto;
}
.art-control-prev.art-ep-disabled:hover,
.art-control-next.art-ep-disabled:hover {
opacity: .35;
filter: grayscale(1) brightness(.55);
}
.artplayer-title {
position: absolute;
top: 0; left: 0; right: 0;
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0));
font-family: system-ui, -apple-system, sans-serif;
box-sizing: border-box;
transition: opacity var(--art-transition-duration, 0.2s) ease;
}
.art-title-text {
color: #fff;
font-size: 14px;
font-weight: 500;
letter-spacing: .2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
.artplayer-autonext {
position: absolute;
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
padding: 0 16px 0;
box-sizing: border-box;
animation: art-autonext-rise .2s ease-out;
}
.art-layers:not(.art-show) .artplayer-autonext {
opacity: 1 !important;
visibility: visible !important;
}
.artplayer-autonext-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px 7px 12px;
background: #3a3d42;
border: 1px solid rgba(0, 0, 0, .25);
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .06) inset;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
max-width: calc(100% - 32px);
pointer-events: auto;
}
.artplayer-autonext-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
.artplayer-autonext-title {
font-size: 12px;
font-weight: 500;
letter-spacing: .1px;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, .92);
}
.artplayer-autonext-sub {
font-size: 10.5px;
opacity: .7;
font-variant-numeric: tabular-nums;
letter-spacing: .1px;
color: rgba(255, 255, 255, .82);
}
.artplayer-autonext-sub b {
font-weight: 600;
color: rgba(255, 255, 255, .95);
}
.artplayer-autonext-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: 2px;
}
.artplayer-autonext-cancel {
padding: 4px 10px;
font-size: 11px;
color: rgba(255, 255, 255, .82);
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 999px;
cursor: pointer;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-cancel:hover {
background: rgba(255, 255, 255, .16);
}
.artplayer-autonext-cancel:active {
transform: scale(.96);
}
.artplayer-autonext-play {
padding: 4px 10px;
font-size: 11px;
color: #1f2328;
background: #fff;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-play:hover {
background: #f0f0f0;
}
.artplayer-autonext-play:active {
transform: scale(.96);
}
@keyframes art-autonext-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`;
/* ============================================================
* 自动切下一集控制器
* ============================================================ */
class AutoNextController {
constructor(artRef) {
this.artRef = artRef;
this._ticker = null;
this._dismissed = false;
this._visible = false;
this._resizeObs = null;
}
get visible() {
return this._visible;
}
cancel() {
if (this._ticker) {
clearInterval(this._ticker);
this._ticker = null;
}
}
show({ name, seconds, onCancel, onComplete }) {
const art = this.artRef();
if (!art) return;
this.hideCountdown();
this._dismissed = false;
const EMBED = 15;
const $ctrl = art?.template?.$controls || art?.controls?.$controls;
const ctrlH = Math.max(40, $ctrl?.getBoundingClientRect?.().height || 40);
const bottomPx = Math.round(ctrlH + EMBED);
this._visible = true;
art.controls.show = true;
art.layers.show = true;
let remaining = seconds;
art.layers.add({
name: 'autoNextCountdown',
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' },
mounted: ($el) => {
const $title = $el.querySelector('.artplayer-autonext-title');
const $sec = $el.querySelector('.artplayer-autonext-sec');
$title.textContent = name || '下一集';
const paint = () => {
const v = String(Math.max(remaining, 0));
if ($sec) {
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
};
paint();
this._resizeObs = new ResizeObserver(() => {
const $el2 = art?.layers?.autoNextCountdown;
const wrap = $el2?.querySelector?.('.artplayer-autonext');
if (!wrap) return;
const $ctrl2 = art?.template?.$controls || art?.controls?.$controls;
const h = Math.max(40, $ctrl2?.getBoundingClientRect?.().height || 40);
wrap.style.bottom = Math.round(h + EMBED) + 'px';
});
safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe');
$el.querySelector('.artplayer-autonext-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (this._dismissed) return;
this._dismissed = true;
safe(() => onCancel && onCancel(), 'AutoNext.onCancel');
});
$el.querySelector('.artplayer-autonext-play')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (this._dismissed) return;
this._dismissed = true;
safe(() => onComplete && onComplete({ immediate: true }), 'AutoNext.onComplete.immediate');
});
},
});
this._ticker = setInterval(() => {
remaining -= 1;
const $el = art?.layers?.autoNextCountdown;
const $sec = $el?.querySelector?.('.artplayer-autonext-sec');
if ($sec) {
const v = String(Math.max(remaining, 0));
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
if (remaining <= 0) {
this.cancel();
if (!this._dismissed) {
this._dismissed = true;
safe(() => onComplete && onComplete({ immediate: false }), 'AutoNext.onComplete.ticker');
}
}
}, 1000);
}
hideCountdown() {
this.cancel();
this._dismissed = true;
if (this._resizeObs) {
safe(() => this._resizeObs.disconnect(), 'AutoNext.disconnect');
this._resizeObs = null;
}
const art = this.artRef();
if (!art || !art.layers) return;
safe(() => art.layers.remove('autoNextCountdown'), 'AutoNext.removeLayer');
this._visible = false;
}
}
/* ============================================================
* UI 控制器:控制栏隐藏/标题层
* ============================================================ */
class UiController {
constructor(artRef) {
this.artRef = artRef;
this._hideCtl = null;
this._setup = false;
this._listeners = new ListenerBag();
this._titleEl = null;
this._titleLayerAdded = false;
}
_makeHideCtl() {
const art = this.artRef();
if (!art) return null;
let hideTimer = null;
const controls = () => art?.controls;
const $controls = () => art?.template?.$controls;
const clearHide = () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const showNow = () => {
if (!art) return;
if (controls()) art.controls.show = true;
art.layers.show = true;
};
const isPinned = () => episodeMenu.isOpen() || this._autoNextPinned?.();
const scheduleHide = () => {
clearHide();
if (isPinned()) return;
hideTimer = setTimeout(() => {
hideTimer = null;
if (!controls() || episodeMenu.isOpen()) return;
if (isPinned()) return;
const el = $controls();
if (el && el.matches(':hover')) return;
art.controls.show = false;
art.layers.show = false;
}, CONFIG.hideDelayMs);
};
return { clearHide, showNow, scheduleHide };
}
setAutoNextPin(fn) {
this._autoNextPinned = fn;
}
setup() {
const art = this.artRef();
if (!art || this._setup) return;
this._setup = true;
this._hideCtl = this._makeHideCtl();
if (art && typeof art.on === 'function' && !this._layerSyncBound) {
this._layerSyncBound = true;
art.on('control', (state) => {
if (!art) return;
if (episodeMenu.isOpen() || this._autoNextPinned?.()) {
if (art.layers) art.layers.show = true;
return;
}
if (art.layers) art.layers.show = !!state;
});
}
const onDocMouseMove = (e) => {
const art2 = this.artRef();
if (!art2) return;
const el = art2?.template?.$controls;
if (!el) return;
const over = el.contains(e.target);
if (over) {
this._hideCtl?.clearHide();
this._hideCtl?.showNow();
} else if (!episodeMenu.isOpen() && !this._autoNextPinned?.()) {
this._hideCtl?.scheduleHide();
}
};
const onDocMouseLeave = () => {
if (episodeMenu.isOpen() || this._autoNextPinned?.()) {
this._hideCtl?.clearHide();
return;
}
this._hideCtl?.scheduleHide();
};
this._listeners.on(document, 'mousemove', onDocMouseMove, { passive: true });
this._listeners.on(document, 'mouseleave', onDocMouseLeave, { passive: true });
}
addTitle(file) {
const art = this.artRef();
if (!art || this._titleLayerAdded) return;
this._titleLayerAdded = true;
const initialName = file ? getFileName(file) : '';
art.layers.add({
name: 'episodeTitle',
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', pointerEvents: 'none' },
mounted: ($el) => {
this._titleEl = $el.querySelector('.art-title-text');
this._setTitleText(initialName);
},
});
this._setTitleText(initialName);
}
updateTitle(file) {
this._setTitleText(file ? getFileName(file) : '');
}
_setTitleText(name) {
const art = this.artRef();
const el = this._titleEl || art?.layers?.episodeTitle?.querySelector?.('.art-title-text');
if (el) el.textContent = name || '';
}
reset() {
this._titleLayerAdded = false;
this._titleEl = null;
this._setup = false;
this._layerSyncBound = false;
this._listeners.clearAll();
this._hideCtl = null;
}
}
/* ============================================================
* 快捷键
* ============================================================ */
class HotkeyController {
constructor(playerRef) {
this.playerRef = playerRef;
this._setup = false;
this._listeners = new ListenerBag();
}
setup() {
const player = this.playerRef();
if (!player || !player.art || this._setup) return;
this._setup = true;
const onKey = (e) => {
const player2 = this.playerRef();
if (!player2?.art) return;
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.repeat) return;
const key = e.key.toLowerCase();
if (key === 'f') {
player2.art.fullscreen = !player2.art.fullscreen;
e.preventDefault();
e.stopPropagation();
} else if (key === 'w') {
player2.art.fullscreenWeb = !player2.art.fullscreenWeb;
e.preventDefault();
e.stopPropagation();
} else if (key === 'm') {
player2.art.muted = !player2.art.muted;
e.preventDefault();
e.stopPropagation();
} else if (key === 'p') {
const idx = player2.getCurrentIndex();
if (idx > 0) player2.switchVideo(player2.filelist[idx - 1]);
e.preventDefault();
e.stopPropagation();
} else if (key === 'n') {
const idx = player2.getCurrentIndex();
if (idx >= 0 && idx < (player2.filelist?.length || 0) - 1) player2.switchVideo(player2.filelist[idx + 1]);
e.preventDefault();
e.stopPropagation();
}
};
this._listeners.on(document, 'keydown', onKey, true);
}
reset() {
this._listeners.clearAll();
this._setup = false;
}
}
/* ============================================================
* Player 协调器:组合各控制器,对外暴露统一接口
* ============================================================ */
class Player {
constructor() {
this.art = null;
this.file = {};
this.filelist = [];
this.flag = '';
this.nativeVideoNode = null;
this.getUrl = null;
this.hls = new HlsController();
this.quality = new QualityController(this.hls);
this.durations = new DurationCache();
this.progress = new ProgressStore(() => this);
this.progress.onSaved = (file) => {
if (!file) return;
const saved = this.progress.getSavedFor(file);
episodeMenu.updateItemProgress(file, saved);
};
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this._autoNext = null;
this._ui = new UiController(() => this.art);
this._hotkey = new HotkeyController(() => this);
this._episodeControlsAdded = false;
this._initialized = false;
this._autoNextTimer = null;
this._autoNextCanceled = false;
this._listeners = new ListenerBag();
this._onTimeUpdateSave = null;
this._onVisibilityChange = null;
this._onArtDestroy = null;
}
getCurrentIndex() {
const { file, filelist } = this;
if (!file || !filelist?.length) return -1;
return filelist.findIndex(
(f) =>
(f.fs_id && file.fs_id && f.fs_id == file.fs_id) || (f.path && file.path && f.path === file.path),
);
}
pickResumeIndex() {
const resume = readResumeFromUrl();
const list = this.filelist;
if (resume.ep != null && Array.isArray(list) && list.length > resume.ep) return resume.ep;
return null;
}
switchVideo(file) {
if (!file) return;
this._switchCtrl.submit(() => this._performSwitch(file));
}
async _performSwitch(file) {
this._cancelAutoNext();
const prevFile = this.file;
this.progress.save();
if (!this.art) return;
const wasFullscreen = !!this.art.fullscreen;
const wasFullscreenWeb = !!this.art.fullscreenWeb;
const volume = this.art.volume ?? 1;
this.file = file;
const token = ++this._switchToken;
const newEpIdx = this.getCurrentIndex();
if (newEpIdx >= 0) writeResumeToUrl({ ep: newEpIdx, t: null });
if (this.flag === 'sharevideo') {
const shareUrl = buildShareUrl(file);
if (!shareUrl) return;
this.getUrl = shareUrl;
} else {
this.getUrl = buildFileUrl(file);
}
this.hls.setUrlBuilder(this.getUrl);
this.quality.build(file.resolution, this.getUrl);
if (!this.quality.list?.length) {
showTip('无法获取视频地址');
return;
}
if (!this.art?.video) {
showTip('播放器未就绪');
return;
}
const resolvedUrl = await this.hls.resolvePlayUrl(this.quality.list[0].url);
if (!resolvedUrl) {
showTip(`无法播放: ${getFileName(file)}`);
return;
}
if (token !== this._switchToken) return;
this.hls.destroy();
safe(() => {
this.art.video.pause();
this.art.video.removeAttribute('src');
this.art.video.load();
}, 'switch.resetVideo');
const hls = this.hls.create(resolvedUrl, this.art.video, file.fs_id || file.path, () => showTip('网络持续错误,请刷新页面'));
if (!hls || token !== this._switchToken) return;
this.quality.apply(this.art);
this.quality.bind(this.art);
this.refreshControlDisplay();
episodeMenu.updateActiveState(this.getCurrentIndex());
this._ui.updateTitle(file);
try {
await this._waitForVideo(this.art.video, hls, token);
} catch (e) {
if (e?.message === 'stale') return;
log.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
const video = this.art.video;
video.muted = false;
video.volume = volume;
safe(() => {
if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true;
if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true;
}, 'restoreFullscreen');
this.durations.bindHlsEvents(hls, file);
this.progress.load();
video.play().catch(() => {});
}
_waitForVideo(video, hls, token) {
return new Promise((resolve, reject) => {
const finish = once((err) => {
clearTimeout(timer);
err ? reject(err) : resolve();
});
const timer = setTimeout(() => finish(new Error('timeout')), 8000);
const onParsed = () => {
if (token !== this._switchToken) {
finish(new Error('stale'));
return;
}
if (video?.readyState >= 1) finish();
else video?.addEventListener('loadedmetadata', () => finish(), { once: true });
};
hls.once(Hls.Events.MANIFEST_PARSED, onParsed);
});
}
_cancelAutoNext() {
if (this._autoNextTimer) {
clearTimeout(this._autoNextTimer);
this._autoNextTimer = null;
}
if (this._autoNext) {
this._autoNext.cancel();
}
this._autoNextCanceled = false;
}
refreshControlDisplay() {
const idx = this.getCurrentIndex();
const len = this.filelist?.length || 0;
const hasPrev = idx > 0;
const hasNext = idx >= 0 && idx < len - 1;
const $prev = this.art?.controls?.prev;
const $next = this.art?.controls?.next;
if ($prev) {
$prev.classList.toggle('art-ep-disabled', !hasPrev);
$prev.setAttribute('aria-disabled', String(!hasPrev));
}
if ($next) {
$next.classList.toggle('art-ep-disabled', !hasNext);
$next.setAttribute('aria-disabled', String(!hasNext));
}
}
addEpisodeControls() {
if (this._episodeControlsAdded) return;
this._episodeControlsAdded = true;
this.durations.prefetch(this.filelist);
episodeMenu.injectStyle();
this.art.controls.add({
name: 'prev',
position: 'left',
index: 5,
html: makeSvg(ICONS.prev),
tooltip: '上一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const idx = this.getCurrentIndex();
if (idx > 0) this.switchVideo(this.filelist[idx - 1]);
},
});
this.art.controls.add({
name: 'next',
position: 'left',
index: 15,
html: makeSvg(ICONS.next),
tooltip: '下一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const idx = this.getCurrentIndex();
if (idx >= 0 && idx < (this.filelist?.length || 0) - 1) this.switchVideo(this.filelist[idx + 1]);
},
});
this.art.controls.add({
name: 'episodes',
position: 'right',
html: '选集',
tooltip: '选集',
style: { padding: '0 10px', fontSize: '14px' },
click: () => {
const btnEl = document.querySelector('.art-control-episodes');
if (episodeMenu.isOpen()) episodeMenu.close();
else if (btnEl) episodeMenu.open(this, btnEl);
},
});
this.refreshControlDisplay();
this._ui.addTitle(this.file);
}
refreshEpisodeControls() {
if (!this.art || this._episodeControlsAdded) return;
this.addEpisodeControls();
}
setupAutoNextLayer() {
if (this._autoNext) return;
this._autoNext = new AutoNextController(() => this.art);
this._ui.setAutoNextPin(() => this._autoNext?.visible);
}
_setupAutoNext() {
const art = this.art;
if (!art) return;
this.setupAutoNextLayer();
art.on('video:ended', () => {
this.progress.clear();
this._autoNextCanceled = false;
const idx = this.getCurrentIndex();
const listLen = this.filelist?.length || 0;
if (idx < 0 || idx >= listLen - 1) return;
const next = this.filelist[idx + 1];
const nextName = getFileName(next);
const tokenAtEnd = this._switchToken;
let seekUnbound = false;
const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click'];
const bindCancel = () => cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek));
const unbindCancel = () => cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek));
const cancelOnSeek = () => {
if (seekUnbound) return;
seekUnbound = true;
if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; }
this._autoNext?.cancel();
this._autoNext?.hideCountdown();
this._autoNextCanceled = true;
unbindCancel();
safe(() => art.play(), 'cancelOnSeek.play');
};
const switchNow = () => {
if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; }
if (this._autoNext) this._autoNext.hideCountdown();
unbindCancel();
if (this._switchToken !== tokenAtEnd) return;
this.switchVideo(next);
};
this._autoNext.show({
name: nextName,
seconds: CONFIG.countdownSec,
onCancel: () => cancelOnSeek(),
onComplete: () => switchNow(),
});
this._autoNextTimer = setTimeout(() => switchNow(), CONFIG.countdownSec * 1000);
bindCancel();
});
}
async init(container) {
if (!this.quality.list?.length) return;
this._initialized = false;
this.destroy();
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this.durations.clear();
this.hls.setUrlBuilder(this.getUrl);
const resolvedUrl = await this.hls.resolvePlayUrl(this.quality.list[0].url);
if (!resolvedUrl) {
showTip('无法获取播放地址,请检查登录状态');
return;
}
this.art = new Artplayer({
container,
url: resolvedUrl,
type: 'm3u8',
customType: {
m3u8: (video, url) => {
this.hls.create(url, video, this.file?.fs_id || this.file?.path, () => showTip('网络持续错误,请刷新页面'));
},
},
poster:
Object.values(this.file.thumbs || {})
.pop()
?.replace(/size=c\d+_u\d+/, 'size=c850_u580') || '',
autoplay: true,
pip: true,
fullscreen: true,
fullscreenWeb: true,
setting: true,
quality: this.quality.list,
playbackRate: true,
aspectRatio: true,
muted: false,
volume: 1,
hotkey: true,
icons: {
loading: '
',
state: '
',
indicator: '
',
},
moreVideoAttr: { crossOrigin: 'anonymous', preload: 'auto' },
});
this.art.on('ready', () => {
this.destroyNativePlayer();
this.art.video.muted = false;
this.progress.load();
this.quality.bind(this.art);
this.addEpisodeControls();
this.durations.bindHlsEvents(this.hls.instance, this.file);
this._ui.setup();
this._hotkey.setup();
this._setupAutoNext();
this._initialized = true;
});
const debouncedSave = debounce(() => this.progress.save(), CONFIG.saveDebounceMs);
let lastSave = 0;
const onTimeUpdateSave = () => {
if (this.art.currentTime > 0 && Date.now() - lastSave > CONFIG.saveThrottleMs) {
lastSave = Date.now();
debouncedSave();
const idx = this.getCurrentIndex();
if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime });
}
};
this.art.on('video:timeupdate', onTimeUpdateSave);
this._onTimeUpdateSave = onTimeUpdateSave;
const flushSave = () => {
if (this.art?.currentTime > 0) {
this.progress.save();
const idx = this.getCurrentIndex();
if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime });
}
};
this.art.on('video:pause', flushSave);
this.art.on('video:seeked', flushSave);
const onVisibilityChange = () => {
if (document.hidden) flushSave();
};
document.addEventListener('visibilitychange', onVisibilityChange);
this._onVisibilityChange = onVisibilityChange;
this.art.on('video:play', () => {
if (this._ui._hideCtl) this._ui._hideCtl.scheduleHide();
});
this._onArtDestroy = () => {
this._cancelAutoNext();
this._listeners.clearAll();
this._ui.reset();
this._hotkey.reset();
this._episodeControlsAdded = false;
episodeMenu.close();
this._initialized = false;
};
this.art.on('destroy', this._onArtDestroy);
}
destroy() {
this._switchToken++;
this._cancelAutoNext();
if (this._onTimeUpdateSave) {
this.art?.off('video:timeupdate', this._onTimeUpdateSave);
this._onTimeUpdateSave = null;
}
if (this._onVisibilityChange) {
document.removeEventListener('visibilitychange', this._onVisibilityChange);
this._onVisibilityChange = null;
}
if (this._onArtDestroy) {
this.art?.off('destroy', this._onArtDestroy);
this._onArtDestroy = null;
}
episodeMenu.close();
if (this.art?.video) {
safe(() => {
this.art.video.muted = true;
this.art.video.pause();
}, 'destroy.mute');
}
if (this.art) {
const art = this.art;
this.art = null;
safe(() => {
if (art.video) {
art.video.pause();
art.video.src = '';
art.video.load();
}
art.destroy(true);
}, 'destroy.art');
}
this.hls.destroy();
this.quality.unbind();
this._episodeControlsAdded = false;
}
destroyNativePlayer() {
document.querySelectorAll('video').forEach((v) => {
if (!v.closest('#artplayer')) {
safe(() => {
v.pause();
v.muted = true;
v.src = '';
v.load();
}, 'destroyNativePlayer.video');
}
});
const pollDestroy = (getTarget) => {
let count = 0;
const id = setInterval(() => {
count++;
const t = getTarget();
if (t?.player) {
clearInterval(id);
safe(() => {
t.player.dispose();
t.player = null;
}, 'pollDestroy.dispose');
} else if (count > 30) {
clearInterval(id);
}
}, 300);
};
if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) {
setTimeout(() => {
unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => {
if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance);
});
}, 1000);
}
if (this.flag === 'video' && this.nativeVideoNode) {
setTimeout(() => {
pollDestroy(() => this.nativeVideoNode?.firstChild);
}, 1000);
}
}
async replacePlayer() {
const videoNode = await waitFor(() => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), {
intervalMs: 500,
maxAttempts: 20,
});
if (!videoNode) return null;
let container = document.getElementById('artplayer');
if (!container) {
container = document.createElement('div');
container.id = 'artplayer';
container.style.cssText = 'width:100%;height:100%';
videoNode.parentNode?.replaceChild(container, videoNode);
}
return container;
}
}
/* ============================================================
* 单例与生命周期钩子
* ============================================================ */
const player = new Player();
episodeMenu.setHooks({
onOpen: () => player._ui._hideCtl?.showNow(),
onClose: () => player._ui._hideCtl?.scheduleHide(),
});
window.addEventListener('beforeunload', () => {
if (player._initialized) player.progress.save();
});
/* ============================================================
* 入口路由
* ============================================================ */
async function handleShare() {
const localsReady = await waitFor(() => unsafeWindow.locals, {
intervalMs: 500,
maxAttempts: 40,
onTimeout: () => log.warn('locals等待超时,放弃初始化'),
});
if (!localsReady) return;
await new Promise((resolve) => {
let done = false;
localsReady.get(
'file_list',
'share_uk',
'shareid',
'sign',
'timestamp',
(file_list, share_uk, shareid, sign, timestamp) => {
if (done) return;
done = true;
if (!file_list?.length) {
resolve();
return;
}
let videoList = [];
try {
const list = unsafeWindow
.require('system-core:context/context.js')
.instanceForSystem.list.getCurrentList();
videoList = list.filter((f) => f.category === 1);
} catch (e) {
videoList = file_list.filter((f) => f.category === 1);
}
if (!videoList.length) {
resolve();
return;
}
videoList = sortByFileNameNumber(videoList);
player.filelist = videoList;
let file = videoList[0];
const resumeEp = player.pickResumeIndex();
if (resumeEp != null) file = videoList[resumeEp];
player.flag = 'sharevideo';
player.file = file;
player.getUrl = buildShareUrl(file);
player.hls.setUrlBuilder(player.getUrl);
player.quality.build(file.resolution, player.getUrl);
player.replacePlayer().then((container) => {
if (container) player.init(container);
resolve();
});
},
);
});
}
async function handlePlay() {
const jqReady = await waitFor(() => unsafeWindow.jQuery, {
intervalMs: 500,
maxAttempts: 40,
onTimeout: () => log.warn('jQuery等待超时,放弃初始化'),
});
if (!jqReady) return;
let hasInit = false;
jqReady(document).ajaxComplete(async (event, xhr, options) => {
const url = options.url || '';
if (url.includes('/api/categorylist')) {
player.filelist = sortByFileNameNumber(
(xhr.responseJSON?.info || []).filter((f) => f.category === 1),
);
player.refreshEpisodeControls();
} else if (url.includes('/api/filemetas')) {
if (hasInit) return;
let file = xhr.responseJSON?.info?.[0];
if (!file) return;
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && player.filelist?.[resumeEp]) file = player.filelist[resumeEp];
hasInit = true;
player.flag = 'playvideo';
player.file = file;
player.getUrl = buildFileUrl(file);
player.hls.setUrlBuilder(player.getUrl);
player.quality.build(file.resolution, player.getUrl);
const container = await player.replacePlayer();
if (container) await player.init(container);
}
});
}
async function handleVideo() {
const app = document.querySelector('#app');
const pinia = await waitFor(
() =>
app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo
? app.__vue_app__.config.globalProperties.$pinia
: null,
{ intervalMs: 500, maxAttempts: 40 },
);
if (!pinia) return;
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.flag = 'video';
player.file = file;
player.filelist = sortByFileNameNumber(list);
let initFile = file;
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && list.length > resumeEp) initFile = list[resumeEp];
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildFileUrl(initFile);
player.hls.setUrlBuilder(player.getUrl);
player.quality.build(initFile.resolution, player.getUrl);
player.file = initFile;
const container = await player.replacePlayer();
if (container) await player.init(container);
}
function ready() {
return new Promise((resolve) => {
if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(resolve, 0);
else document.addEventListener('DOMContentLoaded', resolve);
});
}
ready().then(() => {
const url = location.href;
if (url.includes(CONFIG.urls.sharePattern)) handleShare();
else if (url.includes(CONFIG.urls.playPattern)) handlePlay();
else if (url.includes(CONFIG.urls.videoPattern)) handleVideo();
});
})();