// ==UserScript==
// @name B站弹幕跳伞 - 时间点提醒
// @namespace http://tampermonkey.net/
// @author niweizhuan
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNDEuMDY1IiBoZWlnaHQ9IjEyNy43NDciIHZpZXdCb3g9IjAgMCAzNy4zMjQgMzMuOCI+PHBhdGggZmlsbD0iIzIwYjBlMyIgZD0iTTg2Ljk2MiAxMTIuMzMyYTIuNjYxIDIuNjYxIDAgMCAxIDIuMjYyIDAgNS41MzYgNS41MzYgMCAwIDEgMS4zODQgMS4wMTFsNS4zMjMgNC42NThoMy44MDVsNS4zMjMtNC42NThhNS41ODkgNS41ODkgMCAwIDEgMS4zODQtMS4wMTEgMi42NjEgMi42NjEgMCAwIDEgMy41NCAyLjIwOSAyLjY2MSAyLjY2MSAwIDAgMS0uNTg2IDEuNzgzIDE0Ljg3NyAxNC44NzcgMCAwIDEtMS4xNzEgMS4wNjUgNy42OTEgNy42OTEgMCAwIDEtLjc0NS42MTJoMy4zMjZhNS42NDIgNS42NDIgMCAwIDEgMy45MTIgMS43NTYgNS42NjkgNS42NjkgMCAwIDEgMS43ODQgMy45MTJ2MTUuMzAzYTEwLjc3OCAxMC43NzggMCAwIDEtLjEzNCAyLjMxNSA1LjkwOCA1LjkwOCAwIDAgMS0yLjY2IDMuNzI2IDUuNzIyIDUuNzIyIDAgMCAxLTMuMDYxLjg1Mkg4Ni4yMTdhMTEuMjg0IDExLjI4NCAwIDAgMS0yLjM5Ni0uMTMzIDUuODgyIDUuODgyIDAgMCAxLTMuNjcyLTIuNjYyIDUuNjk1IDUuNjk1IDAgMCAxLS45MDUtMy4wNnYtMTUuMTQzYTExLjkyMyAxMS45MjMgMCAwIDEgMC0yLjIwOSA1Ljg1NSA1Ljg1NSAwIDAgMSA1LjMyMy00LjczN2gzLjQ4NmMtLjU1OS0uNC0xLjAzOC0uODc4LTEuNTQ0LTEuMzA0YTIuNjYxIDIuNjYxIDAgMCAxLS44NTEtMi4xODMgMi42NjEgMi42NjEgMCAwIDEgMS4zMDQtMi4xMDJtLS42MTIgMTAuMzI2YTIuNjYxIDIuNjYxIDAgMCAwLTIuMTAzIDEuOTE2IDMuNTkzIDMuNTkzIDAgMCAwIDAgMS4wMTF2MTIuNTg4YTIuNjYxIDIuNjYxIDAgMCAwIDEuODM3IDIuNjYyIDMuNTEzIDMuNTEzIDAgMCAwIDEuMTQ0LjE4NmgyMS42MzdhMi42NjEgMi42NjEgMCAwIDAgMi41MjgtMS41NyAzLjcyNiAzLjcyNiAwIDAgMCAuMjY2LTEuNzU3di0xMS43MWE0LjQ3MSA0LjQ3MSAwIDAgMCAwLTEuMjc3IDIuNjYxIDIuNjYxIDAgMCAwLTEuNzMtMS44MSA0LjI4NSA0LjI4NSAwIDAgMC0xLjY1LS4yMzlIODcuNjAxYTguODg5IDguODg5IDAgMCAwLTEuMjUxIDB6bTAgMCIgc3R5bGU9InN0cm9rZS13aWR0aDouMDMzMDcyOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTc5LjE5MyAtMTEyLjA4KSIvPjxwYXRoIGQ9Ik04OC45NyAxMjguNjM2Yy4zNjMuMzc3Ljc0NS43NDcgMS4wODggMS4xNDIuNTk3LjY4NyAxLjExOCAxLjE5NyAxLjY2NiAxLjgwOS0uMTI5LTEuMTE3IDEuMzA0LTEuMTk4LjA3NC0xLjc1Ny0uNDA4LjQxNy0uOTQxLjg4NC0xLjM2IDEuMjIzLS4zOTIuMzE2LS44NjMuNjctMS4yMzUuOTUyLTEuOTA3IDEuNDQzLjIyNiA0LjA1MyAyLjEzIDIuNjA3IDAgMCAyLTEuNTM1IDIuODA3LTIuMzAxLjQ0LS40MTcuNjgtLjk1Ni43Mi0xLjU5Mi4wNC0uNjU0LS41MzUtMS4yNC0uNzk0LTEuNDk4LS45Mi0uOTE0LTEuNzQzLTEuOTY4LTIuNTUtMi44MTItMS41NzUtMS44LTQuMTIuNDI4LTIuNTQ2IDIuMjI3ek0xMDYuOTc5IDEyOC42MzZjLS4zNjMuMzc3LS43NDUuNzQ3LTEuMDg4IDEuMTQyLS41OTcuNjg3LTEuMTE4IDEuMTk3LTEuNjY2IDEuODA5LjEyOS0xLjExNy0xLjMwNC0xLjE5OC0uMDc0LTEuNzU3LjQwOC40MTcuOTQxLjg4NCAxLjM2IDEuMjIzLjM5Mi4zMTYuODYzLjY3IDEuMjM1Ljk1MiAxLjkwNyAxLjQ0My0uMjI2IDQuMDUzLTIuMTMgMi42MDcgMCAwLTItMS41MzUtMi44MDctMi4zMDEtLjQ0LS40MTctLjY4LS45NTYtLjcyLTEuNTkyLS4wNC0uNjU0LjUzNS0xLjI0Ljc5NC0xLjQ5OC45Mi0uOTE0IDEuNzQzLTEuOTY4IDIuNTUtMi44MTIgMS41NzUtMS44IDQuMTIuNDI4IDIuNTQ2IDIuMjI3eiIgc3R5bGU9ImZpbGw6IzIwYjBlMztmaWxsLW9wYWNpdHk6MTtzdHJva2Utd2lkdGg6LjUyNDE1OTtzdHJva2UtZGFzaGFycmF5Om5vbmUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC03OS4xOTMgLTExMi4wOCkiLz48L3N2Zz4NCg==
// @version 1.5.3
// @description 识别弹幕中的时间信息(如"空降2:30"),在视频播放到该弹幕发送时间时显示跳伞按钮,点击即可跳到目标时间
// @match https://www.bilibili.com/video/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function(){ 'use strict';
/* ========== 配置 ========== */
const CONFIG = {
maxDanmuLoad: 6000,
fetchRetries: 3,
fetchRetryDelayMs: 800,
maxDisplayDanmu: 120,
triggerWindow: 2,
earliestClusterMinCount: 2,
minDeltaSeconds: 5,
maxDeltaSeconds: 300,
maxBackwardAllowedSec: 5,
weightWindowSeconds: 20,
minSkipDuration: 5,
maxSkipFraction: 0.5,
baseWeight: 0.6,
timeKwExtra: 2.0,
targetKwExtra: 2.0,
weightDanmuBoost: 2.5,
clusterBoostFactor: 0.5,
rightStepSeconds: 5,
rightMaxN: 10,
forbiddenTokens: ['+','%','年','月','日','人','个','比','对','W','万','K','千','百','M','B','G','w','前','一分钟','两分钟','三分钟','五分钟','十分钟','555','kb','k','a','b','d','g','T','1 1','点点','一点','1点','hz','p','os','处理器','CPU','GPU','i','最多','最少','不如','!','//!','的'],
measurementTokens: ['米','码','百米','速度','跑','m','km','km/h','秒','公斤','kg','斤','票','票房','分数','评分','百分比','%','rpm','W','万','K','千','百','M','B','G','点点','块','快','元'],
maxNonTimeCharsAllowed: 3,
nonTimeCharsPenaltyFactor: 0.2,
acceptWeightThreshold: 0.75,
chineseNumberMaxParseLen: 6,
fastForwardBoost: 1.2,
POSTAGE_WINDOW_SECONDS: 25,
POSTAGE_TARGET_SECONDS: 60,
TARGET_GROUP_WINDOW: 4,
// 低权重特殊数字弹幕
lowWeightTokens: ['9.9','111','555','233','444','333','222','911','0721','250','38','711','1024','2048','315','918','123','1.0','1.5','2.0','2.5','3.0','4.0','5.0','6.0','7.0','8.0','9.0','100','200','300','400','500','600'],
lowWeightMultiplier: 0.01,
// 过早触发剔除阈值(秒)
earliestIgnoreDeltaSeconds: 30,
// 新增:早期"几分钟"剔除设置
earliestPostageWindowSeconds: 90,
earliestPostageMaxMinute: 3,
// 时间点提醒显示持续时间(毫秒)
timePointDisplayDuration: 5000, // 5秒
// 防抖设置:跳到同一目标时间的弹幕可能有多条,避免短时间内重复弹出提醒
reminderDebounceMs: 6000, // 同一目标时间在此时长内只显示一次提醒(略大于显示时长)
reminderTargetToleranceSec: 3, // 判断"同一目标时间"的容差(秒)
};
/* ========== 关键词 ========== */
const TIME_KEYWORDS = ['跳伞','跳','快进','空降','跳过','跳至','快进到','加速','向右','右','→','朗','郎','侠','绯红之王','向右下','右下'];
const WEIGHT_A_KEYWORDS = ['0帧起手','零帧起手','丝滑','起手','0帧','变声期','触发连招','连招','回马枪','加速时间','广告','广告跳过','不想看','广告点','触发','起招','起手帧','感谢甲方','恭喜恰饭','高能预警','贴脸开大'];
const WEIGHT_B_KEYWORDS = ['欢迎回来','欢迎回','感谢侠','感谢郎','感谢朗','感谢绯红之王','谢谢回来','指挥部','感谢指挥部'];
/* ========== 状态对象(全局) ========== */
const state = {
runId: 0,
video: null,
cid: null,
danmuCount: 0,
isAnalyzing: false,
jumpRules: new Map(),
lastCandidatesLog: [],
videoListenerRef: null,
mutationObserver: null,
uiRootId: 'bili-danmu-parachute-ui-root',
bvMonitorId: null,
lastBV: null,
pendingTimeouts: [],
gmRequestSeq: 0,
activeRequestSeqs: new Set(),
uiCreated: false,
detectedTimePoints: [], // 存储检测到的跳伞点({triggerTime,targetTime,...})
timePointContainerId: 'bili-timepoint-container', // 时间点容器ID
timePointContainerEl: null, // 时间点容器元素引用(避免依赖 getElementById)
activeReminders: new Map(), // 存储活跃的提醒元素及其定时器
triggeredTimePoints: new Set(), // 已触发的跳伞点 triggerTime 集合(避免重复触发)
lastReminderShownAt: new Map(), // 防抖记录:目标时间(取整) → 上次显示提醒的 wall-clock 时间戳
lastVideoTime: 0, // 上次已知的视频位置(用于检测 seek 跳转)
videoSeekedListenerRef: null, // seeked 事件监听器引用
videoReconnectTimer: null, // 视频元素重连定时器
};
/* ========== UI ========== */
function createUI(){
const existing = document.getElementById(state.uiRootId);
if(existing) return;
const root = document.createElement('div'); root.id = state.uiRootId;
root.style.cssText = 'position:fixed;top:78px;right:48px;z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,Helvetica Neue,Helvetica,Arial,PingFang SC,Hiragino Sans GB,Microsoft YaHei,sans-serif;';
const mini = document.createElement('div'); mini.id='bili-mini-ui';
mini.style.cssText='background:rgba(255,255,255,0.95);color:#18191C;border:1px solid #F1F2F3;border-radius:8px;padding:8px 12px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.08);backdrop-filter:blur(8px);';
mini.innerHTML = `
弹幕跳伞: 准备就绪
`;
root.appendChild(mini);
const panel = document.createElement('div'); panel.id='bili-danmu-parachute-ui';
panel.style.cssText='display:none; width:720px; max-width:92vw; min-width:340px; background:rgba(255,255,255,0.98); color:#18191C; border:1px solid #F1F2F3; border-radius:8px; padding:16px; box-shadow:0 4px 20px rgba(0,0,0,0.12); backdrop-filter:blur(12px);';
panel.innerHTML = `
功能说明:
1. 自动分析弹幕中的时间信息(如"空降2:30"、"1:30开始高能"等)
2. 当视频播放到弹幕发送的时间点时,在播放器上方显示弹幕样式跳伞按钮
3. 点击按钮可直接跳转到弹幕中提到的目标时间点
4. 按钮会在5秒后自动消失,采用半透明弹幕样式不影响观看
5. 支持格式: 1:30, 1分30秒, 1.5, 向右3下 等
检测到的跳伞点(弹幕发送时间 → 目标跳转时间):
`;
root.appendChild(panel);
document.body.appendChild(root);
state.uiCreated = true;
document.getElementById('bili-expand-ui').addEventListener('click', ()=>{ panel.style.display='block'; mini.style.display='none'; });
document.getElementById('bili-close-ui').addEventListener('click', ()=>{ panel.style.display='none'; mini.style.display='block'; addLog('UI 已隐藏(可点击展开)'); });
// 创建独立的时间点容器(弹幕样式跳伞按钮),将挂载到播放器容器内
createTimePointContainer();
}
// 同屏最多显示的跳伞按钮数量
const MAX_ACTIVE_REMINDERS = 2;
function createTimePointContainer() {
// 移除旧容器
const old = document.getElementById(state.timePointContainerId);
if(old) old.remove();
// 创建容器:挂载到播放器内,使用 absolute 定位,确保全屏也可见
const container = document.createElement('div');
container.id = state.timePointContainerId;
container.style.cssText = `
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
pointer-events: none;
width: auto;
max-width: 90%;
text-align: center;
`;
container.innerHTML = `
`;
state.timePointContainerEl = container;
// 尝试将容器挂载到播放器容器内
ensureTimePointContainerPosition();
// 通过 MutationObserver 持续监控播放器容器,确保位置正确(支持全屏切换)
let observer = new MutationObserver(() => {
ensureTimePointContainerPosition();
});
observer.observe(document.body, { childList: true, subtree: true });
// 60 秒后停止观察(页面结构稳定后)
const to = setTimeout(() => { try{ observer.disconnect(); }catch(e){} }, 60000);
state.pendingTimeouts.push(to);
// 监听全屏切换事件
document.addEventListener('fullscreenchange', ensureTimePointContainerPosition);
document.addEventListener('webkitfullscreenchange', ensureTimePointContainerPosition);
document.addEventListener('MSFullscreenChange', ensureTimePointContainerPosition);
}
// 播放器容器选择器(用于挂载跳伞按钮,全屏时这些容器也会存在)
const PLAYER_CONTAINER_SELECTORS = [
'.bpx-player-container',
'#bilibili-player',
'.bilibili-player',
'.bilibili-player-wrap',
'#playerWrap',
'.bpx-player-videoWrap',
'.squirtle-video',
'.player-and-aside-area'
];
// 确保时间点容器挂载到播放器容器内(上方中间),支持全屏
function ensureTimePointContainerPosition() {
const container = state.timePointContainerEl;
if(!container) {
const existing = document.getElementById(state.timePointContainerId);
if(existing){
state.timePointContainerEl = existing;
} else {
return false;
}
}
const el = state.timePointContainerEl;
if(!el) return false;
// 查找当前播放器容器
let targetAnchor = null;
for(const sel of PLAYER_CONTAINER_SELECTORS){
const anchor = document.querySelector(sel);
if(anchor){
// 优先选择当前可见且有尺寸的容器
const rect = anchor.getBoundingClientRect();
if(rect.width > 100 && rect.height > 100){
targetAnchor = anchor;
break;
}else if(!targetAnchor){
targetAnchor = anchor;
}
}
}
// 如果找到目标容器
if(targetAnchor && targetAnchor.parentNode){
// 如果容器还没有在目标容器内,则移动
if(!targetAnchor.contains(el)){
try {
// 将容器作为目标容器的第一个子元素插入(确保在最上层)
if(targetAnchor.firstChild){
targetAnchor.insertBefore(el, targetAnchor.firstChild);
}else{
targetAnchor.appendChild(el);
}
// 确保目标容器有 position 定位
const pos = getComputedStyle(targetAnchor).position;
if(pos === 'static'){
targetAnchor.style.position = 'relative';
}
return true;
} catch(e) { /* continue */ }
}else{
return true;
}
}
// fallback:添加到 body,使用 fixed 定位在视口上方中间
if(el.parentNode !== document.body){
try {
document.body.appendChild(el);
el.style.position = 'fixed';
el.style.top = '80px';
el.style.left = '50%';
el.style.transform = 'translateX(-50%)';
return true;
} catch(e){ return false; }
}
return true;
}
// 防抖:判断该目标时间是否在防抖窗口内近期已显示过提醒
function shouldDebounceReminder(targetTime) {
const now = Date.now();
const key = Math.round(targetTime);
const tol = CONFIG.reminderTargetToleranceSec;
for (const [k, ts] of state.lastReminderShownAt) {
if (Math.abs(k - key) <= tol && (now - ts) < CONFIG.reminderDebounceMs) {
return true; // 近期已显示,需防抖
}
}
return false;
}
// 记录某目标时间的提醒已显示(用于后续防抖判断),顺带清理过期记录
function recordReminderShown(targetTime) {
const key = Math.round(targetTime);
state.lastReminderShownAt.set(key, Date.now());
const now = Date.now();
for (const [k, ts] of state.lastReminderShownAt) {
if (now - ts > CONFIG.reminderDebounceMs * 3) {
state.lastReminderShownAt.delete(k);
}
}
}
// 重置触发状态:清空已触发集合 + 防抖记录(用于 seek/跳转/重连等场景)
function resetTriggeredState() {
state.triggeredTimePoints.clear();
state.lastReminderShownAt.clear();
}
// 显示时间点提醒(跳伞按钮 - 弹幕风格)
function showTimePointReminder(timePoint) {
// 确保容器在页面可见位置
ensureTimePointContainerPosition();
// 优先从缓存获取内部容器,fallback 到 getElementById
let innerContainer = null;
if(state.timePointContainerEl && state.timePointContainerEl.isConnected){
innerContainer = state.timePointContainerEl.querySelector('#timepoint-reminders-container');
}
if(!innerContainer){
innerContainer = document.getElementById('timepoint-reminders-container');
}
if (!innerContainer) {
addLog('警告: 找不到 timepoint-reminders-container,跳伞按钮无法显示');
return;
}
// ====== 同屏数量限制:超出 MAX_ACTIVE_REMINDERS 则移除最老的 ======
if(state.activeReminders.size >= MAX_ACTIVE_REMINDERS){
// 取第一个(最早插入的)
const oldestId = state.activeReminders.keys().next().value;
if(oldestId){
const reminderData = state.activeReminders.get(oldestId);
if(reminderData && reminderData.element){
// 快速淡出移除
reminderData.element.style.transition = 'opacity 0.2s ease-out, transform 0.2s ease-out';
reminderData.element.style.opacity = '0';
reminderData.element.style.transform = 'translateY(-4px) scale(0.96)';
setTimeout(() => hideTimePointReminder(oldestId), 200);
}
}
}
// 创建提醒元素 - 极简弹幕风格:更高透明度,单行,去除副内容
const reminderId = `reminder-${timePoint.triggerTime}-${timePoint.targetTime}-${Date.now()}`;
const reminder = document.createElement('div');
reminder.id = reminderId;
reminder.className = 'timepoint-reminder';
// pointer-events: auto 确保可点击(因为容器设了 none)
reminder.style.cssText = 'pointer-events:auto; opacity:0; transform:translateX(12px) scale(0.96); flex-shrink:0; transition: opacity 0.35s ease-out, transform 0.35s ease-out;';
const btnInner = document.createElement('div');
btnInner.style.cssText = `
background: rgba(0, 0, 0, 0.32);
color: rgba(255, 255, 255, 0.92);
padding: 5px 14px;
border-radius: 3px;
display: inline-flex;
align-items: center;
font-weight: 500;
font-size: 13px;
cursor: pointer;
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(1px);
transition: background 0.18s ease-out, transform 0.15s ease-out, color 0.18s;
line-height: 1.4;
white-space: nowrap;
user-select: none;
`;
btnInner.title = '点击跳转到 ' + timePoint.targetTimeStr;
const mainText = document.createElement('div');
mainText.style.cssText = 'display:flex;align-items:center;gap:5px;';
mainText.innerHTML = `🪂 ${timePoint.targetTimeStr}`;
btnInner.appendChild(mainText);
reminder.appendChild(btnInner);
// 鼠标悬停效果:透明度提高 + 轻微放大
btnInner.addEventListener('mouseenter', () => {
btnInner.style.background = 'rgba(0, 0, 0, 0.55)';
btnInner.style.color = 'rgba(255, 255, 255, 1)';
btnInner.style.transform = 'scale(1.05)';
});
btnInner.addEventListener('mouseleave', () => {
btnInner.style.background = 'rgba(0, 0, 0, 0.32)';
btnInner.style.color = 'rgba(255, 255, 255, 0.92)';
btnInner.style.transform = 'scale(1)';
});
// 添加点击事件,跳转到目标时间
reminder.addEventListener('click', (e) => {
e.stopPropagation();
jumpToTime(timePoint.targetTime);
});
// 添加到容器
innerContainer.appendChild(reminder);
// 触发入场动画(从右侧滑入 + 淡入 + 恢复大小)
requestAnimationFrame(() => {
reminder.style.opacity = '1';
reminder.style.transform = 'translateX(0) scale(1)';
});
// 记录活动提醒和对应的定时器
const timeoutId = setTimeout(() => {
hideTimePointReminder(reminderId);
}, CONFIG.timePointDisplayDuration);
state.activeReminders.set(reminderId, {
element: reminder,
timeoutId: timeoutId
});
// 最后1秒淡出 + 轻微左移
setTimeout(() => {
if (reminder.parentNode) {
reminder.style.opacity = '0';
reminder.style.transform = 'translateX(-8px) scale(0.98)';
}
}, CONFIG.timePointDisplayDuration - 1000);
}
// 隐藏时间点提醒
function hideTimePointReminder(reminderId) {
const reminderData = state.activeReminders.get(reminderId);
if (reminderData) {
clearTimeout(reminderData.timeoutId);
const element = reminderData.element;
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
state.activeReminders.delete(reminderId);
}
}
// 隐藏所有时间点提醒
function hideAllTimePointReminders() {
for (const [reminderId, reminderData] of state.activeReminders) {
clearTimeout(reminderData.timeoutId);
if (reminderData.element && reminderData.element.parentNode) {
reminderData.element.parentNode.removeChild(reminderData.element);
}
}
state.activeReminders.clear();
}
function updateTimePointDisplay() {
// 更新主UI中的跳伞点列表
const timePointsList = document.getElementById('bili-timepoints-list');
if(timePointsList) {
timePointsList.innerHTML = '';
if(state.detectedTimePoints.length === 0) {
timePointsList.innerHTML = '暂无检测到的跳伞点
';
} else {
state.detectedTimePoints.slice().reverse().forEach(point => { // 倒序显示最新的在前
const item = document.createElement('div');
item.className = 'timepoint-item';
item.setAttribute('data-trigger', point.triggerTime);
item.setAttribute('data-target', point.targetTime);
item.style.cssText = 'padding:10px 12px;margin:6px 0;background:#FFFFFF;border-radius:6px;cursor:pointer;border:1px solid #F1F2F3;transition:border-color 0.15s, box-shadow 0.15s;';
item.style.cursor = 'pointer';
item.innerHTML = `
🪂 ${point.triggerTimeStr} → ${point.targetTimeStr}
${point.originText}
`;
item.addEventListener('mouseenter', () => {
item.style.borderColor = '#00AEEC';
item.style.boxShadow = '0 2px 8px rgba(0,174,236,0.12)';
});
item.addEventListener('mouseleave', () => {
item.style.borderColor = '#F1F2F3';
item.style.boxShadow = 'none';
});
item.addEventListener('click', () => jumpToTime(point.targetTime));
timePointsList.appendChild(item);
});
}
}
}
function removeUI(){
const el = document.getElementById(state.uiRootId);
if(el && el.parentNode) el.parentNode.removeChild(el);
state.uiCreated = false;
// 移除时间点容器
const container = document.getElementById(state.timePointContainerId);
if(container && container.parentNode) container.parentNode.removeChild(container);
state.timePointContainerEl = null;
// 清除所有活动提醒
hideAllTimePointReminders();
}
/* ========== 日志与显示 ========== */
function addLog(msg){
const el = document.getElementById('bili-log');
const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
if(el){
const p = document.createElement('div');
p.style.cssText = 'padding:5px 4px;border-bottom:1px solid #F1F2F3;color:#61666D;line-height:1.5;';
p.textContent = line;
el.appendChild(p);
el.scrollTop = el.scrollHeight;
}else{
console.log(line);
}
}
function updateMiniStatus(textOrBool){
const el = document.getElementById('bili-mini-status');
if(!el) return;
if(typeof textOrBool === 'boolean') {
el.textContent = textOrBool ? '弹幕跳伞: 已激活' : '弹幕跳伞: 未激活';
el.style.color = textOrBool ? '#00AEEC' : '#9499A0';
} else {
el.textContent = textOrBool;
el.style.color = '#61666D';
}
}
function formatTime(sec){ sec = Math.floor(sec||0); const m=Math.floor(sec/60); const s=sec%60; return `${m}:${s.toString().padStart(2,'0')}`; }
function normalizeText(s){ if(!s) return s; s = s.replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30)); s = s.replace(/:/g,':').replace(/,/g,',').replace(/\s+/g,' ').trim(); return s; }
/* ========== 中文数字解析 ========== */
const CN_NUM = { '零':0,'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100 };
function chineseToNumber(str){
if(!str) return NaN; str = str.trim();
if(str.length > CONFIG.chineseNumberMaxParseLen) return NaN;
if(str.indexOf('百')!==-1){
const parts = str.split('百'); const h = CN_NUM[parts[0]] || parseInt(parts[0]) || 0; const rest = parts[1] ? (chineseToNumber(parts[1])||0) : 0; return h*100 + rest;
}
if(str.indexOf('十')!==-1){
const parts = str.split('十'); let tens = parts[0]===''?1:(CN_NUM[parts[0]]||parseInt(parts[0])||0); const rest = parts[1] ? (CN_NUM[parts[1]]||parseInt(parts[1])||0) : 0; return tens*10 + rest;
}
let total = 0;
for(const ch of str){
if(CN_NUM.hasOwnProperty(ch)) total = total*10 + CN_NUM[ch];
else if(!isNaN(parseInt(ch))) total = total*10 + parseInt(ch);
else return NaN;
}
return total;
}
function parseNumberToken(tok){
if(tok === undefined || tok === null) return NaN;
tok = tok.toString().trim();
if(tok === '') return NaN;
if(!isNaN(parseInt(tok))) return parseInt(tok);
const cn = chineseToNumber(tok);
return isNaN(cn) ? NaN : cn;
}
/* ========== 上下文判断 ========== */
function isMeasurementContext(text){
if(!text) return false;
const lower = text.toLowerCase();
for(const tk of CONFIG.measurementTokens) if(lower.indexOf(tk)!==-1) return true;
const extra = ['跑','速度','计时','百米','百码','成绩','公里','km','m/s','秒表','米/s'];
for(const e of extra) if(text.indexOf(e)!==-1) return true;
return false;
}
function isScoreContext(text){
if(!text) return false;
const t = text.replace(/\s+/g,'');
const scoreKw = ['满分','评分','分数','打分','得分','多少分','评分为','分数是','给分','给我分','给他分'];
for(const kw of scoreKw) if(t.indexOf(kw) !== -1) return true;
if(/给.{0,8}分/.test(text)) return true;
if(/(?:\d+|[零一二两三四五六七八九十百])分(?:是|,|,|。|$)/.test(text)) {
if(/秒|分钟|:|:/.test(text)) return false;
return true;
}
return false;
}
function isFastForwardInstruction(text){
if(!text) return false;
if(/(?:快进到|快进|跳到|跳至|跳过)(?:到)?/.test(text) && /[0-9零一二两三四五六七八九十百]{1,3}\s*分/.test(text)) return true;
return false;
}
function isPostAgeContext(text){
if(!text) return false;
const raw = text.replace(/\s+/g,'');
const kws = ['发布一分钟','发布于','发布后','刚出炉','刚发布','刚刚发布','刚发布','刚出','第一分钟','刚刚','发布','新鲜','热乎'];
for(const k of kws){ if(raw.indexOf(k) !== -1) return true; }
if(/(?:发布|刚|刚刚|刚出炉).{0,6}[0-9零一二两三四五六七八九十百]{1,3}分/.test(text)) return true;
// 新增:简短形式识别,例如 "两分钟!" / "2分钟" / "2 分钟" 这类单句 —— 视为发布时长语境
if(/^\s*(?:刚|刚刚|发布|新鲜|热乎)?\s*[0-90-9零一二两三四五六七八九十百]{1,3}\s*(?:分|分钟)\s*[!!!]*\s*$/.test(text)) return true;
return false;
}
/* ========== CID 解析 & 弹幕请求 ========== */
async function resolveCid(){
try{
const initial = window.__INITIAL_STATE__ || window.__PLAYINFO__ || window.__playinfo__ || null;
if(initial){
if(initial.videoData && initial.videoData.cid) return initial.videoData.cid;
if(initial.cid) return initial.cid;
if(initial.data && initial.data.cid) return initial.data.cid;
}
const metaCid = document.querySelector('meta[itemprop="cid"]') || document.querySelector('meta[name="video-cid"]');
if(metaCid && metaCid.content) return metaCid.content;
const scripts = Array.from(document.scripts||[]);
for(const s of scripts){
if(!s.textContent) continue;
const m = s.textContent.match(/"cid"\s*:\s*(\d{4,12})/);
if(m) return m[1];
}
const bvidMatch = location.href.match(/(BV[0-9A-Za-z]+)/);
if(bvidMatch){
const bv=bvidMatch[1];
try{
const url = `https://api.bilibili.com/x/web-interface/view?bvid=${bv}`;
const resp = await new Promise((res,rej)=> GM_xmlhttpRequest({ method:'GET', url, onload:r=>res(r), onerror:err=>rej(err) }));
let json = null;
try{ json = (typeof resp.response === 'object') ? resp.response : JSON.parse(resp.responseText || '{}'); }catch(e){}
if(json && json.data){
if(Array.isArray(json.data.pages) && json.data.pages.length>0) return json.data.pages[0].cid || json.data.cid || null;
if(json.data.cid) return json.data.cid;
}
}catch(e){}
}
if(window.__playinfo__ && window.__playinfo__.data && window.__playinfo__.data.cid) return window.__playinfo__.data.cid;
return null;
}catch(e){ console.error('resolveCid error', e); return null; }
}
function fetchDanmu(cid, runIdLocal){
addLog(`开始请求弹幕 (cid=${cid})`);
const url = `https://api.bilibili.com/x/v1/dm/list.so?oid=${cid}`;
let attempt = 0;
function doRequest(){
attempt++;
const seq = ++state.gmRequestSeq;
state.activeRequestSeqs.add(seq);
GM_xmlhttpRequest({
method:'GET', url,
onload(resp){
state.activeRequestSeqs.delete(seq);
if(runIdLocal !== state.runId){ addLog('弹幕响应来自旧 runId,忽略'); return; }
if(resp.status===200 && resp.responseText){
const xml = resp.responseText;
const count = (xml.match(/0){ parseDanmuAndProcess(xml, runIdLocal); return; }
}
tryCommentXml(cid).then(res=>{
if(runIdLocal !== state.runId){ addLog('fallback XML 来自旧 runId,忽略'); return; }
if(res){ const c=(res.match(/0){ parseDanmuAndProcess(res, runIdLocal); return; } }
if(attempt < CONFIG.fetchRetries){ addLog(`重试 list.so(第 ${attempt+1} 次)`); const to = setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); state.pendingTimeouts.push(to); }
else addLog('未能通过 XML 接口获取到弹幕,可能受限(登录/权限)');
});
},
onerror(err){
state.activeRequestSeqs.delete(seq);
addLog(`弹幕请求错误: ${err}`);
if(attempt < CONFIG.fetchRetries){ const to = setTimeout(doRequest, CONFIG.fetchRetryDelayMs*attempt); state.pendingTimeouts.push(to); }
else addLog('请求出错');
}
});
}
doRequest();
}
function tryCommentXml(cid){
return new Promise((resolve)=>{ const url=`https://comment.bilibili.com/${cid}.xml`; GM_xmlhttpRequest({ method:'GET', url, onload(r){ if(r.status===200 && r.responseText) resolve(r.responseText); else resolve(null); }, onerror(){ resolve(null); } }); });
}
/* ========== 处理弹幕并提取时间信息 ========== */
function parseDanmuAndProcess(xml, runIdLocal){
try{
if(runIdLocal !== state.runId){ addLog('parseDanmuAndProcess 来自旧 runId,忽略'); return; }
addLog('解析弹幕中...');
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const dnodes = Array.from(doc.getElementsByTagName('d') || []);
const items = [];
for(let i=0;i {
if(runIdLocal !== state.runId) return;
retryCount++;
// 重新查找视频元素(可能之前未找到)
if(!state.video || !state.video.isConnected){
state.video = findVideoElement();
}
if(state.video && state.video.duration > 0){
addLog(`第 ${retryCount} 次重试成功,视频长度: ${formatTime(state.video.duration)}`);
extractTimePointsFromDanmu(items, runIdLocal);
} else if(retryCount < maxRetries){
addLog(`等待视频元数据... (重试 ${retryCount}/${maxRetries})`);
const to = setTimeout(retryFn, 1500);
state.pendingTimeouts.push(to);
} else {
addLog('视频元数据加载超时,使用默认时长限制处理弹幕');
// 使用一个较大的默认值(24小时),仍然处理弹幕
extractTimePointsFromDanmu(items, runIdLocal, 86400);
}
};
const to = setTimeout(retryFn, 1500);
state.pendingTimeouts.push(to);
return;
}
addLog(`视频长度: ${formatTime(videoDuration)}`);
// 定义时间模式的正则表达式
const patterns = [
{
regex: /([0-90-9零一二两三四五六七八九十百]{1,3})\s*[::]\s*([0-90-9零一二两三四五六七八九十百]{1,3})/g,
extractor: (matches) => {
const min = parseNumberToken(matches[1]);
const sec = parseNumberToken(matches[2]);
if(isNaN(min)||isNaN(sec)||sec>=60) return null;
return min*60 + sec;
},
label: "冒号"
},
{
regex: /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:分|分钟)\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:秒)?/g,
extractor: (matches) => {
const min = parseNumberToken(matches[1]);
const sec = parseNumberToken(matches[2]);
if(isNaN(min)||isNaN(sec)) return null;
return min*60 + sec;
},
label: "分秒"
},
{
regex: /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:分|分钟)(?!\s*秒)/g,
extractor: (matches) => {
const min = parseNumberToken(matches[1]);
if(isNaN(min)) return null;
return min*60;
},
label: "仅分钟"
},
{
regex: /(? {
const min = parseNumberToken(matches[1]);
const sec = parseNumberToken(matches[2]);
if(isNaN(min)||isNaN(sec)||sec>=60) return null;
return min*60 + sec;
},
label: "空格分隔"
},
{
regex: /([0-90-9零一二两三四五六七八九十百]{1,3})\s*(?:点|点钟)\s*([0-90-9零一二两三四五六七八九十百]{1,3})?(?:\s*(?:分|分钟))?(?:\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*秒)?/g,
extractor: (matches) => {
const hour = parseNumberToken(matches[1]);
const min = matches[2] ? parseNumberToken(matches[2]) : 0;
const sec = matches[3] ? parseNumberToken(matches[3]) : 0;
if(isNaN(hour)) return null;
return hour*60 + (isNaN(min) ? 0 : min) + (isNaN(sec) ? 0 : sec);
},
label: "点格式"
},
{
regex: /(? {
if(isMeasurementContext(matches.input)) return null;
const min = parseNumberToken(matches[1]);
const sec = parseNumberToken(matches[2]);
if(isNaN(min)||isNaN(sec)||sec>=60) return null;
return min*60 + sec;
},
label: "点号"
},
{
regex: /(? {
if(isMeasurementContext(matches.input)) return null;
const numStr = matches[1].replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30));
const numVal = parseInt(numStr); if(isNaN(numVal)) return null;
const mod100 = numVal % 100; if(mod100 >= 60) return null;
const mm = Math.floor(numVal/100), ss = mod100;
return mm*60 + ss;
},
label: "连续4位"
},
{
regex: /(? {
if(isMeasurementContext(matches.input)) return null;
const numStr = matches[1].replace(/[0-9]/g,c=>String.fromCharCode(c.charCodeAt(0)-0xFF10+0x30));
const numVal = parseInt(numStr); if(isNaN(numVal)) return null;
const mod100 = numVal % 100; if(mod100 >= 60) return null;
const mm = Math.floor(numVal/100), ss = mod100;
return mm*60 + ss;
},
label: "连续3位"
},
{
regex: /((?:向右)|右)\s*([0-90-9零一二两三四五六七八九十百]{1,3})\s*(下)?/gi,
extractor: (matches) => {
const prefix = matches[1];
const nRaw = matches[2];
const hasXia = !!matches[3];
const isXiangYou = /^向右/i.test(prefix);
if(!(isXiangYou || hasXia)) return null;
const n = isNaN(parseInt(nRaw)) ? chineseToNumber(nRaw) : parseInt(nRaw);
if(isNaN(n) || n <= 0 || n > CONFIG.rightMaxN) return null;
// 向右跳转是相对当前位置的,我们返回预计目标时间
return Math.round(state.video.currentTime + CONFIG.rightStepSeconds * n);
},
label: "向右指令",
special: true
}
];
// 遍历所有弹幕,查找时间信息
items.forEach(item => {
const text = item.text;
const originalText = text;
const danmuSendTime = item.time; // 弹幕发送时间 = 跳伞按钮触发时间
// 应用所有模式
for(const pattern of patterns) {
const regex = new RegExp(pattern.regex.source, pattern.regex.flags.includes('g') ? pattern.regex.flags : pattern.regex.flags + 'g');
let match;
while((match = regex.exec(text)) !== null) {
try {
let targetTimeValue = null;
if(pattern.special) {
// 特殊处理"向右"指令:目标=发送时间+步进
const prefix = match[1];
const nRaw = match[2];
const hasXia = !!match[3];
const isXiangYou = /^向右/i.test(prefix);
if(isXiangYou || hasXia) {
const n = isNaN(parseInt(nRaw)) ? chineseToNumber(nRaw) : parseInt(nRaw);
if(!isNaN(n) && n > 0 && n <= CONFIG.rightMaxN) {
targetTimeValue = danmuSendTime + CONFIG.rightStepSeconds * n;
}
}
} else {
// 普通时间提取:文本中提到的是目标跳转时间
targetTimeValue = pattern.extractor(match);
}
// 验证目标时间合法
if(targetTimeValue !== null && !isNaN(targetTimeValue) && targetTimeValue >= 0 && targetTimeValue <= videoDuration) {
if(danmuSendTime < 0 || danmuSendTime > videoDuration) continue;
// 触发时间不能晚于目标时间(提前提醒才有意义),允许5秒内的微小误差
if(targetTimeValue < danmuSendTime - 5) continue;
// 去重:相同 triggerTime + 相同 targetTime
const exists = state.detectedTimePoints.some(tp =>
Math.abs(tp.triggerTime - danmuSendTime) < 0.5 &&
Math.abs(tp.targetTime - targetTimeValue) < 1
);
if (!exists) {
const timePoint = {
triggerTime: danmuSendTime,
targetTime: targetTimeValue,
triggerTimeStr: formatTime(danmuSendTime),
targetTimeStr: formatTime(targetTimeValue),
displayText: `${formatTime(danmuSendTime)} → ${formatTime(targetTimeValue)}`,
originText: `"${originalText}"中的"${match[0]}"`,
timestamp: Date.now()
};
state.detectedTimePoints.push(timePoint);
if (state.detectedTimePoints.length > 200) {
state.detectedTimePoints.shift();
}
addLog(`发现跳伞点: ${formatTime(danmuSendTime)} → ${formatTime(targetTimeValue)} 来源于 "${originalText}"中的"${match[0]}"`);
}
}
} catch(e) {
console.error('处理时间匹配时出错:', e);
continue;
}
}
}
});
// 按触发时间从小到大排序,便于后续处理
state.detectedTimePoints.sort((a,b) => a.triggerTime - b.triggerTime);
// 打印前几个跳伞点供调试
if(state.detectedTimePoints.length > 0){
const preview = state.detectedTimePoints.slice(0, 5).map(tp => tp.displayText).join(', ');
addLog(`跳伞点示例: ${preview}${state.detectedTimePoints.length > 5 ? ' ...' : ''}`);
}
// 更新时间点显示
updateTimePointDisplay();
// 确保容器已创建并在 DOM 中
if(!state.timePointContainerEl || !state.timePointContainerEl.isConnected){
addLog('容器未就绪,重新创建...');
createTimePointContainer();
}
ensureTimePointContainerPosition();
addLog(`容器状态: ${state.timePointContainerEl && state.timePointContainerEl.isConnected ? '已连接DOM' : '未连接'}`);
// 开始监听视频播放时间
startVideoTimeListener();
updateMiniStatus(true);
addLog(`处理完成,共检测到 ${state.detectedTimePoints.length} 个跳伞点`);
} catch(e) {
console.error(e);
addLog('处理弹幕出错: '+(e.message||e));
}
}
/* ========== 视频元素查找与状态识别 ========== */
// 使用 B站播放器专用选择器,避免选到预览/广告等非主播放器 video
function findVideoElement(){
const selectors = [
'#bilibili-player video',
'.bilibili-player video',
'.bpx-player-container video',
'.bpx-player-videoWrap video',
'.bpx-player-video video',
'.squirtle-video video',
'video'
];
for(const sel of selectors){
const els = document.querySelectorAll(sel);
for(const el of els){
if(!el) continue;
// 优先返回已连接到 DOM 且有有效时长的 video
if(el.isConnected && el.duration > 1) return el;
}
// 若无满足条件的,返回第一个已连接的
for(const el of els){
if(el && el.isConnected) return el;
}
}
return null;
}
// 获取视频当前状态描述字符串(用于日志和调试)
function getVideoStateStr(){
if(!state.video) return '无视频';
const v = state.video;
const parts = [formatTime(v.currentTime) + '/' + formatTime(v.duration || 0)];
if(v.paused) parts.push('暂停');
else parts.push('播放中');
if(v.seeking) parts.push('跳转中');
if(v.ended) parts.push('已结束');
if(v.readyState < 3) parts.push('加载中');
return parts.join(' ');
}
/* ========== 监听视频播放时间并在到达时间点时显示提醒 ========== */
function startVideoTimeListener() {
if (!state.video || !state.video.addEventListener) return;
// 移除之前的监听器
removeVideoListeners();
// seeked 事件:用户拖动进度条后触发,重置已触发集合
state.videoSeekedListenerRef = function() {
try {
if(!state.video || !state.video.isConnected) return;
const ct = state.video.currentTime;
addLog(`检测到 seek 完成,当前位置: ${formatTime(ct)},重置触发状态`);
// 清空已触发集合,让后续播放能重新触发
resetTriggeredState();
// 将当前位置之前的跳伞点标记为已触发(跳过的不再补触发)
state.detectedTimePoints.forEach(tp => {
if(tp.triggerTime < ct - CONFIG.triggerWindow) {
state.triggeredTimePoints.add(tp.triggerTime);
}
});
state.lastVideoTime = ct;
} catch(e) { console.error('seeked 监听出错:', e); }
};
// timeupdate 事件:核心进度监听
state.videoListenerRef = function() {
try {
if(!state.video || !state.video.isConnected) return;
const currentTime = state.video.currentTime;
const isPaused = state.video.paused;
const isSeeking = state.video.seeking;
const prevTime = state.lastVideoTime; // 保存上一次的时间点
// 检测 seek 跳转(时间跳变超过触发窗口)
const timeDelta = currentTime - prevTime;
const isSeekJump = Math.abs(timeDelta) > CONFIG.triggerWindow + 1;
if(isSeekJump) {
addLog(`检测到播放跳转: ${formatTime(prevTime)} → ${formatTime(currentTime)}`);
resetTriggeredState();
// 将跳转目标之前的跳伞点标记为已触发(避免补触发跳过的点)
state.detectedTimePoints.forEach(tp => {
if(tp.triggerTime < currentTime - CONFIG.triggerWindow) {
state.triggeredTimePoints.add(tp.triggerTime);
}
});
}
// 检测循环/重播(时间从末尾跳回开头)
if(timeDelta < -CONFIG.triggerWindow) {
addLog('检测到视频循环/重播,重置所有触发状态');
resetTriggeredState();
}
// 更新 lastVideoTime
state.lastVideoTime = currentTime;
// 暂停或跳转中不触发提醒
if(isPaused || isSeeking) return;
// 检查跳伞点:使用"区间穿越"检测
// 如果 (prevTime, currentTime] 区间穿越了 triggerTime,则触发
// 这样即使 timeupdate 跳帧也不会错过
state.detectedTimePoints.forEach(tp => {
if(state.triggeredTimePoints.has(tp.triggerTime)) return;
if(tp.triggerTime > prevTime && tp.triggerTime <= currentTime + 0.5) {
state.triggeredTimePoints.add(tp.triggerTime);
// 防抖:同一目标时间在防抖窗口内只显示一次提醒
if(shouldDebounceReminder(tp.targetTime)) {
addLog(`防抖:跳过重复提醒 ${tp.displayText} (目标 ${tp.targetTimeStr} 近期已显示)`);
return;
}
recordReminderShown(tp.targetTime);
showTimePointReminder(tp);
addLog(`触发跳伞按钮: ${tp.displayText} (当前: ${formatTime(currentTime)})`);
}
});
} catch(e) {
console.error('视频时间监听出错:', e);
}
};
state.video.addEventListener('timeupdate', state.videoListenerRef);
state.video.addEventListener('seeked', state.videoSeekedListenerRef);
// 初始化 lastVideoTime,并把当前时刻之前的跳伞点都标记为已触发
state.lastVideoTime = state.video.currentTime;
state.detectedTimePoints.forEach(tp => {
if(tp.triggerTime <= state.lastVideoTime) {
state.triggeredTimePoints.add(tp.triggerTime);
}
});
addLog(`视频监听已启动,当前状态: ${getVideoStateStr()},已跳过过去 ${state.triggeredTimePoints.size} 个跳伞点`);
}
// 移除视频事件监听器
function removeVideoListeners(){
try {
if(state.video){
if(state.videoListenerRef) state.video.removeEventListener('timeupdate', state.videoListenerRef);
if(state.videoSeekedListenerRef) state.video.removeEventListener('seeked', state.videoSeekedListenerRef);
}
} catch(e) { console.warn(e); }
state.videoListenerRef = null;
state.videoSeekedListenerRef = null;
}
/* ========== 视频跳转函数 ========== */
function jumpToTime(seconds) {
if (!state.video || !state.video.isConnected) {
// 尝试重新查找
state.video = findVideoElement();
if(!state.video){
alert('未找到视频元素');
return;
}
}
const duration = state.video.duration || 0;
if (seconds < 0 || (duration > 0 && seconds > duration)) {
alert(`时间超出范围: ${formatTime(seconds)}, 视频长度: ${formatTime(duration)}`);
return;
}
try {
state.video.currentTime = seconds;
addLog(`已跳转到: ${formatTime(seconds)},跳转前状态: ${getVideoStateStr()}`);
// 跳转后重置触发状态(seeked 事件会处理)
resetTriggeredState();
state.lastVideoTime = seconds;
// 跳转后隐藏所有提醒
hideAllTimePointReminders();
} catch(e) {
console.error('跳转失败:', e);
addLog(`跳转失败: ${e.message}`);
}
}
/* ========== video 监听 ========== */
function initVideoListener(){
try{
if(!state.video) return;
// 启动定时器,定期检查视频元素是否仍然有效(应对 SPA 下 video 元素被替换)
startVideoReconnectCheck();
// 如果已经检测到时间点,开始监听视频时间
if (state.detectedTimePoints.length > 0) {
startVideoTimeListener();
}
}catch(e){ console.warn(e); }
}
// 定期检查视频元素是否仍然有效,若失效则重新查找
function startVideoReconnectCheck(){
if(state.videoReconnectTimer) clearInterval(state.videoReconnectTimer);
state.videoReconnectTimer = setInterval(()=>{
try{
// 如果当前 video 已失效(脱离 DOM),尝试重新查找
if(state.video && !state.video.isConnected){
addLog('视频元素已失效,尝试重新查找...');
const newVideo = findVideoElement();
if(newVideo){
state.video = newVideo;
resetTriggeredState();
// 把当前时刻之前的跳伞点标记为已触发,避免补触发
state.detectedTimePoints.forEach(tp => {
if(tp.triggerTime <= newVideo.currentTime) {
state.triggeredTimePoints.add(tp.triggerTime);
}
});
state.lastVideoTime = newVideo.currentTime;
addLog(`重新找到视频元素,当前状态: ${getVideoStateStr()}`);
if(state.detectedTimePoints.length > 0){
startVideoTimeListener();
}
}
}
// 更新迷你状态栏,显示当前播放进度和状态
if(state.video && state.video.isConnected){
const stateStr = getVideoStateStr();
const tpCount = state.detectedTimePoints.length;
updateMiniStatus(`弹幕跳伞: ${stateStr} | 跳伞点: ${tpCount}`);
}
}catch(e){ console.warn(e); }
}, 3000);
}
/* ========== 清理 / 终止 ========== */
function teardown(){
addLog('开始 teardown:清理旧状态与 UI');
// 增加 runId 以使旧回调不再生效
state.runId = (state.runId || 0) + 1;
// 移除 UI
try{ removeUI(); }catch(e){ console.warn(e); }
// 移除 video 事件监听器
try{ removeVideoListeners(); }catch(e){ console.warn(e); }
// 清除视频重连定时器
try{ if(state.videoReconnectTimer){ clearInterval(state.videoReconnectTimer); state.videoReconnectTimer = null; } }catch(e){}
state.video = null;
// 断开 mutationObserver
try{ if(state.mutationObserver) { state.mutationObserver.disconnect(); state.mutationObserver = null; } }catch(e){}
// 清除 pending timeouts
try{ state.pendingTimeouts.forEach(t=>clearTimeout(t)); state.pendingTimeouts = []; }catch(e){}
// 取消 active GM 请求标记(回调会被 runId 检查忽略)
try{ state.activeRequestSeqs.clear(); }catch(e){}
// 清除跳转规则、检测到的时间点和活动提醒等
try{
state.jumpRules.clear();
state.detectedTimePoints = [];
resetTriggeredState();
state.lastVideoTime = 0;
hideAllTimePointReminders();
}catch(e){}
state.lastCandidatesLog = [];
state.danmuCount = 0;
state.isAnalyzing = false;
}
/* ========== 初始化流程 ========== */
async function runOnce(){
// 每次运行前,确保已有旧 run 清理(避免冲突)
teardown();
const myRunId = ++state.runId;
addLog('弹幕跳伞脚本启动(runId=' + myRunId + ')');
createUI();
addLog('弹幕跳伞已加载,正在寻找视频元素...');
function findVideo(){
state.video = findVideoElement();
if(state.video){
addLog(`检测到 video 元素,当前状态: ${getVideoStateStr()}`);
initVideoListener();
return true;
}
return false;
}
if(!findVideo()){
addLog('等待 video 元素加载...');
const obs = new MutationObserver(()=>{
if(findVideo()){
try{
obs.disconnect();
addLog('视频元素已找到,开始处理弹幕');
}catch(e){}
}
});
obs.observe(document.body, { childList:true, subtree:true });
state.mutationObserver = obs;
}
const cid = await resolveCid();
if(cid){
state.cid = cid;
addLog('获取到 CID: '+cid);
fetchDanmu(cid, myRunId);
}
else addLog('无法获取 CID,请刷新页面或检查视频');
}
/* ========== BV 监控(每隔 10s) + history hook(更快速响应 SPA 路由) ========== */
function getBVFromHref(){
const m = location.href.match(/(BV[0-9A-Za-z]+)/);
return m ? m[1] : null;
}
function startBVMonitor(){
// 防止重复创建
if(state.bvMonitorId !== null) return;
// 先初始化 lastBV
state.lastBV = getBVFromHref();
// history API hook(捕获 pushState/replaceState)
(function(history){
const push = history.pushState;
const replace = history.replaceState;
history.pushState = function(stateArg){
const ret = push.apply(history, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
history.replaceState = function(stateArg){
const ret = replace.apply(history, arguments);
window.dispatchEvent(new Event('locationchange'));
return ret;
};
})(window.history);
window.addEventListener('popstate', ()=> window.dispatchEvent(new Event('locationchange')));
window.addEventListener('locationchange', ()=> {
const bv = getBVFromHref();
if(bv && bv !== state.lastBV){
addLog(`locationchange 触发:BV 变化 ${state.lastBV} -> ${bv},将重启脚本`);
restartForNewBV(bv);
}
});
// 10s 轮询备份
state.bvMonitorId = setInterval(()=>{
try{
const bv = getBVFromHref();
if(bv && bv !== state.lastBV){
addLog(`轮询检测到 BV 号改变: ${state.lastBV} → ${bv} ,重启脚本`);
restartForNewBV(bv);
}
}catch(e){ console.warn(e); }
}, 10000);
}
function stopBVMonitor(){
if(state.bvMonitorId !== null){ clearInterval(state.bvMonitorId); state.bvMonitorId = null; }
}
function restartForNewBV(newBv){
try{
const old = state.lastBV;
state.lastBV = newBv;
// teardown + runOnce
teardown();
// 等短暂时间再 run(确保 DOM 清理完成)
const to = setTimeout(()=>{ runOnce(); }, 200);
state.pendingTimeouts.push(to);
}catch(e){ console.warn(e); }
}
/* ========== 启动 ========== */
(function boot(){
// 启动 BV 监控(一次性)
startBVMonitor();
if(document.readyState === 'complete' || document.readyState === 'interactive'){ setTimeout(runOnce, 300); }
else window.addEventListener('load', runOnce);
})();
})();
// end