// ==UserScript==
// @name 百度网盘视频优化(免播放缓存)
// @namespace https://scriptcat.org/
// @version 1.3.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.15/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 HLS_CONFIG = {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 600
};
const QUALITY_TEMPLATES = {
1080: "超清 1080P",
720: "高清 720P",
480: "流畅 480P",
360: "省流 360P"
};
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();
//─── 选集面板:挂载到 body,用 fixed 定位,对齐按钮位置 ─────────────────
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);
let top, left;
const gap = 10;
if (rect.top - gap - menuH > 0) {
top = rect.top - gap - menuH;
} else {
top = rect.bottom + gap;
}
left = Math.max(8, rect.right - menuW + 120);
return { top, left, menuW, menuH };
},
open(player, btnEl) {
this.close();
this._playerRef = player;
const idx = player.getCurrentIndex();
const { top, 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;
top: ${top}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;
// 锁定控制栏:打开期间强制保持显示,覆盖 artplayer 自动隐藏
try { player._lockControlsVisible(); } catch (e) { }
// 聚焦当前项
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);
// 鼠标移出选集面板范围时自动关闭面板(控制栏关联也通过 close 中的 _unlockControlsVisible 自然解除)
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.top = pos.top + 'px';
this._el.style.left = pos.left + 'px';
this._el.style.maxHeight = pos.menuH + 'px';
}, 100);
window.addEventListener('resize', this._resizeHandler);
},
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 && prevPlayer) {
try { prevPlayer._unlockControlsVisible(1500); } catch (e) { }
}
if (wasOpen && typeof this._onClose === 'function') {
try { this._onClose(); } catch (e) { }
}
},
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');
const barEl = item.querySelector('.ep-playing-bar');
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-episodes { padding: 0 10px; }
/* ── 暂停图标优化 ── */
.art-state .art-icon {
width: 80px !important;
height: 80px !important;
background: rgba(0, 0, 0, 0.7) !important;
border-radius: 50% !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important;
}
.art-state .art-icon svg {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
`; 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: {},
_keepVisibleRafId: null,
_hideAfterCloseTimer: null,
getVip() {
try {
const locals = unsafeWindow.locals;
const yunData = unsafeWindow.yunData;
if (locals) {
const get = (k) => typeof locals.get === 'function' ? locals.get(k) : locals[k];
return get('is_svip') === 1 ? 2 : get('is_vip') === 1 ? 1 : 0;
}
if (yunData && !yunData.neglect)
return yunData.ISSVIP === 1 ? 2 : yunData.ISVIP === 1 ? 1 : 0;
} catch (e) { }
return 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' });
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() {
if (!this.file) return null;
if (this.file.fs_id) return `video_progress_${this.file.fs_id}`;
if (this.file.path) {
try { return `video_progress_path_${btoa(encodeURIComponent(this.file.path))}`; }
catch (e) { return null; }
}
return null;
},
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;
let key;
if (f.fs_id) key = `video_progress_${f.fs_id}`;
else if (f.path) {
try { key = `video_progress_path_${btoa(encodeURIComponent(f.path))}`; }
catch (e) { return null; }
} else 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;
const retryKey = this.file?.fs_id || this.file?.path || 'default';
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === 'manifestParsingError') { this.showTip('视频地址无效'); return; }
const retries = this._hlsRetries[retryKey] || 0;
if (retries < 3) {
const delay = Math.min(1000 * Math.pow(2, retries), 10000);
this._hlsRetries[retryKey] = 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;
},
async switchHls(url, video, token) {
this.destroyHls();
if (token !== this._switchToken) return null;
this.resetVideo(video);
if (token !== this._switchToken) return null;
return this.createHls(url, video);
},
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 vip = this.getVip();
const match = resolution?.match?.(/width:(\d+),height:(\d+)/);
const area = match ? +match[1] * +match[2] : Infinity;
const list = [];
if (area > 921600 && vip >= 2) list.push(1080);
if (area > 409920) list.push(720);
list.push(480, 360);
this.quality = list.map((q, i) => ({
html: QUALITY_TEMPLATES[q],
url: this.getUrl?.('M3U8_AUTO_' + q) || '',
default: i === 0
}));
},
async performQualitySwitch(url, labelHtml) {
if (!this.art?.video) return;
const currentTime = this.art.currentTime;
const volume = this.art.volume || 1;
const isPlaying = !this.art.video.paused;
const token = ++this._switchToken;
const resolved = await this.resolvePlayUrl(url);
if (!resolved || token !== this._switchToken) return;
const hls = await this.switchHls(resolved, this.art.video, token);
if (!hls || token !== this._switchToken) return;
try { await this.waitForVideo(this.art.video, hls, token); }
catch (e) {
if (e.message === 'stale') return;
console.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
this.art.video.muted = false;
this.art.video.volume = volume;
this.art.video.currentTime = currentTime;
if (isPlaying) this.art.video.play().catch(() => { });
this.updateQualityLabel(labelHtml);
},
updateQualityLabel(html) {
const el = document.querySelector('.art-control-quality .art-selector-value');
if (el) el.innerHTML = html;
},
registerQualityControl() {
if (!this.art || !this.quality?.length || this.quality.length <= 1) return;
try { this.art.controls.remove('quality'); } catch (e) { }
this.art.controls.add({
name: 'quality', position: 'right',
html: this.quality[0].html,
selector: this.quality,
onSelect: (item) => { this.performQualitySwitch(item.url, item.html); return item.html; }
});
},
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;
const vip = this.getVip();
if (this.flag === 'sharevideo') {
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) {
this.showTip('分享信息不完整,无法切换'); return;
}
this.getUrl = (type) => {
if (type.includes('1080') && vip <= 1) type = type.replace('1080', '720');
return `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}` +
`&sign=${sign}×tamp=${timestamp}&shareid=${shareid}` +
`&type=${type}&vip=${vip}&jsToken=${unsafeWindow.jsToken}`;
};
} else {
this.getUrl = (type) => {
if (type.includes('1080') && vip <= 1) type = type.replace('1080', '720');
return `/api/streaming?path=${encodeURIComponent(file.path)}` +
`&app_id=250528&clienttype=0&type=${type}&vip=${vip}&jsToken=${unsafeWindow.jsToken}`;
};
}
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;
const video = this.art.video;
const hls = await this.switchHls(resolvedUrl, video, token);
if (!hls || token !== this._switchToken) return;
this.registerQualityControl();
this.refreshControlDisplay();
episodeMenu.updateActiveState(this.getCurrentIndex());
try { await this.waitForVideo(video, hls, token); }
catch (e) {
if (e.message === 'stale') return;
console.warn('视频加载超时,继续尝试播放');
}
if (token !== this._switchToken) return;
video.muted = false;
video.volume = volume;
this.art.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 hasPrev = idx > 0;
const hasNext = idx >= 0 && idx < (this.filelist?.length || 0) - 1;
const prevBtn = document.querySelector('.art-control-prev');
const nextBtn = document.querySelector('.art-control-next');
if (prevBtn) {
prevBtn.style.opacity = hasPrev ? '' : '0.3';
prevBtn.style.pointerEvents = hasPrev ? '' : 'none';
prevBtn.title = hasPrev ? `上一集 (${this.getFileName(this.filelist[idx - 1])})` : '上一集';
}
if (nextBtn) {
nextBtn.style.opacity = hasNext ? '' : '0.3';
nextBtn.style.pointerEvents = hasNext ? '' : 'none';
nextBtn.title = hasNext ? `下一集 (${this.getFileName(this.filelist[idx + 1])})` : '下一集';
}
},
addEpisodeControls() {
if (this.filelist?.length <= 1 || this._episodeControlsAdded) return;
this._episodeControlsAdded = true;
this.prefetchDurations();
episodeMenu.injectStyle();
this.art.controls.add({
name: 'prev', position: 'left',
html: makeSvg(ICONS.prev), tooltip: '上一集',
click: () => {
const idx = this.getCurrentIndex();
if (idx > 0) this.switchVideo(this.filelist[idx - 1]);
else this.showTip('已是第一集');
}
});
this.art.controls.add({
name: 'next', position: 'left',
html: makeSvg(ICONS.next), tooltip: '下一集',
click: () => {
const idx = this.getCurrentIndex();
if (idx >= 0 && idx < (this.filelist?.length || 0) - 1)
this.switchVideo(this.filelist[idx + 1]);
else this.showTip('已是最后一集');
}
});
this.art.controls.add({
name: 'episodes', position: 'right',
html: makeSvg(ICONS.episodes), tooltip: '选集',
click: () => {
const btnEl = document.querySelector('.art-control-episodes');
if (episodeMenu.isOpen()) episodeMenu.close();
else if (btnEl) episodeMenu.open(this, btnEl);
}
});
this.moveButtonsToPlayPosition();
this.refreshControlDisplay();
},
moveButtonsToPlayPosition() {
const playBtn = document.querySelector('.art-control-play');
if (!playBtn?.parentNode) return;
const parent = playBtn.parentNode;
const prevBtn = document.querySelector('.art-control-prev');
const nextBtn = document.querySelector('.art-control-next');
if (prevBtn && parent.contains(prevBtn)) {
parent.insertBefore(prevBtn, playBtn);
}
if (nextBtn && parent.contains(nextBtn)) {
const afterPlay = playBtn.nextSibling;
parent.insertBefore(nextBtn, afterPlay);
}
},
_setupDocHideControls() {
if (!this.art || this._docHideSetup) return;
this._docHideSetup = true;
let hideTimer = null;
const scheduleHide = () => {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (this.art && this.art.controls && !episodeMenu.isOpen()) {
this.art.controls.show = false;
}
}, 3000);
};
const onDocMouseMove = (e) => {
if (!this.art) return;
const container = this.art.template?.$player;
if (!container) return;
const rect = container.getBoundingClientRect();
const inside =
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) {
// 选集面板打开期间,光标在面板上时不触发控制栏隐藏
if (episodeMenu.isOpen() && this._isPointerOverMenu(e.clientX, e.clientY)) {
clearTimeout(hideTimer);
return;
}
scheduleHide();
} else {
clearTimeout(hideTimer);
}
};
const onDocMouseLeave = () => {
if (episodeMenu.isOpen()) { clearTimeout(hideTimer); return; }
scheduleHide();
};
document.addEventListener('mousemove', onDocMouseMove);
document.addEventListener('mouseleave', onDocMouseLeave);
this._boundDocHandlers.push(
{ el: document, type: 'mousemove', fn: onDocMouseMove },
{ el: document, type: 'mouseleave', fn: onDocMouseLeave }
);
// 选集面板关闭后,延迟触发控制栏隐藏(让用户感知"关闭后稍后消失")
episodeMenu._onClose = () => {
if (!this.art || !this.art.controls) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (this.art && this.art.controls && !episodeMenu.isOpen()) {
this.art.controls.show = false;
}
}, 1500);
};
},
_isPointerOverMenu(x, y) {
const el = episodeMenu._el;
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
},
_setupKeyboardShortcuts() {
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') return;
const container = this.art.template?.$player;
if (!container) return;
const rect = container.getBoundingClientRect();
const inPlayer =
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inPlayer && e.target !== container && !container.contains(e.target)) return;
switch (e.key) {
case 'ArrowLeft':
this.art.currentTime = Math.max(0, this.art.currentTime - 10);
e.preventDefault();
break;
case 'ArrowRight':
this.art.currentTime = Math.min(this.art.duration, this.art.currentTime + 10);
e.preventDefault();
break;
case 'ArrowUp':
this.art.volume = Math.min(1, this.art.volume + 0.1);
e.preventDefault();
break;
case 'ArrowDown':
this.art.volume = Math.max(0, this.art.volume - 0.1);
e.preventDefault();
break;
case ' ':
case 'k':
this.art.playing ? this.art.pause() : this.art.play();
e.preventDefault();
break;
case 'f':
this.art.fullscreen = !this.art.fullscreen;
e.preventDefault();
break;
case 'm':
this.art.muted = !this.art.muted;
e.preventDefault();
break;
case 'Escape':
episodeMenu.close();
break;
}
};
document.addEventListener('keydown', onKey);
this._boundDocHandlers.push({ el: document, type: 'keydown', fn: onKey });
},
_cleanupDocHandlers() {
this._boundDocHandlers.forEach(({ el, type, fn }) => {
try { el.removeEventListener(type, fn); } catch (e) { }
});
this._boundDocHandlers = [];
this._docHideSetup = false;
this._kbSetup = false;
},
// 强制保持控制栏可见(覆盖 artplayer 自带的自动隐藏)
_lockControlsVisible() {
if (this._keepVisibleRafId) return;
if (this._hideAfterCloseTimer) {
clearTimeout(this._hideAfterCloseTimer);
this._hideAfterCloseTimer = null;
}
const tick = () => {
if (!this.art || !this.art.controls) {
this._keepVisibleRafId = null;
return;
}
// 强制显示,覆盖 artplayer 内部 hide timer 的效果
try { this.art.controls.show = true; } catch (e) { }
this._keepVisibleRafId = requestAnimationFrame(tick);
};
this._keepVisibleRafId = requestAnimationFrame(tick);
},
// 解除锁定:停止强制显示,并启动延迟隐藏
_unlockControlsVisible(delay = 1500) {
if (this._keepVisibleRafId) {
cancelAnimationFrame(this._keepVisibleRafId);
this._keepVisibleRafId = null;
}
if (this._hideAfterCloseTimer) {
clearTimeout(this._hideAfterCloseTimer);
}
this._hideAfterCloseTimer = setTimeout(() => {
this._hideAfterCloseTimer = null;
if (this.art && this.art.controls && !episodeMenu.isOpen()) {
try { this.art.controls.show = false; } catch (e) { }
}
}, delay);
},
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.quality[0].url = resolvedUrl;
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,
playbackRate: true,
aspectRatio: true,
screenshot: true,
muted: false,
volume: 1,
icons: {
state: ``,
},
moreVideoAttr: { crossOrigin: 'anonymous', preload: 'auto' }
});
this.art.on('ready', () => {
this.destroyNativePlayer();
this.art.video.muted = false;
this.loadProgress();
this.registerQualityControl();
this.addEpisodeControls();
this.cacheDurationFromHls(this.file);
this._setupDocHideControls();
this._setupKeyboardShortcuts();
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();
// 清理控制栏锁定循环和延迟隐藏计时器
if (this._keepVisibleRafId) {
cancelAnimationFrame(this._keepVisibleRafId);
this._keepVisibleRafId = null;
}
if (this._hideAfterCloseTimer) {
clearTimeout(this._hideAfterCloseTimer);
this._hideAfterCloseTimer = null;
}
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;
},
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 (['video', 'mboxvideo'].includes(this.flag) && 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;
},
_observeVideoContainer(callback) {
const targets = ['#app', 'body'];
let observer = null;
for (const sel of targets) {
const target = document.querySelector(sel);
if (!target) continue;
observer = new MutationObserver(() => {
const videoEl = document.querySelector('#video-wrap, .vp-video__player, #app .video-content');
if (videoEl && !document.getElementById('artplayer')) {
callback(videoEl);
}
});
observer.observe(target, { childList: true, subtree: true });
break;
}
return observer;
}
};
window.addEventListener('beforeunload', () => {
if (player._initialized) player.saveProgress();
player.destroy();
});
// ─── 页面处理 ────────────────────────────────────────────────────────────
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];
const vip = player.getVip();
player.flag = 'sharevideo';
player.file = file;
player.getUrl = (type) => {
if (type.includes('1080') && vip <= 1) type = type.replace('1080', '720');
return `/share/streaming?channel=chunlei&uk=${share_uk}&fid=${file.fs_id}` +
`&sign=${sign}×tamp=${timestamp}&shareid=${shareid}` +
`&type=${type}&vip=${vip}&jsToken=${unsafeWindow.jsToken}`;
};
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);
} else if (url.includes('/api/filemetas')) {
if (hasInit) return;
const file = xhr.responseJSON?.info?.[0];
if (!file) return;
hasInit = true;
const vip = player.getVip();
player.flag = 'playvideo';
player.file = file;
player.getUrl = (type) => {
if (type.includes('1080') && vip <= 1) type = type.replace('1080', '720');
return `/api/streaming?path=${encodeURIComponent(file.path)}` +
`&app_id=250528&clienttype=0&type=${type}&vip=${vip}&jsToken=${unsafeWindow.jsToken}`;
};
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 || [];
const vip = player.getVip();
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 = (type) => {
if (type.includes('1080') && vip <= 1) type = type.replace('1080', '720');
return `/api/streaming?path=${encodeURIComponent(file.path)}` +
`&app_id=250528&clienttype=0&type=${type}&vip=${vip}&jsToken=${unsafeWindow.jsToken}`;
};
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();
});
})();