// ==UserScript== // @name 百度网盘视频优化(免播放缓存) // @namespace https://scriptcat.org/ // @version 3.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.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); }; } // 静默 try/catch 包装:执行 fn,捕获后 warn(label),永不抛出 function safe(fn, label = '', { swallow = true } = {}) { try { return fn(); } catch (e) { if (!swallow) throw e; if (label) console.warn(`[safe:${label}]`, e); return undefined; } } // 同步版本的别名,用于 try { ... } catch (e) { } 这种已经存在的写法就地替换 const trySafe = (tryBlock, label = '') => { try { return tryBlock(); } catch (e) { if (label) console.warn(`[safe:${label}]`, e); } }; // 存储抽象:优先使用油猴 GM_*,在 GM_* 缺失或失败时回落到 localStorage // * 同源策略友好:GM_* 走脚本存储,不占浏览器 localStorage 配额 // * API 形态与 localStorage 一致,便于原地替换 const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; const storage = { getItem(key) { try { if (hasGM) { const v = GM_getValue(key); return v == null ? null : v; } return localStorage.getItem(key); } catch (e) { console.warn('[storage:get]', key, e); return null; } }, setItem(key, value) { try { if (hasGM) { GM_setValue(key, value); return; } localStorage.setItem(key, value); } catch (e) { // 配额溢出自动清理进度历史后重试一次 if (e?.name === 'QuotaExceededError') throw e; throw e; } }, removeItem(key) { try { if (hasGM) { if (typeof GM_deleteValue === 'function') GM_deleteValue(key); else GM_setValue(key, undefined); return; } localStorage.removeItem(key); } catch (e) { console.warn('[storage:remove]', key, e); } }, // 仅在 localStorage 后端时可用,GM_* 模式由脚本自行管理 listKeys(prefix) { if (hasGM) return []; try { return Object.keys(localStorage).filter(k => k.startsWith(prefix)); } catch (e) { console.warn('[storage:list]', e); return []; } }, getRawItem(key) { // 仅 localStorage 后端需要:进度条本身就是个 JSON 字符串,GM_* 模式直接 getItem if (hasGM) return storage.getItem(key); try { return localStorage.getItem(key); } catch (e) { console.warn('[storage:getRaw]', key, e); return null; } } }; async function waitFor(fn, { intervalMs = 500, maxAttempts = 40, onTimeout } = {}) { for (let i = 0; i < maxAttempts; i++) { try { const v = fn(); if (v) return v; } catch (e) { console.warn('[safe:waitFor.poll]', e); } await new Promise(r => setTimeout(r, intervalMs)); } if (onTimeout) try { onTimeout(); } catch (e) { console.warn('[safe:waitFor.timeout]', e); } return null; } 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; } function readResumeFromUrl() { try { const sp = new URLSearchParams(window.location.search); const epRaw = sp.get('ep'); const tRaw = sp.get('t'); const result = {}; if (epRaw != null && /^\d+$/.test(epRaw)) { const ep = parseInt(epRaw, 10); if (ep >= 0) result.ep = ep; } if (tRaw != null && /^\d+(\.\d+)?$/.test(tRaw)) { const t = parseFloat(tRaw); if (t >= 0) result.t = t; } return result; } catch (e) { return {}; } } function writeResumeToUrl(updates) { try { const sp = new URLSearchParams(window.location.search); if ('ep' in updates) { if (updates.ep == null) sp.delete('ep'); else sp.set('ep', String(updates.ep)); } if ('t' in updates) { if (updates.t == null) sp.delete('t'); else sp.set('t', String(Math.floor(updates.t))); } const qs = sp.toString(); const url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash; window.history.replaceState(null, '', url); } catch (e) { /* noop */ } } function pickResumeFile() { const resume = readResumeFromUrl(); const list = player.filelist; if (resume.ep != null && Array.isArray(list) && list.length > resume.ep) { return resume.ep; } 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) { trySafe(() => this._el.removeEventListener('mouseleave', this._menuLeaveHandler), 'menu.removeListener'); } 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); } /* ── 自动切下集倒计时(贴底部控制栏上方,与选集面板同风格) ── */ .artplayer-autonext { position: absolute; left: 0; right: 0; display: flex; justify-content: center; pointer-events: none; padding: 0 16px 0; box-sizing: border-box; animation: art-autonext-rise .2s ease-out; } /* layer 被 art.layers.show=false 隐藏时,倒计时面板仍然保持可见 */ .art-layers:not(.art-show) .artplayer-autonext { opacity: 1 !important; visibility: visible !important; } .artplayer-autonext-inner { display: flex; align-items: center; gap: 10px; padding: 7px 10px 7px 12px; background: #3a3d42; border: 1px solid rgba(0, 0, 0, .25); border-radius: 10px; box-shadow: 0 6px 24px rgba(0, 0, 0, .35), 0 1px 0 rgba(255, 255, 255, .06) inset; color: #fff; font-family: system-ui, -apple-system, sans-serif; max-width: calc(100% - 32px); pointer-events: auto; } .artplayer-autonext-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; } .artplayer-autonext-title { font-size: 12px; font-weight: 500; letter-spacing: .1px; max-width: 320px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(255, 255, 255, .92); } .artplayer-autonext-sub { font-size: 10.5px; opacity: .7; font-variant-numeric: tabular-nums; letter-spacing: .1px; color: rgba(255, 255, 255, .82); } .artplayer-autonext-sub b { font-weight: 600; color: rgba(255, 255, 255, .95); } .artplayer-autonext-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: 2px; } .artplayer-autonext-cancel { padding: 4px 10px; font-size: 11px; color: rgba(255, 255, 255, .82); background: rgba(255, 255, 255, .08); border: 1px solid rgba(255, 255, 255, .14); border-radius: 999px; cursor: pointer; transition: background .15s ease, transform .1s ease; font-family: inherit; line-height: 1.4; } .artplayer-autonext-cancel:hover { background: rgba(255, 255, 255, .16); } .artplayer-autonext-cancel:active { transform: scale(.96); } .artplayer-autonext-play { padding: 4px 10px; font-size: 11px; color: #1f2328; background: #fff; border: 0; border-radius: 999px; cursor: pointer; font-weight: 600; transition: background .15s ease, transform .1s ease; font-family: inherit; line-height: 1.4; } .artplayer-autonext-play:hover { background: #f0f0f0; } .artplayer-autonext-play:active { transform: scale(.96); } @keyframes art-autonext-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); } }; const player = { // ===== 时间常量(统一来源)===== COUNTDOWN: 5, // 视频结束 → 自动切下一集的倒计时秒数 HIDE_DELAY: 2000, // 顶部/底部控制栏自动隐藏延迟(ms) SAVE_DEBOUNCE: 2000, // 进度保存防抖(ms) SAVE_THROTTLE: 5000, // 进度保存节流(ms):节流内的更新会被丢弃 PROGRESS_MIN: 5, // 恢复进度时低于此秒数则不恢复(s) PROGRESS_TTL: 7 * 86400000, // 进度条过期时间(ms,7 天) RETRY_BASE_MS: 500, // 轮询式重试的基础间隔(ms) RETRY_BACKOFF_MAX_MS: 10000, // 错误重试指数退避封顶(ms) RETRY_PER_CALL_MAX: 10000, // 单次重试总封顶(ms,等同于 backoff max) 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 { storage.setItem(key, data); } catch (e) { if (e.name === 'QuotaExceededError') { this._evictOldProgress(); trySafe(() => storage.setItem(key, data), 'saveProgress.retry'); } else { console.warn('无法保存进度:', e); } } }, _evictOldProgress() { try { const keys = storage.listKeys('video_progress'); if (keys.length > 20) { const sorted = keys.map(k => { try { const obj = JSON.parse(storage.getRawItem(k)); return { key: k, time: obj?.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 }) => trySafe(() => storage.removeItem(key), 'evict')); } } catch (e) { console.warn('[safe:evictOldProgress]', e); } }, loadProgress() { const key = this.getProgressKey(); if (!key || !this.art) return; const urlResume = readResumeFromUrl(); const useUrlT = urlResume.t != null; try { if (useUrlT) { if (urlResume.t < this.PROGRESS_MIN) return; if (this.art.duration && urlResume.t >= this.art.duration - 1) return; this.art.currentTime = urlResume.t; return; } const saved = storage.getRawItem(key); if (!saved) return; const obj = this._validateProgress(JSON.parse(saved)); if (!obj) return; if (Date.now() - obj.timestamp > this.PROGRESS_TTL) { storage.removeItem(key); return; } if (obj.currentTime < this.PROGRESS_MIN) return; this.art.currentTime = obj.currentTime; } catch (e) { console.warn('无法读取进度:', e); } }, clearProgress() { const key = this.getProgressKey(); if (key) trySafe(() => storage.removeItem(key), 'clearProgress'); }, getSavedProgressForFile(f) { if (!f) return null; const key = makeProgressKey(f); if (!key) return null; try { const saved = storage.getRawItem(key); if (!saved) return null; const obj = this._validateProgress(JSON.parse(saved)); if (!obj) return null; if (Date.now() - obj.timestamp > this.PROGRESS_TTL) { storage.removeItem(key); return null; } if (obj.currentTime < this.PROGRESS_MIN) return null; return { currentTime: obj.currentTime, duration: obj.duration }; } catch (e) { console.warn('[safe:getProgress]', e); return null; } }, // 校验恢复数据 schema:损坏/字段缺失/数值异常一律丢弃 _validateProgress(obj) { if (!obj || typeof obj !== 'object') return null; const { currentTime, duration, timestamp } = obj; if (!Number.isFinite(currentTime) || currentTime < 0) return null; if (!Number.isFinite(timestamp) || timestamp <= 0) return null; // duration 可能为 NaN(元数据未就绪),允许 null/undefined if (duration != null && (!Number.isFinite(duration) || duration < 0)) return null; return { currentTime, duration: duration || 0, timestamp }; }, destroyHls() { if (!this.hls) return; const hls = this.hls; this.hls = null; trySafe(() => { hls.stopLoad(); hls.detachMedia(); hls.destroy(); }, 'destroyHls'); }, resetVideo(video) { if (!video) return; try { video.pause(); video.removeAttribute('src'); Array.from(video.querySelectorAll('source')).forEach(s => s.remove()); video.load(); } catch (e) { console.warn('[safe:resetVideo]', e); } }, /** * 指数退避重试:每次延迟 = min(baseMs * 2^attempt, maxMs)。 * @param {() => void|boolean} action 要执行的操作;返回 false 表示"本次不算,下次继续" * @param {object} opt * - attempts: 已重试次数(首次传 0) * - maxAttempts: 最大重试次数 * - baseMs: 基础延迟 * - maxMs: 单次延迟封顶 * - onGiveUp: 用尽后回调 * @returns {boolean} 是否还能继续(false 表示已用尽) */ _scheduleBackoff(action, opt) { const { attempts, maxAttempts = 3, baseMs = this.RETRY_BASE_MS, maxMs = this.RETRY_BACKOFF_MAX_MS, onGiveUp } = opt || {}; if (attempts >= maxAttempts) { if (onGiveUp) trySafe(onGiveUp, 'backoff.onGiveUp'); return false; } const delay = Math.min(baseMs * Math.pow(2, attempts), maxMs); setTimeout(() => trySafe(action, 'backoff.action'), delay); return true; }, 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; this._hlsRetries[fileKey] = retries + 1; const MAX = 3; this._scheduleBackoff( () => { if (this.hls === hls) hls.startLoad(); }, { attempts: retries, maxAttempts: MAX, baseMs: 1000, // HLS 网络错误的退避基准 1s,封顶 10s(与原行为一致) maxMs: 10000, onGiveUp: () => this.showTip('网络持续错误,请刷新页面') } ); this.showTip(`网络错误,指数重试 ${retries + 1}/${MAX}…`); } 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.hideAutoNextCountdown(); this._autoNextCancelFns.forEach(fn => trySafe(fn, 'cancelAutoNext.fn')); 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 newEpIdx = this.getCurrentIndex(); if (newEpIdx >= 0) writeResumeToUrl({ ep: newEpIdx, t: null }); if (this.flag === 'sharevideo') { const shareUrl = buildShareUrl(file); if (!shareUrl) { 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; trySafe(() => { if (wasFullscreen && !this.art.fullscreen) this.art.fullscreen = true; if (wasFullscreenWeb && !this.art.fullscreenWeb) this.art.fullscreenWeb = true; }, 'restoreFullscreen'); this.cacheDurationFromHls(file); this.loadProgress(); video.play().catch(() => { }); }, 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')) { return; } const idx = this.getCurrentIndex(); if (idx > 0) this.switchVideo(this.filelist[idx - 1]); } }); 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')) { return; } const idx = this.getCurrentIndex(); if (idx >= 0 && idx < (this.filelist?.length || 0) - 1) this.switchVideo(this.filelist[idx + 1]); } }); 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(); }, showAutoNextCountdown({ name, seconds, onCancel, onComplete }) { if (!this.art) return; this.hideAutoNextCountdown(); const EMBED = 15; const $ctrl = this.art?.template?.$controls || this.art?.controls?.$controls; const ctrlH = Math.max(40, $ctrl?.getBoundingClientRect?.().height || 40); const bottomPx = Math.round(ctrlH + EMBED); // 倒计时面板出现期间控制栏保持显示;这里标记 + 立刻弹出控制栏 this._autoNextVisible = true; trySafe(() => this._hideCtl?.showNow(), 'autoNext.pinControls'); let remaining = seconds; let dismissed = false; this.art.layers.add({ name: 'autoNextCountdown', html: ` `, style: { position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none' }, mounted: ($el) => { const $title = $el.querySelector('.artplayer-autonext-title'); const $sec = $el.querySelector('.artplayer-autonext-sec'); const $btnCancel = $el.querySelector('.artplayer-autonext-cancel'); const $btnPlay = $el.querySelector('.artplayer-autonext-play'); $title.textContent = name || '下一集'; const paint = () => { const v = String(Math.max(remaining, 0)); if ($sec) { $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } }; paint(); this._autoNextResizeObs = new ResizeObserver(() => { const $el2 = this.art?.layers?.autoNextCountdown; const wrap = $el2?.querySelector?.('.artplayer-autonext'); if (!wrap) return; const $ctrl2 = this.art?.template?.$controls || this.art?.controls?.$controls; const h = Math.max(40, $ctrl2?.getBoundingClientRect?.().height || 40); wrap.style.bottom = Math.round(h + EMBED) + 'px'; }); trySafe(() => $ctrl && this._autoNextResizeObs.observe($ctrl), 'autoNext.observe'); const cancelOnce = () => { if (dismissed) return; dismissed = true; trySafe(() => onCancel && onCancel(), 'autoNext.onCancel'); }; const completeNow = () => { if (dismissed) return; dismissed = true; trySafe(() => onComplete && onComplete({ immediate: true }), 'autoNext.onComplete.immediate'); }; $btnCancel.addEventListener('click', (ev) => { ev.stopPropagation(); cancelOnce(); }); $btnPlay.addEventListener('click', (ev) => { ev.stopPropagation(); completeNow(); }); } }); this._autoNextCompleteFns = []; this._autoNextCompleteFns.push(() => { if (dismissed) return; dismissed = true; trySafe(() => onComplete && onComplete({ immediate: false }), 'autoNext.onComplete.ticker'); }); this._autoNextTicker = setInterval(() => { remaining -= 1; const $el = this.art?.layers?.autoNextCountdown; const $sec = $el?.querySelector?.('.artplayer-autonext-sec'); if ($sec) { const v = String(Math.max(remaining, 0)); $sec.textContent = v; $sec.setAttribute('aria-label', `剩余 ${v} 秒`); } if (remaining <= 0) { if (this._autoNextTicker) { clearInterval(this._autoNextTicker); this._autoNextTicker = null; } this._autoNextCompleteFns.forEach(fn => trySafe(fn, 'autoNext.completeFns')); } }, 1000); }, hideAutoNextCountdown() { if (this._autoNextTicker) { clearInterval(this._autoNextTicker); this._autoNextTicker = null; } this._autoNextCompleteFns = []; if (this._autoNextResizeObs) { trySafe(() => this._autoNextResizeObs.disconnect(), 'autoNext.disconnect'); this._autoNextResizeObs = null; } if (!this.art || !this.art.layers) return; trySafe(() => this.art.layers.remove('autoNextCountdown'), 'autoNext.removeLayer'); // 面板消失:解除 pin 并恢复正常空闲隐藏调度(给用户 2 秒反应时间再收控制栏) this._autoNextVisible = false; trySafe(() => this._hideCtl?.scheduleHide(), 'autoNext.resumeHide'); }, _setupDocHideControls() { if (!this.art || this._docHideSetup) return; this._docHideSetup = true; 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; }; // 控制栏是否被某个持久 UI 锁住不许隐藏(倒计时面板就是这种情况) const isPinned = () => this._autoNextVisible; const scheduleHide = () => { clearHide(); if (isPinned()) return; hideTimer = setTimeout(() => { hideTimer = null; if (!controls() || episodeMenu.isOpen()) return; if (isPinned()) return; const el = $controls(); if (el && el.matches(':hover')) return; this.art.controls.show = false; this.art.layers.show = false; }, this.HIDE_DELAY); }; this._hideCtl = { clearHide, showNow, scheduleHide }; 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() || isPinned()) { 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() && !isPinned()) { scheduleHide(); } }; const onDocMouseLeave = () => { if (episodeMenu.isOpen() || isPinned()) { 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; if (isPinned()) { clearHide(); showNow(); return; } const el = $controls(); if (el && el.matches(':hover')) { clearHide(); showNow(); return; } scheduleHide(); }; }, _cleanupDocHandlers() { this._boundDocHandlers.forEach(({ el, type, fn }) => { trySafe(() => el.removeEventListener(type, fn), 'cleanupDocHandlers'); }); this._boundDocHandlers = []; this._docHideSetup = false; this._kbSetup = false; this._hideCtl = null; }, _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]); 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]); 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(), this.SAVE_DEBOUNCE); let lastSave = 0; // 命名函数引用:destroy/重 init 时可解绑,避免 timeupdate 监听器累积 const onTimeUpdateSave = () => { if (this.art.currentTime > 0 && Date.now() - lastSave > this.SAVE_THROTTLE) { lastSave = Date.now(); debouncedSave(); const idx = this.getCurrentIndex(); if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime }); } }; this.art.on('video:timeupdate', onTimeUpdateSave); this._onTimeUpdateSave = onTimeUpdateSave; const flushSave = () => { if (this.art?.currentTime > 0) { this.saveProgress(); const idx = this.getCurrentIndex(); if (idx >= 0) writeResumeToUrl({ t: this.art.currentTime }); } }; this.art.on('video:pause', flushSave); this.art.on('video:seeked', flushSave); const onVisibilityChange = () => { if (document.hidden) flushSave(); }; document.addEventListener('visibilitychange', onVisibilityChange); this._onVisibilityChange = onVisibilityChange; this.art.on('video:play', () => { if (this._hideCtl) this._hideCtl.scheduleHide(); }); 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) { return; } const next = this.filelist[idx + 1]; const nextName = this.getFileName(next); const tokenAtEnd = this._switchToken; let seekUnbound = false; const cancelEvents = ['video:timeupdate', 'video:seeking', 'video:play', 'video:pause', 'video:click']; const bindCancel = () => cancelEvents.forEach(ev => this.art?.on(ev, cancelOnSeek)); const unbindCancel = () => cancelEvents.forEach(ev => this.art?.off(ev, cancelOnSeek)); const cancelOnSeek = () => { if (seekUnbound) return; seekUnbound = true; if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; } this.hideAutoNextCountdown(); this._autoNextCanceled = true; unbindCancel(); }; const switchNow = () => { if (this._autoNextTimer) { clearTimeout(this._autoNextTimer); this._autoNextTimer = null; } this.hideAutoNextCountdown(); unbindCancel(); if (this._switchToken !== tokenAtEnd) return; this.switchVideo(next); }; this.showAutoNextCountdown({ name: nextName, seconds: this.COUNTDOWN, onCancel: () => cancelOnSeek(), onComplete: () => switchNow() }); this._autoNextTimer = setTimeout(() => switchNow(), this.COUNTDOWN * 1000); bindCancel(); this._autoNextCancelFns.push(cancelOnSeek); }); this.art.on('destroy', () => { this._cancelAutoNext(); this._cleanupDocHandlers(); episodeMenu.close(); this._initialized = false; }); }, destroy() { this._switchToken++; this._cancelAutoNext(); this._cleanupDocHandlers(); if (this._onTimeUpdateSave) { this.art?.off('video:timeupdate', this._onTimeUpdateSave); this._onTimeUpdateSave = null; } if (this._onVisibilityChange) { document.removeEventListener('visibilitychange', this._onVisibilityChange); this._onVisibilityChange = null; } episodeMenu.close(); if (this.art?.video) { trySafe(() => { this.art.video.muted = true; this.art.video.pause(); }, 'destroy.mute'); } if (this.art) { const art = this.art; this.art = null; trySafe(() => { if (art.video) { art.video.pause(); art.video.src = ''; art.video.load(); } art.destroy(true); }, 'destroy.art'); } this.destroyHls(); this._episodeControlsAdded = false; this._setupQualityInterceptionBound = false; }, destroyNativePlayer() { document.querySelectorAll('video').forEach(v => { if (!v.closest('#artplayer')) { trySafe(() => { v.pause(); v.muted = true; v.src = ''; v.load(); }, 'destroyNativePlayer.video'); } }); const pollDestroy = (getTarget) => { let count = 0; const id = setInterval(() => { count++; const t = getTarget(); if (t?.player) { clearInterval(id); trySafe(() => { t.player.dispose(); t.player = null; }, 'pollDestroy.dispose'); } 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() { const videoNode = await waitFor( () => document.querySelector('#video-wrap, .vp-video__player, #app .video-content'), { intervalMs: 500, maxAttempts: 20 } ); if (!videoNode) return null; let container = document.getElementById('artplayer'); if (!container) { container = document.createElement('div'); container.id = 'artplayer'; container.style.cssText = 'width:100%;height:100%'; videoNode.parentNode?.replaceChild(container, videoNode); } return container; } }; window.addEventListener('beforeunload', () => { if (player._initialized) player.saveProgress(); }); async function handleShare() { const localsReady = await waitFor(() => unsafeWindow.locals, { intervalMs: 500, maxAttempts: 40, onTimeout: () => console.warn('[BDPlayer] locals等待超时,放弃初始化') }); if (!localsReady) return; await new Promise(resolve => { let done = false; localsReady.get( 'file_list', 'share_uk', 'shareid', 'sign', 'timestamp', (file_list, share_uk, shareid, sign, timestamp) => { if (done) return; done = true; if (!file_list?.length) { resolve(); return; } let videoList = []; 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; let file = videoList[0]; const resumeEp = pickResumeFile(); if (resumeEp != null) file = videoList[resumeEp]; 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() { const jqReady = await waitFor(() => unsafeWindow.jQuery, { intervalMs: 500, maxAttempts: 40, onTimeout: () => console.warn('[BDPlayer] jQuery等待超时,放弃初始化') }); if (!jqReady) return; let hasInit = false; jqReady(document).ajaxComplete(async (event, xhr, options) => { const url = options.url || ''; if (url.includes('/api/categorylist')) { player.filelist = (xhr.responseJSON?.info || []).filter(f => f.category === 1); player.refreshEpisodeControls(); } else if (url.includes('/api/filemetas')) { if (hasInit) return; let file = xhr.responseJSON?.info?.[0]; if (!file) return; const resumeEp = pickResumeFile(); if (resumeEp != null && player.filelist?.[resumeEp]) { file = player.filelist[resumeEp]; } 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 = await waitFor( () => app?.__vue_app__?.config?.globalProperties?.$pinia?.state?._rawValue?.videoinfo?.videoinfo ? app.__vue_app__.config.globalProperties.$pinia : null, { intervalMs: 500, maxAttempts: 40 } ); if (!pinia) return; const file = pinia.state._rawValue.videoinfo.videoinfo; const list = pinia.state._rawValue.recommendListInfo?.selectionVideoList || []; player.flag = 'video'; player.file = file; player.filelist = list; let initFile = file; const resumeEp = pickResumeFile(); if (resumeEp != null && list.length > resumeEp) initFile = list[resumeEp]; const videoNode = document.querySelector('#video-wrap, .vp-video__player, #app .video-content'); if (videoNode) player.nativeVideoNode = videoNode; player.getUrl = buildFileUrl(initFile); player.buildQuality(initFile.resolution); player.file = initFile; 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(); }); })();