// ==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) => `${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 = ` 选集列表 ${player.filelist.length}集 `; 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(); }); })();