Greasy Fork is available in English.
在B站自动用语音读出弹幕内容(轮询版本)
// ==UserScript==
// @name 读弹幕 - B站弹幕语音阅读
// @namespace http://tampermonkey.net/
// @version 0.8.4
// @description 在B站自动用语音读出弹幕内容(轮询版本)
// @author Claude
// @license MIT
// @match https://www.bilibili.com/video/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
const CONFIG = {
enabled: true,
rate: 1,
pitch: 1,
volume: 1,
deduplicateTime: 500,
maxHistorySize: 100,
};
let lastSpokenTexts = {};
let spokenCount = 0;
let synth = window.speechSynthesis;
let processedTexts = new Set(); // 改为记录文本而不是元素引用
let speakQueue = [];
let isProcessingQueue = false;
// ============== 工具函数 ==============
function getDanmuElements() {
const selectors = [
'.bili-danmaku-x-dm',
'.bili-live-chat-item',
'.danmaku-item',
'.bili-danmaku-item',
'[class*="danmaku"]',
];
for (let selector of selectors) {
let elements = document.querySelectorAll(selector);
if (elements.length > 0) {
return elements;
}
}
return [];
}
function extractTextFromDanmu(element) {
if (!element) return '';
const textSelectors = [
'.bili-live-chat-item__content',
'.danmaku-content',
'.bili-danmaku-item__content',
'span',
];
for (let selector of textSelectors) {
let textEl = element.querySelector(selector);
if (textEl) {
return textEl.textContent.trim();
}
}
return element.textContent.trim();
}
function shouldSpeak(text) {
if (!text) return false;
const now = Date.now();
const keys = Object.keys(lastSpokenTexts);
if (keys.length > CONFIG.maxHistorySize) {
keys
.sort((a, b) => lastSpokenTexts[a] - lastSpokenTexts[b])
.slice(0, Math.floor(keys.length / 2))
.forEach(key => delete lastSpokenTexts[key]);
} else {
keys.forEach(key => {
if (now - lastSpokenTexts[key] > CONFIG.deduplicateTime) {
delete lastSpokenTexts[key];
}
});
}
if (lastSpokenTexts[text]) {
return false;
}
lastSpokenTexts[text] = now;
return true;
}
function addToQueue(text) {
if (!text || !CONFIG.enabled) return;
if (!shouldSpeak(text)) {
return;
}
speakQueue.push(text);
processQueue();
}
function processQueue() {
if (isProcessingQueue || speakQueue.length === 0) {
return;
}
if (synth.speaking) {
setTimeout(processQueue, 300);
return;
}
isProcessingQueue = true;
const text = speakQueue.shift();
try {
let utterance = new SpeechSynthesisUtterance(text);
utterance.rate = CONFIG.rate;
utterance.pitch = CONFIG.pitch;
utterance.volume = CONFIG.volume;
utterance.onstart = () => {
// console.log('[读弹幕] ▶ 开始:', text.substring(0, 20));
};
utterance.onend = () => {
spokenCount++;
isProcessingQueue = false;
setTimeout(processQueue, 50);
};
utterance.onerror = (event) => {
console.error('[读弹幕] 语音错误:', event.error);
isProcessingQueue = false;
setTimeout(processQueue, 100);
};
synth.cancel();
synth.speak(utterance);
} catch (e) {
console.error('[读弹幕] 播放失败:', e.message);
isProcessingQueue = false;
setTimeout(processQueue, 100);
}
}
/**
* 核心轮询函数 - 不依赖 MutationObserver
* 每次检查是否有新的未处理弹幕
* 改为用文本内容去重,防止弹幕元素删除后重复读
*/
function pollNewDanmu() {
try {
const allDanmu = getDanmuElements();
allDanmu.forEach(element => {
// 提取文本
let text = extractTextFromDanmu(element);
if (!text) return;
// 用文本去重,而不是元素引用
const textId = `${text}:${element.offsetHeight}:${element.offsetWidth}`; // 用内容+位置作为ID
if (processedTexts.has(textId)) {
return;
}
// 标记为已处理
processedTexts.add(textId);
// 加入队列
addToQueue(text);
});
} catch (e) {
console.error('[读弹幕] 轮询错误:', e.message);
}
}
function createControlPanel() {
let panel = document.createElement('div');
panel.id = 'duanmu-reader-panel';
panel.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: white;
min-width: 220px;
`;
let title = document.createElement('div');
title.style.cssText = `
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
cursor: move;
user-select: none;
`;
title.innerHTML = '🎤 读弹幕 v0.8.3';
// 添加拖拽功能
let isDragging = false;
let offsetX = 0, offsetY = 0;
title.addEventListener('mousedown', (e) => {
// 如果是点击标题本身(收起功能),需要检查是否真的是拖拽
isDragging = true;
offsetX = e.clientX - panel.getBoundingClientRect().left;
offsetY = e.clientY - panel.getBoundingClientRect().top;
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
panel.style.left = (e.clientX - offsetX) + 'px';
panel.style.top = (e.clientY - offsetY) + 'px';
panel.style.right = 'auto';
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
let toggleBtn = document.createElement('button');
toggleBtn.style.cssText = `
width: 100%;
padding: 6px 12px;
margin-bottom: 6px;
border: none;
border-radius: 6px;
background: ${CONFIG.enabled ? '#4ade80' : '#ef4444'};
color: white;
cursor: pointer;
font-size: 12px;
font-weight: bold;
transition: all 0.2s;
`;
toggleBtn.textContent = CONFIG.enabled ? '✓ 已启用' : '✗ 已禁用';
toggleBtn.onclick = () => {
CONFIG.enabled = !CONFIG.enabled;
toggleBtn.textContent = CONFIG.enabled ? '✓ 已启用' : '✗ 已禁用';
toggleBtn.style.background = CONFIG.enabled ? '#4ade80' : '#ef4444';
GM_setValue('duanmu_reader_enabled', CONFIG.enabled);
};
let statsDiv = document.createElement('div');
statsDiv.id = 'duanmu-stats';
statsDiv.style.cssText = `
font-size: 11px;
margin-top: 8px;
padding: 6px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
text-align: center;
line-height: 1.6;
`;
statsDiv.innerHTML = `✓已读: <span id="spoken-count">0</span><br/>⏳队列: <span id="queue-count">0</span><br/>📊页面: <span id="danmu-count">0</span>`;
// 语速调整
let rateLabel = document.createElement('div');
rateLabel.style.cssText = 'font-size: 12px; margin-top: 8px; margin-bottom: 4px; display: flex; justify-content: space-between;';
rateLabel.innerHTML = `<span>语速</span><span>${CONFIG.rate.toFixed(1)}x</span>`;
rateLabel.id = 'rate-label';
let rateSlider = document.createElement('input');
rateSlider.type = 'range';
rateSlider.min = '0.5';
rateSlider.max = '2';
rateSlider.step = '0.1';
rateSlider.value = CONFIG.rate;
rateSlider.style.cssText = `width: 100%; height: 4px; margin-bottom: 8px; cursor: pointer;`;
rateSlider.oninput = (e) => {
CONFIG.rate = parseFloat(e.target.value);
document.getElementById('rate-label').innerHTML = `<span>语速</span><span>${CONFIG.rate.toFixed(1)}x</span>`;
GM_setValue('duanmu_reader_rate', CONFIG.rate);
};
// 音量调整
let volumeLabel = document.createElement('div');
volumeLabel.style.cssText = 'font-size: 12px; margin-bottom: 4px; display: flex; justify-content: space-between;';
volumeLabel.innerHTML = `<span>音量</span><span>${Math.round(CONFIG.volume * 100)}%</span>`;
volumeLabel.id = 'volume-label';
let volumeSlider = document.createElement('input');
volumeSlider.type = 'range';
volumeSlider.min = '0';
volumeSlider.max = '1';
volumeSlider.step = '0.1';
volumeSlider.value = CONFIG.volume;
volumeSlider.style.cssText = `width: 100%; height: 4px; margin-bottom: 8px; cursor: pointer;`;
volumeSlider.oninput = (e) => {
CONFIG.volume = parseFloat(e.target.value);
document.getElementById('volume-label').innerHTML = `<span>音量</span><span>${Math.round(CONFIG.volume * 100)}%</span>`;
GM_setValue('duanmu_reader_volume', CONFIG.volume);
};
let hint = document.createElement('div');
hint.style.cssText = `
font-size: 11px;
margin-top: 8px;
opacity: 0.8;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.2);
`;
hint.innerHTML = 'Alt+R: 切换<br/>拖拽移动<br/>双击收起';
panel.appendChild(title);
panel.appendChild(toggleBtn);
panel.appendChild(statsDiv);
panel.appendChild(rateLabel);
panel.appendChild(rateSlider);
panel.appendChild(volumeLabel);
panel.appendChild(volumeSlider);
panel.appendChild(hint);
document.body.appendChild(panel);
let isCollapsed = false;
title.ondblclick = () => {
isCollapsed = !isCollapsed;
toggleBtn.style.display = isCollapsed ? 'none' : 'block';
statsDiv.style.display = isCollapsed ? 'none' : 'block';
rateLabel.style.display = isCollapsed ? 'none' : 'block';
rateSlider.style.display = isCollapsed ? 'none' : 'block';
volumeLabel.style.display = isCollapsed ? 'none' : 'block';
volumeSlider.style.display = isCollapsed ? 'none' : 'block';
hint.style.display = isCollapsed ? 'none' : 'block';
title.style.marginBottom = isCollapsed ? '0' : '8px';
};
setInterval(() => {
const countEl = document.getElementById('spoken-count');
const queueEl = document.getElementById('queue-count');
const danmuEl = document.getElementById('danmu-count');
if (countEl) countEl.textContent = spokenCount;
if (queueEl) queueEl.textContent = speakQueue.length;
if (danmuEl) danmuEl.textContent = getDanmuElements().length;
}, 500);
}
function setupKeyboardShortcut() {
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key.toUpperCase() === 'R') {
e.preventDefault();
CONFIG.enabled = !CONFIG.enabled;
let btn = document.querySelector('#duanmu-reader-panel button');
if (btn) {
btn.textContent = CONFIG.enabled ? '✓ 已启用' : '✗ 已禁用';
btn.style.background = CONFIG.enabled ? '#4ade80' : '#ef4444';
}
console.log('[读弹幕]', CONFIG.enabled ? '已启用' : '已禁用');
}
});
}
function loadConfig() {
if (typeof GM_getValue !== 'undefined') {
CONFIG.enabled = GM_getValue('duanmu_reader_enabled', true);
CONFIG.rate = parseFloat(GM_getValue('duanmu_reader_rate', 1));
CONFIG.volume = parseFloat(GM_getValue('duanmu_reader_volume', 1));
}
}
function init() {
console.log('[读弹幕] 脚本已加载 v0.8.0 - 轮询模式(不依赖MutationObserver)');
if (!('speechSynthesis' in window)) {
console.error('[读弹幕] 浏览器不支持 Web Speech API');
alert('您的浏览器不支持 Web Speech API,请升级浏览器');
return;
}
loadConfig();
createControlPanel();
setupKeyboardShortcut();
// 启动轮询 - 每 100ms 检查一次新弹幕(加快速度以捕捉快速出现的弹幕)
setInterval(pollNewDanmu, 100);
console.log('[读弹幕] ✓ 轮询模式已启动 (100ms 间隔)');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();