// ==UserScript==
// @name 百度网盘视频优化(免播放缓存)
// @namespace https://scriptcat.org/
// @version 2.0
// @description 基于Claude的百度网盘视频优化(免播放缓存),支持多清晰度切换、连续播放、进度记忆、键盘快捷键
// @author Claude
// @match https://pan.baidu.com/s/*
// @match https://pan.baidu.com/play/video*
// @match https://pan.baidu.com/pfile/video*
// @require https://unpkg.com/hls.js@1.6.16/dist/hls.min.js
// @require https://unpkg.com/artplayer@5.4.0/dist/artplayer.js
// @icon https://nd-static.bdstatic.com/m-static/v20-main/home/img/icon-home-new.b4083345.png
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const PAN_VIDEO_UA = 'xpanvideo;scriptcat;1.3.0;baidu-netdisk-optimize;1.0;ts';
const HLS_CONFIG = {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 600,
fetchSetup: () => ({
headers: { 'User-Agent': PAN_VIDEO_UA },
}),
};
const QUALITY_TEMPLATES = {
1080: "超清 1080P",
720: "高清 720P",
480: "流畅 480P",
360: "省流 360P"
};
const QUALITY_LEVELS = [1080, 720, 480, 360];
const ICONS = {
prev: '',
next: '',
episodes: ''
};
const makeSvg = (inner) =>
``;
function once(fn) {
let called = false;
return (...args) => { if (called) return; called = true; fn(...args); };
}
function debounce(fn, delay) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { timer = null; fn(...args); }, delay);
};
}
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) { console.error('[SwitchController]', e); }
finally { this._running = false; }
}
} get isRunning() { return this._running; }
}
const durationCache = new Map();
function buildFileUrl(file) {
return (type) => `/api/streaming?path=${encodeURIComponent(file.path)}` +
`&app_id=250528&clienttype=0&type=${type}&jsToken=${unsafeWindow.jsToken}`;
}
function buildShareUrl(file) {
const locals = unsafeWindow.locals;
const get = (k) => {
try { return typeof locals.get === 'function' ? locals.get(k) : locals[k]; }
catch (e) { return null; }
};
const [share_uk, shareid, sign, timestamp] =
['share_uk', 'shareid', 'sign', 'timestamp'].map(get);
if (!share_uk || !sign || !timestamp) return null;
return (type) => `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}` +
`&sign=${sign}×tamp=${timestamp}&shareid=${shareid}` +
`&type=${type}&jsToken=${unsafeWindow.jsToken}`;
}
function makeProgressKey(file) {
if (!file) return null;
if (file.fs_id) return `video_progress_${file.fs_id}`;
if (file.path) {
try { return `video_progress_path_${btoa(encodeURIComponent(file.path))}`; }
catch (e) { return null; }
}
return null;
}
const episodeMenu = {
_el: null,
_closeHandler: null,
_resizeHandler: null,
_playerRef: null,
_styleInjected: false,
isOpen() { return !!this._el; },
_calcPosition(btnEl) {
const rect = btnEl.getBoundingClientRect();
const menuW = 300;
const menuH = Math.min(232, window.innerHeight * 0.6);
const gap = -15;
const bottom = window.innerHeight - (rect.top + gap);
const left = Math.max(8, rect.right - menuW + 120);
return { bottom, left, menuW, menuH };
},
open(player, btnEl) {
this.close();
this._playerRef = player;
const idx = player.getCurrentIndex();
const { bottom, left, menuW, menuH } = this._calcPosition(btnEl);
const menu = document.createElement('div');
menu.className = 'ep-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', '选集列表');
menu.style.cssText = `
position: fixed;
bottom: ${bottom}px;
left: ${left}px;
width: ${menuW}px;
max-height: ${menuH}px;
z-index: 2147483647;
`;
const header = document.createElement('div');
header.className = 'ep-header';
header.innerHTML = `
`;
menu.appendChild(header);
const list = document.createElement('div');
list.className = 'ep-list';
list.setAttribute('role', 'listbox');
player.filelist.forEach((f, i) => {
const isCurrent = i === idx;
const name = player.getFileName(f);
const key = f.fs_id || f.path;
const cachedDur = player.getDurationFromFile(f);
const item = document.createElement('div');
item.className = `ep-item${isCurrent ? ' ep-item--active' : ''}`;
item.dataset.epKey = String(key);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(isCurrent));
item.setAttribute('tabindex', '0');
const indexEl = document.createElement('div');
indexEl.className = 'ep-index';
indexEl.innerHTML = isCurrent
? ``
: `${i + 1}`;
const textEl = document.createElement('div');
textEl.className = 'ep-text';
const nameEl = document.createElement('div');
nameEl.className = 'ep-name';
nameEl.textContent = name;
nameEl.title = name;
const metaEl = document.createElement('div');
metaEl.className = 'ep-meta';
const durEl = document.createElement('span');
durEl.className = 'ep-duration';
durEl.textContent = cachedDur != null ? player.formatTime(cachedDur) : '';
metaEl.appendChild(durEl);
const saved = player.getSavedProgressForFile(f);
if (saved) {
const progressEl = document.createElement('span');
progressEl.className = 'ep-progress';
const percent = Math.floor((saved.currentTime / saved.duration) * 100);
progressEl.textContent = `上次看到 ${player.formatTime(saved.currentTime)} (${percent}%)`;
progressEl.title = `上次播放到 ${player.formatTime(saved.currentTime)},共 ${player.formatTime(saved.duration)}`;
metaEl.appendChild(progressEl);
}
textEl.appendChild(nameEl);
textEl.appendChild(metaEl);
item.appendChild(indexEl);
item.appendChild(textEl);
const handleClick = () => {
this.close();
if (i !== idx) player.switchVideo(f);
};
item.addEventListener('click', handleClick);
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); }
});
list.appendChild(item);
});
menu.appendChild(list);
document.body.appendChild(menu);
this._el = menu;
requestAnimationFrame(() => {
const active = list.querySelector('.ep-item--active');
if (active) {
active.focus();
active.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
this._closeHandler = (e) => {
if (this._el && !this._el.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) {
this.close();
}
};
document.addEventListener('click', this._closeHandler);
this._menuLeaveHandler = () => {
if (this._el) this.close();
};
menu.addEventListener('mouseleave', this._menuLeaveHandler);
this._resizeHandler = debounce(() => {
if (!this._el) return;
const pos = this._calcPosition(btnEl);
this._el.style.bottom = pos.bottom + 'px';
this._el.style.left = pos.left + 'px';
this._el.style.maxHeight = pos.menuH + 'px';
}, 100);
window.addEventListener('resize', this._resizeHandler);
if (typeof this._onOpen === 'function') this._onOpen();
},
close() {
const wasOpen = !!this._el;
if (this._el) {
if (this._menuLeaveHandler) {
try { this._el.removeEventListener('mouseleave', this._menuLeaveHandler); } catch (e) { }
}
this._el.remove();
this._el = null;
}
this._menuLeaveHandler = null;
if (this._closeHandler) { document.removeEventListener('click', this._closeHandler); this._closeHandler = null; }
if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; }
const prevPlayer = this._playerRef;
this._playerRef = null;
if (wasOpen && typeof this._onClose === 'function') this._onClose();
},
updateDuration(f, sec) {
if (!this._el || !this._playerRef) return;
const key = f.fs_id || f.path;
const escapedKey = CSS.escape(String(key));
const el = this._el.querySelector(`[data-ep-key="${escapedKey}"] .ep-duration`);
if (el) el.textContent = this._playerRef.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 numEl = item.querySelector('.ep-num');
if (numEl) numEl.style.display = isActive ? 'none' : '';
});
},
injectStyle() {
if (this._styleInjected) return;
this._styleInjected = true;
const style = document.createElement('style');
style.id = 'ep-menu-style';
style.textContent = `
.ep-menu {
background: rgba(18, 18, 22, 0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.65), 0 1px 0 rgba(255,255,255,.04) inset;
animation: epFadeIn .18s ease-out;
pointer-events: auto;
}
@keyframes epFadeIn {
from { opacity:0; transform: translateY(6px) scale(.97); }
to { opacity:1; transform: translateY(0)scale(1); }
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 8px;
border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.ep-header-title {
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: .3px;
font-family: system-ui, sans-serif;
}
.ep-header-count {
color: rgba(255,255,255,.38);
font-size: 11px;
font-family: system-ui, sans-serif;
}
/* ── 滚动列表 ── */
.ep-list {
overflow-y: auto;
padding: 6px 8px 8px;
flex: 1;
min-height: 0;
}
.ep-list::-webkit-scrollbar { width: 4px; }
.ep-list::-webkit-scrollbar-track { background: transparent; }
.ep-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.14);
border-radius: 2px;
}
.ep-list::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.28); }
/* ── 条目 ── */
.ep-item {
display: flex;
align-items: center;
min-height: 44px;
padding: 5px 10px;
gap: 8px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
box-sizing: border-box;
user-select: none;
outline: none;
}
.ep-item:focus-visible {
box-shadow: 0 0 0 2px rgba(30,144,255,.5);
background: rgba(255,255,255,.07);
}
.ep-item:hover{ background: rgba(255,255,255,.07); }
.ep-item--active { background: rgba(30,144,255,.14); }
.ep-item--active:hover { background: rgba(30,144,255,.20); }
/* ── 序号 ── */
.ep-index {
width: 26px;
flex-shrink: 0;display: flex;
align-items: center;
justify-content: center;
}
.ep-num {
color: rgba(255,255,255,.3);
font-size: 12px;
font-variant-numeric: tabular-nums;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-num { color: rgba(30,144,255,.8); }
/* ── 播放中跳动条 ── */
.ep-playing-bar {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.ep-playing-bar span {
display: block;
width: 3px;
border-radius: 2px;
background: #1e90ff;
animation: epBar .9s ease-in-out infinite alternate;
}
.ep-playing-bar span:nth-child(1) { height: 5px; animation-delay: 0s;}
.ep-playing-bar span:nth-child(2) { height: 12px; animation-delay: .2s; }
.ep-playing-bar span:nth-child(3) { height: 7px; animation-delay: .38s; }
@keyframes epBar {
from { transform: scaleY(.35); }
to { transform: scaleY(1); }
}
/* ── 文字区── */
.ep-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ep-name {
color: rgba(255,255,255,.82);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-name {
color: #fff;
font-weight: 500;
}
/* ── meta行 ── */
.ep-meta {
display: flex;
align-items: center;
gap: 8px;
min-height: 14px;
}
.ep-duration {
color: rgba(255,255,255,.28);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
}
.ep-item--active .ep-duration { color: rgba(30,144,255,.6); }
/* ── 上次观看进度 ── */
.ep-progress {
color: rgba(255,185,60,.75);
font-size: 10px;
font-variant-numeric: tabular-nums;
letter-spacing: .2px;
font-family: system-ui, sans-serif;
flex-shrink: 0;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.ep-item--active .ep-progress { color: rgba(255,185,60,1); }
/* ── 控制栏按钮 ── */
.art-control-prev,
.art-control-next,
.art-control-episodes {
opacity: .8;
cursor: pointer;
transition: opacity .2s;
}
.art-control-prev:hover,
.art-control-next:hover,
.art-control-episodes:hover { opacity: 1; }
/* ── 上一集/下一集禁用样式(按钮始终存在) ── */
.art-control-prev.art-ep-disabled,
.art-control-next.art-ep-disabled {
opacity: .35;
filter: grayscale(1) brightness(.55);
cursor: not-allowed;
pointer-events: auto;
}
.art-control-prev.art-ep-disabled:hover,
.art-control-next.art-ep-disabled:hover {
opacity: .35;
filter: grayscale(1) brightness(.55);
}
/* ── 顶部标题层:与底部控制栏同步显隐 ────────────────── */
.artplayer-title {
position: absolute;
top: 0; left: 0; right: 0;
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0));
font-family: system-ui, -apple-system, sans-serif;
box-sizing: border-box;
/* 与底部 .art-controls 走同一 CSS 变量 + 兜底 0.2s,保证上下渐隐时序一致 */
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);
}
`;
document.head.appendChild(style);
}
};
const player = {
art: null,
hls: null,
file: {},
filelist: [],
quality: [],
getUrl: null,
nativeVideoNode: null,
flag: '',
_episodeControlsAdded: false,
_switchCtrl: new SwitchController(),
_switchToken: 0,
_autoNextTimer: null,
_autoNextCancelFns: [],
_boundDocHandlers: [],
_initialized: false,
_hlsRetries: {},
_lastResolution: 0,
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)}`;
},
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 => { try { fn(); return true; } catch (e) { return false; } });
},
getFileName: (f) => f?.server_filename || f?.name || '未命名',
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)
);
},
getDurationFromFile(f) {
if (!f) return null;
const key = f.fs_id || f.path;
if (durationCache.has(key)) return durationCache.get(key);
const raw = f.duration ?? f.video_info?.duration ?? f.media_info?.duration ?? null;
if (raw != null && raw > 0) {
durationCache.set(key, Math.floor(raw));
return Math.floor(raw);
}
return null;
},
cacheDurationFromHls(f) {
if (!this.art?.video || !this.hls || !f) return;
const key = f.fs_id || f.path;
if (durationCache.has(key)) return;
const currentHls = this.hls;
const handler = (_, data) => {
if (this.hls !== currentHls) return;
const dur = data?.details?.totalduration;
if (dur && isFinite(dur) && dur > 0) {
durationCache.set(key, Math.floor(dur));
episodeMenu.updateDuration(f, Math.floor(dur));
currentHls.off(Hls.Events.LEVEL_LOADED, handler);
}
};
currentHls.on(Hls.Events.LEVEL_LOADED, handler);
},
prefetchDurations() {
if (!this.filelist?.length) return;
this.filelist.forEach(f => this.getDurationFromFile(f));
},
async resolvePlayUrl(baseUrl, depth = 0) {
if (depth > 2 || !baseUrl) return null;
try {
const res = await fetch(baseUrl, {
credentials: 'include',
headers: { 'User-Agent': PAN_VIDEO_UA }
});
if (!res.ok) return null;
const text = await res.text();
if (text.trim().startsWith('#EXTM3U')) return baseUrl;
let json;
try { json = JSON.parse(text); } catch (e) { return null; }
if (json.errno === 133 && json.adToken)
return `${baseUrl}&adToken=${encodeURIComponent(json.adToken)}`;
if (json.errno === 9019 || json.errno === 9013) {
const url480 = this.getUrl('M3U8_AUTO_480');
if (url480 && url480 !== baseUrl) return this.resolvePlayUrl(url480, depth + 1);
}
return null;
} catch (e) { return null; }
},
getProgressKey() {
return makeProgressKey(this.file);
},
saveProgress() {
const key = this.getProgressKey();
if (!key || !this.art?.duration || this.art.duration <= 0) return;
const data = JSON.stringify({
currentTime: this.art.currentTime,
duration: this.art.duration,
timestamp: Date.now()
});
try {
localStorage.setItem(key, data);
} catch (e) {
if (e.name === 'QuotaExceededError') {
this._evictOldProgress();
try { localStorage.setItem(key, data); } catch (e2) { }
} else {
console.warn('无法保存进度:', e);
}
}
},
_evictOldProgress() {
try {
const keys = Object.keys(localStorage).filter(k => k.startsWith('video_progress'));
if (keys.length > 20) {
const sorted = keys.map(k => {
try { return { key: k, time: JSON.parse(localStorage.getItem(k)).timestamp || 0 }; }
catch { return { key: k, time: 0 }; }
}).sort((a, b) => a.time - b.time);
const toRemove = sorted.slice(0, keys.length - 15);
toRemove.forEach(({ key }) => { try { localStorage.removeItem(key); } catch (e) { } });
}
} catch (e) { }
},
loadProgress() {
const key = this.getProgressKey();
if (!key || !this.art) return;
try {
const saved = localStorage.getItem(key);
if (!saved) return;
const { currentTime, duration, timestamp } = JSON.parse(saved);
if (Date.now() - timestamp > 7 * 86400000) { localStorage.removeItem(key); return; }
if (currentTime < 5) return;
this.art.currentTime = currentTime;
this.showTip(`已恢复到 ${this.formatTime(currentTime)}`);
} catch (e) {
console.warn('无法读取进度:', e);
}
},
clearProgress() {
const key = this.getProgressKey();
if (key) try { localStorage.removeItem(key); } catch (e) { }
},
getSavedProgressForFile(f) {
if (!f) return null;
const key = makeProgressKey(f);
if (!key) return null;
try {
const saved = localStorage.getItem(key);
if (!saved) return null;
const { currentTime, duration, timestamp } = JSON.parse(saved);
if (Date.now() - timestamp > 7 * 86400000) { localStorage.removeItem(key); return null; }
if (currentTime < 5) return null;
return { currentTime, duration };
} catch (e) { return null; }
},
destroyHls() {
if (!this.hls) return;
const hls = this.hls; this.hls = null;
try { hls.stopLoad(); hls.detachMedia(); hls.destroy(); } catch (e) { }
},
resetVideo(video) {
if (!video) return;
try {
video.pause();
video.removeAttribute('src');
Array.from(video.querySelectorAll('source')).forEach(s => s.remove());
video.load();
} catch (e) { }
},
createHls(url, video) {
if (!Hls.isSupported()) {
if (video?.canPlayType?.('application/vnd.apple.mpegurl')) video.src = url;
else this.showTip('浏览器不支持视频播放');
return null;
}
const hls = new Hls(HLS_CONFIG);
const fileKey = this.file?.fs_id || this.file?.path || 'default';
this._hlsRetries[fileKey] = 0;
hls.on(Hls.Events.ERROR, (_, data) => {
if (this.hls !== hls || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === 'manifestParsingError') { this.showTip('视频地址无效'); return; }
const retries = this._hlsRetries[fileKey] || 0;
if (retries < 3) {
const delay = Math.min(1000 * Math.pow(2, retries), 10000);
this._hlsRetries[fileKey] = retries + 1;
setTimeout(() => {
if (this.hls === hls) hls.startLoad();
}, delay);
this.showTip(`网络错误,指数重试 ${retries + 1}/3…`);
} else {
this.showTip('网络持续错误,请刷新页面');
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
this.showTip('播放失败,请刷新重试');
}
});
hls.loadSource(url);
hls.attachMedia(video);
this.hls = hls;
return hls;
},
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);
});
},
buildQuality(resolution = '') {
const match = resolution?.match?.(/width:(\d+),height:(\d+)/);
let height = match ? +match[2] : 0;
if (!height && this._lastResolution) height = this._lastResolution;
if (height) this._lastResolution = height;
const sortedDesc = [...QUALITY_LEVELS].sort((a, b) => b - a);
const startIdx = sortedDesc.findIndex(q => height >= q);
const levels = startIdx < 0
? [...sortedDesc]
: sortedDesc.slice(startIdx);
this.quality = levels.map((q, i) => ({
html: QUALITY_TEMPLATES[q],
url: this.getUrl?.('M3U8_AUTO_' + q) || '',
default: i === 0
}));
},
_setupQualityInterceptionBound: false,
_setupQualityInterception() {
if (this._setupQualityInterceptionBound) return;
this._setupQualityInterceptionBound = true;
this.art.on('quality', async (url) => {
if (!url) return;
const newUrl = await this.resolvePlayUrl(url);
if (!newUrl) { this.showTip('画质切换失败'); return; }
if (this.hls && this.hls.url === newUrl) return;
this._qualitySwitchState = {
currentTime: this.art.currentTime,
volume: this.art.video.volume,
muted: this.art.video.muted,
playing: !this.art.video.paused,
};
this.destroyHls();
this.resetVideo(this.art.video);
const hls = this.createHls(newUrl, this.art.video);
if (!hls) {
this._qualitySwitchState = null;
return;
}
hls.once(Hls.Events.MANIFEST_PARSED, () => {
const state = this._qualitySwitchState;
if (!state) return;
this._qualitySwitchState = null;
const { currentTime, volume, muted, playing } = state;
this.art.video.muted = muted;
this.art.video.volume = volume;
this.art.currentTime = currentTime;
if (playing) this.art.video.play().catch(() => { });
});
});
},
registerQualityControl() {
if (!this.art || !this.quality?.length) return;
this.art.quality = this.quality;
},
switchVideo(file) {
if (!file) return;
this._switchCtrl.submit(() => this._performSwitch(file));
},
_cancelAutoNext() {
if (this._autoNextTimer) {
clearTimeout(this._autoNextTimer);
this._autoNextTimer = null;
}
this._autoNextCancelFns.forEach(fn => { try { fn(); } catch (e) { } });
this._autoNextCancelFns = [];
this._autoNextCanceled = false;
},
async _performSwitch(file) {
this._cancelAutoNext();
this.saveProgress();
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;
if (this.flag === 'sharevideo') {
const shareUrl = buildShareUrl(file);
if (!shareUrl) { this.showTip('分享信息不完整,无法切换'); return; }
this.getUrl = shareUrl;
} else {
this.getUrl = buildFileUrl(file);
}
this.buildQuality(file.resolution);
if (!this.quality?.length) { this.showTip('无法获取视频地址'); return; }
if (!this.art?.video) { this.showTip('播放器未就绪'); return; }
const resolvedUrl = await this.resolvePlayUrl(this.quality[0].url);
if (!resolvedUrl) { this.showTip(`无法播放: ${this.getFileName(file)}`); return; }
if (token !== this._switchToken) return;
this.destroyHls();
this.resetVideo(this.art.video);
const hls = this.createHls(resolvedUrl, this.art.video);
if (!hls || token !== this._switchToken) return;
this.registerQualityControl();
this.refreshControlDisplay();
episodeMenu.updateActiveState(this.getCurrentIndex());
this.updateEpisodeTitle(file);
try { await this.waitForVideo(this.art.video, hls, token); }
catch (e) {
if (e.message === 'stale') return;
console.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
const video = this.art.video;
video.muted = false;
video.volume = volume;
try {
if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true;
if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true;
} catch (e) { }
this.cacheDurationFromHls(file);
this.loadProgress();
video.play().catch(() => { });
this.showTip(`正在播放: ${this.getFileName(file)}`);
},
refreshControlDisplay() {
const idx = this.getCurrentIndex();
const len = this.filelist?.length || 0;
const hasPrev = idx > 0;
const hasNext = idx >= 0 && idx < len - 1;
const $prev = this.art?.controls?.prev;
const $next = this.art?.controls?.next;
if ($prev) {
$prev.classList.toggle('art-ep-disabled', !hasPrev);
$prev.setAttribute('aria-disabled', String(!hasPrev));
}
if ($next) {
$next.classList.toggle('art-ep-disabled', !hasNext);
$next.setAttribute('aria-disabled', String(!hasNext));
}
},
addEpisodeControls() {
if (this._episodeControlsAdded) return;
this._episodeControlsAdded = true;
this.prefetchDurations();
episodeMenu.injectStyle();
const $prev = this.art.controls.add({
name: 'prev', position: 'left', index: 5,
html: makeSvg(ICONS.prev), tooltip: '上一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) {
this.showTip('已是第一集');
return;
}
const idx = this.getCurrentIndex();
if (idx > 0) this.switchVideo(this.filelist[idx - 1]);
else this.showTip('已是第一集');
}
});
const $next = this.art.controls.add({
name: 'next', position: 'left', index: 15,
html: makeSvg(ICONS.next), tooltip: '下一集',
click: (_component, event) => {
if (event?.currentTarget?.classList?.contains('art-ep-disabled')) {
this.showTip('已是最后一集');
return;
}
const idx = this.getCurrentIndex();
if (idx >= 0 && idx < (this.filelist?.length || 0) - 1)
this.switchVideo(this.filelist[idx + 1]);
else this.showTip('已是最后一集');
}
});
const $episodes = this.art.controls.add({
name: 'episodes', position: 'right',
html: '选集', tooltip: '选集',
style: { padding: '0 10px', fontSize: '14px' },
click: () => {
const btnEl = document.querySelector('.art-control-episodes');
if (episodeMenu.isOpen()) episodeMenu.close();
else if (btnEl) episodeMenu.open(this, btnEl);
}
});
this.refreshControlDisplay();
this.addTitleLayer();
},
addTitleLayer() {
if (!this.art || this._titleLayerAdded) return;
this._titleLayerAdded = true;
const file = this.filelist?.[this.getCurrentIndex?.()] ?? null;
const initialName = file ? this.getFileName(file) : '';
this.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);
},
_setTitleText(name) {
const el = this._titleEl || this.art?.layers?.episodeTitle?.querySelector?.('.art-title-text');
if (el) el.textContent = name || '';
},
updateEpisodeTitle(file) {
this._setTitleText(file ? this.getFileName(file) : '');
},
refreshEpisodeControls() {
if (!this.art || this._episodeControlsAdded) return;
this.addEpisodeControls();
},
_setupDocHideControls() {
if (!this.art || this._docHideSetup) return;
this._docHideSetup = true;
const HIDE_DELAY = 2000;
const controls = () => this.art?.controls;
const $controls = () => this.art?.template?.$controls;
let hideTimer = null;
const clearHide = () => {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
};
const showNow = () => {
if (!this.art) return;
if (controls()) this.art.controls.show = true;
this.art.layers.show = true;
};
const scheduleHide = () => {
clearHide();
hideTimer = setTimeout(() => {
hideTimer = null;
if (!controls() || episodeMenu.isOpen()) return;
const el = $controls();
if (el && el.matches(':hover')) return;
this.art.controls.show = false;
this.art.layers.show = false;
}, HIDE_DELAY);
};
if (this.art && typeof this.art.on === 'function' && !this._layerSyncBound) {
this._layerSyncBound = true;
this.art.on('control', (state) => {
if (!this.art) return;
if (episodeMenu.isOpen()) {
if (this.art.layers) this.art.layers.show = true;
return;
}
if (this.art.layers) this.art.layers.show = !!state;
});
}
const onDocMouseMove = (e) => {
const el = $controls();
if (!el) return;
const over = el.contains(e.target);
if (over) {
clearHide();
showNow();
} else if (!episodeMenu.isOpen()) {
scheduleHide();
}
};
const onDocMouseLeave = () => {
if (episodeMenu.isOpen()) { clearHide(); return; }
scheduleHide();
};
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
document.addEventListener('mouseleave', onDocMouseLeave, { passive: true });
this._boundDocHandlers.push(
{ el: document, type: 'mousemove', fn: onDocMouseMove },
{ el: document, type: 'mouseleave', fn: onDocMouseLeave }
);
episodeMenu._onOpen = () => {
clearHide();
showNow();
};
episodeMenu._onClose = () => {
if (!controls()) return;
const el = $controls();
if (el && el.matches(':hover')) { clearHide(); showNow(); return; }
scheduleHide();
};
},
_cleanupDocHandlers() {
this._boundDocHandlers.forEach(({ el, type, fn }) => {
try { el.removeEventListener(type, fn); } catch (e) { }
});
this._boundDocHandlers = [];
this._docHideSetup = false;
this._kbSetup = false;
},
_setupHotkeyFallback() {
if (!this.art || this._kbSetup) return;
this._kbSetup = true;
const onKey = (e) => {
if (!this.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;
if (e.key === 'f' || e.key === 'F') {
this.art.fullscreen = !this.art.fullscreen;
e.preventDefault();
e.stopPropagation();
} else if (e.key === 'w' || e.key === 'W') {
this.art.fullscreenWeb = !this.art.fullscreenWeb;
e.preventDefault();
e.stopPropagation();
} else if (e.key === 'm' || e.key === 'M') {
this.art.muted = !this.art.muted;
e.preventDefault();
e.stopPropagation();
} else if (e.key === 'p' || e.key === 'P') {
const idx = this.getCurrentIndex();
if (idx > 0) this.switchVideo(this.filelist[idx - 1]);
else this.showTip('已是第一集');
e.preventDefault();
e.stopPropagation();
} else if (e.key === 'n' || e.key === 'N') {
const idx = this.getCurrentIndex();
if (idx >= 0 && idx < (this.filelist?.length || 0) - 1)
this.switchVideo(this.filelist[idx + 1]);
else this.showTip('已是最后一集');
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener('keydown', onKey, true);
this._boundDocHandlers.push({ el: document, type: 'keydown', fn: onKey });
},
async init(container) {
if (!this.quality?.length) return;
this._initialized = false;
this.destroy();
this._switchCtrl = new SwitchController();
this._switchToken = 0;
durationCache.clear();
const resolvedUrl = await this.resolvePlayUrl(this.quality[0].url);
if (!resolvedUrl) { this.showTip('无法获取播放地址,请检查登录状态'); return; }
this.art = new Artplayer({
container,
url: resolvedUrl,
type: 'm3u8',
customType: {
m3u8: (video, url) => {
this.createHls(url, video);
}
},
poster: Object.values(this.file.thumbs || {}).pop()?.replace(/size=c\d+_u\d+/, 'size=c850_u580') || '',
autoplay: true,
pip: true,
fullscreen: true,
fullscreenWeb: true,
setting: true,
quality: this.quality,
playbackRate: true,
aspectRatio: true,
muted: false,
volume: 1,
hotkey: true,
icons: {
loading: '
',
state: '
',
indicator: '
',
},
moreVideoAttr: { crossOrigin: 'anonymous', preload: 'auto' }
});
this.art.on('ready', () => {
this.destroyNativePlayer();
this.art.video.muted = false;
this.loadProgress();
this._setupQualityInterception();
this.addEpisodeControls();
this.cacheDurationFromHls(this.file);
this._setupDocHideControls();
this._setupHotkeyFallback();
this._initialized = true;
});
const debouncedSave = debounce(() => this.saveProgress(), 2000);
let lastSave = 0;
this.art.on('video:timeupdate', () => {
if (this.art.currentTime > 0 && Date.now() - lastSave > 5000) {
lastSave = Date.now();
debouncedSave();
}
});
this.art.on('video:ended', () => {
this.clearProgress();
this._autoNextCanceled = false;
const idx = this.getCurrentIndex();
const listLen = this.filelist?.length || 0;
if (idx >= 0 && idx < listLen - 1) {
const next = this.filelist[idx + 1];
const tokenAtEnd = this._switchToken;
this.showTip(`即将播放: ${this.getFileName(next)}`);
this._autoNextTimer = setTimeout(() => {
if (this._autoNextCanceled) return;
if (this._switchToken !== tokenAtEnd) return;
this.switchVideo(next);
}, 3000);
let seekUnbound = false;
const cancelOnSeek = () => {
if (seekUnbound) return;
seekUnbound = true;
clearTimeout(this._autoNextTimer);
this._autoNextTimer = null;
this._autoNextCanceled = true;
this.art?.off('video:timeupdate', cancelOnSeek);
this.art?.off('video:seeking', cancelOnSeek);
};
this.art.on('video:timeupdate', cancelOnSeek);
this.art.on('video:seeking', cancelOnSeek);
this._autoNextCancelFns.push(cancelOnSeek);
} else {
this.showTip('已播放完所有视频');
}
});
this.art.on('destroy', () => {
this._cancelAutoNext();
this._cleanupDocHandlers();
episodeMenu.close();
this._initialized = false;
});
},
destroy() {
this._switchToken++;
this._cancelAutoNext();
this._cleanupDocHandlers();
episodeMenu.close();
if (this.art?.video) {
try { this.art.video.muted = true; this.art.video.pause(); } catch (e) { }
}
if (this.art) {
const art = this.art; this.art = null;
try {
if (art.video) { art.video.pause(); art.video.src = ''; art.video.load(); }
art.destroy(true);
} catch (e) { }
}
this.destroyHls();
this._episodeControlsAdded = false;
this._setupQualityInterceptionBound = false;
},
destroyNativePlayer() {
document.querySelectorAll('video').forEach(v => {
if (!v.closest('#artplayer')) {
try { v.pause(); v.muted = true; v.src = ''; v.load(); } catch (e) { }
}
});
const pollDestroy = (getTarget) => {
let count = 0;
const id = setInterval(() => {
count++;
const t = getTarget();
if (t?.player) { clearInterval(id); try { t.player.dispose(); t.player = null; } catch (e) { } }
else if (count > 30) clearInterval(id);
}, 300);
};
if (['sharevideo', 'playvideo'].includes(this.flag) && unsafeWindow.require) {
setTimeout(() => {
unsafeWindow.require.async('file-widget-1:videoPlay/context.js', (ctx) => {
if (ctx?.getContext) pollDestroy(() => ctx.getContext()?.playerInstance);
});
}, 1000);
}
if (this.flag === 'video' && this.nativeVideoNode) {
setTimeout(() => { pollDestroy(() => this.nativeVideoNode?.firstChild); }, 1000);
}
},
async replacePlayer(retry = 0) {
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (!videoNode) {
if (retry >= 20) return null;
await new Promise(r => setTimeout(r, 500));
return this.replacePlayer(retry + 1);
}
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;
}
};
window.addEventListener('beforeunload', () => {
if (player._initialized) player.saveProgress();
});
async function handleShare(retry = 0) {
if (!unsafeWindow.locals) {
if (retry >= 40) { console.warn('[BDPlayer] locals等待超时,放弃初始化'); return; }
await new Promise(r => setTimeout(r, 500));
return handleShare(retry + 1);
}
await new Promise(resolve => {
let done = false;
unsafeWindow.locals.get(
'file_list', 'share_uk', 'shareid', 'sign', 'timestamp',
(file_list, share_uk, shareid, sign, timestamp) => {
if (done) return; done = true;
if (!file_list?.length) { resolve(); return; }
let videoList = [];
try {
const list = unsafeWindow.require('system-core:context/context.js')
.instanceForSystem.list.getCurrentList();
videoList = list.filter(f => f.category === 1);
} catch (e) { videoList = file_list.filter(f => f.category === 1); }
if (!videoList.length) { resolve(); return; }
player.filelist = videoList;
const file = videoList[0];
player.flag = 'sharevideo';
player.file = file;
player.getUrl = buildShareUrl(file);
player.buildQuality(file.resolution);
player.replacePlayer().then(container => {
if (container) player.init(container);
resolve();
});
}
);
});
}
async function handlePlay(retry = 0) {
if (!unsafeWindow.jQuery) {
if (retry >= 40) { console.warn('[BDPlayer] jQuery等待超时,放弃初始化'); return; }
await new Promise(r => setTimeout(r, 500));
return handlePlay(retry + 1);
}
let hasInit = false;
unsafeWindow.jQuery(document).ajaxComplete(async (event, xhr, options) => {
const url = options.url || '';
if (url.includes('/api/categorylist')) {
player.filelist = (xhr.responseJSON?.info || []).filter(f => f.category === 1);
player.refreshEpisodeControls();
} else if (url.includes('/api/filemetas')) {
if (hasInit) return;
const file = xhr.responseJSON?.info?.[0];
if (!file) return;
hasInit = true;
player.flag = 'playvideo';
player.file = file;
player.getUrl = buildFileUrl(file);
player.buildQuality(file.resolution);
const container = await player.replacePlayer();
if (container) await player.init(container);
}
});
}
async function handleVideo() {
const app = document.querySelector('#app');
const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia;
if (!pinia?.state?._rawValue?.videoinfo?.videoinfo) {
await new Promise(r => setTimeout(r, 500));
return handleVideo();
}
const file = pinia.state._rawValue.videoinfo.videoinfo;
const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || [];
player.flag = 'video';
player.file = file;
player.filelist = list;
const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoNode) player.nativeVideoNode = videoNode;
player.getUrl = buildFileUrl(file);
player.buildQuality(file.resolution);
const container = await player.replacePlayer();
if (container) await player.init(container);
}
function ready() {
return new Promise(resolve => {
if (document.readyState === 'complete' || document.readyState === 'interactive')
setTimeout(resolve, 0);
else
document.addEventListener('DOMContentLoaded', resolve);
});
}
ready().then(() => {
const url = location.href;
if (url.includes('/s/')) handleShare();
else if (url.includes('/play/video')) handlePlay();
else if (url.includes('/pfile/video')) handleVideo();
});
})();