// ==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) => `${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 = ` 选集列表 ${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; // 锁定控制栏:打开期间强制保持显示,覆盖 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(); }); })();