您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
在 YouTube 视频上显示 B站视频弹幕 [ 油管 | Bilibili | 弹幕]
// ==UserScript== // @name YouTube B站弹幕播放器 // @namespace https://github.com/ZBpine/bilibili-danmaku-download/ // @version 1.6.2 // @description 在 YouTube 视频上显示 B站视频弹幕 [ 油管 | Bilibili | 弹幕] // @author ZBpine // @match https://www.youtube.com/* // @match https://www.bilibili.com/* // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @connect api.bilibili.com // @license MIT // @run-at document-end // ==/UserScript== (async () => { 'use strict'; if (window.top !== window.self) { console.warn('不是顶层窗口,跳过弹幕播放器'); // console.log(window.top, window.self); return; } class DanmakuControlPanel { constructor(dmPlayer, BiliDataManager) { this.panelId = 'dmplayer-ctl-panel'; this.isBilibili = location.hostname.includes('bilibili.com'); this.dmPlayer = dmPlayer; this.BiliDataManager = class extends BiliDataManager { constructor() { super(); this.alignData = []; this.dmList = []; } getDanmakuData() { this.applyAlignment(); return this.dmList; } applyAlignment() { const danmakus = this.data.danmakuData; if (!danmakus?.length) return; const alignments = this.alignData.slice().sort((a, b) => (a.source?.start || 0) - (b.source?.start || 0)); const newDanmakus = []; let lastSEnd = 0; let lastTEnd = 0; for (let i = 0; i <= alignments.length; i++) { const align = alignments[i]; if (!align) continue; const { source, target, mode, comment } = align; const sStart = source.start; const sEnd = source.end; const tStart = target.start; const tEnd = target.end; const sDuration = sEnd - sStart; const tDuration = tEnd - tStart; for (const d of danmakus) { const time = d.progress; if (time >= lastSEnd && time < sStart) { const newTime = time - lastSEnd + lastTEnd; newDanmakus.push({ ...d, progress: Math.round(newTime) }); } else if (time >= sStart && time < sEnd) { let newTime = null; if (mode === 'map') { const ratio = (time - sStart) / sDuration; newTime = tStart + ratio * tDuration; } else { newTime = time - sStart + tStart; if (newTime < tStart || newTime >= tEnd) continue; } newDanmakus.push({ ...d, progress: Math.round(newTime) }); } } if (comment) { const commentId = Date.now() * 1000 + i newDanmakus.push({ content: `${comment}`, progress: Math.round(tStart), type: 'mark', duration: tEnd - tStart, fontsize: 32, color: 0xffffff, ctime: Math.floor(Date.now() / 1000), pool: 0, midHash: 'system', id: commentId, idStr: String(commentId), weight: 10 }); } lastSEnd = sEnd; lastTEnd = tEnd; } for (const d of danmakus) { const time = d.progress; if (time >= lastSEnd) { const newTime = time - lastSEnd + lastTEnd; newDanmakus.push({ ...d, progress: Math.round(newTime) }); } } this.dmList = newDanmakus; } }; this.dmStore = { key: 'dm-player', GMCache: GM_getValue('cache', {}), 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); }, cache: { get: (id) => { return this.dmStore.GMCache?.[id]; }, set: (id, data) => { this.dmStore.GMCache[id] = data; GM_setValue('cache', this.dmStore.GMCache); }, remove: (id) => { if (this.dmStore.GMCache) delete this.dmStore.GMCache[id]; GM_setValue('cache', this.dmStore.GMCache); }, list: () => { return Object.entries(this.dmStore.GMCache); }, clear: () => { this.dmStore.GMCache = {}; GM_setValue('cache', this.dmStore.GMCache); } }, binded: { get: (id) => { return this.dmStore.getConfig().binded?.[id]; }, set: (id, data) => { const cfg = this.dmStore.getConfig(); cfg.binded = cfg.binded || {}; cfg.binded[id] = data; this.dmStore.setConfig(cfg); }, remove: (id) => { const cfg = this.dmStore.getConfig(); if (cfg.binded) delete cfg.binded[id]; this.dmStore.setConfig(cfg); }, list: () => { const binded = this.dmStore.getConfig().binded || {}; return Object.entries(binded); }, clear: () => { const cfg = this.dmStore.getConfig(); delete cfg.binded; this.dmStore.setConfig(cfg); } } }; this.dmPlayer.domAdapter.injectStyle('dmplayer-danmaku-mark', ` @keyframes dmplayer-animate-mark { 0% { opacity: 0; } 5% { opacity: 0.6; } 95% { opacity: 0.4; } 100% { opacity: 0; } } .dmplayer-danmaku-mark { left: 10px; top: 10px; animation-name: dmplayer-animate-mark; animation-timing-function: cubic-bezier(0,1,1,0) !important; }` ); this.autoBind = this.dmStore.get('autoBind', true); this.videoId = null; this.data = {}; } 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; const key = e.key.toLowerCase(); if (key === 'd') { if (this.toggleBtn) { this.toggleBtn.click(); } } else if (key === 's') { if (this.searchBtn) { this.searchBtn.click(); } } }); } getCurrentInfo() { let id, url, title; if (this.isBilibili) { const idObj = this.BiliDataManager.parseUrl(location.href); id = idObj.id; if (idObj.url) url = idObj.url; else { url = 'https://www.bilibili.com/' if (id.startsWith('BV')) url += 'video/' + id; else if (id.startsWith('ep')) url += 'bangumi/play/' + id; } title = document.title.replace(/[-_–—|]+.*?(bilibili|哔哩哔哩).*/gi, '').trim(); } else { id = new URLSearchParams(location.search).get('v'); url = 'https://www.youtube.com/watch?v=' + id; title = document.title.replace(' - YouTube', '').trim(); } return { id, url, title }; } observeVideoChange() { let href = null; const updateVideoId = () => { if (location.href === href) return; href = location.href; const newId = this.getCurrentInfo().id; if (newId && newId !== this.videoId) { console.log(`[🎬 检测到视频变化] ${this.videoId} → ${newId}`); this.dmPlayer.clear(); setTimeout(() => this.update(newId), 100); } } const observer = new MutationObserver(updateVideoId); observer.observe(document.body, { childList: true, subtree: true }); updateVideoId(); } showTip(message, { duration = 3000 } = {}) { const dark = !this.isBilibili; 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); } logError(desc, err) { this.showTip(desc + ':' + err.message); this.dmPlayer.logTagError(desc, err); } init() { if (document.getElementById(this.panelId)) return; this.dmPlayer.setOptions(this.dmStore.get('settings', {})); const panel = document.createElement('div'); panel.id = this.panelId; Object.assign(panel.style, { position: 'fixed', left: '0px', bottom: '40px', transform: 'translateX(calc(-100% + 20px))', zIndex: 10000, transition: 'transform 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', gridAutoFlow: 'column', gridAutoColumns: '36px', gridTemplateRows: '36px 36px', gap: '6px' }); if (this.isBilibili) { panel.style.background = '#ccc'; } panel.addEventListener('mouseenter', () => { panel.style.transform = 'translateX(0)'; panel.style.opacity = '1'; }); panel.addEventListener('mouseleave', () => { panel.style.transform = 'translateX(calc(-100% + 20px))'; panel.style.opacity = '0.2'; }); const createPanelButton = (label, title, onclick) => { const btn = document.createElement('button'); btn.textContent = label; btn.title = title; Object.assign(btn.style, { padding: '6px', background: this.isBilibili ? '#eee' : '#555', color: this.isBilibili ? 'black' : 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', width: '100%' }); btn.onclick = onclick; panel.appendChild(btn); return btn; } this.searchBtn = createPanelButton('🔍', '搜索弹幕', () => this.showSearchPanel()); this.bindBtn = createPanelButton('🔗', '绑定视频', () => this.bindVideoID()); this.loadBtn = createPanelButton('📂', '载入文件', () => this.fileInput.click()); this.saveBtn = createPanelButton('💾', '保存弹幕', () => this.cacheData()); this.toggleBtn = createPanelButton('✅', '开关弹幕', () => { this.dmPlayer.toggle(); this.toggleBtn.textContent = this.dmPlayer.showing ? '✅' : '🚫'; }); this.configBtn = createPanelButton('⚙️', '打开设置', () => this.showConfigPanel()); const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json,.xml'; fileInput.style.display = 'none'; fileInput.id = 'dm-input-file'; fileInput.onchange = (e) => { const file = e.target.files[0]; fileInput.value = ''; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const text = e.target.result; const data = new this.BiliDataManager(); let load = {}; if (text.startsWith('<')) { // XML 文件 load.danmakuData = this.BiliDataManager.parseDanmakuXml(text); } else { const json = JSON.parse(text); if (Array.isArray(json)) { // 纯弹幕数组 load.danmakuData = json; } else if (json.danmakuData) { // 完整结构 Object.assign(load, json); } else { throw new Error('不支持的 JSON 格式'); } } if (!load.danmakuData?.length) throw new Error('弹幕数据为空'); const current = this.getCurrentInfo(); load.id ??= current.id; load.url ??= current.url; load.title ??= current.title; data.source = 'local'; data.setData(load); this.loadDanmakuSuccess(data); } catch (err) { this.logError('❌ 加载失败', err); } }; reader.readAsText(file); }; this.fileInput = fileInput; document.body.appendChild(fileInput); document.body.appendChild(panel); this.bindHotkey(); this.dmPlayer.init(); } update(videoId) { if (!videoId) return; this.videoId = videoId; this.dmPlayer.update(); this.dmPlayer.logTag(`当前视频:${videoId}`); this.bindVideoID(false); const data = this.data[videoId]; if (data?.dmList?.length) { this.dmPlayer.load(data.dmList); return; } const bindInfo = this.dmStore.binded.get(videoId); if (bindInfo) this.loadData(bindInfo, true); } loadDanmakuSuccess(data) { this.data[this.videoId] = data; this.dmPlayer.load(data.getDanmakuData()); const info = data.info; const title = info?.title || '(未知标题)'; const time = info?.fetchtime ? new Date(info.fetchtime * 1000).toLocaleString('zh-CN', { hour12: false }) : '(未知)'; this.showTip(`🎉 成功载入${data.source}数据:\n🎬 ${title}\n💬 共 ${data.dmCount} 条弹幕\n🕒 抓取时间:${time}`); } async loadData({ source, target }, binded = false) { try { const id = source.id; if (!id) return; const data = new this.BiliDataManager(); const excuteBind = () => { if (binded) { data.binded = true; this.bindVideoID(false); } else { if (this.autoBind) this.bindVideoID(true, true); } }; const from = source.from; data.source = from; if (target) Object.assign(data, target); switch (from) { case 'cache': const cache = await this.dmStore.cache.get(id); if (cache?.data) { data.setData(cache.data); this.loadDanmakuSuccess(data); excuteBind(); } else { this.showTip('⚠ 缓存数据不存在'); } break; case 'server': const server = this.dmStore.get('server'); if (server) { const idObj = this.BiliDataManager.parseUrl(id); delete idObj.id; const params = new URLSearchParams(idObj); try { const res = await fetch(`${server}/video?${params.toString()}`); const json = await res.json(); data.setData(json); this.loadDanmakuSuccess(data); excuteBind(); } catch (err) { this.logError('❌ 请检查服务器', err); } } break; default: await data.getData(id); await data.getDanmakuXml(); this.loadDanmakuSuccess(data); excuteBind(); const newDm = await data.getDanmakuPb(); if (newDm > 0) this.loadDanmakuSuccess(data); break; } } catch (err) { this.logError('❌ 弹幕数据加载失败', err); } } cacheData() { const data = this.data[this.videoId]; if (!data) { this.showTip('⚠ 未有弹幕数据'); return; } const id = data.info?.id; if (!id) { this.showTip('⚠ 未知弹幕数据'); return; } this.dmStore.cache.set(id, { info: data.info, data: data.data }); data.source = 'cache'; this.bindVideoID(true, true); this.showTip('✅ 弹幕数据已缓存'); } bindVideoID(toggle = true, force = false) { const data = this.data[this.videoId]; if (toggle) { if (!data) { this.showTip('⚠ 未有弹幕数据'); return; } data.binded = !data.binded; if (force) data.binded = true; if (data.binded) { try { const info = data.info; const current = this.getCurrentInfo(); const bindData = { source: { id: info.id, url: info.url, title: info.title + (info.subtitle ? ` ${info.subtitle}` : ''), from: data.source }, target: { id: current.id, url: current.url, title: current.title, alignData: data.alignData } }; this.dmStore.binded.set(this.videoId, bindData); } catch (err) { this.logError('❌ 绑定视频失败', err); } } else { this.dmStore.binded.remove(this.videoId); } } if (data?.binded) { this.bindBtn.textContent = '🗑️'; this.bindBtn.title = '取消绑定'; } else { this.bindBtn.textContent = '🔗'; this.bindBtn.title = '绑定视频'; } } showSearchPanel() { const { panel, overlay } = this.showPanel(); const initialKeyword = this.getCurrentInfo().title; const titleEl = document.createElement('div'); titleEl.textContent = '选择一个视频以载入弹幕:'; titleEl.style.fontWeight = 'bold'; titleEl.style.fontSize = '16px'; const input = document.createElement('input'); Object.assign(input.style, { padding: '6px 10px', fontSize: '14px', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' }); input.type = 'text'; input.value = initialKeyword; const resultsBox = document.createElement('div'); resultsBox.style.display = 'flex'; resultsBox.style.flexDirection = 'column'; resultsBox.style.gap = '6px'; 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 similar = (a, b) => { const m = a.length; const n = b.length; const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } const lcs = dp[m][n]; return (2 * lcs) / (m + n); }; const searchingLabel = document.createElement('div'); searchingLabel.textContent = '🔍 搜索中...'; const renderResults = (keyword) => { if (keyword.startsWith('url=')) { const { id } = this.BiliDataManager.parseUrl(keyword.substring(4)); if (id) { this.loadData({ source: { id, from: 'bilibili' } }); overlay.remove(); } else { resultsBox.textContent = '❌ 无效的链接'; } return; } resultsBox.textContent = ''; resultsBox.appendChild(searchingLabel); const renderGroup = (titleText, groupList, source = 'bilibili') => { if (searchingLabel.isConnected) searchingLabel.remove(); 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 = item.url; 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 = () => { overlay.remove(); this.loadData({ source: { id: item.id, from: source } }); }; row.appendChild(titleLine); row.appendChild(infoLine); resultsBox.appendChild(row); }); }; try { let asyncFinish = 0; let asyncTotal = 2; // ➤ 缓存 const cacheList = []; this.dmStore.cache.list().forEach(([_, data]) => { const info = data?.info; if (!info?.title) return; const title = info.title + (info.subtitle ? ` ${info.subtitle}` : ''); const similarity = similar(title, keyword); if (similarity > 0.3) { cacheList.push({ similarity, id: info.id, title, author: info.owner?.name, play: info.stat?.view, video_review: info.stat?.danmaku, duration: info.duration, url: info.url }); } }); cacheList.sort((a, b) => b.similarity - a.similarity); if (cacheList.length) renderGroup('📦 本地缓存', cacheList, 'cache'); // ➤ 服务器 const server = this.dmStore.get('server'); if (server) { try { fetch(`${server}/search?keyword=${encodeURIComponent(keyword)}&type=video`) .then(res => res.json()).then(list => { list.forEach(item => { item.id = item.bvid; item.url = 'https://www.bilibili.com/video/' + item.bvid; }); asyncFinish++; if (list.length) renderGroup('🌐 服务器数据:', list, 'server'); else { if (asyncFinish === asyncTotal) { resultsBox.textContent = '❌ 没有找到相关视频' } } }); } catch (e) { this.showTip('⚠ 请检查服务器是否正确'); console.warn('❌ 远程搜索失败:', e); } } else { asyncTotal--; } // ➤ B站 this.BiliDataManager.api.searchVideo(keyword).then(list => { list.forEach(item => { item.id = item.bvid; item.url = 'https://www.bilibili.com/video/' + item.bvid; }); asyncFinish++; if (list.length) renderGroup('📺 B站视频:', list, 'bilibili'); else { if (asyncFinish === asyncTotal) { resultsBox.textContent = '❌ 没有找到相关视频' } } }); } 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); renderResults(initialKeyword); } showConfigPanel() { const { panel, overlay } = this.showPanel(); const title = document.createElement('div'); title.textContent = '⚙️ 设置'; title.style.fontSize = '18px'; title.style.fontWeight = 'bold'; panel.appendChild(title); // 标签按钮容器 const tabButtons = document.createElement('div'); Object.assign(tabButtons.style, { display: 'flex', gap: '6px', borderBottom: '1px solid #ccc', margin: '10px 0' }); panel.appendChild(tabButtons); // 页面内容容器 const tabContent = document.createElement('div'); tabContent.style.marginBottom = '20px'; panel.appendChild(tabContent); // 标签切换函数 const tabPages = {}; const switchTab = (tabName) => { for (const [name, page] of Object.entries(tabPages)) { page.style.display = (name === tabName) ? 'flex' : 'none'; } for (const btn of tabButtons.children) { btn.style.fontWeight = (btn.dataset.tab === tabName) ? 'bold' : 'normal'; btn.style.borderBottom = (btn.dataset.tab === tabName) ? '2px solid #0077cc' : 'none'; } }; const createTab = (name, labelText, createContent) => { const btn = document.createElement('button'); btn.textContent = labelText; btn.dataset.tab = name; Object.assign(btn.style, { background: 'none', border: 'none', padding: '8px 12px', cursor: 'pointer', fontSize: '14px' }); btn.onclick = () => switchTab(name); tabButtons.appendChild(btn); const page = document.createElement('div'); page.style.display = 'none'; page.style.flexDirection = 'column'; page.style.gap = '6px'; tabPages[name] = page; tabContent.appendChild(page); createContent(page); return page; }; const createLabeledButtonRow = (labelText, buttonObj) => { const row = document.createElement('div'); row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.alignItems = 'center'; 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 (!buttonObj) return row const button = document.createElement('button'); Object.assign(button, buttonObj); Object.assign(button.style, { width: '130px', height: '28px', fontSize: '14px', border: '1px solid #ccc', borderRadius: '4px', background: '#f0f0f0', cursor: 'pointer', flexShrink: '0' }); row.appendChild(button); return row; }; const createSelect = (list, getName = n => n) => { const select = document.createElement('select'); list.forEach(n => { const option = document.createElement('option'); option.value = String(n); option.textContent = String(getName(n)); select.appendChild(option); }) return select; }; createTab('display', '📺 弹幕显示', (page) => { 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 = this.dmStore.get(keyPath, this.dmPlayer.options[key].value); Object.assign(input.style, { width: '20px', height: '20px', cursor: 'pointer' }); input.onchange = () => { const val = input.checked; this.dmStore.set(keyPath, val); this.dmPlayer.setOptions(val, key); this.showTip(`✅ 已保存 ${labelText}:${val ? '开启' : '关闭'}`); }; controlRow.append(input); } else if (options.type === 'number') { input.value = this.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) { this.dmStore.set(keyPath, val); this.dmPlayer.setOptions(val, key); this.showTip(`✅ 已保存 ${labelText}:${val}`); } else { this.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; }; page.appendChild(createLabeledButtonRow('📺 弹幕显示设置', { textContent: '👁️ 预览', onmousedown: () => overlay.style.opacity = '0', onmouseup: () => overlay.style.opacity = '1', onmouseleave: () => overlay.style.opacity = '1' })); page.appendChild(createContralRow( '🌫️ 不透明度', 'opacity', { type: 'number', min: 0.1, max: 1.0, step: 0.1 }, '设置弹幕透明度(0.1 ~ 1.0)越小越透明' )); page.appendChild(createContralRow( '📐 显示区域', 'displayArea', { type: 'number', min: 0.1, max: 1.0, step: 0.1 }, '允许弹幕占屏幕高度范围,1.0 全屏' )); page.appendChild(createContralRow( '🚀 弹幕速度', 'speed', { type: 'number', min: 3, max: 9, step: 1 }, '影响弹幕持续时间以及滚动弹幕的速度' )); page.appendChild(createContralRow( '⏩ 同步倍速', 'syncRate', { type: 'checkbox' }, '弹幕速度同步视频播放倍速' )); page.appendChild(createContralRow( '🔁 合并重复', 'mergeRepeats', { type: 'checkbox' }, '是否合并内容相同且时间接近的弹幕' )); page.appendChild(createContralRow( '🔀 允许重叠', 'overlap', { type: 'checkbox' }, '开启则允许弹幕重叠,否则丢弃会重叠的弹幕' )); // --- 弹幕阴影设置模块 --- let shadowConfig = this.dmPlayer.options?.shadowEffect?.value || [{ type: 0, offset: 1, radius: 1, repeat: 1 }]; const shadowHeader = createLabeledButtonRow('🌑 弹幕阴影设置', { textContent: '💾 保存', onclick: () => { this.dmStore.set('settings.shadowEffect', shadowConfig); this.dmPlayer.setOptions(shadowConfig, 'shadowEffect'); } }); page.appendChild(shadowHeader); // 预设选择 const presetSelect = createSelect(['重墨', '描边', '45°投影', '自定义']); Object.assign(presetSelect.style, { fontSize: '14px', padding: '4px 8px' }); page.appendChild(presetSelect); // 默认配置项 const presets = { '重墨': [{ type: 0, offset: 1, radius: 1, repeat: 1 }], '描边': [{ type: 1, offset: 0, radius: 1, repeat: 3 }], '45°投影': [ { type: 1, offset: 0, radius: 1, repeat: 1 }, { type: 2, offset: 1, radius: 2, repeat: 1 } ] }; const formArea = document.createElement('div'); page.appendChild(formArea); const addBtn = document.createElement('button'); addBtn.textContent = '➕ 添加阴影项'; Object.assign(addBtn.style, { width: '120px', padding: '4px', cursor: 'pointer' }); page.appendChild(addBtn); const label = (text) => { const span = document.createElement('span'); span.textContent = text; span.style.fontWeight = 'bold'; return span; }; const renderConfigItems = (configList) => { formArea.replaceChildren(); configList.forEach((cfg, index) => { const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '4px', border: '1px solid #ccc' }); const range = (start, end) => Array.from({ length: end - start + 1 }, (_, i) => start + i); const typeSel = createSelect(range(0, 2), n => ['重墨', '描边', '45°投影'][n]); typeSel.value = String(cfg.type); typeSel.onchange = () => configList[index].type = parseInt(typeSel.value); const offsetSel = createSelect(range(-1, 10), n => n === -1 ? '递增' : `${n}px`); offsetSel.value = String(cfg.offset); offsetSel.onchange = () => configList[index].offset = parseInt(offsetSel.value); const radiusSel = createSelect(range(-1, 10), n => n === -1 ? '递增' : `${n}px`); radiusSel.value = String(cfg.radius); radiusSel.onchange = () => configList[index].radius = parseInt(radiusSel.value); const repeatSel = createSelect(range(1, 10)); repeatSel.value = String(cfg.repeat || 1); repeatSel.onchange = () => configList[index].repeat = parseInt(repeatSel.value); const del = document.createElement('button'); del.textContent = '删除'; del.onclick = () => { configList.splice(index, 1); renderConfigItems(configList); }; row.append( label('类型:'), typeSel, label('偏移:'), offsetSel, label('半径:'), radiusSel, label('重复:'), repeatSel, del ); formArea.appendChild(row); }); }; addBtn.onclick = () => { shadowConfig.push({ type: 0, offset: 1, radius: 1, repeat: 1 }); renderConfigItems(shadowConfig); }; presetSelect.onchange = () => { const val = presetSelect.value; if (val === '自定义') { renderConfigItems(shadowConfig); addBtn.style.display = ''; } else { shadowConfig = JSON.parse(JSON.stringify(presets[val])); // 深拷贝 renderConfigItems([]); addBtn.style.display = 'none'; } }; // 自动判断并选中 preset let matchedPreset = '自定义'; // 默认自定义 if (Array.isArray(shadowConfig)) { for (const key of Object.keys(presets)) { const preset = presets[key]; const same = preset.length === shadowConfig.length && preset.every((item, i) => item.type === shadowConfig[i].type && item.offset === shadowConfig[i].offset && item.radius === shadowConfig[i].radius && item.repeat === shadowConfig[i].repeat ); if (same) { matchedPreset = key; break; } } } presetSelect.value = matchedPreset; presetSelect.onchange(); }); createTab('cache', '📦 缓存管理', (page) => { const autoBindRow = document.createElement('div'); Object.assign(autoBindRow.style, { display: 'flex', height: '36px', alignItems: 'center', flexDirection: 'row', gap: '18px' }); const label = document.createElement('div'); label.textContent = '自动绑定视频(载入/缓存数据时)'; label.style.fontWeight = 'bold'; autoBindRow.append(label); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = this.dmStore.get('autoBind', true); Object.assign(input.style, { width: '20px', height: '20px', cursor: 'pointer' }); input.onchange = () => this.dmStore.set('autoBind', input.checked); autoBindRow.append(input); page.append(autoBindRow); const createLabel = (info, name, width) => { const label = document.createElement('div'); if (!info) { label.textContent = '❌ 未知' + name; return label; } label.title = info.title; const idLine = document.createElement('a'); idLine.textContent = `${name} [▶️ ${info.id}]`; idLine.href = info.url; idLine.target = '_blank'; Object.assign(idLine.style, { fontSize: '13px', color: '#1a73e8', textDecoration: 'none', marginBottom: '2px', whiteSpace: 'nowrap' }); const titleLine = document.createElement('div'); titleLine.textContent = info.title; Object.assign(titleLine.style, { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width, }); label.append(idLine, titleLine); return label; }; const createButton = (text, onclick) => { const btn = document.createElement('button'); btn.textContent = text; Object.assign(btn.style, { width: '60px', cursor: 'pointer' }); btn.onclick = onclick; return btn; }; const createList = (name, manager, handleRow) => { const listBox = document.createElement('div'); Object.assign(listBox.style, { display: 'flex', flexDirection: 'column', gap: '8px' }); const header = createLabeledButtonRow(`📦 ${name}`, { textContent: `🧹 清空${name}`, onclick: () => { if (confirm(`确定要清空所有${name}吗?`)) { manager.clear(); listBox.textContent = `📭 所有${name}已清除`; this.showTip(`🧹 所有${name}已清空`); } } }); page.append(header, listBox); const list = manager.list(); if (list.length === 0) { listBox.textContent = `📭 当前没有${name}`; return; } list.forEach(([id, item]) => { try { const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }); handleRow({ row, item }); const delBtn = createButton('🗑 删除', () => { manager.remove(id); row.remove(); this.showTip(`🗑 已删除${name}:${id}`); }); row.appendChild(delBtn); listBox.appendChild(row); } catch (err) { this.dmPlayer.logTagError(err); } }) }; createList('绑定视频', this.dmStore.binded, ({ row, item }) => { const { source, target } = item; const srcLabel = createLabel(source, source.from, '190px'); const trgLabel = createLabel(target, '当前', '190px'); const bindLabel = document.createElement('div'); bindLabel.textContent = '<绑定>'; bindLabel.style.color = 'gray'; row.append(trgLabel, bindLabel, srcLabel); }); createList('缓存弹幕', this.dmStore.cache, ({ row, item }) => { const info = item.info; const label = createLabel(info, '缓存', '360px'); const saveBtn = createButton('下载', () => { const json = JSON.stringify(item.data); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = info.id.replace(/[\\/:*?"<>|]/g, '_') + '.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); row.append(label, saveBtn); }); }); createTab('server', '🌐 服务器设置', (page) => { const serverHeader = createLabeledButtonRow('🌐 服务器地址:', { textContent: '💾 保存', onclick: () => { this.dmStore.set('server', serverInput.value.trim()); this.showTip('✅ 地址已保存'); } }); const serverInput = document.createElement('input'); Object.assign(serverInput.style, { padding: '6px 10px', fontSize: '14px', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' }); serverInput.value = this.dmStore.get('server', ''); page.appendChild(serverHeader); page.appendChild(serverInput); }); createTab('alignment', '🎯 视频对齐', (page) => { let alignData = this.data[this.videoId]?.alignData || []; const parseTimeToMs = (text) => { if (!text.includes(':')) return 0; const [min, sec] = text.trim().split(':'); return Math.round((parseInt(min) * 60 + parseFloat(sec)) * 1000); }; const formatMsToTime = (ms) => { const min = Math.floor(ms / 60000); const sec = (ms % 60000) / 1000; return `${min}:${sec}`; }; const container = document.createElement('div'); container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '8px'; const render = () => { container.replaceChildren(); alignData.forEach((entry, index) => { const row = document.createElement('div'); row.style.display = 'flex'; row.style.flexDirection = 'column'; row.style.border = '1px solid #ccc'; row.style.padding = '10px'; row.style.gap = '6px'; const createRow = (widgets) => { const wrapper = document.createElement('div'); wrapper.style.display = 'flex'; wrapper.style.alignItems = 'center'; wrapper.style.gap = '6px'; widgets.forEach(widget => wrapper.appendChild(widget)); return wrapper; }; const createInput = (placeholder, value, width, onInput) => { const input = document.createElement('input'); input.placeholder = placeholder; input.value = value; input.style.width = width + 'px'; input.onchange = () => onInput(input.value); return input; }; // 源视频输入 const sourceStart = createInput('开始时间', formatMsToTime(entry.source?.start || 0), 80, val => { entry.source = entry.source || {}; entry.source.start = parseTimeToMs(val); }); const sourceEnd = createInput('结束时间', formatMsToTime(entry.source?.end || 0), 80, val => { entry.source = entry.source || {}; entry.source.end = parseTimeToMs(val); }); // 目标视频输入 const targetStart = createInput('开始时间', formatMsToTime(entry.target?.start || 0), 80, val => { entry.target = entry.target || {}; entry.target.start = parseTimeToMs(val); }); const targetEnd = createInput('结束时间', formatMsToTime(entry.target?.end || 0), 80, val => { entry.target = entry.target || {}; entry.target.end = parseTimeToMs(val); }); const modeSelect = createSelect(['map', 'shift'], opt => opt === 'map' ? '映射' : '顺移'); modeSelect.value = entry.mode || 'shift'; modeSelect.onchange = () => { entry.mode = modeSelect.value; }; const commentInput = createInput('附言', entry.comment || '', 200, val => { entry.comment = val; }); const delBtn = document.createElement('button'); delBtn.textContent = '🗑 删除'; delBtn.style.cursor = 'pointer'; delBtn.onclick = () => { alignData.splice(index, 1); render(); }; const cLabel = (text) => { const label = document.createElement('div'); label.textContent = text; return label; } row.appendChild(createRow([cLabel('原视频:'), sourceStart, cLabel('→'), sourceEnd])); row.appendChild(createRow([cLabel('现视频:'), targetStart, cLabel('→'), targetEnd])); row.appendChild(createRow([modeSelect, commentInput, delBtn])); container.appendChild(row); }); }; const createButton = (text, onclick) => { const Btn = document.createElement('button'); Btn.textContent = text; Object.assign(Btn.style, { width: '120px', padding: '4px', cursor: 'pointer' }); Btn.onclick = onclick; return Btn; }; const buttonRow = document.createElement('div'); buttonRow.style.display = 'flex'; buttonRow.style.justifyContent = 'space-between'; buttonRow.style.alignItems = 'center'; buttonRow.style.marginTop = '10px'; buttonRow.appendChild(createButton('➕ 添加对齐片段', () => { alignData.push({ source: { start: 0, end: 0 }, target: { start: 0, end: 0 }, mode: 'shift', comment: '' }); render(); })); buttonRow.appendChild(createButton('📋 粘贴设置', async () => { try { const text = await navigator.clipboard.readText(); const parsed = JSON.parse(text); if (!Array.isArray(parsed)) throw new Error('剪贴板内容不是有效的数组'); const isValid = parsed.every(item => item.source && item.target && item.mode ); if (!isValid) throw new Error('剪贴板内容不是有效的对齐数据'); alignData = parsed; render(); this.showTip('📋 成功粘贴对齐设置'); } catch (err) { this.logError('❌ 粘贴失败', err); } })); buttonRow.appendChild(createButton('📋 复制设置', () => { const json = JSON.stringify(alignData, null); navigator.clipboard.writeText(json).then(() => { this.showTip('✅ 已复制所有对齐设置'); }).catch(() => { this.showTip('❌ 复制失败'); }); })); buttonRow.appendChild(createButton('💾 保存', () => { const data = this.data[this.videoId] if (data) { data.alignData = alignData; if (data.binded) this.bindVideoID(true, true); this.dmPlayer.load(data.getDanmakuData()); this.showTip('✅ 对齐设置已保存'); } else { this.showTip('未有弹幕数据'); } })); render(); const desc = document.createElement('div'); Object.assign(desc.style, { fontSize: '13px', lineHeight: '1.6', background: '#f9f9f9', padding: '10px', border: '1px solid #ccc', borderRadius: '6px', marginBottom: '10px' }); const addLine = (text, isBold = false, isCode = false) => { const line = document.createElement('div'); if (isCode) { const code = document.createElement('code'); code.textContent = text; line.appendChild(code); } else { line.textContent = isBold ? `• ${text}` : text; if (isBold) line.style.fontWeight = 'bold'; } desc.appendChild(line); }; addLine('⚠️ 对齐设置说明:'); desc.appendChild(document.createElement('hr')); addLine('当原视频和新视频的时间段不一致(如删减/增加片段)时,可通过设置对齐项同步弹幕。'); addLine('映射:将原时间段线性映射到新时间段。', true); addLine('顺移:平移时间,超出新时间段的丢弃。', true); addLine('附言:可插入一条左上角弹幕提示观众。', true); addLine('时间格式为 分:秒 或 分:秒.毫秒', false); page.appendChild(desc); page.appendChild(container); page.appendChild(buttonRow); }); switchTab('display'); } showPanel() { const existing = document.getElementById('dmplayer-panel'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'dmplayer-panel'; Object.assign(overlay.style, { 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' }); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const panel = document.createElement('div'); Object.assign(panel.style, { 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' }); overlay.appendChild(panel); document.body.appendChild(overlay); return { panel, overlay }; } } let dmPanel; try { const path = 'https://cdn.jsdelivr.net/gh/ZBpine/[email protected]/tampermonkey/'; const { BiliDanmakuPlayer } = await import(path + 'BiliDanmakuPlayer.js'); const { createBiliDataManagerImport } = await import(path + 'BiliDataManager.js'); const dmPlayer = new BiliDanmakuPlayer(); const BiliDataManager = await createBiliDataManagerImport(GM_xmlhttpRequest, 'Danmaku Player'); dmPanel = new DanmakuControlPanel(dmPlayer, BiliDataManager); unsafeWindow.dmPlayerCtl = dmPanel; } catch (err) { console.error('加载失败:', err); } /* * chromium的浏览器会自动关闭AdblockPlus拦截Youtube的广告 * 于是AdblockPlus推出了实验性广告拦截 * 方法是隐藏原本的视频,插入一个可以阻拦广告的iframe视频 * https://developers.google.com/youtube/iframe_api_reference?hl=zh-tw * 以下为解决办法 */ function wrapYTPlayer(player) { const PlayerState = unsafeWindow.YT?.PlayerState; return { get currentTime() { return player.getCurrentTime?.() ?? player.playerInfo?.currentTime ?? 0; }, set currentTime(val) { player.seekTo?.(val, true); }, get duration() { return player.getDuration?.() ?? player.playerInfo?.duration ?? 0; }, get playbackRate() { return player.getPlaybackRate?.() ?? player.playerInfo?.playbackRate ?? 1; }, set playbackRate(val) { player.setPlaybackRate?.(val); }, get paused() { return (player.getPlayerState?.() ?? player.playerInfo?.playerState) === PlayerState?.PAUSED; }, get ended() { return (player.getPlayerState?.() ?? player.playerInfo?.playerState) === PlayerState?.ENDED; }, get volume() { return (player.getVolume?.() ?? player.playerInfo?.volume ?? 100) / 100; }, set volume(val) { player.setVolume?.(Math.max(0, Math.min(1, val)) * 100); }, get muted() { return player.isMuted?.() ?? player.playerInfo?.muted ?? false; }, set muted(val) { if (val) player.mute?.(); else player.unMute?.(); }, play() { player.playVideo?.(); }, pause() { player.pauseVideo?.(); } }; } function transformIframeDOMAdapter(domAdapter) { if (!domAdapter) return; if (unsafeWindow.iframePlayer) { domAdapter.backup ??= { getVideoWrapper: domAdapter.getVideoWrapper, bindVideoEvent: domAdapter.bindVideoEvent, videoGetter: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(domAdapter), 'video') }; 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?.(); } }); } const YTPlayerWrapper = wrapYTPlayer(unsafeWindow.iframePlayer); Object.defineProperty(domAdapter, 'video', { get() { return YTPlayerWrapper; }, configurable: true }); } else { if (domAdapter.backup) { Object.assign(domAdapter, domAdapter.backup); if (domAdapter.backup.videoGetter) { Object.defineProperty(domAdapter, 'video', domAdapter.backup.videoGetter); } } 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.update?.(); } 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?.update?.(); 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 (!dmPanel.isBilibili) loadYouTubeIframeAPI(() => { observeIframePlayer() }); async function waitForVideo(timeout = 10000) { const start = Date.now(); return new Promise((resolve, reject) => { const check = () => { const video = document.querySelector('video'); if (video) { console.log('🎥 检测到 <video> 元素'); resolve(video); } else if (Date.now() - start >= timeout) { reject(new Error('⏰ 超时:未检测到 <video> 元素')); } else { requestAnimationFrame(check); } }; check(); }); } try { await waitForVideo(); dmPanel.init(); dmPanel.observeVideoChange(); } catch (err) { console.warn(err); } })();