// ==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 = `
弹幕跳伞助手 v1.5
功能说明:
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