// ==UserScript==
// @name 百度网盘视频播放器
// @namespace https://scriptcat.org/
// @version 2.11
// @description 基于ClaudeAI的百度网盘视频播放器,全站激活;支持多清晰度切换、连续播放、进度记忆、键盘快捷键、.lb视频文件任意页面点击即播;直链四通道获取(网页sign/APP locatedownload/TV mediainfo/xpan)并逐条Range验证;.lb三层修复播放:流式私有头剥离→周期块头去块/尾巴修剪→整文件本地修复(原生直放/MSE转封装),不装任何插件
// @match https://pan.baidu.com/*
// @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
// @connect openapi.baidu.com
// @connect pan.baidu.com
// @connect pcs.baidu.com
// @connect *.pcs.baidu.com
// @connect baidupcs.com
// @connect *.baidupcs.com
// @connect bdstatic.com
// @connect *.bdstatic.com
// @connect jsdelivr.net
// @connect cdn.jsdelivr.net
// @connect fastly.jsdelivr.net
// @connect gcore.jsdelivr.net
// @connect unpkg.com
// @connect bcebos.com
// @connect *.bcebos.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ============================================================
* 配置
* ============================================================ */
const CONFIG = Object.freeze({
userAgent: 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts',
hls: Object.freeze({
debug: false,
enableWorker: true,
lowLatencyMode: false,
// 缓冲不宜过大:1080P 高码率下过大的前向/后向缓冲会撑爆内存,引发卡顿
backBufferLength: 30,
maxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferSize: 30 * 1000 * 1000,
maxBufferHole: 0.5,
startLevel: -1,
autoStartLoad: true,
abrEwmaDefaultEstimate: 5000000,
}),
qualityTemplates: Object.freeze({
1080: '超清 1080P',
720: '高清 720P',
480: '流畅 480P',
360: '省流 360P',
}),
qualityLevels: Object.freeze([1080, 720, 480, 360]),
countdownSec: 5,
hideDelayMs: 2000,
saveDebounceMs: 2000,
saveThrottleMs: 3000,
progressMinSec: 5,
progressTtlMs: 15 * 86400000,
retry: Object.freeze({
baseMs: 500,
maxMs: 10000,
hlsNetworkBaseMs: 1000,
hlsNetworkMaxMs: 10000,
hlsNetworkMaxAttempts: 3,
evictKeep: 15,
evictTrigger: 20,
}),
urls: Object.freeze({
sharePattern: '/s/',
playPattern: '/play/video',
videoPattern: '/pfile/video',
mboxvideoPattern: '/mboxvideo',
}),
logPrefix: '[BDPlayer]',
});
const ICONS = Object.freeze({
prev: '',
next: '',
});
// 注意:不要覆盖 hls.js 的 fetchSetup。
// 自定义 fetchSetup 若只返回 { headers },会丢弃 hls.js 传入的 initParams,
// 丢失 abort signal(无法取消过期分片请求)与 Range 头,造成播放卡顿。
// 且 User-Agent 属于 fetch 禁改头,覆盖并无收益。
/* ============================================================
* 工具
* ============================================================ */
const makeSvg = (inner) =>
``;
const once = (fn) => {
let called = false;
return (...args) => {
if (called) return;
called = true;
fn(...args);
};
};
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delay);
};
};
// 控制台仅在切换播放时输出一段播报;其它路径统一静默。
const log = {
announce(file) {
const title = file ? getFileName(file) : '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
console.info(CONFIG.logPrefix, `当前时间:${stamp}\n当前播放:${title}`);
},
info: () => {},
warn: () => {},
error: () => {},
};
const safe = (fn, label = '') => {
try {
return fn();
} catch (e) {
if (label) log.warn(`safe:${label}`, e);
return undefined;
}
};
/* ============================================================
* 生命周期基类:Disposable
* - 所有子系统继承,统一 dispose() 入口
* - 自带 _listeners 集中管理监听/定时器/轮询
* - 支持 _track(child) 注册子 disposable
* ============================================================ */
class Disposable {
constructor() {
this._disposed = false;
this._childDisposables = new Set();
this._listeners = new ListenerBag();
}
_track(d) {
if (!d) return d;
this._childDisposables.add(d);
return d;
}
dispose() {
if (this._disposed) return;
this._disposed = true;
this._childDisposables.forEach((d) => safe(() => d.dispose?.(), `${this.constructor.name}.dispose`));
this._childDisposables.clear();
this._listeners.clearAll();
}
}
/* ============================================================
* AutoNext 状态机枚举
* IDLE 未显示
* SHOWN 倒计时进行中
* CANCELLED 用户取消 / seek 取消 / 路由变更
* COMPLETED 倒计时结束 / 立即播放
* ============================================================ */
const AutoNextState = Object.freeze({
IDLE: 'idle',
SHOWN: 'shown',
CANCELLED: 'cancelled',
COMPLETED: 'completed',
});
const dumpCache = (player, label = '') => {
// DEBUG: 注释掉下面一行以隐藏调试输出
return;
try {
const filelist = player?.filelist || [];
const hlsCache = player?.hls?._cacheList || [];
const qualityList = player?.quality?.list || [];
const tag = label ? ` [${label}]` : '';
console.info('[PlayerCache]' + tag, {
filelist,
hls_cacheList: hlsCache,
quality_list: qualityList,
});
} catch (e) {
console.warn('[PlayerCache] dump 失败:', e);
}
};
unsafeWindow.dumpPlayerCache = () => dumpCache(unsafeWindow.player);
/** GM 兜底请求:补全绝对地址,并带上 Referer(否则部分接口返回 errno=0 但空数据) */
const gmFetchViaGM = (abs, referer) =>
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: abs,
responseType: 'text',
timeout: 10000,
headers: { Referer: referer || location.origin + '/' },
onload: (r) => resolve(r.responseText),
onerror: (r) => reject(new Error(`HTTP ${r?.status ?? 'error'}`)),
ontimeout: () => reject(new Error('timeout')),
});
});
/**
* 统一请求入口。同源接口优先走页面原生 fetch(credentials 含登录 cookie、
* 自带 Referer,与网盘自身请求完全一致);GM_xmlhttpRequest 发出的请求
* 缺少这些上下文,百度接口会返回 errno=0 但 result/list 为空。
* 跨域请求(dlink 等)仍走 GM。
*/
const gmFetch = (url) => {
const abs = url.startsWith('http') ? url : location.origin + url;
const sameOrigin = abs.startsWith(location.origin + '/');
if (sameOrigin) {
return fetch(abs, {
credentials: 'include',
headers: { Accept: 'application/json, text/plain, */*' },
})
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.catch((e) => {
safe(() => lbDiag(`原生 fetch 失败(${e?.message || e}),回退 GM 请求`), 'gmFetch.fallback');
return gmFetchViaGM(abs);
});
}
return gmFetchViaGM(abs);
};
const parseM3u8Duration = (text) => {
if (!text || typeof text !== 'string') return 0;
const re = /#EXTINF:([0-9]+(?:\.[0-9]+)?)/g;
let total = 0;
let m;
while ((m = re.exec(text))) {
total += parseFloat(m[1]) || 0;
}
return total;
};
async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout, label = 'waitFor' } = {}) {
for (let i = 0; i < maxAttempts; i++) {
try {
const v = fn();
if (v) return v;
} catch (e) {
log.warn(`${label}.poll`, e);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
if (onTimeout) safe(onTimeout, `${label}.timeout`);
return null;
}
const base64Encode = (str) => {
try {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
String.fromCharCode(parseInt(p1, 16))));
} catch (_) {
return null;
}
};
/** ListenerBag 集中管理监听器 / 定时器 / 轮询,destroy 时一次性清理 */
class ListenerBag {
constructor() {
this._listeners = [];
this._timers = new Set();
this._intervals = new Set();
}
on(target, type, fn, options) {
target.addEventListener(type, fn, options);
this._listeners.push({ target, type, fn, options });
}
once(target, type, fn, options) {
const wrapped = (...args) => {
this.off(target, type, wrapped);
fn(...args);
};
target.addEventListener(type, wrapped, { ...(options || {}), once: true });
this._listeners.push({ target, type, fn: wrapped, options: { ...(options || {}), once: true } });
return wrapped;
}
off(target, type, fn) {
target.removeEventListener(type, fn);
}
trackArtListener(art, type, fn) {
art.on(type, fn);
this._listeners.push({ target: art, type, fn, options: undefined });
}
untrackArtListener(art, type, fn) {
try { art.off(type, fn); } catch (_) { /* target 已销毁 */ }
}
setTimeout(fn, delay) {
const id = setTimeout(() => {
this._timers.delete(id);
fn();
}, delay);
this._timers.add(id);
return id;
}
clearTimeout(id) {
if (id == null) return;
clearTimeout(id);
this._timers.delete(id);
}
setInterval(fn, delay) {
const id = setInterval(fn, delay);
this._intervals.add(id);
return id;
}
clearInterval(id) {
if (id != null && this._intervals.has(id)) {
clearInterval(id);
this._intervals.delete(id);
}
}
clearAll() {
this._listeners.forEach(({ target, type, fn, options }) => {
try {
if (target && typeof target.removeEventListener === 'function') {
target.removeEventListener(type, fn, options);
} else if (target && typeof target.off === 'function') {
target.off(type, fn);
}
} catch (_) { /* target 已卸载 */ }
});
this._listeners = [];
this._timers.forEach((id) => clearTimeout(id));
this._timers.clear();
this._intervals.forEach((id) => clearInterval(id));
this._intervals.clear();
}
}
/** 简易事件总线 */
class EventBus {
constructor() {
this._handlers = new Map();
}
on(event, handler) {
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
this._handlers.get(event).add(handler);
return () => this.off(event, handler);
}
off(event, handler) {
this._handlers.get(event)?.delete(handler);
}
emit(event, payload) {
this._handlers.get(event)?.forEach((fn) => safe(() => fn(payload), `EventBus.${event}`));
}
}
/** 异步串行化控制器:丢弃过期请求,只执行最后一次 */
class SwitchController {
constructor() {
this._running = false;
this._pending = null;
}
submit(fn) {
this._pending = fn;
this._drain();
}
async _drain() {
if (this._running) return;
while (this._pending) {
const fn = this._pending;
this._pending = null;
this._running = true;
try {
await fn();
} catch (e) {
log.error('SwitchController', e);
} finally {
this._running = false;
}
}
}
get isRunning() {
return this._running;
}
}
/* ============================================================
* 存储抽象:GM_* 优先,回落 localStorage
* - 集中处理 QuotaExceededError 时的 ev_old_progress 回收
* - 暴露一次性 helpers 取代 listKeys 拉取 + JSON.parse 二次循环
* ============================================================ */
const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const hasGMList = typeof GM_listValues === 'function';
const hasGMDel = typeof GM_deleteValue === 'function';
const STORAGE_PREFIX = 'video_progress_';
function evictOldProgress() {
try {
const entries = [];
const visit = (rawKey) => {
if (!rawKey || !rawKey.startsWith(STORAGE_PREFIX)) return;
const obj = safe(() => JSON.parse(storage.getRawItem(rawKey)), 'evict.parse');
entries.push({ key: rawKey, time: obj?.timestamp || 0 });
};
if (hasGM) {
if (!hasGMList) return;
const all = safe(() => GM_listValues(), 'evict.list') || [];
all.forEach((k) => visit(k));
} else {
const keys = safe(() => Object.keys(localStorage), 'evict.keys') || [];
keys.forEach((k) => visit(k));
}
if (entries.length <= CONFIG.retry.evictTrigger) return;
entries.sort((a, b) => a.time - b.time);
const toRemove = entries.slice(0, entries.length - CONFIG.retry.evictKeep);
toRemove.forEach(({ key }) => safe(() => storage.removeItem(key), 'evict.remove'));
} catch (e) {
log.warn('evictOldProgress', e);
}
}
const storage = {
getItem(key) {
try {
if (hasGM) {
const v = GM_getValue(key);
return v == null ? null : v;
}
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.get', key, e);
return null;
}
},
setItem(key, value) {
try {
if (hasGM) {
GM_setValue(key, value);
return;
}
localStorage.setItem(key, value);
} catch (e) {
if (e?.name === 'QuotaExceededError') {
evictOldProgress();
try {
if (hasGM) GM_setValue(key, value);
else localStorage.setItem(key, value);
} catch (e2) {
log.warn('storage.set.retry', key, e2);
}
} else {
log.warn('storage.set', key, e);
}
}
},
removeItem(key) {
try {
if (hasGM) {
if (hasGMDel) GM_deleteValue(key);
else log.warn('storage.remove.unsupported', key);
return;
}
localStorage.removeItem(key);
} catch (e) {
log.warn('storage.remove', key, e);
}
},
getRawItem(key) {
if (hasGM) return storage.getItem(key);
try {
return localStorage.getItem(key);
} catch (e) {
log.warn('storage.getRaw', key, e);
return null;
}
},
};
/* ============================================================
* 进度键与解析
* ============================================================ */
const fileKeyOf = (file) => {
if (!file) return null;
if (file.fs_id) return String(file.fs_id);
if (file.path) {
const encoded = base64Encode(file.path);
return encoded ? `path_${encoded}` : null;
}
return null;
};
const progressKey = (file) => {
const k = fileKeyOf(file);
return k ? `${STORAGE_PREFIX}${k}` : null;
};
function parseStoredProgress(file) {
const key = progressKey(file);
if (!key) return null;
const raw = storage.getRawItem(key);
if (!raw) return null;
let obj;
try {
obj = JSON.parse(raw);
} catch (e) {
log.warn('parseStoredProgress.json', e);
return null;
}
if (typeof obj?.currentTime !== 'number' || obj.currentTime < 0) return null;
if (typeof obj?.timestamp !== 'number' || obj.timestamp <= 0) return null;
const duration = typeof obj.duration === 'number' && obj.duration >= 0 ? obj.duration : 0;
if (duration > 0 && obj.currentTime > duration + 1) return null;
if (Date.now() - obj.timestamp > CONFIG.progressTtlMs) {
storage.removeItem(key);
return null;
}
if (obj.currentTime < CONFIG.progressMinSec) return null;
return { currentTime: obj.currentTime, duration, timestamp: obj.timestamp };
}
/* ============================================================
* 选集元数据:时长 / 进度 单一来源
* 取代旧的 EpisodeCache + DurationCache 双类
* ============================================================ */
class EpisodeMeta extends Disposable {
constructor() {
super();
this._map = new Map();
this.events = new EventBus();
}
_key(file) {
return fileKeyOf(file);
}
_ensure(file) {
const k = this._key(file);
if (!k) return null;
let entry = this._map.get(k);
if (!entry) {
entry = { duration: null, durationVerified: false, currentTime: null, timestamp: null };
this._map.set(k, entry);
}
return entry;
}
setDuration(file, sec, verified = false) {
const entry = this._ensure(file);
if (!entry || !Number.isFinite(sec) || sec <= 0) return;
// HLS EXTINF 累加总 ≤ 真实时长;用 Math.ceil 避免被 floor 砍掉 ~1s
// 与 artplayer 显示的 art.duration (≈ seekable.end) 对齐
const dur = Math.ceil(sec);
// verified=false 不能覆盖已 verified 的值(HLS 推断不能反向覆盖真实时长)
if (entry.durationVerified && !verified) return;
// 值未变且 verified 状态相同 → 跳过(避免 durationchange 重复 emit)
if (entry.duration === dur && !!entry.durationVerified === !!verified) return;
entry.duration = dur;
entry.durationVerified = entry.durationVerified || !!verified;
this.events.emit('duration', { file, duration: dur, verified: entry.durationVerified });
}
isDurationVerified(file) {
const entry = this._map.get(this._key(file));
return !!(entry && entry.duration != null && entry.durationVerified);
}
setProgress(file, currentTime, timestamp) {
const entry = this._ensure(file);
if (!entry) return;
if (currentTime == null) {
if (entry.currentTime == null && entry.timestamp == null) return;
entry.currentTime = null;
entry.timestamp = null;
} else {
entry.currentTime = Math.floor(currentTime);
entry.timestamp = timestamp ?? Date.now();
}
this.events.emit('progress', { file, currentTime: entry.currentTime, timestamp: entry.timestamp });
}
get(file) {
const k = this._key(file);
if (!k) return null;
const entry = this._map.get(k);
return entry ? { ...entry } : null;
}
getDuration(file) {
const entry = this._map.get(this._key(file));
return entry?.duration ?? null;
}
prefetch(list) {
if (!list?.length) return;
for (const f of list) {
if (!f) continue;
const entry = this._ensure(f);
if (!entry || entry.duration != null) continue;
const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null;
if (raw != null && Number.isFinite(raw) && raw > 0) {
entry.duration = Math.floor(raw);
}
}
}
preloadFromStorage(filelist) {
if (!filelist?.length) return;
for (const f of filelist) {
if (!f) continue;
const parsed = parseStoredProgress(f);
if (!parsed) continue;
const k = this._key(f);
const existing = this._map.get(k) || {};
// storage 中 parsed.duration = 上次 progress.save() 写入的 art.duration(真实)
// 若 entry 无 duration,才写入并标记 verified=true(避免后续 HLS 推断反覆盖)
if (existing.duration == null) {
existing.duration = parsed.duration;
existing.durationVerified = true;
}
existing.currentTime = parsed.currentTime;
existing.timestamp = parsed.timestamp;
this._map.set(k, existing);
}
}
*iterateKeys() {
yield* this._map.keys();
}
clear() {
this._map.clear();
this.events.emit('clear');
}
dispose() {
this.clear();
super.dispose();
}
}
/* ============================================================
* 文件排序 / 文件名 / 时间格式化
* ============================================================ */
function sortByLocale(list) {
return [...list].sort((a, b) =>
(a.server_filename || a.name || '').localeCompare(b.server_filename || b.name || '', undefined, { numeric: true })
);
}
const getFileName = (f) => f?.server_filename || f?.filename || f?.name || '未命名';
/** 从当前页面 URL(hash 路由)解析网盘目录路径,如 /山东语文/xxx */
function getDirPathFromUrl() {
try {
const hash = location.hash || '';
const q = hash.split('?')[1];
if (q) {
const path = new URLSearchParams(q).get('path');
if (path) return path;
}
// 旧版 URL: /disk/home?path=/xxx 或 ?dir=
const sp = new URLSearchParams(location.search);
return sp.get('path') || sp.get('dir') || null;
} catch (_) {
return null;
}
}
/** 目录列表接口缓存:dirPath → Promise */
const _dirListCache = new Map();
/**
* 调用网盘目录列表接口获取文件数组(含 fs_id/server_filename/size/path)。
* 这是最可靠的途径:不依赖页面内部 store 结构,只要登录 cookie 有效。
*/
function fetchDirList(dirPath) {
if (!dirPath) return Promise.resolve(null);
const key = dirPath;
if (!_dirListCache.has(key)) {
const tryList = async () => {
// 两套参数逐个尝试;记录原始响应便于排查
const variants = [
`/api/list?order=name&desc=0&showempty=0&web=1&page=1&num=1000&dir=${encodeURIComponent(dirPath)}&clienttype=0&app_id=250528`,
`/api/list?dir=${encodeURIComponent(dirPath)}&num=1000&clienttype=0&app_id=250528`,
];
for (const url of variants) {
try {
const text = await gmFetch(url);
let json = null;
try { json = JSON.parse(text); } catch (_) { }
const list = json?.info || json?.list;
lbDiag(
`/api/list(${decodeURIComponent(dirPath).slice(0, 30)}): errno=${json?.errno ?? 'ok'}, ` +
`条数=${Array.isArray(list) ? list.length : 0}, 响应=${(text || '').slice(0, 120)}`,
);
if (Array.isArray(list) && list.length) return list;
} catch (e) {
lbDiag(`/api/list 请求失败: ${e?.message || e}`);
}
}
return null;
};
_dirListCache.set(
key,
tryList().catch(() => {
_dirListCache.delete(key);
return null;
}),
);
}
return _dirListCache.get(key);
}
function formatTime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
sec = Math.floor(sec);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
function showTip(msg) {
const fns = [
() => unsafeWindow.require('system-core:system/uiService/tip/tip.js').show({ mode: 'success', msg }),
() => unsafeWindow.toast?.show({ type: 'svip', message: msg, duration: 3000 }),
() => unsafeWindow.$bus?.$Toast?.addToast?.({ type: 'success', content: msg, durtime: 3000 }),
];
fns.some((fn) => safe(fn));
}
/* ============================================================
* URL 构建
* ============================================================ */
const checkJsToken = () => {
if (!unsafeWindow.jsToken) {
showTip('登录状态异常,请刷新页面');
return false;
}
return true;
};
/**
* 拉取 jsToken:新版网盘(/disk/main Vue 页面)没有全局 jsToken 变量,
* 需通过网盘自身的模板变量接口获取(依赖登录 cookie)
*/
let _jsTokenPromise = null;
async function ensureJsToken() {
if (unsafeWindow.jsToken) return unsafeWindow.jsToken;
lbDiag('jsToken 缺失,调用 gettemplatevariable 拉取…');
if (!_jsTokenPromise) {
_jsTokenPromise = (async () => {
// 多参数组合逐个尝试,并记录原始响应(便于定位返回结构变化)
const variants = [
`/api/gettemplatevariable?clienttype=0&app_id=250528&web=1&fields=${encodeURIComponent('["jsToken"]')}`,
`/api/gettemplatevariable?clienttype=0&app_id=250528&fields=${encodeURIComponent('["jsToken"]')}`,
`/api/gettemplatevariable?fields=${encodeURIComponent('["jsToken"]')}`,
];
for (const url of variants) {
try {
const text = await gmFetch(url);
lbDiag(`gettemplatevariable 响应: ${(text || '').slice(0, 160)}`);
let json = null;
try { json = JSON.parse(text); } catch (_) { }
let t = json?.result?.jsToken || json?.jsToken || json?.data?.jsToken;
if (!t && typeof json?.result === 'string') {
try { t = JSON.parse(json.result)?.jsToken; } catch (_) { }
}
if (t) {
unsafeWindow.jsToken = t;
lbDiag('jsToken 获取成功');
return t;
}
} catch (e) {
lbDiag(`gettemplatevariable 请求异常: ${e?.message || e}`);
}
}
// 兜底1:嗅探器已捕获
if (_sniffedTokens.jsToken) {
unsafeWindow.jsToken = _sniffedTokens.jsToken;
lbDiag('jsToken 来源: 页面请求嗅探');
return _sniffedTokens.jsToken;
}
// 兜底2:旧版页面 HTML 内嵌令牌
const tk = await fetchPageTokens();
if (tk.jsToken) {
unsafeWindow.jsToken = tk.jsToken;
lbDiag('jsToken 来源: /disk/home 页面扫描');
return tk.jsToken;
}
return null;
})().then((t) => {
_jsTokenPromise = null;
return t;
});
}
return _jsTokenPromise;
}
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}`;
}
/* ============================================================
* .lb 文件在线流式播放支持(不下载整文件)
* .lb 通常是改名的普通视频文件(mp4/mkv 等),百度不会为陌生扩展名转码 HLS。
* 策略:获取原始文件下载直链后——
* - 首选 mediabunny 流式转封装 → MSE(Range 边读边转,秒开/可拖动)
* - 兜底直链流式播放(浏览器自带 Range 分段加载)
* - 都失败 → 明确报错,绝不整文件下载
* ============================================================ */
const isLbFile = (file) => /\.(lb)$/i.test(getFileName(file) || '');
const gmFetchJson = (url) =>
gmFetch(url).then((text) => {
try { return JSON.parse(text); } catch (_) { return null; }
});
async function getSignTimestamp() {
// 1) 旧版页面全局 locals
const locals = unsafeWindow.locals;
const get = (k) => {
try {
return typeof locals?.get === 'function' ? locals.get(k) : locals?.[k];
} catch (_) {
return null;
}
};
const sign = get('sign');
const timestamp = get('timestamp');
if (sign && timestamp) return { sign, timestamp };
// 2) 嗅探器从页面自身请求中捕获的(sign/timestamp 成对)
if (_sniffedTokens.sign && _sniffedTokens.timestamp) {
lbDiag('sign/timestamp 来源: 页面请求嗅探');
return { sign: _sniffedTokens.sign, timestamp: _sniffedTokens.timestamp };
}
// 3) gettemplatevariable(新版页面可能返回空,多参数尝试)
for (const fields of ['["sign","timestamp"]', '["sign","timestamp","jsToken"]']) {
const url = `/api/gettemplatevariable?clienttype=0&app_id=250528&web=1&fields=${encodeURIComponent(fields)}`;
try {
const json = await gmFetchJson(url);
if (json?.result?.sign && json?.result?.timestamp) {
lbDiag('sign/timestamp 来源: gettemplatevariable');
return { sign: json.result.sign, timestamp: json.result.timestamp };
}
} catch (e) { }
}
// 4) 旧版页面 HTML 内嵌令牌(/disk/home)
const tk = await fetchPageTokens();
if (tk.sign && tk.timestamp) {
lbDiag('sign/timestamp 来源: /disk/home 页面扫描');
return { sign: tk.sign, timestamp: tk.timestamp };
}
return { sign: null, timestamp: null };
}
/** 网盘下载接口 errno → 中文原因 */
const DLINK_ERRORS = {
'-6': '登录态失效,请刷新页面重新登录',
'2': '参数错误(文件可能已被删除或移动)',
'-7': '文件名含特殊字符,接口拒绝访问',
'110': '账号被封禁或异常',
'113': '该文件不允许下载(违规内容或需会员)',
'134': '无下载权限(此文件类型需会员账号)',
'135': '账号未实名认证,无法下载此文件',
'136': '下载流量超限(普通账号每日限额)',
};
/* ============================================================
* xpan 兜底链路(不依赖 sign/jsToken)
* 新版 /disk/main 拿不到 sign → 走开放平台 OAuth + xpan filemetas。
* OAuth 复用网盘登录 cookie(.baidu.com 域,openapi.baidu.com 可收到),
* 隐式授权一次后 access_token 有效约 30 天,缓存到 GM 存储。
* ============================================================ */
const XPAN_AUTHORIZE_URL =
'https://openapi.baidu.com/oauth/2.0/authorize?client_id=IlLqBbU3GjQ0t46TRwFateTprHWl39zF' +
'&response_type=token&redirect_uri=oob&confirm_login=0&scope=basic,netdisk';
/** GM 请求封装:返回 { status, text, finalUrl } */
function gmRequest(opts) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
timeout: 15000,
...opts,
onload: (r) => resolve(r),
onerror: (r) => reject(new Error(`HTTP ${r?.status ?? 'error'}`)),
ontimeout: () => reject(new Error('timeout')),
});
});
}
let _accessTokenPromise = null;
/** 获取(或强制刷新)网盘 access_token */
function getPanAccessToken(force) {
if (!force) {
try {
const cached = GM_getValue('bdp_xpan_token');
if (cached?.token && Date.now() < cached.expires) return Promise.resolve(cached.token);
} catch (_) { }
}
if (!force && _accessTokenPromise) return _accessTokenPromise;
const extract = (u) => (String(u || '').match(/access_token=([^]+)/) || [])[1] || null;
_accessTokenPromise = (async () => {
lbDiag('xpan: 开始 OAuth 授权流程…');
// 1) 若此前已授权过,authorize 会直接 302 到 login_success#access_token=…
try {
const r1 = await gmRequest({ url: XPAN_AUTHORIZE_URL });
const t = extract(r1.finalUrl);
if (t) {
lbDiag('xpan: OAuth 免确认授权成功(已有授权记录)');
GM_setValue('bdp_xpan_token', { token: t, expires: Date.now() + 29 * 86400000 });
return t;
}
} catch (e) {
lbDiag(`xpan: OAuth 第一步异常: ${e?.message || e}`);
}
// 2) 解析授权页表单,提交同意授权
try {
const r2 = await gmRequest({ url: XPAN_AUTHORIZE_URL });
const html = r2.responseText || '';
const bdstoken = (html.match(/name="bdstoken"\s+value="([^"]+)"/) || [])[1];
const client_id = (html.match(/name="client_id"\s+value="([^"]+)"/) || [])[1];
if (!bdstoken || !client_id) {
lbDiag(`xpan: 授权页解析失败(可能未登录),长度=${html.length}`);
return null;
}
const body =
`grant_permissions_arr=netdisk&bdstoken=${encodeURIComponent(bdstoken)}` +
`&client_id=${encodeURIComponent(client_id)}&response_type=token&display=page&grant_permissions=basic%2Cnetdisk`;
await gmRequest({
method: 'POST',
url: XPAN_AUTHORIZE_URL,
data: body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const r3 = await gmRequest({ url: XPAN_AUTHORIZE_URL });
const t = extract(r3.finalUrl);
if (t) {
lbDiag('xpan: OAuth 授权成功,access_token 已缓存');
GM_setValue('bdp_xpan_token', { token: t, expires: Date.now() + 29 * 86400000 });
return t;
}
lbDiag(`xpan: 授权后仍未拿到 token,finalUrl=${String(r3.finalUrl).slice(0, 80)}`);
return null;
} catch (e) {
lbDiag(`xpan: OAuth 授权异常: ${e?.message || e}`);
return null;
}
})();
return _accessTokenPromise;
}
/** xpan filemetas 获取 dlink(个人网盘,无需 sign) */
async function resolveDlinkViaXpan(fsId) {
const query = (token) =>
`/rest/2.0/xpan/multimedia?method=filemetas&dlink=1&access_token=${encodeURIComponent(token)}&fsids=${encodeURIComponent(JSON.stringify([fsId]))}`;
let token = await getPanAccessToken();
if (!token) return { dlink: null, error: '拿不到 access_token(OAuth 授权失败,请确认网盘已登录)' };
for (let attempt = 0; attempt < 2; attempt++) {
try {
const json = await gmFetchJson(query(token));
const dlink = Array.isArray(json?.list) ? json.list[0]?.dlink : null;
if (dlink) {
lbDiag(`xpan filemetas 成功: dlink 长度 ${dlink.length}`);
return { dlink, error: null };
}
const errno = String(json?.errno ?? 'unknown');
lbDiag(`xpan filemetas 失败(${attempt + 1}/2): errno=${errno}, 响应=${JSON.stringify(json).slice(0, 200)}`);
// 9019/-6/111: token 失效 → 强制重新授权再试一次
if (['9019', '-6', '111'].includes(errno)) {
token = await getPanAccessToken(true);
if (!token) return { dlink: null, error: 'access_token 刷新失败' };
continue;
}
return { dlink: null, error: DLINK_ERRORS[errno] || `xpan 接口返回错误(errno ${errno})` };
} catch (e) {
lbDiag(`xpan 请求异常(${attempt + 1}/2): ${e?.message || e}`);
if (attempt === 1) return { dlink: null, error: `xpan 请求异常:${e?.message || e}` };
}
}
return { dlink: null, error: 'xpan 获取直链失败' };
}
/**
* xpan filemetas 补全文件元数据(path/size)。
* 页面 DOM 兜底路径构造的 file 对象常缺这两项——locatedownload/mediainfo
* 通道需要 path,MSE 流式播放需要 size,缺了会导致通道静默跳过。
*/
async function enrichFileMeta(fsId) {
try {
const token = await getPanAccessToken();
if (!token) return null;
const url = `/rest/2.0/xpan/multimedia?method=filemetas&access_token=${encodeURIComponent(token)}&fsids=${encodeURIComponent(JSON.stringify([fsId]))}`;
const json = await gmFetchJson(url);
const item = Array.isArray(json?.list) ? json.list[0] : null;
if (item) {
const meta = {};
if (item.path) meta.path = item.path;
if (Number(item.size) > 0) meta.size = Number(item.size);
if (Object.keys(meta).length) {
lbDiag(`元数据补全: path=${meta.path}, size=${meta.size}`);
return meta;
}
}
} catch (e) {
lbDiag(`元数据补全失败: ${e?.message || e}`);
}
return null;
}
/* ---------- 直链多通道获取 + 逐条验证 ----------
* 实测教训(v2.9 日志):xpan 第三方公共应用 dlink 从浏览器 GM 请求(自动带
* Cookie + netdisk UA)可用(Range 探测 206),但本机无 Cookie 的 curl 一律
* 403 "user not exists"——之前的"必死通道"结论仅适用于无登录态环境。
* 四条通道依次尝试,每条候选直链都先 Range 探测 16 字节验证:
* ① 网页 /api/download(sign+timestamp,网页身份,最可靠)
* ② APP PCS locatedownload(浏览器 Cookie + 客户端 UA → 原文件直链)
* ③ TV /api/mediainfo(Cookie → 流直链,未转码文件可能无效)
* ④ 开放平台 xpan(第三方应用身份 + 浏览器 Cookie,实测可用)
*/
const UA_NETDISK = 'netdisk';
const UA_NETDISK_APP = 'netdisk;P2SP;2.2.91.136;netdisk;11.30.2;22021211RC;android-android;12;JSbridge4.4.0;jointBridge;1.1.0;';
/** GM GET JSON:跨域请求由管理器自动携带浏览器 Cookie;解析失败也返回原始文本 */
function gmGetJson(url, headers, timeout = 15000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: headers || {},
responseType: 'text',
timeout,
onload: (r) => {
const text = String(r.responseText || '');
let json = null;
try { json = JSON.parse(text); } catch (_) { }
resolve({ status: r.status, json, text });
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('超时')),
});
});
}
/** 从文件头字节识别封装格式 */
function detectContainer(buf) {
if (buf.length >= 8) {
const tag = String.fromCharCode(buf[4], buf[5], buf[6], buf[7]);
if (tag === 'ftyp') return 'mp4';
}
if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) return 'mkv';
if (buf.length >= 1 && buf[0] === 0x47) return 'ts';
return 'unknown';
}
/**
* 深度签名扫描:在缓冲区任意偏移处寻找已知封装(.lb 私有头剥离用)。
* 返回 { offset, kind }(最小偏移优先)或 null。kind: mp4/mkv/flv/ts。
*/
function detectContainerAt(buf) {
const n = buf.length;
// MP4/MOV:size(u32) + 'ftyp'(size 合理性校验防误报,ftyp 盒很小)
const ftypMax = Math.min(n - 8, 262144);
for (let i = 4; i <= ftypMax; i++) {
if (buf[i] === 0x66 && buf[i + 1] === 0x74 && buf[i + 2] === 0x79 && buf[i + 3] === 0x70) {
const sz = (buf[i - 4] << 24 >>> 0) + (buf[i - 3] << 16) + (buf[i - 2] << 8) + buf[i - 1];
if (sz >= 8 && sz <= 65536) return { offset: i - 4, kind: 'mp4' };
}
}
// MKV/WebM:EBML 魔数 1A 45 DF A3
for (let i = 0; i < n - 4; i++) {
if (buf[i] === 0x1a && buf[i + 1] === 0x45 && buf[i + 2] === 0xdf && buf[i + 3] === 0xa3) return { offset: i, kind: 'mkv' };
}
// FLV:'F' 'L' 'V' 0x01
for (let i = 0; i < n - 4; i++) {
if (buf[i] === 0x46 && buf[i + 1] === 0x4c && buf[i + 2] === 0x56 && buf[i + 3] === 0x01) return { offset: i, kind: 'flv' };
}
// MPEG-TS:以 188 字节为周期连续出现 0x47 同步字节(连续 24 个包 ≈ 4.5KB 才认定)
const tsMax = Math.min(n, 188 * 40);
for (let p = 0; p < 188 && p < tsMax; p++) {
let count = 0;
for (let pos = p; pos < tsMax; pos += 188) {
if (buf[pos] !== 0x47) break;
count++;
}
if (count >= 24) return { offset: p, kind: 'ts', packets: count };
}
return null;
}
/** TS 同步字节 0x47 的 188 周期命中数(诊断用,返回最优相位命中数) */
function countTsSync(buf) {
const n = Math.min(buf.length, 188 * 30);
let best = 0;
for (let p = 0; p < 188 && p < n; p++) {
let count = 0;
for (let pos = p; pos < n; pos += 188) {
if (buf[pos] !== 0x47) break;
count++;
}
if (count > best) best = count;
}
return best;
}
/**
* 单字节 XOR 解混淆探测(私有格式常见的廉价混淆)。
* 对前 1KB 逐 key 异或后查签名,返回 { key, kind } 或 null。
*/
function detectXorObfuscation(buf) {
if (!buf || buf.length < 8) return null;
for (let k = 1; k < 256; k++) {
if ((buf[0] ^ k) === 0x1a && (buf[1] ^ k) === 0x45 && (buf[2] ^ k) === 0xdf && (buf[3] ^ k) === 0xa3) return { key: k, kind: 'mkv' };
if (buf.length >= 8 && (buf[4] ^ k) === 0x66 && (buf[5] ^ k) === 0x74 && (buf[6] ^ k) === 0x79 && (buf[7] ^ k) === 0x70) return { key: k, kind: 'mp4' };
if (buf.length > 376 && (buf[0] ^ k) === 0x47 && (buf[188] ^ k) === 0x47 && (buf[376] ^ k) === 0x47) return { key: k, kind: 'ts' };
}
return null;
}
/** 前 N 字节 hex+ASCII 转储(诊断 .lb 私有头用) */
function hexDump(buf, maxBytes) {
const n = Math.min(buf.length, maxBytes || 96);
const lines = [];
for (let base = 0; base < n; base += 16) {
const hex = [], asc = [];
for (let i = 0; i < 16 && base + i < n; i++) {
const b = buf[base + i];
hex.push(b.toString(16).padStart(2, '0'));
asc.push(b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.');
}
lines.push(`${base.toString(16).padStart(4, '0')} ${hex.join(' ')} |${asc.join('')}|`);
}
return lines.join('\n');
}
/* ---------- .lb 结构修复工具(v2.11)----------
* 实测(v2.10 日志):.lb = 59 字节私有头 + MP4,但剥离后 mediabunny 报
* "无音视频轨道"——中后段结构仍被私有数据破坏。两种可能:
* H1 周期块头:每 L 字节重复出现私有头(魔数 = 文件前 8 字节)
* H2 私有尾巴:文件尾在 moov 之后追加了索引块,截断 box 链
* 以下工具对两种情况做"虚拟剥离/修剪",流式与整文件两条路复用。
*/
/** 在 buf 中查找 sig 出现的多个位置(首字节快速跳过,跳过重叠命中) */
function findBytes(buf, sig, from = 0, maxHits = 16) {
const hits = [];
const n = buf.length - sig.length;
const s0 = sig[0];
outer: for (let i = Math.max(0, from); i <= n; i++) {
if (buf[i] !== s0) continue;
for (let j = 1; j < sig.length; j++) {
if (buf[i + j] !== sig[j]) continue outer;
}
hits.push(i);
if (hits.length >= maxHits) break;
i += sig.length - 1;
}
return hits;
}
function readU32(buf, off) {
return ((buf[off] << 24) >>> 0) + (buf[off + 1] << 16) + (buf[off + 2] << 8) + buf[off + 3];
}
function concatBytes(parts) {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
/** 解析 MP4 顶层 box 链(诊断用),返回 { summary, boxes } */
function parseBoxChain(buf) {
const boxes = [];
let off = 0;
while (off + 8 <= buf.length && boxes.length < 24) {
const size = readU32(buf, off);
let type = '';
for (let i = 0; i < 4; i++) type += String.fromCharCode(buf[off + 4 + i]);
if (!/^[\x20-\x7e]{4}$/.test(type)) {
return { summary: `${boxes.map((b) => b.type).join(',')} ⟂ ${off} 处断链(类型非法)`, boxes };
}
if (size < 8 || off + size > buf.length) {
boxes.push({ type, size });
return { summary: `${boxes.map((b) => b.type).join(',')} ⟂ ${off}(${type}, ${size < 8 ? `size 非法 ${size}` : `size=${size} 超出缓冲`})`, boxes };
}
boxes.push({ type, size });
off += size;
}
return { summary: boxes.map((b) => `${b.type}(${b.size})`).join(' → '), boxes };
}
/** 在 buf 中从 searchFrom 起找最后一个合法 moov box([size u32]['moov'])。返回 { start, size } | null */
function findMoovBox(buf, searchFrom = 0) {
const sig = [0x6d, 0x6f, 0x6f, 0x76]; // 'moov'
let found = null;
for (const p of findBytes(buf, sig, Math.max(4, searchFrom), 64)) {
const size = readU32(buf, p - 4);
if (size >= 8 && p - 4 + size <= buf.length) found = { start: p - 4, size };
}
return found;
}
/**
* 整文件本地修复 .lb(纯内存操作,流式失败后的兜底)。
* 依次尝试:头剥离 → 周期块头去块(+尾部修剪)→ 仅尾部修剪 → 仅剥离头。
* 返回 { buf, strip, kind, note } 或 null(结构无法识别)。
*/
function repairLbBuffer(raw) {
if (!raw || raw.length < 16) return null;
const head = raw.subarray(0, Math.min(262144, raw.length));
const hit = detectContainerAt(head);
const strip = hit && (hit.kind === 'mp4' || hit.kind === 'mkv') ? hit.offset : 0;
const kind = hit ? hit.kind : 'unknown';
if (!strip) {
const x = detectXorObfuscation(raw);
if (x && (x.kind === 'mp4' || x.kind === 'mkv')) {
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw[i] ^ x.key;
return { buf: out, strip: 0, kind: x.kind, note: `全文件 XOR 解密 key=0x${x.key.toString(16)}` };
}
return null;
}
// ① 周期块头去块:头 8 字节魔数在后续位置周期复现
const magic = raw.subarray(0, 8);
const occ = findBytes(raw, magic, 8, 32);
if (occ.length && occ[0] >= 4096) {
const L = occ[0];
const mult = occ.filter((o) => o % L === 0);
const outliers = occ.length - mult.length;
if (mult.length >= 2 && outliers <= 2) {
const parts = [];
for (let b = 0; b * L < raw.length; b++) {
const s = b * L + strip;
const e = Math.min(raw.length, (b + 1) * L);
if (e > s) parts.push(raw.subarray(s, e));
}
let out = concatBytes(parts);
let note = `剥离头${strip}B + 去块 L=${L}(${parts.length} 块${outliers ? `,${outliers} 个非周期魔数忽略` : ''})`;
// 去块后再做尾部修剪(去掉块状索引尾巴)
const mv = findMoovBox(out, Math.max(0, out.length - 1048576));
if (mv && mv.start + mv.size <= out.length - 16) {
note += ` + 剪尾${out.length - mv.start - mv.size}B`;
out = out.subarray(0, mv.start + mv.size);
}
lbDiag(`本地修复: ${note}`);
return { buf: out, strip, kind, note };
}
lbDiag(`本地修复: 魔数出现于 [${occ.slice(0, 8)}] 但非等间隔(L=${occ[0]}),跳过去块`);
} else {
lbDiag(`本地修复: 全文件内无私有头魔数复现(occ=${occ.length})`);
}
// ② 仅尾部修剪:moov 之后有多余私有尾巴 → 截到 moov 结束
const mv = findMoovBox(raw, Math.max(0, raw.length - 1048576));
if (mv && mv.start + mv.size <= raw.length - 16) {
const out = raw.subarray(strip, mv.start + mv.size);
const note = `剥离头${strip}B + 剪尾${raw.length - strip - out.length}B`;
lbDiag(`本地修复: ${note}`);
return { buf: out, strip, kind, note };
}
// ③ 仅剥离头
return { buf: raw.subarray(strip), strip, kind, note: `仅剥离头${strip}B` };
}
/** 整文件下载(双车道并行 Range 分片 + 进度回调),返回完整 Uint8Array */
async function downloadWholeFile(dlink, size, onProgress) {
const CHUNK = 8 * 1024 * 1024;
const out = new Uint8Array(size);
let next = 0;
let done = 0;
const worker = async () => {
while (true) {
const i = next++;
const start = i * CHUNK;
if (start >= size) return;
const end = Math.min(size, start + CHUNK);
const buf = await gmFetchRange(dlink, start, end);
out.set(buf, start);
done += end - start;
safe(() => onProgress?.(done, size), 'lb.dlProgress');
}
};
await Promise.all([worker(), worker()]);
return out;
}
/**
* 直链可用性验证(关键!):Range 取前 16 字节(netdisk UA)。
* 返回 { ok, kind, status, reason }——kind 顺带完成封装嗅探。
*/
function verifyDlink(url) {
return new Promise((resolve) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { Range: 'bytes=0-15', 'User-Agent': UA_NETDISK },
responseType: 'arraybuffer',
timeout: 15000,
onload: (r) => {
const status = r.status || 0;
const buf = new Uint8Array(r.response || new ArrayBuffer(0));
const kind = detectContainer(buf);
if ((status === 206 || status === 200) && buf.length > 0) {
resolve({ ok: true, kind, status, reason: '' });
} else {
resolve({ ok: false, kind, status, reason: `HTTP ${status}(链接被拒绝或已失效)` });
}
},
onerror: () => resolve({ ok: false, kind: 'unknown', status: 0, reason: '网络错误' }),
ontimeout: () => resolve({ ok: false, kind: 'unknown', status: 0, reason: '探测超时' }),
});
} catch (e) {
resolve({ ok: false, kind: 'unknown', status: 0, reason: String(e?.message || e) });
}
});
}
/** APP 通道:PCS locatedownload(浏览器 Cookie + 客户端 UA → 原文件直链,支持 Range) */
async function resolveDlinkViaLocated(file) {
const path = file?.path;
if (!path) return { dlink: null, note: '缺少文件路径' };
const url = `https://d.pcs.baidu.com/rest/2.0/pcs/file?app_id=250528&method=locatedownload&path=${encodeURIComponent(path)}&clienttype=17&version=2.2.91.136&use=1`;
try {
const { json, status, text } = await gmGetJson(url, { 'User-Agent': UA_NETDISK_APP });
const urls = Array.isArray(json?.urls) ? json.urls : [];
const dlink = urls.length ? (urls[0]?.url || (typeof urls[0] === 'string' ? urls[0] : null)) : null;
if (dlink) {
lbDiag(`locatedownload 成功: dlink 长度 ${dlink.length}`);
return { dlink, note: '' };
}
const code = String(json?.error_code ?? json?.errno ?? status ?? 'unknown');
const note = (code === '31045' || code === '-6')
? `code ${code}(Cookie 未随请求送达或登录态失效)`
: `code ${code}`;
lbDiag(`locatedownload 失败: ${note}, 响应=${(json ? JSON.stringify(json) : text).slice(0, 160)}`);
return { dlink: null, note };
} catch (e) {
return { dlink: null, note: e?.message || String(e) };
}
}
/** TV 通道:mediainfo(Cookie → 流直链;未转码文件可能无效) */
async function resolveDlinkViaMediaInfo(file) {
const path = file?.path;
if (!path) return { dlink: null, note: '缺少文件路径' };
const url = `/api/mediainfo?type=M3U8_FLV_264_480&path=${encodeURIComponent(path)}&clienttype=80&origin=dlna`;
let json = null;
try {
const r = await gmGetJson(location.origin + url, { 'User-Agent': UA_NETDISK_APP });
json = r.json;
} catch (_) { }
if (!json?.info?.dlink) {
// GM 未带 Cookie 时兜底:页面原生 fetch(必带 Cookie,UA 为浏览器)
try {
const r = await fetch(location.origin + url, { credentials: 'include' });
json = await r.json();
} catch (_) { }
}
const dlink = json?.info?.dlink || null;
if (dlink) {
lbDiag(`mediainfo 成功: dlink 长度 ${dlink.length}`);
return { dlink, note: '' };
}
const code = String(json?.errno ?? 'unknown');
lbDiag(`mediainfo 失败: errno=${code}`);
return { dlink: null, note: `errno ${code}` };
}
/**
* 获取原始文件下载直链(个人网盘 / 分享页),多通道 + 逐条验证。
* 返回 { dlink, kind, error }:kind 为验证时嗅探的封装格式。
*/
async function resolveDlink(file, flag) {
// 个人网盘下载只需 sign+timestamp;jsToken 仅分享页需要(try 而不阻塞)
safe(() => { ensureJsToken(); }, 'dlink.ensureJsToken');
if (flag === 'sharevideo' && !checkJsToken()) {
return { dlink: null, kind: null, error: '登录状态异常(拿不到 jsToken)' };
}
const fsId = file?.fs_id;
if (!fsId) return { dlink: null, kind: null, error: '缺少文件 ID(fs_id)' };
const attempts = [];
/** 验证候选直链;无直链/验证失败记入 attempts,成功返回 { dlink, kind } */
const tryCandidate = async (name, dlink, fallbackNote) => {
if (!dlink) {
attempts.push({ name, note: fallbackNote || '无直链' });
lbDiag(`直链通道[${name}]: 未获取到直链(${fallbackNote || '无直链'})`);
return null;
}
const v = await verifyDlink(dlink);
lbDiag(`直链验证[${name}]: ok=${v.ok}, kind=${v.kind}, status=${v.status}${v.reason ? ', ' + v.reason : ''}`);
if (v.ok) return { dlink, kind: v.kind };
attempts.push({ name, note: v.reason });
return null;
};
const failAll = () => ({
dlink: null,
kind: null,
error: '全部直链通道不可用:' + attempts.map((a) => `${a.name} ${a.note}`).join(';') + '。请确认网盘已登录后重试;若持续失败请复制诊断日志反馈',
});
const extractDlink = (json) => {
const d = json?.dlink;
return Array.isArray(d) ? (d[0]?.dlink || null) : typeof d === 'string' ? d : null;
};
// 分享页:只有 sign 分享链路
if (flag === 'sharevideo') {
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 { dlink: null, kind: null, error: '分享页参数缺失(sign/timestamp)' };
const url = `/share/download?channel=chunlei&web=1&app_id=250528&clienttype=0&uk=${share_uk}&fid=${fsId}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&jsToken=${unsafeWindow.jsToken}`;
const json = await gmFetchJson(url);
const hit = await tryCandidate('分享链路', extractDlink(json),
DLINK_ERRORS[String(json?.errno ?? 'unknown')] || `errno ${json?.errno ?? 'unknown'}`);
if (hit) return hit;
return failAll();
}
if (flag === 'mboxvideo') return { dlink: null, kind: null, error: '消息内视频暂不支持' };
// ① 网页通道:sign 可用走 /api/download(网页同源身份,最可靠)
const { sign, timestamp } = await getSignTimestamp();
if (sign && timestamp) {
const fidlist = encodeURIComponent(JSON.stringify([fsId]));
const url = `/api/download?sign=${sign}×tamp=${timestamp}&fidlist=${fidlist}&clienttype=0&app_id=250528&web=1`;
const json = await gmFetchJson(url);
const hit = await tryCandidate('网页sign', extractDlink(json),
DLINK_ERRORS[String(json?.errno ?? 'unknown')] || `errno ${json?.errno ?? 'unknown'}`);
if (hit) return hit;
} else {
attempts.push({ name: '网页sign', note: '未获取到 sign/timestamp(新版网盘页面常见)' });
}
// ② APP 通道:locatedownload(Cookie + 客户端 UA)
{
const r = await resolveDlinkViaLocated(file);
const hit = await tryCandidate('APP通道', r.dlink, r.note);
if (hit) return hit;
}
// ③ TV 通道:mediainfo
{
const r = await resolveDlinkViaMediaInfo(file);
const hit = await tryCandidate('TV通道', r.dlink, r.note);
if (hit) return hit;
}
// ④ 开放平台 xpan(第三方公共应用身份;多数已无下载权限 → 403,仅兜底)
{
const r = await resolveDlinkViaXpan(fsId);
const hit = await tryCandidate('xpan开放平台', r.dlink, r.error);
if (hit) return hit;
// 变体:dlink 追加 access_token(社区流传的补参方式)
if (r.dlink) {
const token = await getPanAccessToken();
if (token) {
const hit2 = await tryCandidate('xpan+token', r.dlink + '&access_token=' + encodeURIComponent(token));
if (hit2) return hit2;
}
}
}
return failAll();
}
/** .lb 诊断日志环形缓冲:记录解析每一步,失败时供用户复制反馈;同时实时显示到遮罩 */
const LB_DIAG = [];
const lbDiag = (msg) => {
const t = new Date();
const ts = `${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
const line = `[${ts}] ${msg}`;
LB_DIAG.push(line);
if (LB_DIAG.length > 60) LB_DIAG.shift();
// 诊断必须真实输出到控制台(曾因 log.info 为空函数导致排障全靠猜)
safe(() => console.info('[bdplayer-lb]', msg), 'lbDiag.console');
// 实时显示到遮罩日志区(黑屏上可见每一步,不挡操作)
try {
const logEl = document.getElementById('bdplayer-lb-diaglog');
if (logEl) {
const div = document.createElement('div');
div.textContent = line;
logEl.appendChild(div);
while (logEl.childNodes.length > 12) logEl.removeChild(logEl.firstChild);
logEl.scrollTop = logEl.scrollHeight;
}
} catch (_) { }
};
const lbDiagDump = () => {
const head = [
`脚本版本: ${GM_info?.script?.version || 'unknown'}`,
`页面: ${location.href.slice(0, 200)}`,
`UA: ${navigator.userAgent.slice(0, 100)}`,
`jsToken: ${unsafeWindow.jsToken ? '已获取' : '缺失'}`,
'---',
];
return head.concat(LB_DIAG).join('\n');
};
/** 嗅探器捕获的令牌:jsToken / sign+timestamp 成对保存 */
const _sniffedTokens = {};
/**
* jsToken 嗅探器:在 document-start 劫持页面自身的 XHR/fetch,
* 从网盘自己的接口响应中捕获 jsToken / sign / timestamp
* (最可靠的来源——页面自己请求一定带全上下文)。
* 必须在页面脚本执行前安装(本脚本 @run-at document-start)。
*/
function installJsTokenSniffer() {
const capture = (text) => {
try {
if (typeof text !== 'string' || text.length > 2_000_000) return;
let m;
if (!_sniffedTokens.jsToken && (m = text.match(/"jsToken"\s*:\s*"([^"]{8,})"/))) {
_sniffedTokens.jsToken = m[1];
if (!unsafeWindow.jsToken) {
unsafeWindow.jsToken = m[1];
lbDiag('已从页面自身请求中捕获 jsToken');
}
}
// sign 与 timestamp 必须成对捕获(来自同一次响应)
if (!_sniffedTokens.sign) {
const s = text.match(/"sign"\s*:\s*"([^"]{8,})"/);
const t = text.match(/"timestamp"\s*:\s*(\d{10,13})/);
if (s && t) {
_sniffedTokens.sign = s[1];
_sniffedTokens.timestamp = t[1];
lbDiag('已从页面自身请求中捕获 sign/timestamp');
}
}
} catch (_) { }
};
try {
const XO = unsafeWindow.XMLHttpRequest;
const origOpen = XO.prototype.open;
const origSend = XO.prototype.send;
XO.prototype.open = function (method, u, ...rest) {
this.__bdp_url = String(u || '');
return origOpen.call(this, method, u, ...rest);
};
XO.prototype.send = function (...args) {
// 注意:页面 XHR 常用 responseType='json',此时读 responseText 会抛
// InvalidStateError(曾刷屏报错且漏抓令牌)——按类型取值
this.addEventListener('load', function () {
try {
const t = this.responseType;
if (t === '' || t === 'text') capture(this.responseText);
else if (t === 'json' && this.response) capture(JSON.stringify(this.response));
} catch (_) { }
});
return origSend.apply(this, args);
};
} catch (e) { }
try {
const origFetch = unsafeWindow.fetch;
if (origFetch) {
unsafeWindow.fetch = function (...args) {
return origFetch.apply(this, args).then((res) => {
try { res.clone().text().then(capture).catch(() => { }); } catch (_) { }
return res;
});
};
}
} catch (e) { }
}
// 立即安装:页面自身的首次接口请求发生在 document-start 之后、load 之前
installJsTokenSniffer();
/**
* 页面令牌兜底:新版 /disk/main 的 gettemplatevariable 只返回空数组,
* 但旧版页面(/disk/home)的 HTML 内嵌了 sign/timestamp/jsToken(yunData)。
* 同时扫描当前页 DOM 中 SSR 注入的令牌。
*/
let _pageTokensPromise = null;
function extractTokens(text) {
const tk = {};
let m;
if ((m = text.match(/"jsToken"\s*:\s*"([^"]{8,})"/))) tk.jsToken = m[1];
const s = text.match(/"sign"\s*:\s*"([^"]{8,})"/);
const t = text.match(/"timestamp"\s*:\s*(\d{10,13})/);
if (s && t) { tk.sign = s[1]; tk.timestamp = t[1]; }
if ((m = text.match(/"bdstoken"\s*:\s*"([^"]{8,})"/))) tk.bdstoken = m[1];
return tk;
}
function fetchPageTokens() {
if (_pageTokensPromise) return _pageTokensPromise;
_pageTokensPromise = (async () => {
// 1) 当前页面 DOM 内嵌令牌(SSR 数据)
try {
const html = document.documentElement.outerHTML;
const tk = extractTokens(html);
lbDiag(`DOM 令牌扫描: jsToken=${!!tk.jsToken}, sign=${!!tk.sign}, bdstoken=${!!tk.bdstoken}`);
if (tk.jsToken || tk.sign) return tk;
} catch (e) { }
// 2) 旧版页面 HTML(同源 fetch,带登录 cookie)
for (const u of ['/disk/home', '/disk/home#index', '/api/gettemplatevariable?clienttype=0&app_id=250528&web=1&fields=' + encodeURIComponent('["sign","timestamp","jsToken"]')]) {
try {
const r = await fetch(location.origin + u, { credentials: 'include' });
const text = await r.text();
const tk = extractTokens(text);
lbDiag(`页面令牌扫描(${u.slice(0, 30)}): jsToken=${!!tk.jsToken}, sign=${!!tk.sign}, 长度=${text.length}, 头部=${text.slice(0, 60).replace(/\s+/g, ' ')}`);
if (tk.jsToken || tk.sign) return tk;
} catch (e) {
lbDiag(`页面令牌扫描失败(${u.slice(0, 30)}): ${e?.message || e}`);
}
}
return {};
})();
return _pageTokensPromise;
}
/* ============================================================
* .lb → MP4 浏览器端转封装(mediabunny + MediaSource)
* .lb 本质是改名的普通视频(多为 MP4/MKV)。此模块通过 GM 跨域
* Range 请求流式读取网盘原文件,mediabunny 边下边解封装并转成
* fragmented MP4 喂给 MediaSource:
* - 秒开:读到文件头 + 首个分片即可起播,无需整文件下载
* - 全速:Range 请求带 netdisk UA,绕开浏览器直链限速
* - 可拖动:跳到未缓冲区时用 trim 从目标位置重启转封装流
* 兜底链:MSE 失败 → 直连流式播放 → 明确报错(绝不整文件下载)
* ============================================================ */
// 多 CDN 源:jsdelivr 主域在国内常被墙,fastly/gcore 端点与 unpkg 依次兜底
const MB_VER = '1.55.6';
const MB_MJS_URLS = [
`https://cdn.jsdelivr.net/npm/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.mjs`,
`https://fastly.jsdelivr.net/npm/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.mjs`,
`https://gcore.jsdelivr.net/npm/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.mjs`,
`https://unpkg.com/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.mjs`,
];
const MB_CJS_URLS = [
`https://cdn.jsdelivr.net/npm/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.cjs`,
`https://fastly.jsdelivr.net/npm/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.cjs`,
`https://unpkg.com/mediabunny@${MB_VER}/dist/bundles/mediabunny.min.cjs`,
];
let _mbModulePromise = null;
/** 懒加载 mediabunny(官方只发行 ESM/CJS 包,无 IIFE):
* 1) 逐源动态 import(页面 CSP 允许时最快)
* 2) 逐源 GM 拉取 ESM 源码 + blob URL import(绕开对远程模块的 CSP)
* 3) 逐源 GM 拉取 CJS 包 + Function 求值(最后手段,需 unsafe-eval) */
function loadMediabunny() {
if (_mbModulePromise) return _mbModulePromise;
_mbModulePromise = (async () => {
const host = (u) => { try { return new URL(u).host; } catch (_) { return u; } };
// 1) 动态 import
for (const url of MB_MJS_URLS) {
try {
return await import(url);
} catch (e) {
lbDiag(`mediabunny import 失败(${host(url)}): ${e?.message || e}`);
}
}
// 2) GM 拉取 ESM + blob import
for (const url of MB_MJS_URLS) {
try {
const r = await gmRequest({ url, timeout: 30000 });
const code = r?.responseText || '';
if (code.length < 100000 || !/mediabunny|Mp4OutputFormat|Conversion/.test(code.slice(0, 8000))) {
throw new Error('包内容异常或过短');
}
const blobUrl = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
try {
return await import(blobUrl);
} finally {
// 模块已求值,稍后释放 blob URL
setTimeout(() => safe(() => URL.revokeObjectURL(blobUrl), 'mb.revoke'), 60000);
}
} catch (e) {
lbDiag(`mediabunny GM+blob 加载失败(${host(url)}): ${e?.message || e}`);
}
}
// 3) GM 拉取 CJS + Function 求值
for (const url of MB_CJS_URLS) {
try {
const r = await gmRequest({ url, timeout: 30000 });
const code = r?.responseText || '';
if (code.length < 100000) throw new Error('CJS 包拉取过短');
const mod = { exports: {} };
new Function('module', 'exports', code)(mod, mod.exports);
if (mod.exports?.Input && mod.exports?.Conversion) return mod.exports;
throw new Error('CJS 求值结果异常');
} catch (e) {
lbDiag(`mediabunny CJS 加载失败(${host(url)}): ${e?.message || e}`);
}
}
throw new Error('mediabunny 所有 CDN 源加载失败(jsdelivr/unpkg 均不可达)');
})().catch((e) => {
_mbModulePromise = null; // 失败后允许下次重试
throw e;
});
return _mbModulePromise;
}
/** GM 跨域 Range 读取直链(netdisk UA 全速);end 为开区间;失败自动重试 1 次 */
function gmFetchRange(url, start, end) {
const once = () => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { Range: `bytes=${start}-${end - 1}`, 'User-Agent': 'netdisk' },
responseType: 'arraybuffer',
timeout: 60000,
onload: (r) => {
if (r.status >= 200 && r.status < 300 && r.response) resolve(new Uint8Array(r.response));
else reject(new Error(`分片读取失败 HTTP ${r.status}`));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('分片读取超时')),
});
});
return once().catch((e) => once().catch(() => { throw e; }));
}
/** .lb → fMP4 流式转封装播放器(见块首注释) */
class LbMseStreamer extends Disposable {
constructor(dlink, file) {
super();
this.dlink = dlink;
this.file = file;
this.mb = null;
this.input = null;
this.mediaSource = null;
this.url = null; // MediaSource objectURL,交给 Artplayer 当 src
this.sourceBuffer = null;
this.conversion = null;
this.duration = 0;
this.trimStart = 0; // 当前流对应原片的起始秒
this.audioTranscode = null; // 源音频 MSE 不支持(AC3/DTS 等)时转 AAC
this.mime = '';
this._video = null;
this._fatal = null;
this._appendQueue = Promise.resolve();
this._queueEpoch = 0; // 重启流后旧的追加任务作废
this._restarting = false;
this._pendingSeek = null;
this._applyingRestartSeek = false;
this._wdTimer = null;
}
/** 读文件头、校验编码兼容性、创建 MediaSource。成功返回 { url, duration } */
async prepare() {
if (typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported) {
throw new Error('当前浏览器不支持 MediaSource');
}
this.mb = await loadMediabunny();
const MB = this.mb;
let size = Number(this.file?.size) || 0;
if (!Number.isFinite(size) || size <= 0) size = await this._probeSize();
if (!size) throw new Error('无法获知文件大小(Range 探测失败)');
lbDiag(`MSE: 文件 ${(size / 1048576).toFixed(1)}MB,流式读取文件头…`);
const source = new MB.CustomSource({
getSize: () => size,
read: (start, end) => gmFetchRange(this.dlink, start, end),
maxCacheSize: 64 * 1024 * 1024,
prefetchProfile: 'network',
});
this.input = new MB.Input({ formats: MB.ALL_FORMATS, source });
if (!(await this._quietly(this.input.canRead(), 12000))) {
// .lb 私有封装两步自救:① 剥离私有头 ② 单字节 XOR 解混淆
const probe = await this._probeContainerOffset(size);
const hit = probe?.hit || null;
const xor = probe?.xor || null;
const retryInput = (makeSource, what) => {
const src = new MB.CustomSource(makeSource());
this.input = new MB.Input({ formats: MB.ALL_FORMATS, source: src });
return this._quietly(this.input.canRead(), 12000).then((ok) => {
if (!ok) throw new Error(`${what}后仍无法识别封装(详见控制台诊断日志)`);
});
};
try {
if (hit && (hit.kind === 'mp4' || hit.kind === 'mkv') && hit.offset > 0) {
const off = hit.offset;
lbDiag(`MSE: 在偏移 ${off} 检测到 ${hit.kind} 封装,剥离 .lb 私有头重试…`);
this._stripOffset = off;
await retryInput(() => ({
getSize: () => size - off,
read: (start, end) => gmFetchRange(this.dlink, start + off, end + off),
maxCacheSize: 64 * 1024 * 1024,
prefetchProfile: 'network',
}), '剥离私有头');
} else if (xor && (xor.kind === 'mp4' || xor.kind === 'mkv')) {
const k = xor.key;
lbDiag(`MSE: 检测到单字节 XOR 混淆(key=0x${k.toString(16)}),解密为 ${xor.kind} 重试…`);
this._xorKey = k;
await retryInput(() => ({
getSize: () => size,
read: async (start, end) => {
const raw = await gmFetchRange(this.dlink, start, end);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw[i] ^ k;
return out;
},
maxCacheSize: 64 * 1024 * 1024,
prefetchProfile: 'network',
}), 'XOR 解混淆');
} else {
throw null; // 走统一诊断
}
} catch (e) {
if (e && e.message) {
await this._dumpHeaderDiag(size, hit || xor);
throw e;
}
await this._dumpHeaderDiag(size, hit || xor);
const found = hit || xor;
const hint = found?.kind === 'ts'
? `:检测到 MPEG-TS 流${hit ? `(偏移 ${hit.offset})` : ''},当前转封装组件不支持 TS,请复制诊断日志反馈`
: found?.kind === 'flv'
? `:检测到 FLV 封装(偏移 ${hit?.offset ?? '?'}),当前转封装组件不支持 FLV,请复制诊断日志反馈`
: '(未发现任何已知封装签名,可能为加密私有格式,详见控制台诊断日志)';
throw new Error(`无法识别文件封装格式${hint}`);
}
}
return this._initPipeline(size);
}
/**
* 公共管线:轨道检查(含流式结构修复)→ 编码兼容 → 时长 → MediaSource。
* opts.fromBuffer: 整文件模式(跳过流式修复);opts.allowNative: 编码浏览器
* 原生支持时直接返回 blob URL(秒拖动,无需 MSE)。
*/
async _initPipeline(size, opts = {}) {
let vTrack = await this.input.getPrimaryVideoTrack();
let aTrack = await this.input.getPrimaryAudioTrack();
if (!vTrack && !aTrack && !opts.fromBuffer) {
// .lb 中后段结构修复:周期块头去块 / 尾部私有尾巴修剪
if (await this._tryRepairs(size)) {
vTrack = await this.input.getPrimaryVideoTrack();
aTrack = await this.input.getPrimaryAudioTrack();
}
}
if (!vTrack && !aTrack) {
if (!opts.fromBuffer) await this._dumpHeaderDiag(size, { kind: 'mp4', offset: this._stripOffset || 0 });
throw new Error('文件内没有音视频轨道(.lb 结构损坏,详见诊断日志)');
}
const vCodec = vTrack ? await vTrack.getCodecParameterString() : null;
const aCodec = aTrack ? await aTrack.getCodecParameterString() : null;
const container = vTrack ? 'video' : 'audio';
const mimeOf = (...codecs) => `${container}/mp4; codecs="${codecs.filter(Boolean).join(', ')}"`;
// 整文件修复后优先原生直放:编码浏览器原生支持 → blob URL 播放(秒拖/无重启)
if (opts.allowNative && this._repairedBlob) {
const probe = document.createElement('video');
const combined = [vCodec, aCodec].filter(Boolean).join(', ');
const nativeMime = `${container}/mp4${combined ? `; codecs="${combined}"` : ''}`;
const nativeOk = probe.canPlayType(nativeMime) === 'probably'
|| (!aCodec && probe.canPlayType(`${container}/mp4`) === 'probably');
if (nativeOk) {
this._native = true;
this.url = URL.createObjectURL(this._repairedBlob);
this.duration = await this._resolveDuration();
lbDiag(`修复后原生直放: mime=${nativeMime}, 时长=${this.duration ? `${this.duration.toFixed(1)}s` : '未知'}`);
return { url: this.url, duration: this.duration, native: true };
}
lbDiag(`原生直放不可用(canPlayType=${probe.canPlayType(nativeMime) || '空'}),转 MSE 转封装`);
}
if (typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported) {
throw new Error('当前浏览器不支持 MediaSource 且编码无法原生播放');
}
if (vCodec && aCodec && MediaSource.isTypeSupported(mimeOf(vCodec, aCodec))) {
this.mime = mimeOf(vCodec, aCodec);
} else if (vCodec && !aTrack && MediaSource.isTypeSupported(mimeOf(vCodec))) {
this.mime = mimeOf(vCodec);
} else if (!vTrack && aCodec && MediaSource.isTypeSupported(mimeOf(aCodec))) {
this.mime = mimeOf(aCodec);
} else if (vCodec && aCodec && MediaSource.isTypeSupported(mimeOf(vCodec))) {
// 视频可直通、音频编码 MSE 不支持 → 音频转码 AAC(速度快)
this.mime = mimeOf(vCodec);
this.audioTranscode = { codec: 'aac', bitrate: 192000 };
lbDiag(`MSE: 音频 ${aCodec} 不受浏览器支持,将转码为 AAC`);
} else {
throw new Error(`浏览器不支持该编码(视频 ${vCodec || '无'} / 音频 ${aCodec || '无'})`);
}
this.duration = await this._resolveDuration();
lbDiag(`MSE: mime=${this.mime}, 时长=${this.duration ? `${this.duration.toFixed(1)}s` : '未知'}`);
this.mediaSource = new MediaSource();
this.url = URL.createObjectURL(this.mediaSource);
this._listeners.once(this.mediaSource, 'sourceopen', () => this._onSourceOpen());
return { url: this.url, duration: this.duration };
}
/**
* 整文件修复模式:本地修复 .lb 字节 → 原生直放 / MSE 转封装。
* raw = 已完整下载的文件字节(repairLbBuffer 负责修复)。
*/
async prepareFromBuffer(raw) {
const rep = repairLbBuffer(raw);
if (!rep) {
lbDiag(`整文件修复失败: 结构无法识别\n文件头 128 字节:\n${hexDump(raw.subarray(0, 128))}\nbox 链: ${parseBoxChain(raw).summary}`);
throw new Error('整文件本地修复失败(结构无法识别,详见诊断日志)');
}
this._stripOffset = rep.strip;
this._repairedBlob = new Blob([rep.buf], { type: rep.kind === 'mkv' ? 'video/x-matroska' : 'video/mp4' });
lbDiag(`整文件修复: ${rep.note},修复后 ${(rep.buf.length / 1048576).toFixed(1)}MB(${rep.kind})`);
let mb = null;
try {
mb = await loadMediabunny();
} catch (e) {
lbDiag(`转封装组件不可用(${e?.message || e}),尝试无组件原生直放…`);
}
if (!mb) {
// 无 mediabunny:仅剩"修复后原生直放"一条路(浏览器直接播 MP4,无需转码库)
if (rep.kind !== 'mp4') throw new Error('转封装组件不可用且非 MP4 封装,无法播放');
const probe = document.createElement('video');
if (probe.canPlayType('video/mp4') !== 'probably') throw new Error('转封装组件不可用,且浏览器无法原生播放该文件');
this._native = true;
this.url = URL.createObjectURL(this._repairedBlob);
lbDiag('无组件原生直放: 直接交给 video 元素');
return { url: this.url, duration: 0, native: true };
}
this.mb = mb;
const MB = this.mb;
this.input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BlobSource(this._repairedBlob) });
if (!(await this._quietly(this.input.canRead(), 15000))) {
throw new Error('修复后仍无法识别封装(详见诊断日志)');
}
return this._initPipeline(rep.buf.length, { allowNative: true, fromBuffer: true });
}
/** 用新 source 重建 Input 并验证轨道可用(成功则替换 this.input) */
async _rebuildInput(spec) {
const MB = this.mb;
const src = new MB.CustomSource(Object.assign({
maxCacheSize: 64 * 1024 * 1024,
prefetchProfile: 'network',
}, spec));
const input = new MB.Input({ formats: MB.ALL_FORMATS, source: src });
if (!(await this._quietly(input.canRead(), 12000))) return false;
const v = await this._quietly(input.getPrimaryVideoTrack(), 15000);
const a = await this._quietly(input.getPrimaryAudioTrack(), 15000);
if (!v && !a) return false;
this.input = input;
return true;
}
/**
* 流式结构修复(无轨道时调用):① 周期块头去块 ② 尾部私有尾巴修剪。
* 任一方案让轨道可读即成功(替换 this.input 并返回 true)。
*/
async _tryRepairs(size) {
const strip = this._stripOffset || 0;
const probe = this._probeBuf;
if (!strip || !probe || probe.length < 512) return false;
const magic = probe.subarray(0, 8);
const startsWithMagic = (a) => a && a.length >= 4
&& a[0] === magic[0] && a[1] === magic[1] && a[2] === magic[2] && a[3] === magic[3];
// ① 周期块头:探针内魔数复现 + L/2L 远程验证
const occ = findBytes(probe, magic, 8, 8);
if (occ.length && occ[0] >= 4096) {
const L = occ[0];
const head1 = await this._quietly(gmFetchRange(this.dlink, L, L + 8), 12000);
const head2 = await this._quietly(gmFetchRange(this.dlink, 2 * L, 2 * L + 8), 12000);
if (startsWithMagic(head1) && startsWithMagic(head2)) {
const dataLen = L - strip;
let vSize = 0;
for (let b = 0; b * L < size; b++) vSize += Math.max(0, Math.min(L, size - b * L) - strip);
lbDiag(`结构修复①: 周期块头 L=${L}(头${strip}B+数据${dataLen}B),虚拟大小 ${vSize}`);
const ok = await this._rebuildInput({
getSize: () => vSize,
read: async (start, end) => {
const parts = [];
let pos = start;
while (pos < end) {
const b = Math.floor(pos / dataLen);
const r = pos - b * dataLen;
const take = Math.min(end - pos, dataLen - r);
const absS = b * L + strip + r;
const absE = Math.min(size, absS + take);
if (absE > absS) parts.push(await gmFetchRange(this.dlink, absS, absE));
pos += take;
}
return parts.length === 1 ? parts[0] : concatBytes(parts);
},
});
if (ok) return true;
lbDiag('结构修复①: 去块后仍无轨道,继续尝试尾巴修剪…');
} else {
lbDiag(`结构修复①: 魔数复现于 [${occ.slice(0, 4)}] 但 L/2L 远程验证失败,跳过`);
}
} else {
lbDiag(`结构修复①: 前 ${(probe.length / 1024) | 0}KB 内无块头魔数复现`);
}
// ② 尾巴修剪:尾部找 moov,其后若有多余私有字节 → 缩小虚拟大小
const tailLen = Math.min(262144, size - strip);
const tail = tailLen > 1024 ? await this._quietly(gmFetchRange(this.dlink, size - tailLen, size), 20000) : null;
if (tail) {
const absBase = size - tailLen;
const mv = findMoovBox(tail);
if (mv && absBase + mv.start + mv.size <= size - 16) {
const vSize = absBase + mv.start + mv.size - strip;
lbDiag(`结构修复②: moov 后有 ${size - strip - vSize} 字节私有尾巴,虚拟大小 ${size - strip} → ${vSize}`);
const ok = await this._rebuildInput({
getSize: () => vSize,
read: (start, end) => gmFetchRange(this.dlink, start + strip, Math.min(end + strip, size)),
});
if (ok) return true;
lbDiag('结构修复②: 修剪后仍无轨道');
} else {
lbDiag(`结构修复②: 尾部 ${tailLen}B ${mv ? 'moov 后无多余尾巴' : '未找到 moov box'}`);
}
}
// 诊断:剥离后的 box 链走向
const stripped = probe.subarray(strip);
lbDiag(`诊断: 剥离后 box 链 = ${parseBoxChain(stripped).summary}`);
return false;
}
/** 探测直链文件大小(Content-Range 总长) */
_probeSize() {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: this.dlink,
headers: { Range: 'bytes=0-0', 'User-Agent': 'netdisk' },
timeout: 15000,
onload: (r) => {
const m = String(r.responseHeaders || '').match(/content-range:\s*bytes\s+\d+-\d+\/(\d+)/i);
resolve(m ? Number(m[1]) : 0);
},
onerror: () => resolve(0),
ontimeout: () => resolve(0),
});
});
}
/** 拉取文件前 256KB:深扫明文容器签名 + 单字节 XOR 混淆探测。返回 { hit, xor }(并缓存探针供结构修复用) */
async _probeContainerOffset(size) {
try {
const scanLen = Math.min(262144, size);
const buf = await this._quietly(gmFetchRange(this.dlink, 0, scanLen), 25000);
if (!buf || !buf.length) {
lbDiag('封装偏移探测: 前 256KB 拉取失败');
return null;
}
this._probeBuf = buf;
const hit = detectContainerAt(buf);
const xor = hit ? null : detectXorObfuscation(buf);
lbDiag(`封装偏移探测: ${hit ? `${hit.kind} @ ${hit.offset}` : '无明文签名'}${xor ? `;XOR 混淆命中: key=0x${xor.key.toString(16)} → ${xor.kind}` : ''}`);
return { hit, xor };
} catch (e) {
lbDiag(`封装偏移探测异常: ${e?.message || e}`);
return null;
}
}
/** 失败诊断:hex 转储文件头 + 多偏移粗扫(判断是否块状/加密结构),全部打日志 */
async _dumpHeaderDiag(size, probe) {
try {
const head = await this._quietly(gmFetchRange(this.dlink, 0, 96), 15000);
if (head) lbDiag(`.lb 文件头 96 字节:\n${hexDump(head)}`);
if (head) {
const hx = detectXorObfuscation(head);
if (hx) lbDiag(`文件头 XOR 探测: key=0x${hx.key.toString(16)} → ${hx.kind}`);
}
lbDiag(`头部签名扫描结论: ${probe ? `${probe.kind} @ ${probe.offset}${probe.packets ? ` (${probe.packets} 个TS包)` : ''}` : '未发现任何已知封装'}`);
// 多偏移粗扫:若签名在大偏移处反复出现 → 块状结构;全无 → 可能整体加密
for (const off of [65536, 1048576, 8388608, Math.max(0, size - 4096)]) {
if (off >= size - 64 || off < 0) continue;
const win = await this._quietly(gmFetchRange(this.dlink, off, off + 4096), 15000);
if (!win) { lbDiag(`偏移 ${off} 扫描: 拉取失败`); continue; }
const hit = detectContainerAt(win);
lbDiag(`偏移 ${off} 扫描: ${hit ? `${hit.kind} @ 相对 ${hit.offset}` : '无签名'}, TS同步命中=${countTsSync(win)}/30`);
}
} catch (_) { /* 诊断自身不许抛 */ }
}
/** 带超时执行,异常/超时统一返回 null */
_quietly(promise, ms) {
return Promise.race([
promise,
new Promise((_, rej) => this._listeners.setTimeout(() => rej(new Error('timeout')), ms || 10000)),
]).catch(() => null);
}
async _resolveDuration() {
let d = await this._quietly(this.input.getDurationFromMetadata(), 8000);
if (!d || !Number.isFinite(d) || d <= 0) {
d = await this._quietly(this.input.computeDuration(), 15000);
}
return Number.isFinite(d) && d > 0 ? d : 0;
}
_onSourceOpen() {
if (this._disposed) return;
const ms = this.mediaSource;
if (!ms || ms.readyState !== 'open') return;
if (this.duration > 0) {
safe(() => { ms.duration = Math.max(0.1, this.duration - this.trimStart); }, 'mse.setDuration');
}
try {
this.sourceBuffer = ms.addSourceBuffer(this.mime);
} catch (e) {
this._emitFatal(`创建 SourceBuffer 失败:${e?.message || e}`);
return;
}
this._listeners.on(this.sourceBuffer, 'error', () => {
if (!this.sourceBuffer.updating) this._evictPlayed();
});
this._runConversion(this.trimStart).catch((e) => {
if (this._disposed || this._restarting) return;
this._emitFatal(`转封装失败:${e?.message || e}`);
});
}
/** 从原片 start 秒开始转封装(trim 输出时间轴归零);等待追加即施加背压 */
async _runConversion(start) {
const MB = this.mb;
if (this.conversion) {
const old = this.conversion;
this.conversion = null;
safe(() => { old.cancel().catch(() => {}); }, 'mse.cancelOld');
}
this.trimStart = Math.max(0, start);
const writable = new WritableStream(
{ write: (chunk) => this._appendChunk(chunk?.data) },
{ highWaterMark: 4 },
);
const output = new MB.Output({
format: new MB.Mp4OutputFormat({ fastStart: 'fragmented', minimumFragmentDuration: 2 }),
target: new MB.StreamTarget(writable),
});
const audioCfg = this.audioTranscode;
const conversion = await MB.Conversion.init({
input: this.input,
output,
// 只保留主音轨/主视频轨(多轨 MKV 只取第一条,规避 MSE 多轨兼容问题)
video: (t) => (t.number > 1 ? { discard: true } : {}),
audio: (t) => (t.number > 1 ? { discard: true } : (audioCfg || {})),
...(this.trimStart > 0 ? { trim: { start: this.trimStart } } : {}),
});
if (this._disposed) return;
if (!conversion.isValid) {
throw new Error('没有可输出的轨道(编码不受支持)');
}
this.conversion = conversion;
try {
await conversion.execute();
} catch (e) {
// 被新管线取代或主动取消的旧任务不算错误
if (conversion !== this.conversion || this._disposed) return;
throw e;
}
if (!this._disposed && this.mediaSource?.readyState === 'open') {
safe(() => this.mediaSource.endOfStream(), 'mse.endOfStream');
}
}
/** 追加 fMP4 分片到 SourceBuffer(串行队列;限流保证缓冲不超配额) */
_appendChunk(data) {
if (this._disposed || !data?.length) return Promise.resolve();
const sb = this.sourceBuffer;
const ms = this.mediaSource;
if (!sb || !ms || ms.readyState !== 'open') return Promise.resolve();
const epoch = this._queueEpoch;
const run = async () => {
await this._waitForBufferSpace(epoch);
if (this._disposed || epoch !== this._queueEpoch) return;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await this._appendBufferSync(sb, data);
return;
} catch (e) {
const quota = String(e?.name || '').includes('Quota');
if (!quota || attempt > 0 || !this._evictPlayed()) return; // 无法恢复则跳过该分片
await this._waitSbIdle(sb);
}
}
};
this._appendQueue = this._appendQueue.then(run, run);
return this._appendQueue;
}
_appendBufferSync(sb, data) {
return new Promise((resolve, reject) => {
const onDone = () => { sb.removeEventListener('updateend', onDone); sb.removeEventListener('error', onErr); resolve(); };
const onErr = () => { sb.removeEventListener('updateend', onDone); sb.removeEventListener('error', onErr); reject(new Error('SourceBuffer error')); };
sb.addEventListener('updateend', onDone);
sb.addEventListener('error', onErr);
try {
sb.appendBuffer(data);
} catch (e) {
sb.removeEventListener('updateend', onDone);
sb.removeEventListener('error', onErr);
reject(e);
}
});
}
/** 前方缓冲超过阈值时暂停喂入——背压沿管线传导,暂停下载与转封装 */
_waitForBufferSpace(epoch) {
return new Promise((resolve) => {
const check = () => {
if (this._disposed || epoch !== this._queueEpoch) return resolve();
const ahead = this._bufferedAhead();
if (ahead == null || ahead < 90) return resolve();
this._listeners.setTimeout(check, 1000);
};
check();
});
}
/** 播放头前方的缓冲秒数;播放头不在缓冲内返回 null */
_bufferedAhead() {
const v = this._video;
const b = this.sourceBuffer?.buffered;
if (!v || !b?.length) return null;
const cur = v.currentTime || 0;
for (let i = 0; i < b.length; i++) {
if (cur >= b.start(i) - 0.5 && cur <= b.end(i)) return b.end(i) - cur;
}
return null;
}
/** 逐出播放头后方 60 秒之前的数据,释放 SourceBuffer 配额 */
_evictPlayed() {
const sb = this.sourceBuffer;
const v = this._video;
if (!sb || !v || !sb.buffered?.length) return false;
const keepFrom = Math.max(0, (v.currentTime || 0) - 60);
for (let i = 0; i < sb.buffered.length; i++) {
const s = sb.buffered.start(i);
const e = sb.buffered.end(i);
if (e <= keepFrom) {
return safe(() => { sb.remove(s, e); return true; }, 'mse.evict') || false;
}
if (s < keepFrom && e > keepFrom) {
return safe(() => { sb.remove(s, keepFrom); return true; }, 'mse.evict') || false;
}
}
return false;
}
_waitSbIdle(sb, ms) {
return new Promise((resolve) => {
const deadline = Date.now() + (ms || 2000);
const check = () => {
if (!sb.updating || Date.now() > deadline || this._disposed) resolve();
else setTimeout(check, 30);
};
check();
});
}
/** 绑定到 Artplayer:接管拖动跳转与致命错误回调 */
attach(art, hooks) {
this._video = art?.video || null;
this._fatal = hooks?.onFatal || null;
const v = this._video;
if (!v) return;
// 跳到未缓冲区域 → 从目标处重启转封装流(回退 2 秒找关键帧)
this._listeners.on(v, 'seeking', () => {
if (this._applyingRestartSeek) {
this._applyingRestartSeek = false;
return;
}
const t = v.currentTime;
if (!Number.isFinite(t) || t < 0) return;
if (this._restarting) { this._pendingSeek = t; return; }
if (this._inBuffered(t, 1.5)) return;
this._restartFrom(t);
});
// 定期逐出已播放数据,控制内存
this._listeners.setInterval(() => {
if (!this.sourceBuffer?.updating) this._evictPlayed();
}, 300000);
// 首帧看门狗:长时间无任何数据 → 致命错误(触发整文件下载兜底)
this._armFirstFrameWatchdog(0);
this._listeners.on(v, 'loadeddata', () => this._listeners.clearTimeout(this._wdTimer));
}
_armFirstFrameWatchdog(retries) {
if (this._disposed) return;
this._wdTimer = this._listeners.setTimeout(() => {
if (this._disposed || !this._video) return;
const v = this._video;
const hasData = v.readyState >= 2 || (this.sourceBuffer?.buffered?.length > 0);
if (hasData) return;
if (retries < 2) { this._armFirstFrameWatchdog(retries + 1); return; }
this._emitFatal('流式播放长时间未出画面');
}, 25000);
}
_inBuffered(t, slack) {
const b = this._video?.buffered;
if (!b?.length) return false;
for (let i = 0; i < b.length; i++) {
if (t >= b.start(i) - slack && t <= b.end(i) + slack) return true;
}
return false;
}
/** 跳到未缓冲区:换新 MediaSource,从 t-2s 重启转封装流(trim 归零时间轴) */
async _restartFrom(t) {
if (this._restarting || this._disposed) return;
this._restarting = true;
const start = Math.max(0, t - 2);
lbDiag(`MSE: 跳转 ${t.toFixed(1)}s(未缓冲),从 ${start.toFixed(1)}s 重启流`);
const v = this._video;
safe(() => v?.pause());
// 终止旧管线
const conv = this.conversion;
this.conversion = null;
this._queueEpoch++;
this._appendQueue = Promise.resolve();
if (conv) safe(() => { conv.cancel().catch(() => {}); }, 'mse.restart.cancel');
// MediaSource objectURL 只能绑定一次 video,必须换新实例
const oldUrl = this.url;
safe(() => { if (oldUrl) URL.revokeObjectURL(oldUrl); }, 'mse.restart.revoke');
try { if (this.mediaSource?.readyState === 'open') this.mediaSource.endOfStream(); } catch (_) {}
this.sourceBuffer = null;
this.mediaSource = new MediaSource();
this.url = URL.createObjectURL(this.mediaSource);
this.trimStart = start;
this._listeners.once(this.mediaSource, 'sourceopen', () => this._onSourceOpen());
if (v) {
this._listeners.once(v, 'loadedmetadata', () => {
const target = Math.max(0, t - this.trimStart);
const maxT = Math.max(0.1, (this.duration || 1e9) - this.trimStart - 0.5);
safe(() => {
this._applyingRestartSeek = true;
v.currentTime = Math.min(target, maxT);
}, 'mse.restart.seek');
const p = v.play();
if (p?.catch) p.catch(() => {});
});
safe(() => { v.src = this.url; v.load(); }, 'mse.restart.src');
}
// 首批数据到达后解锁,处理期间累积的跳转请求
const unlock = () => {
if (!this._restarting) return;
this._restarting = false;
const pt = this._pendingSeek;
this._pendingSeek = null;
if (pt != null && Number.isFinite(pt) && !this._inBuffered(pt, 1.5)) this._restartFrom(pt);
};
if (v) this._listeners.once(v, 'canplay', unlock);
this._listeners.setTimeout(unlock, 15000);
}
_emitFatal(reason) {
if (this._disposed) return;
lbDiag(`MSE 致命错误: ${reason}`);
safe(() => this._fatal?.(reason), 'mse.fatal');
}
dispose() {
if (this._disposed) return;
const conv = this.conversion;
this.conversion = null;
if (conv) safe(() => { conv.cancel().catch(() => {}); }, 'mse.dispose.cancel');
if (this.url) safe(() => URL.revokeObjectURL(this.url), 'mse.dispose.revoke');
try { if (this.mediaSource?.readyState === 'open') this.mediaSource.endOfStream(); } catch (_) {}
safe(() => this.input?.dispose?.(), 'mse.dispose.input');
this.input = null;
this.mediaSource = null;
this.sourceBuffer = null;
this._video = null;
super.dispose();
}
}
/** 激活徽章:页面打开 15 秒内右下角显示,确认脚本在运行 */
function installBadge() {
const show = () => {
if (document.getElementById('bdplayer-badge')) return;
const b = document.createElement('div');
b.id = 'bdplayer-badge';
b.textContent = `✓ 播放脚本 v${GM_info?.script?.version} 已激活`;
b.style.cssText =
'position:fixed;bottom:10px;right:10px;z-index:2147483646;background:rgba(20,20,20,.8);' +
'color:#4a9eff;font-size:12px;padding:6px 12px;border-radius:14px;font-family:monospace;' +
'pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.4);';
document.body.appendChild(b);
setTimeout(() => { try { b.remove(); } catch (_) { } }, 15000);
};
if (document.body) show();
else window.addEventListener('DOMContentLoaded', show, { once: true });
}
/** 读取网盘文件列表(新版 Vue UI 走 pinia,旧版走 require) */
function getDiskFileList() {
const found = [];
const looksLikeFile = (x) =>
x && typeof x === 'object' && (x.server_filename || (x.fs_id != null && (x.path || x.category != null)));
const visit = (obj, depth) => {
if (!obj || depth > 4 || found.length) return;
if (Array.isArray(obj)) {
if (obj.length && obj.every(looksLikeFile)) {
found.push(...obj);
return;
}
obj.forEach((x) => visit(x, depth + 1));
return;
}
if (typeof obj === 'object') {
for (const v of Object.values(obj)) visit(v, depth + 1);
}
};
try {
const list = unsafeWindow
.require('system-core:context/context.js')
.instanceForSystem.list.getCurrentList();
if (Array.isArray(list) && list.length) return list;
} catch (_) { /* 新版 UI 无 require,走 pinia */ }
try {
const app = document.querySelector('#app');
const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia;
if (pinia) visit(pinia.state._rawValue, 0);
} catch (_) { /* pinia 不可用 */ }
return found.length ? found : null;
}
/**
* .lb 文件在网盘文件列表页的点击拦截播放器
* 百度把 .lb 归为普通文件,点击不会进视频页;本类拦截点击,
* 在全屏浮层中直接启动 ArtPlayer 播放(走 .lb 直连/下载逻辑)
*/
class LbDiskLauncher extends Disposable {
constructor(playerRef) {
super();
this._playerRef = playerRef;
this._overlay = null;
this._listeners.on(document, 'click', (e) => this._onClick(e), true);
this._listeners.on(document, 'keydown', (e) => {
if (e.key === 'Escape' && this._overlay) this._closeOverlay();
});
}
_onClick(e) {
if (this._overlay) return;
// 复选框等控件不拦截
if (e.target?.closest?.('input, [class*="check"], [class*="Check"]')) return;
// 不拦截自身播放器 UI 内的点击
if (e.target?.closest?.('#artplayer, .ep-menu, .art-video-player, #bdplayer-lb-overlay')) return;
let row = this._findRow(e.target);
let name = row ? this._extractName(row) : null;
// 兜底:新版 UI 可能没有 data-id/title 属性,按文件列表数据匹配文件名文本
if (!name) {
const alt = this._findRowByList(e.target);
if (alt) {
row = alt.row;
name = alt.name;
}
}
if (!row || !name) return;
e.preventDefault();
e.stopPropagation();
this._launch(row, name);
}
/** 从点击目标向上找包含 .lb 文件名的最小祖先元素(新版 UI 兜底策略) */
_findRowByList(el) {
const list = getDiskFileList();
if (!list?.length) return null;
const lbNames = [...new Set(list.filter(isLbFile).map(getFileName).filter(Boolean))];
if (!lbNames.length) return null;
let cur = el;
for (let i = 0; cur && cur !== document.body && i < 12; i++, cur = cur.parentElement) {
const text = cur.textContent || '';
if (!text || text.length > 800) continue; // 跳过整个列表容器级别的巨型节点
for (const n of lbNames) {
if (text.includes(n)) return { row: cur, name: n };
}
}
return null;
}
_findRow(el) {
const byDataId = el?.closest?.('[data-id]');
if (byDataId) return byDataId;
let cur = el;
for (let i = 0; cur && i < 10; i++, cur = cur.parentElement) {
if (cur.querySelector?.('[title$=".lb"], [title$=".LB"]')) return cur;
}
return null;
}
_extractName(row) {
const t = row.querySelector?.('[title$=".lb"], [title$=".LB"]');
if (t) return t.getAttribute('title');
const nameEl = row.querySelector?.(
'.file-name, .file-name-text, a.filename, [class*="fileName"], [class*="file-name"]'
);
const txt = nameEl?.textContent?.trim();
return txt && /\.lb$/i.test(txt) ? txt : null;
}
async _resolveFile(row, name) {
const list = getDiskFileList() || [];
const dataId = row.getAttribute?.('data-id');
if (dataId && /^\d+$/.test(dataId)) {
const hit = list.find((f) => String(f.fs_id) === dataId);
if (hit) return { file: hit, list };
return { file: { fs_id: Number(dataId), server_filename: name }, list };
}
let hit = list.find((f) => getFileName(f) === name);
if (hit) return { file: hit, list };
// 兜底:页面内部数据找不到,直接调网盘目录列表接口按名匹配(最可靠)
lbDiag('页面内部数据未命中,转 /api/list 按目录查询…');
const dirPath = getDirPathFromUrl();
if (!dirPath) {
lbDiag('URL 中解析不到目录 path');
return { file: { server_filename: name }, list };
}
const apiList = await fetchDirList(dirPath);
if (apiList) {
hit = apiList.find((f) => getFileName(f) === name);
if (hit) {
lbDiag(`/api/list 命中: fs_id=${hit.fs_id}, size=${hit.size}`);
return { file: hit, list: apiList };
}
lbDiag(`/api/list 该目录下未找到「${name}」(可能文件名有不可见字符)`);
// 宽松匹配:忽略首尾空格和连续空格差异
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
hit = apiList.find((f) => norm(getFileName(f)) === norm(name));
if (hit) {
lbDiag(`宽松匹配命中: fs_id=${hit.fs_id}`);
return { file: hit, list: apiList };
}
}
return { file: { server_filename: name }, list: apiList || list };
}
_openOverlay() {
const overlay = document.createElement('div');
overlay.id = 'bdplayer-lb-overlay';
overlay.style.cssText = `position:fixed;inset:0;z-index:${TOP_Z};background:#000;`;
const closeBtn = document.createElement('div');
closeBtn.textContent = '×';
closeBtn.title = '关闭播放器 (Esc)';
closeBtn.style.cssText =
`position:absolute;top:12px;right:16px;z-index:${TOP_Z + 1};width:40px;height:40px;` +
'line-height:38px;text-align:center;font-size:28px;color:#fff;background:rgba(0,0,0,.55);' +
'border-radius:50%;cursor:pointer;user-select:none;';
overlay.appendChild(closeBtn);
// 解析期间的加载提示,避免"黑屏无反馈"
const loading = document.createElement('div');
loading.id = 'bdplayer-lb-loading';
loading.style.cssText =
'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;' +
'color:#9a9a9a;font-size:15px;letter-spacing:1px;background:#000;';
loading.textContent = '正在解析 .lb 文件,请稍候…';
overlay.appendChild(loading);
// 实时诊断日志区:黑屏上滚动显示脚本每一步(不挡操作)
const diaglog = document.createElement('div');
diaglog.id = 'bdplayer-lb-diaglog';
diaglog.style.cssText =
`position:absolute;bottom:14px;left:14px;right:14px;max-height:32vh;overflow:auto;` +
`font-size:11px;line-height:1.7;color:rgba(255,255,255,.65);font-family:monospace;` +
`pointer-events:none;text-shadow:0 1px 2px #000;z-index:2;`;
overlay.appendChild(diaglog);
// 回放已有日志(点击前积累的)
LB_DIAG.slice(-8).forEach((line) => {
const div = document.createElement('div');
div.textContent = line;
diaglog.appendChild(div);
});
document.body.appendChild(overlay);
this._overlay = overlay;
this._listeners.on(closeBtn, 'click', (e) => {
e.stopPropagation();
this._closeOverlay();
});
return overlay;
}
_closeOverlay() {
this._clearWatchdog();
const player = this._playerRef();
safe(() => player?._teardown?.(), 'lb.disk.teardown');
safe(() => { player?._onPlayerFail ? (player._onPlayerFail = null) : null; }, 'lb.disk.clearHook');
safe(() => this._overlay?.remove(), 'lb.disk.removeOverlay');
this._overlay = null;
}
/** 解析失败:把原因显示在遮罩内,可一键复制诊断日志 */
_showError(reason) {
this._clearWatchdog();
const overlay = this._overlay;
if (!overlay) {
showTip(`.lb 解析失败:${reason}`);
return;
}
let loading = overlay.querySelector('#bdplayer-lb-loading');
if (!loading) {
// 成功路径已移除 loading 层,这里重建(回退下载失败等场景)
loading = document.createElement('div');
loading.id = 'bdplayer-lb-loading';
loading.style.cssText =
'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;' +
'color:#9a9a9a;font-size:15px;letter-spacing:1px;background:#000;z-index:5;';
overlay.appendChild(loading);
}
loading.innerHTML = '';
loading.style.overflow = 'auto';
const box = document.createElement('div');
box.style.cssText = 'max-width:88%;text-align:center;';
const title = document.createElement('div');
title.style.cssText = 'font-size:17px;font-weight:600;color:#ff6b6b;margin-bottom:10px;';
title.textContent = '.lb 文件解析失败';
const detail = document.createElement('div');
detail.style.cssText = 'font-size:14px;color:#ddd;line-height:1.7;margin-bottom:18px;';
detail.textContent = `原因:${reason}`;
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:18px;';
const copyBtn = document.createElement('button');
copyBtn.textContent = '复制诊断信息';
copyBtn.style.cssText =
'padding:10px 22px;font-size:14px;color:#fff;background:#4a9eff;border:0;border-radius:8px;cursor:pointer;';
copyBtn.onclick = async () => {
const text = lbDiagDump();
let ok = false;
try { ok = await navigator.clipboard.writeText(text); ok = true; } catch (_) { ok = false; }
copyBtn.textContent = ok ? '已复制,发给AI即可' : '复制失败,请长按下方文本';
if (ok) return;
// 剪贴板不可用时展开可长按选择的文本框(平板兜底)
const pre = document.createElement('textarea');
pre.readOnly = true;
pre.value = text;
pre.style.cssText =
'width:86vw;height:40vh;margin-top:14px;font-size:12px;line-height:1.5;color:#eee;' +
'background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:10px;box-sizing:border-box;';
box.appendChild(pre);
};
const close = document.createElement('button');
close.textContent = '关闭';
close.style.cssText =
'padding:10px 22px;font-size:14px;color:#fff;background:rgba(255,255,255,.12);' +
'border:1px solid rgba(255,255,255,.3);border-radius:8px;cursor:pointer;';
close.onclick = () => this._closeOverlay();
actions.append(copyBtn, close);
box.append(title, detail, actions);
loading.appendChild(box);
}
async _launch(row, name) {
const player = this._playerRef();
if (!player) return;
lbDiag(`拦截点击 .lb 文件: ${name}`);
let file, list;
try {
({ file, list } = await this._resolveFile(row, name));
lbDiag(`文件解析: fs_id=${file?.fs_id ?? '无'}, 列表内可播项=${(list || []).filter((f) => f?.category === 1 || isLbFile(f)).length}`);
} catch (e) {
this._openOverlay();
this._showError(`识别文件时脚本异常:${e?.message || e}`);
return;
}
if (!file.fs_id) {
this._openOverlay();
this._showError(`无法识别该 .lb 文件:网盘目录接口里也找不到「${name}」。请确认文件就在当前目录,或把日志复制反馈`);
return;
}
// 补全 path/size(DOM 兜底构造的 file 常缺;locatedownload/mediainfo 通道与 MSE 都依赖)
if (!file.path || !file.size) {
const meta = await enrichFileMeta(file.fs_id);
if (meta) file = Object.assign({}, file, meta);
}
const playable = (list || []).filter((f) => f?.category === 1 || isLbFile(f));
// 分享页走分享下载接口,其余走个人网盘接口
player.flag = location.pathname.startsWith('/s/') ? 'sharevideo' : 'video';
player.file = file;
player.filelist = sortByLocale(playable.length ? playable : [file]);
const overlay = this._openOverlay();
const container = document.createElement('div');
container.id = 'artplayer';
container.style.cssText = 'width:100%;height:100%';
overlay.appendChild(container);
// 解析失败时在遮罩内显示原因
player._onPlayerFail = (reason) => this._showError(reason || '未知错误');
// 看门狗:30 秒内既没出画面也没进错误画面,强制显示错误画面(杜绝永恒黑屏)
this._clearWatchdog();
const armWatchdog = () => {
this._wdTimer = setTimeout(() => {
const v = player?.art?.video;
if (!this._overlay) return;
if (overlay.querySelector('#bdplayer-lb-loading')?.querySelector('button')) return; // 已是错误画面
if (v && v.readyState >= 2) return; // 已出画面
// MSE 流式准备/播放中:由 LbMseStreamer 内部看门狗自治(失败自动转直连流式播放)
if (player?._lbPreparing || player?._lbStreamer) {
lbDiag('watchdog: MSE 流式播放准备/进行中,顺延 30 秒');
armWatchdog();
return;
}
lbDiag(`watchdog 触发: 30秒未出画面, video.readyState=${v?.readyState ?? '无video元素'}`);
this._showError('30 秒内未能出画面(直链可能被网盘拦截或请求停滞),请复制诊断信息反馈');
}, 30000);
};
armWatchdog();
// 视频真正出画面后解除看门狗
const clearWd = () => this._clearWatchdog();
this._listeners.on(document, 'loadeddata', clearWd, { once: true, capture: true });
try {
// 新版网盘页面没有全局 jsToken,先通过接口拉取(否则 buildFileUrl 直接失败)
await ensureJsToken();
player.getUrl = player.flag === 'sharevideo' ? buildShareUrl(file) : buildFileUrl(file);
if (!player.getUrl || typeof player.getUrl !== 'function') {
this._showError('无法构建播放地址(登录状态异常,拿不到 jsToken),请刷新页面重试');
return;
}
player.hls.setUrlBuilder(player.getUrl);
await player.init(container);
// 播放器创建成功,移除加载提示(失败则遮罩已被钩子关闭)
const loading = overlay.querySelector('#bdplayer-lb-loading');
safe(() => loading?.remove(), 'lb.disk.removeLoading');
} catch (e) {
lbDiag(`_launch 异常: ${e?.message || e}\n${(e?.stack || '').slice(0, 300)}`);
this._showError(`播放流程异常:${e?.message || e}`);
}
}
_clearWatchdog() {
if (this._wdTimer) {
clearTimeout(this._wdTimer);
this._wdTimer = null;
}
}
}
/* ============================================================
* HLS 控制器
* 缓存:JSON 数组 [{ fileKey, qualities: [{ q, r }] }]
* 同集候选清晰度并行;多集之间串行
* ============================================================ */
class HlsController extends Disposable {
constructor() {
super();
this.instance = null;
this._fileKey = 'default';
this._getUrlForQuality = null;
this._retries = {};
this._cacheList = [];
this._pendingRequests = new Map();
this._backoffTimers = new Set();
}
get fileKey() {
return this._fileKey;
}
_findEpisodeIndex(fileKey) {
if (!fileKey) return -1;
for (let i = 0; i < this._cacheList.length; i++) {
if (this._cacheList[i].fileKey === fileKey) return i;
}
return -1;
}
_getOrCreateQualities(fileKey) {
const idx = this._findEpisodeIndex(fileKey);
if (idx >= 0) return this._cacheList[idx].qualities;
const entry = { fileKey, qualities: [] };
this._cacheList.push(entry);
return entry.qualities;
}
_findQualityUrl(qualities, q) {
for (let i = 0; i < qualities.length; i++) {
if (qualities[i].q === q) return qualities[i].r;
}
return null;
}
getCachedUrl(file, quality) {
return this.getCachedUrlByKey(fileKeyOf(file), quality);
}
getCachedUrlByKey(key, quality) {
if (!key) return null;
const idx = this._findEpisodeIndex(key);
if (idx < 0) return null;
return this._findQualityUrl(this._cacheList[idx].qualities, quality);
}
setUrlBuilder(fn) {
this._getUrlForQuality = (quality) => fn?.('M3U8_AUTO_' + quality);
}
resolveUrl(url) {
// adToken 流程:百度会先返回 errno=133 + adToken,需带上 adToken 重试;
// 可能连续多次返回 133,需循环跟随。只有最终拿到 #EXTM3U 才算地址可用,
// 否则(如 1080P 权限不足返回错误 JSON)不得当作有效画质,
// 否则会把坏地址设为默认画质导致播放失败。
const MAX_TOKEN_ROUNDS = 4;
const step = async (u) => {
const text = await gmFetch(u);
if (!text) return null;
if (text.trim().startsWith('#EXTM3U')) return u;
let json;
try { json = JSON.parse(text); } catch (_) { return null; }
if (json.errno === 133 && json.adToken) {
return `${u}&adToken=${encodeURIComponent(json.adToken)}`;
}
return null;
};
return (async () => {
let current = url;
for (let i = 0; i < MAX_TOKEN_ROUNDS; i++) {
try {
const next = await step(current);
if (!next) return null;
if (next === current) return next;
current = next;
} catch (e) {
log.warn('gmFetch', e?.message);
return null;
}
}
return null;
})();
}
async resolveEpisodeQualities(fileKey, candidates, getUrl) {
if (!fileKey || !Array.isArray(candidates) || !candidates.length) return [];
const qualities = this._getOrCreateQualities(fileKey);
const tasks = candidates.map(async (q) => {
const cached = this._findQualityUrl(qualities, q);
if (cached) return { q, r: cached };
const url = getUrl?.('M3U8_AUTO_' + q);
if (!url) return null;
if (this._pendingRequests.has(url)) {
await this._pendingRequests.get(url);
const cached2 = this._findQualityUrl(qualities, q);
return cached2 ? { q, r: cached2 } : null;
}
const promise = this.resolveUrl(url);
this._pendingRequests.set(url, promise);
try {
const r = await promise;
if (r) {
qualities.push({ q, r });
return { q, r };
}
return null;
} finally {
this._pendingRequests.delete(url);
}
});
const results = (await Promise.all(tasks)).filter(Boolean);
results.sort((a, b) => b.q - a.q);
return results;
}
clearUrlCache() {
this._cacheList = [];
this._pendingRequests.clear();
this._retries = {};
}
exportCacheAsJson() {
return this._cacheList.map((entry) => ({
fileKey: entry.fileKey,
qualities: [...entry.qualities].sort((a, b) => b.q - a.q),
}));
}
importCacheFromJson(arr) {
if (!Array.isArray(arr)) return;
const next = [];
for (const entry of arr) {
if (!entry || !entry.fileKey || !Array.isArray(entry.qualities)) continue;
const qualities = entry.qualities
.filter((qe) => typeof qe.q === 'number' && typeof qe.r === 'string')
.map((qe) => ({ q: qe.q, r: qe.r }))
.sort((a, b) => b.q - a.q);
next.push({ fileKey: entry.fileKey, qualities });
}
this._cacheList = next;
}
_scheduleBackoff(action, opt, ownerHls) {
const { attempts, maxAttempts = 3, baseMs = CONFIG.retry.baseMs, maxMs = CONFIG.retry.maxMs, onGiveUp } = opt || {};
if (attempts >= maxAttempts) {
if (onGiveUp) safe(onGiveUp, 'backoff.giveUp');
return false;
}
const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs);
const timerId = setTimeout(() => {
this._backoffTimers.delete(timerId);
if (ownerHls && ownerHls !== this.instance) return;
safe(action, 'backoff.action');
}, delay);
this._backoffTimers.add(timerId);
return true;
}
_clearBackoffTimers() {
this._backoffTimers.forEach((id) => clearTimeout(id));
this._backoffTimers.clear();
}
create(url, video, fileKey, onGiveUpNetwork) {
if (!Hls.isSupported()) {
if (video?.canPlayType?.('application/vnd.apple.mpegurl')) {
video.src = url;
return null;
}
showTip('浏览器不支持视频播放');
return null;
}
this._fileKey = fileKey || 'default';
this._retries[this._fileKey] = 0;
const hls = new Hls({ ...CONFIG.hls });
hls.on(Hls.Events.ERROR, (_, data) => {
if (this.instance !== hls || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === 'manifestParsingError') {
showTip('视频地址无效');
return;
}
const retries = this._retries[this._fileKey] || 0;
this._retries[this._fileKey] = retries + 1;
const MAX = CONFIG.retry.hlsNetworkMaxAttempts;
this._scheduleBackoff(
() => { if (this.instance === hls) hls.startLoad(); },
{
attempts: retries,
maxAttempts: MAX,
baseMs: CONFIG.retry.hlsNetworkBaseMs,
maxMs: CONFIG.retry.hlsNetworkMaxMs,
onGiveUp: onGiveUpNetwork,
},
hls,
);
showTip(`网络错误,指数重试 ${retries + 1}/${MAX}…`);
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// 分级恢复:首次 recoverMediaError,复发则交换音频编解码器后再恢复,避免无限循环
hls._mediaErrors = (hls._mediaErrors || 0) + 1;
if (hls._mediaErrors === 1) {
hls.recoverMediaError();
} else if (hls._mediaErrors === 2) {
hls.swapAudioCodec();
hls.recoverMediaError();
} else {
showTip('视频解码失败,请刷新重试');
}
} else {
showTip('播放失败,请刷新重试');
}
});
hls.loadSource(url);
hls.attachMedia(video);
this.instance = hls;
return hls;
}
destroy() {
if (this._disposed) return;
this._clearBackoffTimers();
if (!this.instance) return;
const hls = this.instance;
this.instance = null;
safe(() => {
hls.stopLoad();
hls.detachMedia();
hls.destroy();
}, 'HlsController.destroy');
}
dispose() {
this.destroy();
super.dispose();
}
}
/* ============================================================
* 画质控制
* ============================================================ */
class QualityController extends Disposable {
constructor(hlsController) {
super();
this.hls = hlsController;
this.list = [];
this.getUrl = null;
this._lastResolution = 0;
this._switchState = null;
this._art = null;
this._qualityHandler = null;
}
async build(resolution, getUrl) {
this.getUrl = getUrl;
const match = resolution?.match?.(/width:(\d+),height:(\d+)/);
let videoHeight = match ? +match[2] : 0;
if (!videoHeight && this._lastResolution) videoHeight = this._lastResolution;
const sortedDesc = [...CONFIG.qualityLevels].sort((a, b) => b - a);
// 根据视频实际分辨率过滤:只保留不超过视频高度的最高画质
let candidates = sortedDesc;
if (videoHeight > 0) {
this._lastResolution = videoHeight;
const startIdx = sortedDesc.findIndex((q) => videoHeight >= q);
if (startIdx >= 0) candidates = sortedDesc.slice(startIdx);
}
const fileKey = this.hls.fileKey;
const resolvedList = await this.hls.resolveEpisodeQualities(fileKey, candidates, getUrl);
if (!resolvedList.length) {
this.list = [];
return;
}
this.list = resolvedList.map((item, i) => ({
html: CONFIG.qualityTemplates[item.q],
url: item.r,
default: i === 0,
}));
}
apply(art) {
if (art && this.list?.length) art.quality = this.list;
}
bind(art) {
if (!art || this._qualityHandler) return;
this._art = art;
this._qualityHandler = async (url) => {
if (!url) return;
const newUrl = await this.hls.resolveUrl(url);
if (!newUrl) {
showTip('画质切换失败');
return;
}
if (this.hls.instance && this.hls.instance.url === newUrl) return;
this._switchState = {
currentTime: art.currentTime,
volume: art.video.volume,
muted: art.video.muted,
playing: !art.video.paused,
};
this.hls.destroy();
safe(() => {
art.video.pause();
art.video.removeAttribute('src');
art.video.load();
}, 'quality.resetVideo');
const newHls = this.hls.create(newUrl, art.video, this.hls.fileKey, () => showTip('画质切换失败'));
if (!newHls) {
this._switchState = null;
return;
}
newHls.once(Hls.Events.MANIFEST_PARSED, () => {
const state = this._switchState;
if (!state) return;
this._switchState = null;
const { currentTime, volume, muted, playing } = state;
art.video.muted = muted;
art.video.volume = volume;
art.currentTime = currentTime;
if (playing) art.video.play().catch(() => { });
});
};
art.on('quality', this._qualityHandler);
}
unbind() {
if (this._art && this._qualityHandler) {
safe(() => this._art.off('quality', this._qualityHandler), 'QualityController.unbind');
}
this._art = null;
this._qualityHandler = null;
}
dispose() {
this.unbind();
super.dispose();
}
}
/* ============================================================
* 进度存储
* ============================================================ */
class ProgressSaveAgent extends Disposable {
constructor(playerRef, episodeMeta) {
super();
this.playerRef = playerRef;
this.episodeMeta = episodeMeta;
this._file = () => this.playerRef()?.file || null;
}
getKey(file = this._file()) {
return progressKey(file);
}
save(file = this._file()) {
const key = progressKey(file);
const art = this.playerRef()?.art;
if (!key || !art?.duration || art.duration <= 0) return;
const data = JSON.stringify({
currentTime: art.currentTime,
duration: art.duration,
timestamp: Date.now(),
});
storage.setItem(key, data);
this.episodeMeta.setProgress(file, art.currentTime, Date.now());
}
load() {
const file = this._file();
const art = this.playerRef()?.art;
if (!file || !art) return;
const obj = parseStoredProgress(file);
if (!obj) return;
art.currentTime = obj.currentTime;
this.episodeMeta.setProgress(file, obj.currentTime, obj.timestamp);
}
clear(file = this._file()) {
const key = progressKey(file);
if (!key) return;
safe(() => storage.removeItem(key), 'clearProgress');
this.episodeMeta.setProgress(file, null, null);
}
getSavedFor(file) {
if (!file) return null;
const entry = this.episodeMeta.get(file);
if (entry?.currentTime != null && entry.timestamp > 0) {
return { currentTime: entry.currentTime, duration: entry.duration ?? 0 };
}
return parseStoredProgress(file);
}
}
/* ============================================================
* 选集面板 — ArtPlayer 插件
* 始终返回同一实例对象;状态由内部 _state 控制
* ============================================================ */
const DRAWER_WIDTH = 280;
const DRAWER_WIDTH_NARROW = 220;
const DRAWER_NARROW_BREAKPOINT = 480;
const DRAWER_HEIGHT = 400;
const DRAWER_TRANSITION_MS = 250;
const TOP_Z = 2147483647;
const calcDrawerPosition = () => {
const width = window.innerWidth <= DRAWER_NARROW_BREAKPOINT ? DRAWER_WIDTH_NARROW : DRAWER_WIDTH;
return {
right: 0,
top: '50%',
marginTop: -DRAWER_HEIGHT / 2,
height: DRAWER_HEIGHT,
width,
};
};
const EPISODE_MENU_CSS = `
.ep-menu {
background: rgba(18, 18, 22, 0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.08);
border-right: none;
border-radius: 12px 0 0 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.65), 0 1px 0 rgba(255,255,255,.04) inset;
pointer-events: auto;
will-change: transform;
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 8px;
border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.ep-header-title {
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: .3px;
font-family: system-ui, sans-serif;
}
.ep-header-count {
color: rgba(255,255,255,.38);
font-size: 11px;
font-family: system-ui, sans-serif;
}
.ep-list {
overflow-y: auto;
padding: 6px 8px 8px;
flex: 1;
min-height: 0;
}
.ep-list::-webkit-scrollbar { width: 4px; }
.ep-list::-webkit-scrollbar-track { background: transparent; }
.ep-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.14);
border-radius: 2px;
}
.ep-list::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.28); }
.ep-item {
display: flex;
align-items: center;
min-height: 44px;
padding: 5px 10px;
gap: 8px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
outline: none;
}
.ep-item:focus-visible {
box-shadow: 0 0 0 2px rgba(30,144,255,.5);
background: rgba(255,255,255,.07);
}
.ep-item:hover{ background: rgba(255,255,255,.07); }
.ep-item--active { background: rgba(30,144,255,.14); }
.ep-item--active:hover { background: rgba(30,144,255,.20); }
.ep-index {
width: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.ep-num {
color: rgba(255,255,255,.3);
font-size: 12px;
font-variant-numeric: tabular-nums;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-num { color: rgba(30,144,255,.8); }
.ep-playing-bar {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.ep-playing-bar span {
display: block;
width: 3px;
border-radius: 2px;
background: #1e90ff;
animation: epBar .9s ease-in-out infinite alternate;
}
.ep-playing-bar span:nth-child(1) { height: 5px; animation-delay: 0s;}
.ep-playing-bar span:nth-child(2) { height: 12px; animation-delay: .2s; }
.ep-playing-bar span:nth-child(3) { height: 7px; animation-delay: .38s; }
@keyframes epBar {
from { transform: scaleY(.35); }
to { transform: scaleY(1); }
}
.ep-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-name {
color: rgba(255,255,255,.82);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-name {
color: #fff;
font-weight: 500;
}
.ep-meta {
display: flex;
align-items: center;
gap: 8px;
min-height: 14px;
}
.ep-duration {
color: rgba(255,255,255,.28);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-duration { color: rgba(30,144,255,.6); }
.ep-progress {
color: rgba(255,185,60,.75);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.ep-item--active .ep-progress { color: rgba(255,185,60,1); }
.art-control-prev,
.art-control-next,
.art-control-episodes {
opacity: .8;
cursor: pointer;
transition: opacity .2s;
}
.art-control-prev:hover,
.art-control-next:hover,
.art-control-episodes:hover { opacity: 1; }
div.art-control-prev.art-ep-disabled,
div.art-control-next.art-ep-disabled {
opacity: .5;
}
.artplayer-title {
position: absolute;
top: 0; left: 0; right: 0;
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0));
font-family: system-ui, -apple-system, sans-serif;
box-sizing: border-box;
transition: opacity var(--art-transition-duration, 0.2s) ease;
}
.art-title-text {
color: #fff;
font-size: 14px;
font-weight: 500;
letter-spacing: .2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
.artplayer-autonext {
position: absolute;
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
padding: 0 16px 0;
box-sizing: border-box;
animation: art-autonext-rise .2s ease-out;
}
.art-layers:not(.art-show) .artplayer-autonext {
opacity: 1 !important;
visibility: visible !important;
}
.artplayer-autonext-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px 7px 12px;
background: #3a3d42;
border: 1px solid rgba(0, 0, 0, .25);
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .06) inset;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
max-width: calc(100% - 32px);
pointer-events: auto;
}
.artplayer-autonext-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
.artplayer-autonext-title {
font-size: 12px;
font-weight: 500;
letter-spacing: .1px;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, .92);
}
.artplayer-autonext-sub {
font-size: 10.5px;
opacity: .7;
font-variant-numeric: tabular-nums;
letter-spacing: .1px;
color: rgba(255, 255, 255, .82);
}
.artplayer-autonext-sub b {
font-weight: 600;
color: rgba(255, 255, 255, .95);
}
.artplayer-autonext-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: 2px;
}
.artplayer-autonext-cancel {
padding: 4px 10px;
font-size: 11px;
color: rgba(255, 255, 255, .82);
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 999px;
cursor: pointer;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-cancel:hover {
background: rgba(255, 255, 255, .16);
}
.artplayer-autonext-cancel:active {
transform: scale(.96);
}
.artplayer-autonext-play {
padding: 4px 10px;
font-size: 11px;
color: #1f2328;
background: #fff;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
transition: background .15s ease, transform .1s ease;
font-family: inherit;
line-height: 1.4;
}
.artplayer-autonext-play:hover {
background: #f0f0f0;
}
.artplayer-autonext-play:active {
transform: scale(.96);
}
@keyframes art-autonext-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`;
function ensureEpisodeMenuStyle() {
if (document.getElementById('ep-menu-style')) return;
const style = document.createElement('style');
style.id = 'ep-menu-style';
style.textContent = EPISODE_MENU_CSS;
document.head.appendChild(style);
}
/* ============================================================
* 选集面板 — 正式 class(替代原 createEpisodeMenu 闭包)
* 状态由实例内部维护;单例通过模块级 _episodeMenu 暴露
* ============================================================ */
class EpisodeMenu extends Disposable {
constructor() {
super();
this._el = null;
this._resizeHandler = null;
this._menuLeaveHandler = null;
this._playerRef = null;
this._hooks = { onOpen: null, onClose: null };
}
_getPlayer() {
return this._playerRef || null;
}
_buildItem(player, f, i, idx) {
const isCurrent = i === idx;
const name = getFileName(f);
const key = fileKeyOf(f);
const cachedDur = player.episodeMeta.getDuration(f);
const saved = player.episodeMeta.get(f);
const item = document.createElement('div');
item.className = `ep-item${isCurrent ? ' ep-item--active' : ''}`;
item.dataset.epKey = String(key);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(isCurrent));
item.setAttribute('tabindex', '0');
const indexEl = document.createElement('div');
indexEl.className = 'ep-index';
const indexInner = document.createElement('span');
indexInner.className = isCurrent ? 'ep-playing-bar' : 'ep-num';
if (isCurrent) {
indexInner.innerHTML = '';
} else {
indexInner.textContent = String(i + 1);
}
indexEl.appendChild(indexInner);
const textEl = document.createElement('div');
textEl.className = 'ep-text';
const nameEl = document.createElement('div');
nameEl.className = 'ep-name';
nameEl.textContent = name;
nameEl.title = name;
const metaEl = document.createElement('div');
metaEl.className = 'ep-meta';
const durEl = document.createElement('span');
durEl.className = 'ep-duration';
durEl.textContent = cachedDur != null ? formatTime(cachedDur) : '';
metaEl.appendChild(durEl);
if (saved && saved.currentTime != null && saved.duration > 0) {
const progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
metaEl.appendChild(progressEl);
}
textEl.appendChild(nameEl);
textEl.appendChild(metaEl);
item.appendChild(indexEl);
item.appendChild(textEl);
const handleClick = () => {
if (item.dataset.switched === '1') return;
item.dataset.switched = '1';
if (i !== idx) player.switchVideo(f);
this.close();
};
item.addEventListener('click', handleClick);
// 触摸端加速响应:pointer 事件即时触发,避免 300ms click 延迟与合成 mouseleave 中断
let pDown = false, pDownX = 0, pDownY = 0;
item.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') return;
pDown = true;
pDownX = e.clientX;
pDownY = e.clientY;
}, { passive: true });
item.addEventListener('pointerup', (e) => {
if (e.pointerType === 'mouse' || !pDown) return;
pDown = false;
if (Math.abs(e.clientX - pDownX) < 10 && Math.abs(e.clientY - pDownY) < 10) {
e.preventDefault();
handleClick();
}
}, { passive: false });
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
});
return item;
}
_applyPosition() {
if (!this._el) return;
const pos = calcDrawerPosition();
const { style } = this._el;
style.right = pos.right + 'px';
style.top = pos.top;
style.bottom = 'auto';
style.marginTop = pos.marginTop + 'px';
style.height = pos.height + 'px';
style.width = pos.width + 'px';
}
open(player) {
const target = player || this._getPlayer();
if (!target) return;
if (this._el) this.close();
const idx = target.getCurrentIndex();
const pos = calcDrawerPosition();
const menu = document.createElement('div');
menu.className = 'ep-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', '选集列表');
menu.style.cssText = `position:fixed;right:${pos.right}px;top:${pos.top};margin-top:${pos.marginTop}px;height:${pos.height}px;width:${pos.width}px;z-index:${TOP_Z};transform:translateX(100%);transition:transform ${DRAWER_TRANSITION_MS}ms cubic-bezier(.22,.61,.36,1);`;
menu.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
}, { passive: false });
const header = document.createElement('div');
header.className = 'ep-header';
header.innerHTML = ``;
menu.appendChild(header);
const list = document.createElement('div');
list.className = 'ep-list';
list.setAttribute('role', 'listbox');
const fragment = document.createDocumentFragment();
target.filelist.forEach((f, i) => fragment.appendChild(this._buildItem(target, f, i, idx)));
list.appendChild(fragment);
menu.appendChild(list);
const host =
target?.art?.template?.$player ||
document.querySelector('#artplayer') ||
document.body;
host.appendChild(menu);
menu.style.pointerEvents = 'auto';
this._el = menu;
this._playerRef = target;
requestAnimationFrame(() => {
if (this._el !== menu) return;
menu.style.transform = 'translateX(0)';
const active = list.querySelector('.ep-item--active');
if (active) {
active.focus();
active.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
this._menuLeaveHandler = (e) => {
// 仅桌面端鼠标离开菜单时自动关闭;触摸场景下跳过,避免误触发
if (e && e.pointerType && e.pointerType !== 'mouse') return;
if (this._el === menu) this.close();
};
menu.addEventListener('mouseleave', this._menuLeaveHandler);
menu.addEventListener('pointerleave', this._menuLeaveHandler);
this._resizeHandler = debounce(() => this._applyPosition(), 100);
window.addEventListener('resize', this._resizeHandler);
target.episodeMeta.prefetch(target.filelist);
if (typeof this._hooks.onOpen === 'function') this._hooks.onOpen();
}
close() {
const wasOpen = !!this._el;
const el = this._el;
if (!el) return;
if (this._menuLeaveHandler) {
safe(() => el.removeEventListener('mouseleave', this._menuLeaveHandler), 'menu.removeListener.mouseleave');
safe(() => el.removeEventListener('pointerleave', this._menuLeaveHandler), 'menu.removeListener.pointerleave');
}
const finalize = once(() => {
el.removeEventListener('transitionend', finalize);
el.remove();
});
el.addEventListener('transitionend', finalize);
setTimeout(finalize, DRAWER_TRANSITION_MS + 50);
el.style.transform = 'translateX(100%)';
this._el = null;
this._menuLeaveHandler = null;
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
this._playerRef = null;
if (wasOpen && typeof this._hooks.onClose === 'function') this._hooks.onClose();
}
toggle(player) {
if (this._el) this.close();
else this.open(player);
}
isOpen() {
return !!this._el;
}
updateDuration(file, sec) {
if (!this._el || !file) return;
const key = fileKeyOf(file);
const escapedKey = CSS.escape(String(key));
const el = this._el.querySelector(`[data-ep-key="${escapedKey}"] .ep-duration`);
if (el) el.textContent = formatTime(sec);
}
updateActiveState(idx) {
if (!this._el) return;
const items = this._el.querySelectorAll('.ep-item');
items.forEach((item, i) => {
const isActive = i === idx;
item.classList.toggle('ep-item--active', isActive);
item.setAttribute('aria-selected', String(isActive));
const indexWrap = item.querySelector('.ep-index');
if (!indexWrap) return;
const existing = indexWrap.firstElementChild;
if (isActive) {
if (!existing || !existing.classList.contains('ep-playing-bar')) {
if (existing) existing.remove();
const bar = document.createElement('span');
bar.className = 'ep-playing-bar';
bar.innerHTML = '';
indexWrap.appendChild(bar);
}
} else {
if (!existing || !existing.classList.contains('ep-num')) {
if (existing) existing.remove();
const num = document.createElement('span');
num.className = 'ep-num';
num.textContent = String(i + 1);
indexWrap.appendChild(num);
}
}
});
}
updateItemProgress(file) {
if (!this._el || !file) return;
const player = this._getPlayer();
if (!player) return;
const key = fileKeyOf(file);
const escapedKey = CSS.escape(String(key));
const item = this._el.querySelector(`[data-ep-key="${escapedKey}"]`);
if (!item) return;
const saved = player.episodeMeta.get(file);
const metaEl = item.querySelector('.ep-meta');
if (!metaEl) return;
let progressEl = metaEl.querySelector('.ep-progress');
if (!saved || saved.currentTime == null || !saved.duration || saved.duration <= 0) {
if (progressEl) progressEl.remove();
return;
}
if (!progressEl) {
progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
metaEl.appendChild(progressEl);
}
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `已播放 ${formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${formatTime(saved.currentTime)},共 ${formatTime(saved.duration)}`;
}
setHooks({ onOpen, onClose } = {}) {
this._hooks.onOpen = onOpen || null;
this._hooks.onClose = onClose || null;
}
destroy() {
this.close();
}
dispose() {
this.destroy();
super.dispose();
}
}
const _episodeMenu = new EpisodeMenu();
const getEpisodeMenu = () => _episodeMenu;
/* ============================================================
* 选集面板 — 按钮注入插件
* 负责在 artplayer controls 注入"选集"按钮,调用 ensureEpisodeMenuStyle()。
* 面板本身由 _episodeMenu 单例管理。
* ============================================================ */
const EPISODE_MENU_PLUGIN_NAME = 'episodeMenu';
function episodeMenuPlugin(options = {}) {
return function installEpisodeMenu(art) {
ensureEpisodeMenuStyle();
const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null);
art.controls.add({
name: EPISODE_MENU_PLUGIN_NAME,
position: 'right',
html: '选集',
tooltip: '选集',
style: { padding: '0 10px', fontSize: '14px' },
click: () => {
if (_episodeMenu.isOpen()) _episodeMenu.close();
else _episodeMenu.open(getPlayer());
},
});
return {
name: EPISODE_MENU_PLUGIN_NAME,
open: () => _episodeMenu.open(getPlayer()),
close: () => _episodeMenu.close(),
toggle: () => (getPlayer() && _episodeMenu.toggle(getPlayer())),
isOpen: () => _episodeMenu.isOpen(),
setHooks: (hooks) => _episodeMenu.setHooks(hooks),
updateDuration: (f, sec) => _episodeMenu.updateDuration(f, sec),
updateActiveState: (i) => _episodeMenu.updateActiveState(i),
updateItemProgress: (f) => _episodeMenu.updateItemProgress(f),
destroy: () => _episodeMenu.destroy(),
};
};
}
/* ============================================================
* 选集上/下一集 Nav 插件
* ============================================================ */
function episodeNavPlugin(options = {}) {
return function installEpisodeNav(art) {
const getPlayer = () => (typeof options.getPlayer === 'function' ? options.getPlayer() : null);
const prev = art.controls.add({
name: 'prev',
position: 'left',
index: 5,
html: makeSvg(ICONS.prev),
tooltip: '上一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const player = getPlayer();
if (!player) return;
const idx = player.getCurrentIndex();
if (idx > 0) {
player.switchVideo(player.filelist[idx - 1]);
}
},
});
const next = art.controls.add({
name: 'next',
position: 'left',
index: 15,
html: makeSvg(ICONS.next),
tooltip: '下一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) return;
const player = getPlayer();
if (!player) return;
const idx = player.getCurrentIndex();
const len = player.filelist?.length || 0;
if (idx >= 0 && idx < len - 1) {
player.switchVideo(player.filelist[idx + 1]);
}
},
});
function setEnabled(el, enabled) {
if (!el) return;
if (enabled) {
el.classList.remove('art-ep-disabled');
el.removeAttribute('aria-disabled');
el.style.opacity = '';
} else {
el.classList.add('art-ep-disabled');
el.setAttribute('aria-disabled', 'true');
el.style.opacity = '0.5';
}
}
const resolveEl = (name, captured) => {
if (captured && captured instanceof HTMLElement && captured.isConnected) return captured;
const fromArt = art.controls && art.controls[name];
if (fromArt instanceof HTMLElement && fromArt.isConnected) return fromArt;
const root = art.template && art.template.$controls;
if (!root) return null;
const found = root.querySelector(`div.art-control-${name}`);
return found instanceof HTMLElement ? found : null;
};
const refreshFromPlayer = (player) => {
const idx = player.getCurrentIndex();
const len = player.filelist?.length || 0;
setEnabled(resolveEl('prev', prev), idx > 0);
setEnabled(resolveEl('next', next), idx >= 0 && idx < len - 1);
};
const scheduleRefresh = () => {
const player = getPlayer();
if (!player) return;
refreshFromPlayer(player);
requestAnimationFrame(() => refreshFromPlayer(player));
setTimeout(() => refreshFromPlayer(player), 400);
};
return {
name: 'episodeNav',
refresh() {
scheduleRefresh();
},
destroy() {
// artplayer 内部 on destroy 会自动清理 controls
},
};
};
}
/* ============================================================
* 自动切下一集控制器
* ============================================================ */
class AutoNextController extends Disposable {
constructor(artRef) {
super();
this.artRef = artRef;
this.state = AutoNextState.IDLE;
this._token = 0;
this._ticker = null;
this._visible = false;
this._resizeObs = null;
this._layerName = null;
this._onCancelCb = null;
this._onCompleteCb = null;
}
get visible() {
return this._visible;
}
get isTerminal() {
return this.state === AutoNextState.CANCELLED || this.state === AutoNextState.COMPLETED;
}
_setState(s) {
if (this.state === s) return;
this.state = s;
}
_cancelTicker() {
if (this._ticker) {
clearInterval(this._ticker);
this._ticker = null;
}
}
_disconnectResize() {
if (this._resizeObs) {
safe(() => this._resizeObs.disconnect(), 'AutoNext.disconnect');
this._resizeObs = null;
}
}
cancel() {
this._cancelTicker();
}
show({ name, seconds, onCancel, onComplete }) {
if (this._disposed) return;
const art = this.artRef();
if (!art) return;
this.hideCountdown();
this._token++;
const token = this._token;
this._setState(AutoNextState.SHOWN);
this._onCancelCb = onCancel;
this._onCompleteCb = onComplete;
const EMBED = 15;
const $ctrl = art?.template?.$controls || art?.controls?.$controls;
const ctrlH = Math.max(40, $ctrl?.getBoundingClientRect?.().height || 40);
const bottomPx = Math.round(ctrlH + EMBED);
this._visible = true;
art.controls.show = true;
art.layers.show = true;
let remaining = seconds;
const layerName = `autoNextCountdown_${token}`;
this._layerName = layerName;
const $layer = art.layers.add({
name: layerName,
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' },
mounted: ($el) => {
if (token !== this._token) return;
const $title = $el.querySelector('.artplayer-autonext-title');
const $sec = $el.querySelector('.artplayer-autonext-sec');
$title.textContent = name || '下一集';
const paint = () => {
const v = String(Math.max(remaining, 0));
if ($sec) {
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
};
paint();
this._resizeObs = new ResizeObserver(() => {
const wrap = $el?.querySelector?.('.artplayer-autonext');
if (!wrap) return;
const $ctrl2 = art?.template?.$controls || art?.controls?.$controls;
const h = Math.max(40, $ctrl2?.getBoundingClientRect?.().height || 40);
wrap.style.bottom = Math.round(h + EMBED) + 'px';
});
safe(() => $ctrl && this._resizeObs.observe($ctrl), 'AutoNext.observe');
$el.querySelector('.artplayer-autonext-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (token !== this._token) return;
if (this.state !== AutoNextState.SHOWN) return;
this._setState(AutoNextState.CANCELLED);
safe(() => this._onCancelCb?.(), 'AutoNext.onCancel');
});
$el.querySelector('.artplayer-autonext-play')?.addEventListener('click', (ev) => {
ev.stopPropagation();
if (token !== this._token) return;
if (this.state !== AutoNextState.SHOWN) return;
this._setState(AutoNextState.COMPLETED);
safe(() => this._onCompleteCb?.({ immediate: true }), 'AutoNext.onComplete.immediate');
});
},
});
this._ticker = setInterval(() => {
if (token !== this._token) {
this._cancelTicker();
return;
}
remaining -= 1;
const $sec = $layer?.querySelector?.('.artplayer-autonext-sec');
if ($sec) {
const v = String(Math.max(remaining, 0));
$sec.textContent = v;
$sec.setAttribute('aria-label', `剩余 ${v} 秒`);
}
if (remaining <= 0) {
this._cancelTicker();
if (this.state === AutoNextState.SHOWN) {
this._setState(AutoNextState.COMPLETED);
safe(() => this._onCompleteCb?.({ immediate: false }), 'AutoNext.onComplete.ticker');
}
}
}, 1000);
}
hideCountdown() {
this._cancelTicker();
this._disconnectResize();
const art = this.artRef();
const layerName = this._layerName;
this._layerName = null;
this._onCancelCb = null;
this._onCompleteCb = null;
if (!art || !layerName || !art.layers?.[layerName]) {
this._visible = false;
return;
}
safe(() => art.layers.remove(layerName), 'AutoNext.removeLayer');
this._visible = false;
}
dispose() {
this.hideCountdown();
super.dispose();
}
}
/* ============================================================
* UI 控制:控制栏隐藏 + 标题层
* 提供 hideVisor() / showVisor() 接口,
* 由 Player 决定何时调用(避免与 AutoNext 双向耦合)
* ============================================================ */
class UiController extends Disposable {
constructor(artRef) {
super();
this.artRef = artRef;
this._hideCtl = null;
this._setup = false;
this._titleEl = null;
this._titleLayerAdded = false;
this._layerSyncBound = false;
this._pinned = () => false;
}
setPinnedProbe(fn) {
this._pinned = typeof fn === 'function' ? fn : () => false;
}
_makeHideCtl() {
const art = this.artRef();
if (!art) return null;
let hideTimer = null;
const $controls = () => art?.template?.$controls;
const clearHide = () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const showNow = () => {
if (!art) return;
if (art.controls) art.controls.show = true;
art.layers.show = true;
};
const isPinned = () => this._pinned();
const scheduleHide = () => {
clearHide();
if (isPinned()) return;
hideTimer = setTimeout(() => {
hideTimer = null;
if (!art?.controls || isPinned()) return;
const el = $controls();
if (el && el.matches(':hover')) return;
art.controls.show = false;
art.layers.show = false;
}, CONFIG.hideDelayMs);
};
return { clearHide, showNow, scheduleHide };
}
setup() {
const art = this.artRef();
if (!art || this._setup) return;
this._setup = true;
this._hideCtl = this._makeHideCtl();
if (typeof art.on === 'function' && !this._layerSyncBound) {
this._layerSyncBound = true;
this._listeners.trackArtListener(art, 'control', (state) => {
if (!art) return;
if (this._pinned()) {
if (art.layers) art.layers.show = true;
return;
}
if (art.layers) art.layers.show = !!state;
});
}
this._listeners.on(document, 'mousemove', (e) => {
const el = art?.template?.$controls;
if (!el) return;
if (el.contains(e.target)) {
this._hideCtl?.clearHide();
this._hideCtl?.showNow();
} else if (!this._pinned()) {
this._hideCtl?.scheduleHide();
}
}, { passive: true });
this._listeners.on(document, 'mouseleave', () => {
if (this._pinned()) {
this._hideCtl?.clearHide();
return;
}
this._hideCtl?.scheduleHide();
}, { passive: true });
}
addTitle(file) {
const art = this.artRef();
if (!art || this._titleLayerAdded) return;
this._titleLayerAdded = true;
const initialName = file ? getFileName(file) : '';
art.layers.add({
name: 'episodeTitle',
html: `
`,
style: { position: 'absolute', top: '0', left: '0', right: '0', pointerEvents: 'none' },
mounted: ($el) => {
this._titleEl = $el.querySelector('.art-title-text');
this._setTitleText(initialName);
},
});
this._setTitleText(initialName);
}
updateTitle(file) {
this._setTitleText(file ? getFileName(file) : '');
}
_setTitleText(name) {
const art = this.artRef();
const el = this._titleEl || art?.layers?.episodeTitle?.querySelector?.('.art-title-text');
if (el) el.textContent = name || '';
}
showNow() {
this._hideCtl?.showNow();
}
scheduleHide() {
this._hideCtl?.scheduleHide();
}
reset() {
this._titleLayerAdded = false;
this._titleEl = null;
this._setup = false;
this._layerSyncBound = false;
this._hideCtl = null;
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* 快捷键
* ============================================================ */
class HotkeyController extends Disposable {
constructor(playerRef) {
super();
this.playerRef = playerRef;
}
setup() {
const player = this.playerRef();
if (!player?.art) return;
this._listeners.on(document, 'keydown', (e) => {
const player2 = this.playerRef();
if (!player2?.art) return;
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.repeat) return;
const key = e.key.toLowerCase();
if (key === 'f') {
player2.art.fullscreen = !player2.art.fullscreen;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'w') {
player2.art.fullscreenWeb = !player2.art.fullscreenWeb;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'm') {
player2.art.muted = !player2.art.muted;
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'p') {
const idx = player2.getCurrentIndex();
if (idx > 0) player2.switchVideo(player2.filelist[idx - 1]);
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
} else if (key === 'n') {
const idx = player2.getCurrentIndex();
if (idx >= 0 && idx < (player2.filelist?.length || 0) - 1) player2.switchVideo(player2.filelist[idx + 1]);
e.preventDefault();
e.stopPropagation();
player2._ui.scheduleHide();
}
}, true);
}
reset() {
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* 触摸手势控制器:水平滑动调整播放进度(移动端)
* - 单指水平滑动 → 在视频时长内按比例 seek
* - 实时显示目标时间,touchend 时应用并阻止合成 click
* ============================================================ */
class TouchGestureController extends Disposable {
constructor(playerRef) {
super();
this.playerRef = playerRef;
}
setup() {
const player = this.playerRef();
const art = player?.art;
const video = art?.video;
if (!art || !video) return;
const MOVE_THRESHOLD = 10; // 触发滑动的最小位移
const DIRECTION_RATIO = 1.5; // 水平分量需为垂直的 1.5 倍才判定为水平滑动
const SEEK_RATIO = 0.5; // 屏宽对应 50% 时长
let startX = 0, startY = 0;
let startCurrent = 0, duration = 0;
let seeking = false;
let lastTarget = 0;
let suppressClickUntil = 0;
this._listeners.on(video, 'touchstart', (e) => {
if (!e.touches || e.touches.length !== 1) return;
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
startCurrent = art.currentTime || 0;
duration = art.duration || 0;
seeking = false;
lastTarget = startCurrent;
}, { passive: true });
this._listeners.on(video, 'touchmove', (e) => {
if (!e.touches || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!seeking) {
if (Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) return;
if (Math.abs(dx) < Math.abs(dy) * DIRECTION_RATIO) return;
seeking = true;
}
if (e.cancelable) e.preventDefault();
const rect = video.getBoundingClientRect();
const w = rect.width || video.clientWidth || 1;
if (w <= 0) return;
const ratio = Math.max(-1, Math.min(1, dx / w));
const delta = ratio * duration * SEEK_RATIO;
let target = startCurrent + delta;
if (target < 0) target = 0;
if (duration > 0 && target > duration) target = duration;
lastTarget = target;
try { art.notice.show = `${formatTime(target)} / ${formatTime(duration)}`; } catch (_) {}
}, { passive: false });
const endHandler = () => {
if (!seeking) return;
seeking = false;
suppressClickUntil = Date.now() + 400;
try {
if (Number.isFinite(lastTarget) && Math.abs(lastTarget - (art.currentTime || 0)) > 0.5) {
art.currentTime = lastTarget;
}
art.notice.show = '';
} catch (_) {}
};
this._listeners.on(video, 'touchend', endHandler, { passive: false });
this._listeners.on(video, 'touchcancel', endHandler, { passive: false });
// 抑制滑动后合成的 click(避免被 Artplayer 当成播放/暂停)
this._listeners.on(video, 'click', (e) => {
if (Date.now() < suppressClickUntil) {
e.preventDefault?.();
e.stopPropagation?.();
e.stopImmediatePropagation?.();
}
}, { capture: true });
}
reset() {
this._listeners.clearAll();
}
dispose() {
this.reset();
super.dispose();
}
}
/* ============================================================
* AutoNext 调度器:监听 video:ended → 触发倒计时
* - 由 Player 持有,独立生命周期
* - 显式 token + 状态机防止取消混乱
* ============================================================ */
class AutoNextScheduler extends Disposable {
constructor(player) {
super();
this.player = player;
this._artListenerToken = null;
}
attach(art) {
if (this._disposed) return;
const handler = (ev) => this._onEnded(ev);
art.on('video:ended', handler);
this._artListenerToken = { art, handler };
}
_onEnded() {
const player = this.player;
if (!player || player._disposed) return;
if (!player._autoNext || player._autoNext._disposed) {
player._autoNext = player._track(new AutoNextController(() => player.art));
player._ui.setPinnedProbe(() => player._autoNext?.visible || getEpisodeMenu().isOpen());
}
const art = player.art;
if (!art) return;
player.progress.clear();
const idx = player.getCurrentIndex();
const listLen = player.filelist?.length || 0;
if (idx < 0 || idx >= listLen - 1) return;
const next = player.filelist[idx + 1];
const nextName = getFileName(next);
const tokenAtEnd = player._switchToken;
const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click'];
const clearAutoNextTimer = () => {
if (player._autoNextTimer != null) {
player._listeners.clearTimeout(player._autoNextTimer);
player._autoNextTimer = null;
}
};
const cancelOnSeek = () => {
if (art._disposed || player._disposed) return;
player._cancelAutoNext();
clearAutoNextTimer();
cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek));
safe(() => art.play(), 'cancelOnSeek.play');
};
const switchNow = () => {
if (player._disposed) return;
player._autoNext?.hideCountdown();
clearAutoNextTimer();
cancelEvents.forEach((ev) => art.off(ev, cancelOnSeek));
if (player._switchToken !== tokenAtEnd) return;
player.switchVideo(next);
};
player._autoNext.show({
name: nextName,
seconds: CONFIG.countdownSec,
onCancel: () => cancelOnSeek(),
onComplete: () => switchNow(),
});
player._autoNextTimer = player._listeners.setTimeout(() => switchNow(), CONFIG.countdownSec * 1000);
cancelEvents.forEach((ev) => art.on(ev, cancelOnSeek));
}
dispose() {
if (this._artListenerToken) {
safe(() => this._artListenerToken.art.off('video:ended', this._artListenerToken.handler), 'AutoNextScheduler.detach');
this._artListenerToken = null;
}
super.dispose();
}
}
/* ============================================================
* Player 协调器
* - 持有所有子系统,统一通过 Disposable 链释放
* - 调度:路由 / 切换 / 进度 / 倒计时
* ============================================================ */
class Player extends Disposable {
constructor() {
super();
this.art = null;
this.file = null;
this.filelist = [];
this.flag = '';
this.nativeVideoNode = null;
this.getUrl = null;
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this._autoNext = null;
this._autoNextTimer = null;
this._autoNextScheduler = null;
this._episodeControlsAdded = false;
this._initialized = false;
this._epMenuUnsubs = [];
this._lbDirect = null; // .lb 直连信息(MSE 失败时兜底直连流式播放)
this._lbStreamer = null; // .lb MSE 流式转封装播放器(mediabunny)
this._lbPreparing = false; // .lb MSE 流式准备中(外层看门狗据此顺延)
this._onPlayerFail = null; // 初始化失败回调(网盘页 .lb 遮罩自动关闭)
this._track(this.hls = new HlsController());
this._track(this.quality = new QualityController(this.hls));
this._track(this.episodeMeta = new EpisodeMeta());
this._track(this.progress = new ProgressSaveAgent(() => this, this.episodeMeta));
this._track(this._ui = new UiController(() => this.art));
this._track(this._hotkey = new HotkeyController(() => this));
this._track(this._touchGesture = new TouchGestureController(() => this));
this._track(getEpisodeMenu());
this._epMenuUnsubs = [];
this._epMenuUnsubs.push(this.episodeMeta.events.on('progress', ({ file }) => {
if (!file) return;
getEpisodeMenu().updateItemProgress(file);
}));
this._epMenuUnsubs.push(this.episodeMeta.events.on('duration', ({ file, duration }) => {
if (!file || !Number.isFinite(duration)) return;
getEpisodeMenu().updateDuration(file, Math.ceil(duration));
}));
}
getCurrentIndex() {
const { file, filelist } = this;
if (!file || !filelist?.length) return -1;
return filelist.findIndex(
(f) =>
(f.fs_id && file.fs_id && f.fs_id == file.fs_id) ||
(f.path && file.path && f.path === file.path),
);
}
_getFolderKey() {
if (this.flag === 'sharevideo') {
const locals = unsafeWindow.locals;
const shareid = safe(() => typeof locals.get === 'function' ? locals.get('shareid') : locals?.shareid);
if (shareid) return `share_${shareid}`;
}
if (this.flag === 'video' || this.flag === 'playvideo') {
try {
const sp = new URLSearchParams(window.location.search);
const p = sp.get('path');
if (p) return `path_${p}`;
} catch (_) { /* URL parse fail */ }
}
return null;
}
pickResumeIndex() {
const list = this.filelist;
const folderKey = this._getFolderKey();
if (!folderKey || !Array.isArray(list) || !list.length) return null;
try {
const raw = storage.getRawItem('last_episode');
if (!raw) return null;
const obj = JSON.parse(raw);
const savedEp = obj?.[folderKey];
if (Number.isInteger(savedEp) && savedEp >= 0 && list.length > savedEp) return savedEp;
} catch (e) {
log.warn('pickResumeIndex', e);
}
return null;
}
_saveLastEpisode(epIdx) {
if (!Number.isInteger(epIdx) || epIdx < 0) return;
const folderKey = this._getFolderKey();
if (!folderKey) return;
try {
const raw = storage.getRawItem('last_episode');
const obj = raw ? JSON.parse(raw) : {};
obj[folderKey] = epIdx;
storage.setItem('last_episode', JSON.stringify(obj));
} catch (e) {
log.warn('_saveLastEpisode', e);
}
}
switchVideo(file) {
if (!file) return;
this._switchCtrl.submit(() => this._performSwitch(file));
}
/* ---------- .lb 在线流式播放(不下载整文件) ---------- */
async _resolveLbUrl(file) {
if (!file) return { url: null, error: '文件信息为空' };
lbDiag(`开始解析 .lb: fs_id=${file.fs_id}, flag=${this.flag}, size=${file.size || '?'}`);
// 多通道解析+MSE 准备期间都算"准备中"(外层 30s 看门狗据此顺延,避免误报超时)
this._lbPreparing = true;
try {
const { dlink, kind, error } = await resolveDlink(file, this.flag);
if (!dlink) return { url: null, error: error || '无法获取下载直链' };
this._disposeLbStreamer();
// 首选:mediabunny 流式转封装 → MSE(Range 边读边转:秒开 / 全速 / 可拖动 / MKV→MP4)
let mseError = '';
try {
const streamer = new LbMseStreamer(dlink, file);
const prepared = await streamer.prepare();
this._lbStreamer = streamer;
lbDiag('MSE 流式转封装就绪,交给播放器');
return { url: prepared.url, error: null, mse: true };
} catch (e) {
mseError = e?.message || String(e);
this._disposeLbStreamer();
lbDiag(`MSE 流式播放不可用: ${mseError}`);
}
// 兜底:整文件修复模式——下载完整文件 → 本地修复(去块/剪尾)→ 原生直放 / MSE。
// (直链直连已被实测证明死路:d.pcs.baidu.com 不带 CORS 头,video 元素必被拦)
const wholeSize = Number(file.size) || 0;
if (wholeSize > 0 && wholeSize <= 1024 * 1048576) {
lbDiag('进入整文件修复模式:下载 → 本地修复 → 播放…');
try {
const raw = await downloadWholeFile(dlink, wholeSize, (done, total) => {
const el = document.getElementById('bdplayer-lb-loading');
if (el && !el.querySelector('button')) {
el.textContent = `整文件修复模式:下载中 ${(done / 1048576).toFixed(1)}MB / ${(total / 1048576).toFixed(1)}MB(${Math.floor((done / total) * 100)}%)`;
}
});
lbDiag(`整文件下载完成: ${raw.length} 字节,本地修复中…`);
const streamer = new LbMseStreamer(dlink, file);
const prepared = await streamer.prepareFromBuffer(raw);
this._lbStreamer = streamer;
lbDiag(`修复后播放就绪(${prepared.native ? '原生直放' : 'MSE 转封装'}),交给播放器`);
return { url: prepared.url, error: null, mse: !prepared.native };
} catch (e2) {
lbDiag(`整文件修复模式失败: ${e2?.message || e2}`);
return { url: null, error: `流式与整文件修复均失败(${e2?.message || e2})。请复制控制台诊断日志反馈` };
}
}
return { url: null, error: `流式转换不可用(${mseError}),且文件超出整文件修复范围(>1GB)` };
} catch (e) {
lbDiag(`解析异常: ${e?.message || e}`);
return { url: null, error: `解析异常:${e?.message || e}` };
} finally {
this._lbPreparing = false;
}
}
/* 直连/原生 blob 播放失败 → 明确报错(绝不整文件下载) */
_bindLbFallback() {
const info = this._lbDirect;
this._lbDirect = null;
const native = this._lbStreamer?._native;
if ((!info && !native) || !this.art?.video) return;
const video = this.art.video;
const fail = (reason) => {
lbDiag(`直连/原生播放失败: ${reason || '未知'}`);
const msg = native
? `修复后仍无法播放(${reason || '未知错误'})。请复制控制台诊断日志反馈`
: `无法在线播放该文件(${reason || '未知错误'})。注意:网盘直链域名(d.pcs.baidu.com)不允许网页跨域直连播放,此路兜底注定受限;流式转换又不可用。请复制诊断日志反馈`;
showTip(msg);
safe(() => this._onPlayerFail?.(msg), 'lb.directFail');
};
let ok = false;
const timer = this._listeners.setTimeout(() => {
if (!ok) fail('加载超时');
}, 15000);
this._listeners.on(video, 'loadedmetadata', () => {
ok = true;
this._listeners.clearTimeout(timer);
}, { once: true });
this._listeners.on(video, 'error', () => {
if (ok) return;
lbDiag('video error 事件(直连播放被拒绝)');
this._listeners.clearTimeout(timer);
fail('浏览器拒绝播放,编码不受支持');
}, { once: true });
}
_disposeLbStreamer() {
if (!this._lbStreamer) return;
const s = this._lbStreamer;
this._lbStreamer = null;
safe(() => s.dispose(), 'lb.disposeStreamer');
}
_attachLbStreamer() {
if (!this._lbStreamer || !this.art) return;
this._lbStreamer.attach(this.art, { onFatal: (reason) => this._lbMseFallback(reason) });
}
/* MSE 流式播放中途失败 → 明确报错(直连已被实测证明是 CORS 死路,不再绕) */
_lbMseFallback(reason) {
if (!this.art) return;
this._disposeLbStreamer();
lbDiag(`MSE 流式播放失败: ${reason || '未知'}`);
const msg = `流式播放失败:${reason || '未知错误'}。可尝试重新打开该文件(将自动走整文件修复模式)`;
showTip(msg);
safe(() => this._onPlayerFail?.(msg), 'lb.mseFail');
}
_waitForVideoElement(video, token) {
return new Promise((resolve, reject) => {
const finish = once((err) => {
clearTimeout(timer);
err ? reject(err) : resolve();
});
const timer = setTimeout(() => finish(new Error('timeout')), 8000);
if (token !== undefined && token !== this._switchToken) {
finish(new Error('stale'));
return;
}
if (video?.readyState >= 1) finish();
else video?.addEventListener('loadedmetadata', () => {
if (token !== undefined && token !== this._switchToken) finish(new Error('stale'));
else finish();
}, { once: true });
});
}
async _performSwitch(file) {
this._cancelAutoNext();
const prevFile = this.file;
if (prevFile) this.progress.save(prevFile);
if (!this.art) return;
const wasFullscreen = !!this.art.fullscreen;
const wasFullscreenWeb = !!this.art.fullscreenWeb;
const volume = this.art.volume ?? 1;
this.file = file;
const token = ++this._switchToken;
const cardNodes = document.getElementsByClassName('vp-video-page-card__video-detail');
const title = getFileName(file);
const match = Array.from(cardNodes).find((n) => (n.textContent || '').includes(title));
if (match) match.click();
const newEpIdx = this.getCurrentIndex();
if (newEpIdx >= 0) this._saveLastEpisode(newEpIdx);
this.getUrl = this.flag === 'sharevideo'
? buildShareUrl(file)
: this.flag === 'mboxvideo'
? buildMboxUrl(file)
: buildFileUrl(file);
if (!this.getUrl) return;
this.hls.setUrlBuilder(this.getUrl);
// .lb 文件百度不会转码,跳过无意义的 M3U8 探测
if (isLbFile(file)) {
this.quality.list = [];
} else {
await this.quality.build(file.resolution, this.getUrl);
}
if (!this.quality.list?.length) {
if (!isLbFile(file)) {
showTip('无法获取视频地址');
return;
}
// .lb 文件:百度不转码,走在线流式播放(MSE 转封装 / 直连流式)
const lb = await this._resolveLbUrl(file);
if (!lb?.url) {
const msg = lb?.error || '解析失败';
showTip(`.lb 解析失败:${msg}`);
safe(() => this._onPlayerFail?.(msg), 'lb.resolveFail');
return;
}
if (token !== this._switchToken) return;
const lbUrl = lb.url;
this.hls.destroy();
safe(() => {
this.art.video.pause();
this.art.video.removeAttribute('src');
// 直链/blob 播放不能带跨域属性,否则会被 CORS 拦截
this.art.video.crossOrigin = null;
this.art.video.load();
}, 'lb.resetVideo');
safe(() => this.art.switchUrl(lbUrl), 'lb.switch');
// MSE 流式:接管拖动/看门狗;直连/缓存:绑加载失败兜底
if (this._lbStreamer && !this._lbStreamer._native) this._attachLbStreamer();
else this._bindLbFallback();
this.refreshControlDisplay();
getEpisodeMenu().updateActiveState(this.getCurrentIndex());
this._ui.updateTitle(file);
try {
await this._waitForVideoElement(this.art.video, token);
} catch (e) {
if (e?.message === 'stale') return;
log.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
const lbVideo = this.art.video;
lbVideo.muted = false;
lbVideo.volume = volume;
this.progress.load();
log.announce(file);
lbVideo.play().catch(() => { });
return;
}
if (token !== this._switchToken) return;
const resolvedUrl = this.quality.list[0].url;
if (!resolvedUrl) {
showTip(`无法播放: ${getFileName(file)}`);
return;
}
this.hls.destroy();
safe(() => {
this.art.video.pause();
this.art.video.removeAttribute('src');
this.art.video.load();
}, 'switch.resetVideo');
const hls = this.hls.create(resolvedUrl, this.art.video, fileKeyOf(file), () => showTip('网络持续错误,请刷新页面'));
if (!hls || token !== this._switchToken) return;
this.quality.apply(this.art);
this.quality.bind(this.art);
this.refreshControlDisplay();
getEpisodeMenu().updateActiveState(this.getCurrentIndex());
this._ui.updateTitle(file);
this._bindHlsDuration(hls, file);
try {
await this._waitForVideo(this.art.video, hls, token);
} catch (e) {
if (e?.message === 'stale') return;
log.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
const video = this.art.video;
video.muted = false;
video.volume = volume;
safe(() => {
if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true;
if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true;
}, 'restoreFullscreen');
this.progress.load();
log.announce(file);
video.play().catch(() => { });
}
_bindHlsDuration(hls, file) {
if (!hls || !file) return;
const key = fileKeyOf(file);
if (!key) return;
const handler = (_, data) => {
const dur = data?.details?.totalduration;
// 仅作兜底:仅在真实 duration 尚未验证时写入
if (dur && Number.isFinite(dur) && dur > 0
&& !this.episodeMeta.isDurationVerified(file)) {
this.episodeMeta.setDuration(file, dur, false);
}
};
hls.once(Hls.Events.LEVEL_LOADED, handler);
}
_waitForVideo(video, hls, token) {
return new Promise((resolve, reject) => {
const finish = once((err) => {
clearTimeout(timer);
err ? reject(err) : resolve();
});
const timer = setTimeout(() => finish(new Error('timeout')), 8000);
const onParsed = () => {
if (token !== this._switchToken) {
finish(new Error('stale'));
return;
}
if (video?.readyState >= 1) finish();
else video?.addEventListener('loadedmetadata', () => finish(), { once: true });
};
hls.once(Hls.Events.MANIFEST_PARSED, onParsed);
});
}
_cancelAutoNext() {
this._autoNext?.hideCountdown();
this._ui.scheduleHide();
}
refreshControlDisplay() {
this.art?.plugins?.episodeNav?.refresh?.();
}
addEpisodeControls() {
if (this._episodeControlsAdded) return;
this._episodeControlsAdded = true;
this.episodeMeta.prefetch(this.filelist);
this.refreshControlDisplay();
this._ui.addTitle(this.file);
}
_setupAutoNext(art) {
if (!art) return;
if (!this._autoNext) {
this._autoNext = this._track(new AutoNextController(() => this.art));
this._ui.setPinnedProbe(() => this._autoNext?.visible || getEpisodeMenu().isOpen());
}
if (!this._autoNextScheduler || this._autoNextScheduler._disposed) {
this._autoNextScheduler = this._track(new AutoNextScheduler(this));
}
this._autoNextScheduler.attach(art);
}
_teardown() {
if (this._disposed) return;
this._cancelAutoNext();
this._disposeLbStreamer();
this._autoNextTimer = null;
this._episodeControlsAdded = false;
this._initialized = false;
if (this._epMenuUnsubs?.length) {
this._epMenuUnsubs.forEach((un) => { try { un(); } catch (_) { } });
this._epMenuUnsubs = [];
}
getEpisodeMenu().close();
if (this.art?.video) {
safe(() => {
this.art.video.muted = true;
this.art.video.pause();
}, 'teardown.mute');
}
if (this.art) {
const art = this.art;
this.art = null;
safe(() => {
if (art.video) {
art.video.pause();
art.video.src = '';
art.video.load();
}
art.destroy(true);
}, 'teardown.destroy');
}
// 一次性清理 artplayer 事件(timeupdate / pause / seeked / destroy / visibilitychange)
// 子系统(Hls / Quality / Ui / Hotkey / EpisodeMeta / AutoNext / AutoNextScheduler)
// 已通过 _track 注册,由 super.dispose() 链式释放,无需重复调用。
this._listeners.clearAll();
}
dispose() {
this._teardown();
super.dispose();
}
async init(container) {
if (!this.getUrl) {
showTip('无法获取播放地址,请检查登录状态');
safe(() => this._onPlayerFail?.('无法获取播放地址(jsToken 缺失)'), 'init.failHook.noUrl');
return;
}
this._teardown();
this._switchCtrl = new SwitchController();
this._switchToken = 0;
this.hls.setUrlBuilder(this.getUrl);
// .lb 文件百度不会转码,跳过无意义的 M3U8 探测(可省 10 秒+等待)
if (isLbFile(this.file)) {
this.quality.list = [];
} else {
await this.quality.build(this.file?.resolution, this.getUrl);
}
let resolvedUrl = this.quality.list[0]?.url || null;
let lbMode = false;
if (!resolvedUrl && isLbFile(this.file)) {
// .lb 文件:百度不转码,走在线流式播放(MSE 转封装 / 直连流式)
lbMode = true;
const lb = await this._resolveLbUrl(this.file);
resolvedUrl = lb?.url || null;
if (!resolvedUrl) {
showTip(`.lb 解析失败:${lb?.error || '未知错误'}`);
safe(() => this._onPlayerFail?.(lb?.error || '未知错误'), 'init.failHook.lb');
return;
}
}
if (!resolvedUrl) {
showTip('无法获取播放地址,请检查登录状态');
safe(() => this._onPlayerFail?.('无法获取播放地址'), 'init.failHook.noResolved');
return;
}
this.art = new Artplayer({
container,
url: resolvedUrl,
// Artplayer 要求 type 为 string;.lb 直连播放时必须整个省略该字段(传 undefined 会抛校验错误)
...(lbMode ? {} : { type: 'm3u8' }),
customType: {
m3u8: (video, url) => {
this.hls.create(url, video, fileKeyOf(this.file), () => showTip('网络持续错误,请刷新页面'));
},
},
poster:
Object.values(this.file?.thumbs || {})
.pop()
?.replace(/size=c\d+_u\d+/, 'size=c850_u580') || '',
autoplay: true,
pip: true,
fullscreen: true,
fullscreenWeb: true,
setting: true,
...(lbMode ? {} : { quality: this.quality.list }),
playbackRate: true,
aspectRatio: true,
muted: false,
volume: 1,
hotkey: true,
// 单击不再切换播放/暂停,改为双击切换
click: () => {},
dblclick: function () {
this.toggle();
},
icons: {
loading: '
',
state: '
',
indicator: '
',
},
moreVideoAttr: lbMode
? { preload: 'auto' }
: { crossOrigin: 'anonymous', preload: 'auto' },
plugins: [
episodeMenuPlugin({ getPlayer: () => this }),
episodeNavPlugin({ getPlayer: () => this }),
],
});
if (lbMode) {
// MSE 流式:接管拖动/看门狗;直连/缓存:绑加载失败兜底
if (this._lbStreamer && !this._lbStreamer._native) this._attachLbStreamer();
else this._bindLbFallback();
}
getEpisodeMenu().setHooks({
onOpen: () => this._ui.showNow(),
onClose: () => this._ui.scheduleHide(),
});
this.art.on('ready', () => {
this.destroyNativePlayer();
this.art.video.muted = false;
this.progress.load();
this.episodeMeta.preloadFromStorage(this.filelist);
this.quality.bind(this.art);
this.addEpisodeControls();
this._bindHlsDuration(this.hls.instance, this.file);
this._ui.setup();
this._hotkey.setup();
this._touchGesture.setup();
this._setupAutoNext(this.art);
log.announce(this.file);
this._initialized = true;
});
const debouncedSave = debounce(() => this.progress.save(), CONFIG.saveDebounceMs);
let lastSave = 0;
this._listeners.trackArtListener(this.art, 'video:timeupdate', () => {
if (!this.art?.currentTime || this.art.currentTime <= 0) return;
if (Date.now() - lastSave > CONFIG.saveThrottleMs) {
lastSave = Date.now();
debouncedSave();
}
});
// 视频元数据加载完成 → 写入真实 duration,覆盖 HLS 推断值
const applyRealDuration = () => {
const realDur = this.art?.duration;
if (!Number.isFinite(realDur) || realDur <= 0) return;
this.episodeMeta.setDuration(this.file, realDur, true);
};
this._listeners.trackArtListener(this.art, 'video:loadedmetadata', applyRealDuration);
this._listeners.trackArtListener(this.art, 'video:canplay', applyRealDuration);
this._listeners.trackArtListener(this.art, 'video:durationchange', applyRealDuration, { once: true });
const flushSave = () => {
if (!this.art?.currentTime || this.art.currentTime <= 0) return;
this.progress.save();
};
this._listeners.trackArtListener(this.art, 'video:pause', flushSave);
this._listeners.trackArtListener(this.art, 'video:seeked', flushSave);
this._listeners.on(document, 'visibilitychange', () => {
if (document.hidden) flushSave();
}, { passive: true });
this._listeners.trackArtListener(this.art, 'destroy', () => {
this._teardown();
});
}
destroy() {
this._teardown();
}
destroyNativePlayer() {
document.querySelectorAll('video').forEach((v) => {
if (!v.closest('#artplayer')) {
safe(() => {
v.pause();
v.muted = true;
v.src = '';
v.load();
}, 'destroyNativePlayer.video');
}
});
const pollDestroy = (getTarget) => {
let count = 0;
const id = this._listeners.setInterval(() => {
count++;
const t = getTarget();
if (t?.player) {
this._listeners.clearInterval(id);
safe(() => {
t.player.dispose();
t.player = null;
}, 'pollDestroy.dispose');
} else if (count > 30) {
this._listeners.clearInterval(id);
}
}, 300);
};
if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) {
setTimeout(() => {
unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => {
if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance);
});
}, 1000);
}
if (this.flag === 'video' && this.nativeVideoNode) {
setTimeout(() => {
pollDestroy(() => this.nativeVideoNode?.firstChild);
}, 1000);
}
}
async replacePlayer() {
const videoNode = await waitFor(() => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), {
intervalMs: 500,
maxAttempts: 20,
label: 'replacePlayer',
});
if (!videoNode) return null;
let container = document.getElementById('artplayer');
if (!container) {
container = document.createElement('div');
container.id = 'artplayer';
container.style.cssText = 'width:100%;height:100%';
videoNode.parentNode?.replaceChild(container, videoNode);
}
return container;
}
}
/* ============================================================
* 单例与生命周期
* ============================================================ */
const player = new Player();
unsafeWindow.player = player;
let lbDiskLauncher = null; // 网盘文件列表页的 .lb 点击拦截播放器
window.addEventListener('beforeunload', () => {
if (player._initialized) player.progress.save();
});
/* ============================================================
* 入口路由
* ============================================================ */
function probeAsideTopOp() {
const el = document.getElementsByClassName('vp-aside-box__top-operation')[0];
const hit = el?.textContent.includes('查看全部');
log.info(`vp-aside-box__top-operation ${hit ? '存在' : '找到'}`);
if (hit) el.click();
}
async function handleShare() {
const localsReady = await waitFor(() => unsafeWindow.locals, {
intervalMs: 500,
maxAttempts: 40,
label: 'handleShare.locals',
onTimeout: () => log.warn('locals等待超时,放弃初始化'),
});
if (!localsReady) return;
await new Promise((resolve) => {
let done = false;
localsReady.get(
'file_list',
'share_uk',
'shareid',
'sign',
'timestamp',
(file_list, share_uk, shareid, sign, timestamp) => {
if (done) return;
done = true;
if (!file_list?.length) { resolve(); return; }
let videoList = [];
const isPlayable = (f) => f?.category === 1 || isLbFile(f);
try {
const list = unsafeWindow
.require('system-core:context/context.js')
.instanceForSystem.list.getCurrentList();
videoList = list.filter(isPlayable);
} catch (_) {
videoList = file_list.filter(isPlayable);
}
if (!videoList.length) { resolve(); return; }
player.filelist = sortByLocale(videoList);
let file = videoList[0];
const resumeEp = player.pickResumeIndex();
if (resumeEp != null) file = videoList[resumeEp];
player.flag = 'sharevideo';
player.file = file;
player.getUrl = buildShareUrl(file);
if (!player.getUrl) { resolve(); return; }
player.hls.setUrlBuilder(player.getUrl);
(async () => {
await player.quality.build(file.resolution, player.getUrl);
dumpCache(player, 'handleShare.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
resolve();
})();
},
);
});
}
async function handlePlay() {
const jqReady = await waitFor(() => unsafeWindow.jQuery, {
intervalMs: 500,
maxAttempts: 40,
label: 'handlePlay.jQuery',
onTimeout: () => log.warn('jQuery等待超时,放弃初始化'),
});
if (!jqReady) return;
let hasInit = false;
jqReady(document).ajaxComplete(async (event, xhr, options) => {
const url = options.url || '';
if (url.includes('/api/categorylist')) {
player.filelist = sortByLocale((xhr.responseJSON?.info || []).filter((f) => f?.category === 1 || isLbFile(f)));
if (player.art) player.addEpisodeControls();
} else if (url.includes('/api/filemetas')) {
if (hasInit) return;
const info = xhr.responseJSON?.info?.[0];
if (!info) return;
const resumeEp = player.pickResumeIndex();
const list = player.filelist || [];
let file = null;
if (resumeEp != null && list[resumeEp]) {
file = list[resumeEp];
} else {
file = list.find((f) =>
(info.fs_id && f.fs_id && f.fs_id == info.fs_id) ||
(info.path && f.path && f.path === info.path)
) || info;
}
hasInit = true;
player.flag = 'playvideo';
player.file = file;
player.getUrl = buildFileUrl(file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
player.quality.build(file.resolution, player.getUrl).then(() => dumpCache(player, 'handlePlay.init'));
const container = await player.replacePlayer();
if (container) await player.init(container);
}
});
}
async function handleVideo() {
const app = document.querySelector('#app');
const pinia = await waitFor(
() =>
app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo
? app.__vue_app__.config.globalProperties.$pinia
: null,
{ intervalMs: 500, maxAttempts: 40, label: 'handleVideo.pinia' },
);
if (!pinia) return;
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.filelist = sortByLocale(list);
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && list.length > resumeEp) {
player.file = list[resumeEp];
} else {
player.file = file;
}
player.flag = 'video';
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildFileUrl(player.file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
await player.quality.build(player.file.resolution, player.getUrl);
dumpCache(player, 'handleVideo.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
}
async function handleMboxVideo() {
const app = document.querySelector('#app');
const pinia = await waitFor(
() =>
app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo
? app.__vue_app__.config.globalProperties.$pinia
: null,
{ intervalMs: 500, maxAttempts: 40, label: 'handleMboxVideo.pinia' },
);
if (!pinia) return;
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.filelist = sortByLocale(list);
const resumeEp = player.pickResumeIndex();
if (resumeEp != null && list.length > resumeEp) {
player.file = list[resumeEp];
} else {
player.file = file;
}
player.flag = 'mboxvideo';
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildMboxUrl(player.file);
if (!player.getUrl) return;
player.hls.setUrlBuilder(player.getUrl);
await player.quality.build(player.file.resolution, player.getUrl);
dumpCache(player, 'handleMboxVideo.init');
const container = await player.replacePlayer();
if (container) await player.init(container);
}
/**
* 全站常驻:安装 .lb 点击拦截播放器。
* 不再依赖路由匹配,任何 pan.baidu.com 页面(网盘主页/分享页/播放页)
* 一加载就激活,点击 .lb 文件即弹全屏播放器。
*/
function installLbLauncher() {
if (lbDiskLauncher) return;
lbDiskLauncher = new LbDiskLauncher(() => player);
}
if (document.readyState === 'complete') probeAsideTopOp();
else window.addEventListener('load', probeAsideTopOp, { once: true });
const ROUTE_HANDLERS = {
share: handleShare,
play: handlePlay,
video: handleVideo,
mboxvideo: handleMboxVideo,
};
const ROUTES = Object.freeze([
{ pattern: CONFIG.urls.sharePattern, handler: ROUTE_HANDLERS.share },
{ pattern: CONFIG.urls.playPattern, handler: ROUTE_HANDLERS.play },
{ pattern: CONFIG.urls.videoPattern, handler: ROUTE_HANDLERS.video },
{ pattern: CONFIG.urls.mboxvideoPattern, handler: ROUTE_HANDLERS.mboxvideo },
]);
(async () => {
if (document.readyState !== 'complete') {
await new Promise((r) => window.addEventListener('load', r, { once: true }));
}
// 激活徽章:确认脚本已在本页运行
installBadge();
// 全站激活 .lb 点击拦截(无需等待文件列表渲染,点击时才解析)
installLbLauncher();
// 启动诊断:确认版本(用户反馈日志里必须能看到 v2.2 才说明已更新)
lbDiag(`脚本启动: v${GM_info?.script?.version}, readyState=${document.readyState}`);
// 主动预取 jsToken:新版网盘页面没有全局 jsToken,提前拉取,
// 点击播放时不再现场等待(也避免"jsToken: 缺失")
ensureJsToken();
// 预取当前目录文件列表:点击 .lb 时 _resolveFile 直接命中缓存,无需等待网络
const prefetchDir = () => {
const dirPath = getDirPathFromUrl();
if (dirPath) {
lbDiag(`预取目录列表: ${decodeURIComponent(dirPath).slice(0, 50)}`);
fetchDirList(dirPath);
}
};
prefetchDir();
// 网盘是 hash 路由,目录切换不刷新页面:跟随变化重新预取
window.addEventListener('hashchange', prefetchDir);
const url = location.href;
for (const route of ROUTES) {
if (url.includes(route.pattern)) {
await route.handler();
break;
}
}
})();
})();