您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
加载本地 B站弹幕 JSON文件,在 YouTube 视频上显示
// ==UserScript== // @name YouTube B站弹幕播放器 // @namespace https://github.com/ZBpine/bilibili-danmaku-download/ // @version 1.5.1 // @description 加载本地 B站弹幕 JSON文件,在 YouTube 视频上显示 // @author ZBpine // @match https://www.youtube.com/* // @match https://www.bilibili.com/* // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect api.bilibili.com // @license MIT // @run-at document-end // ==/UserScript== (async () => { 'use strict'; class DanmakuControlPanel { constructor() { this.panelId = 'dm-panel'; this.dmPlayer = new BiliDanmakuPlayer(); this.dmPlayerCall = (methodName, ...args) => { if (this.dmPlayer && typeof this.dmPlayer[methodName] === 'function') { return this.dmPlayer[methodName](...args); } } this.videoId = null; } bindHotkey() { if (this.hotkeyBound) return; this.hotkeyBound = true; document.addEventListener('keydown', (e) => { const target = e.target; const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; if (isTyping) return; if (e.key.toLowerCase() === 'd') { if (this.toggleBtn) { this.toggleBtn.click(); } } }); } init() { if (document.getElementById(this.panelId)) return; const buttonStyle = { padding: '6px', background: '#555', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', width: '100%' }; if (isBilibili) { buttonStyle.background = '#eee'; buttonStyle.color = 'black'; this.dmPlayer.logStyle.style = 'background: #00a2d8; color: white; padding: 2px 6px; border-radius: 3px;'; this.dmPlayer.logStyle.errorStyle = 'background: #ff4d4f; color: white; padding: 2px 6px; border-radius: 3px;'; } this.dmPlayerCall('setOptions', dmStore.get('settings', {})); this.searchBtn = document.createElement('button'); this.searchBtn.textContent = '🔍'; this.searchBtn.title = '搜索弹幕'; this.searchBtn.onclick = () => this.onSearch(); Object.assign(this.searchBtn.style, buttonStyle); this.configBtn = document.createElement('button'); this.configBtn.textContent = '⚙️'; this.configBtn.title = '打开设置'; Object.assign(this.configBtn.style, buttonStyle); this.configBtn.onclick = () => this.showConfigPanel(); this.toggleBtn = document.createElement('button'); this.toggleBtn.textContent = '✅'; this.toggleBtn.title = '开关弹幕'; Object.assign(this.toggleBtn.style, buttonStyle); this.toggleBtn.onclick = () => { if (!this.dmPlayer) return; this.dmPlayer.toggle(); this.toggleBtn.textContent = this.dmPlayer.showing ? '✅' : '🚫'; }; this.loadBtn = document.createElement('button'); this.loadBtn.id = 'dm-btn-load'; Object.assign(this.loadBtn.style, buttonStyle); this.loadCtl = { setLoad: () => { this.loadBtn.textContent = '📂'; this.loadBtn.title = '载入弹幕文件'; this.fileInput.value = ''; this.loadBtn.onclick = () => this.fileInput.click(); }, setSave: (data) => { this.loadBtn.textContent = '💾'; this.loadBtn.title = '保存到浏览器本地存储'; this.loadBtn.onclick = () => { dmStore.setCache(this.videoId, data); showTip('✅ 已保存到本地'); this.loadCtl.setClear(); }; }, setClear: () => { this.loadBtn.textContent = '🗑️'; this.loadBtn.title = '释放浏览器本地存储'; this.loadBtn.onclick = () => { dmStore.removeCache(this.videoId); showTip('🗑️ 已移除本地存储'); this.loadCtl.setLoad(); }; } } const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.style.display = 'none'; fileInput.id = 'dm-input-file'; fileInput.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const json = JSON.parse(e.target.result); this.loadDanmakuSuccess(json); this.loadCtl.setSave(json); } catch (err) { this.dmPlayer.logTagError('❌ 弹幕 JSON 加载失败', err); showTip('❌ 加载失败:' + err.message); } }; reader.readAsText(file); }; this.fileInput = fileInput; document.body.appendChild(fileInput); const panel = document.createElement('div'); panel.id = this.panelId; Object.assign(panel.style, { position: 'fixed', width: '80px', left: '-90px', bottom: '40px', zIndex: 10000, transition: 'left 0.3s ease-in-out, opacity 0.3s ease', opacity: '0.2', background: '#333', borderRadius: '0px 20px 20px 0px', padding: '10px', paddingRight: '20px', display: 'grid', gridTemplateColumns: '36px 36px', gridAutoRows: '32px', gap: '6px' }); if (isBilibili) { panel.style.background = '#ccc'; } panel.addEventListener('mouseenter', () => { panel.style.left = '0px'; panel.style.opacity = '1'; }); panel.addEventListener('mouseleave', () => { panel.style.left = '-90px'; panel.style.opacity = '0.2'; }); panel.append(this.searchBtn, this.loadBtn, this.configBtn, this.toggleBtn); document.body.appendChild(panel); this.bindHotkey(); } update(videoId) { if (!videoId) return; this.dmPlayer.logTag(`当前视频:${videoId}`); this.videoId = videoId; this.dmPlayerCall('clear'); // 检查是否已有存储的弹幕 const saved = dmStore.getCache(videoId); if (saved) { try { this.loadDanmakuSuccess(saved); showTip(`📦 自动载入本地弹幕`); this.loadCtl.setClear(); } catch (err) { showTip('❌ 本地弹幕解析失败:' + err.message); this.dmPlayer.logTagError('❌ 本地弹幕解析失败:', err); dmStore.removeCache(videoId); this.loadCtl.setLoad(); } } else { this.loadCtl.setLoad(); } } loadDanmakuSuccess(data) { this.dmPlayer.init(); this.dmPlayer.load(data.danmakuData); const count = data.danmakuData.length; const title = data.videoData?.title || '(未知标题)'; const time = data.fetchtime ? new Date(data.fetchtime * 1000).toLocaleString('zh-CN', { hour12: false }) : '(未知)'; showTip(`🎉 成功载入:\n🎬 ${title}\n💬 共 ${count} 条弹幕\n🕒 抓取时间:${time}`); } async onSearch() { try { const selected = await this.showSearchSelector(); if (!selected) return; const data = await getDanmakuDataByBvid(selected.bvid, selected.source); this.loadDanmakuSuccess(data); this.loadCtl.setSave(data); } catch (err) { showTip(`❌ 请求失败:${err.message}`); this.dmPlayer.logTagError('❌ 请求失败:', err); } } async showSearchSelector() { let initialKeyword = document.title.replace(' - YouTube', ''); if (isBilibili) { initialKeyword = document.title.replace(/[-_–—|]+.*?(bilibili|哔哩哔哩).*/gi, '').trim(); } let resolveFn; const returnPromise = new Promise((resolve) => { resolveFn = resolve; }); const overlay = this.createStyledEl('overlay'); const panel = this.createStyledEl('panel'); const titleEl = document.createElement('div'); titleEl.textContent = '选择一个视频以载入弹幕:'; titleEl.style.fontWeight = 'bold'; titleEl.style.fontSize = '16px'; const input = this.createStyledEl('input'); input.type = 'text'; input.value = initialKeyword; const resultsBox = document.createElement('div'); resultsBox.style.display = 'flex'; resultsBox.style.flexDirection = 'column'; resultsBox.style.gap = '6px'; const cleanup = (result = null) => { overlay.remove(); resolveFn(result); }; document.addEventListener('keydown', function escClose(e) { if (e.key === 'Escape') { cleanup(); document.removeEventListener('keydown', escClose); } }); overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; const formatCount = (n) => { n = parseInt(n || '0'); if (isNaN(n)) return '0'; if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; return n.toString(); }; const normalizeTimeStr = (duration) => { if (typeof duration === 'number' && !isNaN(duration)) { // duration 是秒数,直接格式化为 h:mm:ss const totalSeconds = duration; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } else { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } } if (typeof duration === 'string' && /^\d+:\d{1,2}$/.test(duration)) { const [min, sec] = duration.split(':').map(Number); if (isNaN(min) || isNaN(sec)) return duration; // 原样返回不合法值 if (min > 99) { const hours = Math.floor(min / 60); const minutes = min % 60; return `${hours}:${String(minutes).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } else { return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } } return duration; // 不合法或未知格式,原样返回 }; const renderResults = async (keyword) => { resultsBox.textContent = '🔍 搜索中...'; try { const list = await searchVideosByKeyword(keyword); if (!Array.isArray(list) || list.length === 0) { resultsBox.textContent = '❌ 没有找到相关视频'; return; } resultsBox.textContent = ''; // ➤ 按来源分组 const localResults = list.filter(item => item.source === 'local'); const onlineResults = list.filter(item => item.source !== 'local'); // ➤ 渲染分组函数 const renderGroup = (titleText, groupList) => { if (groupList.length === 0) return; const titleRow = document.createElement('div'); titleRow.textContent = titleText; Object.assign(titleRow.style, { fontWeight: 'bold', marginTop: '10px', marginBottom: '4px', borderBottom: '1px solid #ccc', paddingBottom: '4px' }); resultsBox.appendChild(titleRow); groupList.forEach(item => { const row = document.createElement('div'); Object.assign(row.style, { padding: '8px 10px', borderRadius: '6px', cursor: 'pointer', background: '#f8f8f8', display: 'flex', flexDirection: 'column', gap: '4px' }); row.addEventListener('mouseenter', () => row.style.background = '#e0e0e0'); row.addEventListener('mouseleave', () => row.style.background = '#f8f8f8'); const titleLine = document.createElement('div'); titleLine.textContent = `📺 ${item.title.replace(/<[^>]+>/g, '')}` titleLine.style.fontWeight = '500'; const infoLine = document.createElement('div'); Object.assign(infoLine.style, { display: 'flex', gap: '12px', fontSize: '12px', color: '#666', flexWrap: 'wrap' }); const author = document.createElement('span'); author.textContent = `👤 ${item.author || 'UP未知'}`; const play = document.createElement('span'); play.textContent = `👁 ${formatCount(item.play)}`; const danmu = document.createElement('span'); danmu.textContent = `💬 ${formatCount(item.video_review)}`; const duration = document.createElement('span'); if (item.duration) { duration.textContent = `🕒 ${normalizeTimeStr(item.duration)}`; } const link = document.createElement('a'); link.href = `https://www.bilibili.com/video/${item.bvid}`; link.textContent = '🔗 打开'; link.target = '_blank'; Object.assign(link.style, { fontSize: '12px', color: '#1a73e8', textDecoration: 'none' }); link.addEventListener('click', e => e.stopPropagation()); infoLine.append(author, play, danmu, duration, link); row.onclick = () => cleanup(item); row.appendChild(titleLine); row.appendChild(infoLine); resultsBox.appendChild(row); }); }; // ➤ 渲染两组 renderGroup('📦 本地弹幕:', localResults); renderGroup('🌐 B站视频:', onlineResults); } catch (e) { resultsBox.textContent = `❌ 搜索失败:${e.message}`; } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const kw = input.value.trim(); if (kw) renderResults(kw); } }); panel.append(titleEl, input, resultsBox); overlay.appendChild(panel); document.body.appendChild(overlay); await renderResults(initialKeyword); return returnPromise; } showConfigPanel() { const existing = document.getElementById('dm-config-panel'); if (existing) existing.remove(); const overlay = this.createStyledEl('overlay'); overlay.id = 'dm-config-panel'; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const panel = this.createStyledEl('panel'); const title = document.createElement('div'); title.textContent = '⚙️ 设置'; title.style.fontSize = '18px'; title.style.fontWeight = 'bold'; panel.appendChild(title); const createLabeledButtonRow = (labelText, buttonText, onClick) => { const row = document.createElement('div'); row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.alignItems = 'center'; row.style.borderTop = '1px solid #ccc'; const label = document.createElement('div'); label.textContent = labelText; label.style.fontWeight = 'bold'; label.style.fontSize = '16px' label.style.margin = '10px 0' row.appendChild(label); if (!buttonText && !onClick) return row const button = document.createElement('button'); button.textContent = buttonText; Object.assign(button.style, { width: '130px', height: '28px', fontSize: '14px', border: '1px solid #ccc', borderRadius: '4px', background: '#f0f0f0', cursor: 'pointer', flexShrink: '0' }); button.onclick = onClick; row.appendChild(button); return row; } const createContralRow = (labelText, key, options, desc) => { const keyPath = `settings.${key}`; const wrapper = document.createElement('div'); Object.assign(wrapper.style, { display: 'flex', height: '36px', alignItems: 'center', flexDirection: 'row', gap: '18px' }); const controlRow = document.createElement('div'); Object.assign(controlRow.style, { display: 'flex', alignItems: 'center', gap: '6px' }); const label = document.createElement('div'); label.textContent = labelText; Object.assign(label.style, { fontWeight: 'bold', flexShrink: '0' }); wrapper.append(label); const input = document.createElement('input'); Object.assign(input, options); if (options.type === 'checkbox') { input.checked = dmStore.get(keyPath, this.dmPlayer.options[key].value); Object.assign(input.style, { width: '20px', height: '20px', cursor: 'pointer' }); input.onchange = () => { const val = input.checked; dmStore.set(keyPath, val); this.dmPlayerCall('setOptions', val, key); showTip(`✅ 已保存 ${labelText}:${val ? '开启' : '关闭'}`); }; controlRow.append(input); } else if (options.type === 'number') { input.value = dmStore.get(keyPath, this.dmPlayer.options[key].value); Object.assign(input.style, { width: '60px', height: '20px', padding: '0', textAlign: 'center', fontSize: '14px' }); const saveBtn = document.createElement('button'); saveBtn.textContent = '💾 保存'; Object.assign(saveBtn.style, { width: '80px', height: '28px', fontSize: '14px', cursor: 'pointer' }); saveBtn.onclick = () => { const val = Number.isInteger(Number(options.step)) ? parseInt(input.value) : parseFloat(input.value); if (!isNaN(val) && val >= options.min && val <= options.max) { dmStore.set(keyPath, val); this.dmPlayerCall('setOptions', val, key); showTip(`✅ 已保存 ${labelText}:${val}`); } else { showTip('❌ 输入不合法'); } }; controlRow.append(input, saveBtn); } wrapper.append(controlRow); if (desc) { const descEl = document.createElement('div'); descEl.textContent = desc; Object.assign(descEl.style, { fontSize: '12px', color: '#666', marginLeft: 'auto' }); wrapper.append(descEl); } return wrapper; } const SectionStyle = { display: 'flex', flexDirection: 'column', gap: '6px', } // --- 服务器设置模块 --- const serverSection = document.createElement('div'); Object.assign(serverSection.style, SectionStyle); const serverHeader = createLabeledButtonRow('🌐 服务器地址:', '💾 保存', () => { dmStore.set('server', serverInput.value.trim()); showTip('✅ 地址已保存'); }); const serverInput = this.createStyledEl('input'); serverInput.value = dmStore.get('server', ''); serverSection.appendChild(serverHeader); serverSection.appendChild(serverInput); panel.appendChild(serverSection); // --- 弹幕显示设置模块 --- const settingSection = document.createElement('div'); Object.assign(settingSection.style, SectionStyle); settingSection.appendChild(createLabeledButtonRow('📺 弹幕显示设置')) settingSection.appendChild(createContralRow( '🌫️ 不透明度', 'opacity', { type: 'number', min: 0.1, max: 1.0, step: 0.1 }, '设置弹幕透明度(0.1 ~ 1.0)越小越透明' )); settingSection.appendChild(createContralRow( '📐 显示区域', 'displayArea', { type: 'number', min: 0.1, max: 1.0, step: 0.1 }, '允许弹幕占屏幕高度范围,1.0 全屏' )); settingSection.appendChild(createContralRow( '🚀 弹幕速度', 'speed', { type: 'number', min: 3, max: 9, step: 1 }, '影响弹幕持续时间以及滚动弹幕的速度' )); settingSection.appendChild(createContralRow( '🔁 合并重复', 'mergeRepeats', { type: 'checkbox' }, '是否合并内容相同且时间接近的弹幕' )); settingSection.appendChild(createContralRow( '🔀 允许重叠', 'overlap', { type: 'checkbox' }, '开启则允许弹幕重叠,否则丢弃会重叠的弹幕' )); panel.appendChild(settingSection); // --- 缓存管理模块 --- const cacheSection = document.createElement('div'); Object.assign(cacheSection.style, SectionStyle); const cacheHeader = createLabeledButtonRow('📦 本地缓存弹幕', '🧹 清空所有缓存', () => { if (confirm('确定要清空所有本地缓存弹幕吗?')) { dmStore.clearAllCache(); cacheList.textContent = '📭 所有缓存已清除'; showTip('🧹 所有弹幕缓存已清空'); } }); const cacheList = document.createElement('div'); cacheList.style.display = 'flex'; cacheList.style.flexDirection = 'column'; cacheList.style.gap = '8px'; const cache = dmStore.get('cache', {}); const keys = Object.keys(cache); if (keys.length === 0) { cacheList.textContent = '📭 当前没有缓存弹幕'; } else { keys.forEach(videoId => { try { const json = cache[videoId]; const title = json.videoData?.title; const row = document.createElement('div'); row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.alignItems = 'center'; const label = document.createElement('div'); const idLine = document.createElement('a'); idLine.textContent = `[▶️ ${videoId}]`; if (isBilibili) { idLine.href = videoId.startsWith('ep') ? `https://www.bilibili.com/bangumi/play/${videoId}` : `https://www.bilibili.com/video/${videoId}`; } else { idLine.href = `https://www.youtube.com/watch?v=${videoId}`; } idLine.target = '_blank'; Object.assign(idLine.style, { fontSize: '13px', color: '#1a73e8', textDecoration: 'none', marginBottom: '2px', whiteSpace: 'nowrap' }); const titleLine = document.createElement('div'); titleLine.textContent = `${title}`; titleLine.style.fontWeight = '500'; label.appendChild(idLine); label.appendChild(titleLine); const delBtn = document.createElement('button'); delBtn.textContent = '🗑 删除'; delBtn.style.cursor = 'pointer'; delBtn.onclick = () => { dmStore.removeCache(videoId); row.remove(); showTip(`🗑 已删除缓存:${title}`); }; row.appendChild(label); row.appendChild(delBtn); cacheList.appendChild(row); } catch (err) { this.dmPlayer.logTagError(err); } }); } cacheSection.appendChild(cacheHeader); cacheSection.appendChild(cacheList); panel.appendChild(cacheSection); overlay.appendChild(panel); document.body.appendChild(overlay); } createStyledEl(type) { const el = document.createElement(type === 'input' ? 'input' : 'div'); // 样式模板 const styles = { overlay: { position: 'fixed', top: '0', left: '0', right: '0', bottom: '0', background: 'rgba(0, 0, 0, 0.4)', zIndex: 10001, display: 'flex', alignItems: 'center', justifyContent: 'center' }, panel: { background: '#fff', width: '500px', maxHeight: '80vh', overflowY: 'auto', padding: '20px', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', fontSize: '14px', fontFamily: 'sans-serif', display: 'flex', flexDirection: 'column', gap: '16px' }, input: { padding: '6px 10px', fontSize: '14px', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' } }; if (styles[type]) Object.assign(el.style, styles[type]); return el; } } function showTip(message, { dark = true, duration = 3000 } = {}) { if (isBilibili) dark = false; const tip = document.createElement('div'); tip.textContent = message; Object.assign(tip.style, { position: 'fixed', bottom: '20px', right: '20px', padding: '10px 14px', borderRadius: '6px', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)', fontSize: '14px', zIndex: 9999, whiteSpace: 'pre-line', opacity: '0', transition: 'opacity 0.3s ease', background: dark ? 'rgba(50, 50, 50, 0.9)' : '#f0f0f0', color: dark ? '#fff' : '#000', border: dark ? '1px solid #444' : '1px solid #ccc' }); document.body.appendChild(tip); requestAnimationFrame(() => { tip.style.opacity = '1'; }); setTimeout(() => { tip.style.opacity = '0'; tip.addEventListener('transitionend', () => tip.remove()); }, duration); console.log('[💡tip]', message); } async function waitForVideo(timeout = 10000) { const start = Date.now(); return new Promise((resolve, reject) => { const check = () => { const video = document.querySelector('video'); if (video) { resolve(video); } else if (Date.now() - start >= timeout) { reject(new Error('⏰ 超时:未检测到 <video> 元素')); } else { requestAnimationFrame(check); } }; check(); }); } function getCurrentVideoId() { if (isBilibili) { const bvidMatch = location.href.match(/BV[a-zA-Z0-9]+/); if (bvidMatch) return bvidMatch[0]; const epidMatch = location.href.match(/ep(\d+)/); if (epidMatch) return 'ep' + epidMatch[1]; } else { return new URLSearchParams(location.search).get('v'); } return null; } function observeVideoChange() { let lastId = ''; const observer = new MutationObserver(() => { const newId = getCurrentVideoId(); if (newId && newId !== lastId) { console.log(`[🎬 检测到视频变化] ${lastId} → ${newId}`); lastId = newId; if (newId) { dmPanel.init(); dmPanel.update(newId); } } }); observer.observe(document.body, { childList: true, subtree: true }); } async function searchVideosByKeyword(keyword) { const server = dmStore.get('server'); if (server) { try { const res = await fetch(`${server}/search?keyword=${encodeURIComponent(keyword)}&type=video`); const list = await res.json(); if (Array.isArray(list)) return list; } catch (e) { showTip('⚠ 请检查服务器是否正确'); console.warn('❌ 远程搜索失败:', e); } } try { const params = { search_type: 'video', keyword, page: 1 }; const res = await client.request({ url: 'https://api.bilibili.com/x/web-interface/search/type', params, sign: true, desc: `搜索 ${keyword}` }); const list = res.data?.result || []; list.forEach(item => item.source = 'bilibili'); return list; } catch (e) { console.error('❌ GM搜索失败:', e); throw new Error('搜索失败'); } } async function getDanmakuDataByBvid(bvid, source = 'bilibili') { const server = dmStore.get('server'); if (server) { try { const res = await fetch(`${server}/video?bvid=${bvid}&source=${source}`); const data = await res.json(); return data; } catch (e) { showTip('⚠ 请检查服务器是否正确'); console.warn('❌ 远程弹幕获取失败:', e); } } try { const videoDataRes = await client.request({ url: 'https://api.bilibili.com/x/web-interface/view', params: { bvid }, desc: `获取视频信息 ${bvid}` }); const videoData = videoDataRes.data const cid = videoData.cid; const xml = await client.request({ url: 'https://api.bilibili.com/x/v1/dm/list.so', params: { oid: cid }, responseType: 'text', desc: `获取弹幕 XML cid=${cid}` }); const danmakuData = parseDanmakuXml(xml); return { bvid, cid, videoData, danmakuData, fetchtime: Math.floor(Date.now() / 1000) }; } catch (e) { console.error('❌ GM弹幕获取失败:', e); throw new Error('弹幕获取失败'); } } function parseDanmakuXml(xml) { const danmakus = []; const regex = /<d p="([^"]+)">([^<]*)<\/d>/g; let match; while ((match = regex.exec(xml)) !== null) { const p = match[1].split(","); if (p.length < 8) continue; danmakus.push({ progress: Math.trunc(parseFloat(p[0]) * 1000), mode: parseInt(p[1]), fontsize: parseInt(p[2]), color: parseInt(p[3]), ctime: parseInt(p[4]), pool: parseInt(p[5]), midHash: p[6], id: p[7], weight: p[8] ? parseInt(p[8]) : 0, content: match[2].trim() }); } return danmakus; } const isBilibili = location.hostname.includes('bilibili.com'); const urlOfPlayer = 'https://cdn.jsdelivr.net/gh/ZBpine/[email protected]/tampermonkey/BiliDanmakuPlayer.js'; const urlOfClient = 'https://cdn.jsdelivr.net/gh/ZBpine/bilibili-danmaku-download/tampermonkey/BiliClientGM.js'; const { BiliDanmakuPlayer } = await import(urlOfPlayer); const { BiliClientGM } = await import(urlOfClient); const dmPanel = new DanmakuControlPanel(); const dmStore = { key: 'dm-player', getConfig() { return JSON.parse(localStorage.getItem(this.key) || '{}'); }, setConfig(obj) { localStorage.setItem(this.key, JSON.stringify(obj)); }, get(key, def) { const cfg = this.getConfig(); return key.split('.').reduce((o, k) => (o || {})[k], cfg) ?? def; }, set(key, value) { const cfg = this.getConfig(); const keys = key.split('.'); let obj = cfg; for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] = obj[keys[i]] || {}; obj = obj[keys[i]]; } obj[keys.at(-1)] = value; this.setConfig(cfg); }, removeCache(videoId) { const cfg = this.getConfig(); if (cfg.cache) delete cfg.cache[videoId]; this.setConfig(cfg); }, getCache(videoId) { return this.getConfig().cache?.[videoId]; }, setCache(videoId, json) { const cfg = this.getConfig(); cfg.cache = cfg.cache || {}; cfg.cache[videoId] = json; this.setConfig(cfg); }, clearAllCache() { const cfg = this.getConfig(); delete cfg.cache; this.setConfig(cfg); } }; const client = new BiliClientGM(GM_xmlhttpRequest); await client.init(); /* * chromium的浏览器会自动关闭AdblockPlus拦截Youtube的广告 * 于是AdblockPlus推出了实验性广告拦截 * 方法是隐藏原本的视频,插入一个可以阻拦广告的iframe视频 * 以下为解决办法 */ function transformIframeDOMAdapter(domAdapter) { if (!domAdapter) return; if (unsafeWindow.iframePlayer) { domAdapter.backup ??= { getCurrentTime: domAdapter.getCurrentTime, getPlaybackRate: domAdapter.getPlaybackRate, getVideoWrapper: domAdapter.getVideoWrapper, bindVideoEvent: domAdapter.bindVideoEvent }; domAdapter.getCurrentTime = function () { const player = unsafeWindow.iframePlayer; return player.getCurrentTime?.() ?? player.playerInfo?.currentTime ?? 0; } domAdapter.getPlaybackRate = function () { const player = unsafeWindow.iframePlayer; return player.getPlaybackRate?.() ?? player.playerInfo?.playbackRate ?? 1; } domAdapter.getVideoWrapper = function () { const iframe = document.querySelector('iframe#yt-haven-embed-player'); return iframe.parentElement; } domAdapter.bindVideoEvent = function () { domAdapter._resizeObserver = new ResizeObserver(() => { domAdapter.player.resize?.(); }); const iframe = document.querySelector('iframe#yt-haven-embed-player'); domAdapter._resizeObserver.observe(iframe); if (domAdapter._isIframeBound) return; domAdapter._isIframeBound = true; unsafeWindow.iframePlayer.addEventListener('onStateChange', (event) => { const state = event.data; const PlayerState = unsafeWindow.YT.PlayerState; console.log('[iframe 播放器] 播放状态改变', Object.keys(PlayerState).find(k => PlayerState[k] === state) || state); if (state === PlayerState.PLAYING) { domAdapter.player.play?.(); } else if (state === PlayerState.PAUSED) { domAdapter.player.pause?.(); } else if (state === PlayerState.CUED) { domAdapter.player.resize?.(); } }); } } else { if (domAdapter.backup) { Object.assign(domAdapter, domAdapter.backup); } delete domAdapter._isIframeBound; } } function observeIframePlayer() { let player = null; const setupPlayer = async (iframe) => { if (!iframe || typeof unsafeWindow.YT?.Player !== 'function') return; if (iframe.dataset.ytBound) return; iframe.dataset.ytBound = '1'; console.log('[iframe 播放器] 尝试绑定'); player = new unsafeWindow.YT.Player(iframe, { events: { onReady: () => { try { unsafeWindow.iframePlayer = player; if (dmPanel.dmPlayer) { transformIframeDOMAdapter(dmPanel.dmPlayer.domAdapter); dmPanel.dmPlayer.init?.(); dmPanel.dmPlayer.paused = false; } console.log('[iframe 播放器] 已绑定'); } catch (e) { console.error('[iframe 播放器] 绑定失败', e); } } } }); }; const destroyPlayer = () => { if (player && typeof player.destroy === 'function') { player.destroy(); } unsafeWindow.iframePlayer = null; player = null; transformIframeDOMAdapter(dmPanel.dmPlayer?.domAdapter); dmPanel.dmPlayer?.init?.(); console.log('[iframe 播放器] 被移除'); }; const observer = new MutationObserver(() => { const iframe = document.querySelector('iframe#yt-haven-embed-player'); if (iframe && iframe !== unsafeWindow.iframePlayer?.getIframe()) { setupPlayer(iframe); } else if (!iframe && unsafeWindow.iframePlayer) { destroyPlayer(); } }); observer.observe(document.body, { childList: true, subtree: true }); // 初始检查 const existing = document.querySelector('iframe#yt-haven-embed-player'); if (existing) setupPlayer(existing); } function loadYouTubeIframeAPI(callback) { if (unsafeWindow.YT && typeof unsafeWindow.YT.Player === 'function') { callback?.(); return; } unsafeWindow.onYouTubeIframeAPIReady = () => { if (unsafeWindow.YT && typeof unsafeWindow.YT.Player === 'function') { console.log('[YT] Iframe API loaded'); callback?.(); } else { console.warn('[YT] Iframe API load failure'); } }; let scriptUrl = 'https://www.youtube.com/iframe_api'; try { // 创建 Trusted Types 策略 const policy = window.trustedTypes?.createPolicy?.('youtube-api-policy', { createScriptURL: (url) => url }); if (policy) { scriptUrl = policy.createScriptURL(scriptUrl); } } catch (e) { console.warn('[YT] Trusted Types policy creation failed:', e); } const tag = document.createElement('script'); tag.src = scriptUrl; tag.id = 'iframe-api-script'; tag.async = true; // 插入 script 标签 const firstScriptTag = document.getElementsByTagName('script')[0]; if (firstScriptTag) { firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); } else { document.head.appendChild(tag); } } if (!isBilibili) loadYouTubeIframeAPI(() => { observeIframePlayer() }); try { await waitForVideo(); observeVideoChange(); } catch (err) { console.warn(err); } })();