// ==UserScript==
// @name Bilibili Tools
// @namespace http://tampermonkey.net/
// @version 6.0.0
// @author geraldpeng & claude 4.5 sonnet
// @description 字幕提取、AI总结、Notion集成、笔记保存、播放速度控制、SponsorBlock广告跳过 - 六合一工具集
// @license MIT
// @match *://www.bilibili.com/*
// @match *://search.bilibili.com/*
// @match *://space.bilibili.com/*
// @match *://*/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js
// @connect api.bilibili.com
// @connect aisubtitle.hdslb.com
// @connect api.notion.com
// @connect openrouter.ai
// @connect bsbsb.top
// @connect *
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
/**
* 常量定义模块
* 集中管理所有魔法数字和配置常量
*/
// ==================== 时间相关常量 ====================
const TIMING = {
// 检测间隔
CHECK_SUBTITLE_INTERVAL: 500, // 检测字幕按钮的间隔 (ms)
CHECK_MAX_ATTEMPTS: 20, // 最多检测次数(10秒)
// 延迟时间
SUBTITLE_ACTIVATION_DELAY: 1500, // 激活字幕的延迟
SUBTITLE_CAPTURE_DELAY: 500, // 捕获字幕的延迟
MENU_OPEN_DELAY: 500, // 打开菜单的延迟
CLOSE_SUBTITLE_DELAY: 100, // 关闭字幕显示的延迟
VIDEO_SWITCH_DELAY: 2000, // 视频切换后的延迟
AUTO_ACTIONS_DELAY: 500, // 自动操作的延迟
// 超时时间
AI_SUMMARY_TIMEOUT: 120000, // AI总结超时 (2分钟)
NOTION_SEND_TIMEOUT: 30000, // Notion发送超时 (30秒)
// Toast显示时间
TOAST_DURATION: 2000, // Toast默认显示时间
};
// ==================== 文本长度限制 ====================
const LIMITS = {
NOTION_TEXT_CHUNK: 1900, // Notion单个text对象的最大长度(留安全余量)
NOTION_TEXT_MAX: 2000, // Notion官方限制
NOTION_PAGE_ID_LENGTH: 32, // Notion Page ID的标准长度
};
// ==================== 状态类型 ====================
const BALL_STATUS = {
IDLE: 'idle', // 初始状态
LOADING: 'loading', // 加载中
ACTIVE: 'active', // 有字幕,可点击
NO_SUBTITLE: 'no-subtitle', // 无字幕
ERROR: 'error', // 错误
};
// ==================== 事件类型 ====================
const EVENTS = {
// 字幕相关
SUBTITLE_LOADED: 'subtitle:loaded',
SUBTITLE_FAILED: 'subtitle:failed',
SUBTITLE_REQUESTED: 'subtitle:requested',
// AI相关
AI_SUMMARY_START: 'ai:summary:start',
AI_SUMMARY_COMPLETE: 'ai:summary:complete',
AI_SUMMARY_FAILED: 'ai:summary:failed',
AI_SUMMARY_CHUNK: 'ai:summary:chunk',
// Notion相关
NOTION_SEND_START: 'notion:send:start',
NOTION_SEND_COMPLETE: 'notion:send:complete',
NOTION_SEND_FAILED: 'notion:send:failed',
// UI相关
UI_PANEL_TOGGLE: 'ui:panel:toggle',
UI_BALL_STATUS_CHANGE: 'ui:ball:status:change',
// 视频相关
VIDEO_CHANGED: 'video:changed',
};
// ==================== AI默认配置 ====================
const DEFAULT_PROMPT = `请用中文总结以下视频字幕内容,使用Markdown格式输出。
要求:
1. 在开头提供TL;DR(不超过50字的核心摘要)
2. 使用标题、列表等Markdown格式组织内容
3. 突出关键信息和要点
字幕内容:
`;
const AI_DEFAULT_CONFIGS = [
{
id: 'openrouter',
name: 'OpenRouter',
url: 'https://openrouter.ai/api/v1/chat/completions',
apiKey: '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',
model: 'alibaba/tongyi-deepresearch-30b-a3b:free',
prompt: DEFAULT_PROMPT,
isOpenRouter: true
},
{
id: 'openai',
name: 'OpenAI',
url: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
model: 'gpt-3.5-turbo',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'siliconflow',
name: '硅基流动',
url: 'https://api.siliconflow.cn/v1/chat/completions',
apiKey: '',
model: 'Qwen/Qwen2.5-7B-Instruct',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'deepseek',
name: 'DeepSeek',
url: 'https://api.deepseek.com/v1/chat/completions',
apiKey: '',
model: 'deepseek-chat',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'moonshot',
name: '月之暗面 Kimi',
url: 'https://api.moonshot.cn/v1/chat/completions',
apiKey: '',
model: 'moonshot-v1-8k',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'zhipu',
name: '智谱AI',
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
apiKey: '',
model: 'glm-4-flash',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'yi',
name: '零一万物',
url: 'https://api.lingyiwanwu.com/v1/chat/completions',
apiKey: '',
model: 'yi-large',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'dashscope',
name: '阿里云百炼',
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
apiKey: '',
model: 'qwen-plus',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
},
{
id: 'gemini',
name: 'Google Gemini',
url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
apiKey: '',
model: 'gemini-1.5-flash',
prompt: DEFAULT_PROMPT,
isOpenRouter: false
}
];
// ==================== 存储键名 ====================
const STORAGE_KEYS = {
AI_CONFIGS: 'ai_configs',
AI_SELECTED_ID: 'selected_ai_config_id',
AI_AUTO_SUMMARY: 'ai_auto_summary_enabled',
NOTION_API_KEY: 'notion_api_key',
NOTION_PARENT_PAGE_ID: 'notion_parent_page_id',
NOTION_DATABASE_ID: 'notion_database_id',
NOTION_AUTO_SEND: 'notion_auto_send_enabled',
};
// ==================== Z-Index层级 ====================
const Z_INDEX = {
BALL: 2147483647, // 最高层
CONTAINER: 2147483646, // 次高层
TOAST: 2147483645, // Toast层
AI_MODAL: 2147483643, // AI模态框
};
// ==================== API相关 ====================
const API = {
NOTION_VERSION: '2022-06-28',
NOTION_BASE_URL: 'https://api.notion.com/v1',
};
// ==================== 正则表达式 ====================
const REGEX = {
BVID_FROM_PATH: /\/video\/(BV[1-9A-Za-z]{10})/,
BVID_FROM_URL: /BV[1-9A-Za-z]{10}/,
NOTION_PAGE_ID: /([a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i,
};
// ==================== 选择器 ====================
const SELECTORS = {
VIDEO: 'video',
VIDEO_CONTAINER: '.bpx-player-container, #bilibili-player',
SUBTITLE_BUTTON: '.bpx-player-ctrl-subtitle-result',
SUBTITLE_CLOSE_SWITCH: '.bpx-player-ctrl-subtitle-close-switch[data-action="close"]',
VIDEO_TITLE_H1: 'h1.video-title',
};
// ==================== SponsorBlock 配置 ====================
const SPONSORBLOCK = {
// API配置
API_URL: 'https://bsbsb.top/api/skipSegments',
CACHE_EXPIRY: 1800000, // 30分钟
// 视频质量配置
MIN_SCORE: 0.06,
MIN_VIEWS: 1000,
TAG_COLOR: 'linear-gradient(135deg, #FF6B6B, #FF4D4D)',
TAG_TEXT: '🔥 精选',
TOP_TAG_COLOR: 'linear-gradient(135deg, #FFD700, #FFA500)',
TOP_TAG_TEXT: '🏆 顶级',
// 片段类别配置
CATEGORIES: {
'sponsor': { name: '广告', color: '#00d400' },
'selfpromo': { name: '无偿/自我推广', color: '#ffff00' },
'interaction': { name: '三连/订阅提醒', color: '#cc00ff' },
'poi_highlight': { name: '精彩时刻/重点', color: '#ff1684' },
'intro': { name: '过场/开场动画', color: '#00ffff' },
'outro': { name: '鸣谢/结束画面', color: '#0202ed' },
'preview': { name: '回顾/概要', color: '#008fd6' },
'filler': { name: '离题闲聊/玩笑', color: '#7300FF' },
'music_offtopic': { name: '音乐:非音乐部分', color: '#ff9900' },
'exclusive_access': { name: '柔性推广/品牌合作', color: '#008a5c' },
'mute': { name: '静音片段', color: '#B54D4B' }
},
// 默认设置
DEFAULT_SETTINGS: {
skipCategories: ['sponsor'],
showAdBadge: true,
showQualityBadge: true,
showProgressMarkers: true
}
};
/**
* 样式模块
* 集中管理所有CSS样式
*/
const CSS_STYLES = `
/* ==================== 小球样式 ==================== */
#subtitle-ball {
position: absolute;
right: -30px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #999;
cursor: pointer;
z-index: ${Z_INDEX.BALL};
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
transition: all 0.3s ease;
animation: breath-ball-normal 2s ease-in-out infinite;
}
#subtitle-ball:hover {
transform: translateY(-50%) scale(1.2);
box-shadow: 0 3px 10px rgba(0,0,0,0.35);
}
#subtitle-ball.active {
background-color: #feebea;
cursor: pointer;
}
#subtitle-ball.loading {
background-color: #3b82f6;
animation: breath-ball 1.2s ease-in-out infinite;
}
#subtitle-ball.no-subtitle {
background-color: #999;
cursor: default;
opacity: 0.6;
}
#subtitle-ball.error {
background-color: #ff0000;
cursor: default;
}
@keyframes breath-ball-normal {
0%, 100% { transform: translateY(-50%) scale(1); }
50% { transform: translateY(-50%) scale(1.05); }
}
@keyframes breath-ball {
0%, 100% { transform: translateY(-50%) scale(1); opacity: 1; }
50% { transform: translateY(-50%) scale(1.15); opacity: 0.7; }
}
/* ==================== 字幕容器样式 ==================== */
#subtitle-container {
position: absolute;
top: 0;
left: 100%;
width: 420px;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
color: #fff;
border-radius: 16px;
font-size: 14px;
line-height: 1.8;
display: none;
flex-direction: column;
overflow: hidden;
box-shadow: -4px 0 24px rgba(0,0,0,0.5);
border: 1px solid rgba(254, 235, 234, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: ${Z_INDEX.CONTAINER - 1};
margin-left: 10px;
}
#subtitle-container.show {
display: flex;
}
/* ==================== 头部样式 ==================== */
.subtitle-header {
font-size: 16px;
font-weight: 700;
padding: 20px;
border-bottom: 1px solid rgba(254, 235, 234, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
background: rgba(254, 235, 234, 0.15);
color: #fff;
border-radius: 16px 16px 0 0;
user-select: none;
}
.subtitle-header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.subtitle-close {
cursor: pointer;
font-size: 24px;
line-height: 1;
color: rgba(255, 255, 255, 0.6);
opacity: 0.7;
transition: all 0.2s;
}
.subtitle-close:hover {
opacity: 1;
color: #fff;
transform: scale(1.1);
}
/* ==================== 内容区域样式 ==================== */
.subtitle-content {
flex: 1;
overflow-y: auto;
padding: 15px 20px 20px 20px;
background-color: transparent;
}
.subtitle-content::-webkit-scrollbar {
width: 6px;
}
.subtitle-content::-webkit-scrollbar-thumb {
background-color: rgba(254, 235, 234, 0.4);
border-radius: 3px;
}
.subtitle-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(254, 235, 234, 0.6);
}
.subtitle-content::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.05);
}
/* ==================== 字幕列表样式 ==================== */
.subtitle-toggle-btn {
padding: 8px 12px;
margin-bottom: 15px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
height: auto;
}
.subtitle-toggle-btn:hover {
background: rgba(254, 235, 234, 0.2);
border-color: #feebea;
transform: scale(1.05);
}
.subtitle-toggle-icon {
transition: transform 0.3s ease;
display: inline-block;
font-size: 12px;
}
.subtitle-toggle-btn.expanded .subtitle-toggle-icon {
transform: rotate(90deg);
}
.subtitle-list-container {
display: none;
}
.subtitle-list-container.expanded {
display: block;
}
.subtitle-item {
margin-bottom: 6px;
padding: 10px 12px;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(254, 235, 234, 0.2);
}
.subtitle-item:hover {
background: rgba(254, 235, 234, 0.15);
border-color: #feebea;
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(254, 235, 234, 0.2);
}
.subtitle-item.current {
background: rgba(254, 235, 234, 0.25);
border-color: #feebea;
box-shadow: 0 2px 12px rgba(254, 235, 234, 0.3);
}
.subtitle-time {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
margin-bottom: 4px;
font-weight: 600;
}
.subtitle-text {
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
}
/* ==================== AI图标样式 ==================== */
.ai-icon {
cursor: pointer;
width: 24px;
height: 24px;
opacity: 0.7;
transition: opacity 0.2s, transform 0.2s;
}
.ai-icon:hover {
opacity: 1;
transform: scale(1.1);
}
.ai-icon.loading {
animation: breath-ai 1.2s ease-in-out infinite;
pointer-events: none;
}
.ai-icon.disabled {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
}
@keyframes breath-ai {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.6; }
}
/* ==================== 下载图标样式 ==================== */
.download-icon {
cursor: pointer;
width: 20px;
height: 20px;
opacity: 0.7;
transition: opacity 0.2s, transform 0.2s;
}
.download-icon:hover {
opacity: 1;
transform: scale(1.1);
}
/* ==================== Notion图标样式 ==================== */
.notion-icon {
cursor: pointer;
width: 24px;
height: 24px;
opacity: 0.7;
transition: opacity 0.2s, transform 0.2s;
}
.notion-icon:hover {
opacity: 1;
transform: scale(1.1);
}
.notion-icon.loading {
animation: breath-notion 1.2s ease-in-out infinite;
}
@keyframes breath-notion {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.6; }
}
/* ==================== Toast提示样式 ==================== */
.notion-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.85);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: ${Z_INDEX.TOAST};
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.notion-toast.show {
opacity: 1;
}
/* ==================== AI总结样式 ==================== */
.ai-summary-section {
padding: 15px;
margin-bottom: 15px;
background: rgba(254, 235, 234, 0.1);
border-radius: 12px;
border: 1px solid rgba(254, 235, 234, 0.3);
}
.ai-summary-title {
color: #fff;
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(254, 235, 234, 0.3);
}
.ai-summary-content {
color: #e5e7eb;
font-size: 14px;
line-height: 1.7;
word-wrap: break-word;
}
.ai-summary-loading {
color: rgba(255, 255, 255, 0.6);
font-style: italic;
}
/* ==================== Markdown样式 ==================== */
.ai-summary-content h1,
.ai-summary-content h2,
.ai-summary-content h3 {
color: #fff;
margin-top: 12px;
margin-bottom: 8px;
font-weight: 700;
}
.ai-summary-content h1 { font-size: 17px; }
.ai-summary-content h2 { font-size: 16px; }
.ai-summary-content h3 { font-size: 15px; }
.ai-summary-content ul,
.ai-summary-content ol {
margin: 8px 0;
padding-left: 20px;
}
.ai-summary-content li {
margin: 4px 0;
}
.ai-summary-content p {
margin: 8px 0;
}
.ai-summary-content code {
background: rgba(255, 255, 255, 0.1);
color: #feebea;
padding: 3px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.ai-summary-content pre {
background: rgba(0, 0, 0, 0.5);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 10px 0;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.ai-summary-content pre code {
background-color: transparent;
padding: 0;
border: none;
}
.ai-summary-content blockquote {
border-left: 4px solid #feebea;
background: rgba(254, 235, 234, 0.1);
padding: 12px;
padding-left: 16px;
margin: 10px 0;
border-radius: 4px;
}
.ai-summary-content strong {
color: #fff;
font-weight: 700;
}
.ai-summary-content a {
color: #feebea;
text-decoration: underline;
font-weight: 600;
}
.ai-summary-content a:hover {
color: #fff;
}
/* ==================== 配置模态框样式 ==================== */
.config-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: ${Z_INDEX.AI_MODAL};
display: none;
align-items: center;
justify-content: center;
}
.config-modal.show {
display: flex;
}
.config-modal-content {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
border-radius: 16px;
padding: 0;
width: 700px;
max-width: 90%;
max-height: 85vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
color: #fff;
display: flex;
flex-direction: column;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.config-modal-header {
font-size: 24px;
font-weight: 700;
padding: 30px 30px 20px 30px;
display: flex;
align-items: center;
gap: 12px;
background: rgba(254, 235, 234, 0.15);
color: white;
border-radius: 16px 16px 0 0;
border-bottom: 1px solid rgba(254, 235, 234, 0.2);
}
.config-modal-body {
flex: 1;
overflow-y: auto;
padding: 30px;
background-color: transparent;
}
.config-field {
margin-bottom: 20px;
}
.config-field label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: #e5e7eb;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.config-field label::before {
content: '•';
color: #feebea;
font-size: 18px;
font-weight: bold;
}
.config-field input,
.config-field textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 10px;
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.config-field input:hover,
.config-field textarea:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(254, 235, 234, 0.5);
}
.config-field input:focus,
.config-field textarea:focus {
outline: none;
border-color: #feebea;
box-shadow: 0 0 0 3px rgba(254, 235, 234, 0.15);
background: rgba(255, 255, 255, 0.15);
}
.config-field input::placeholder,
.config-field textarea::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.config-field textarea {
font-family: inherit;
resize: vertical;
min-height: 120px;
line-height: 1.6;
}
.config-field input[type="checkbox"] {
width: auto;
margin-right: 8px;
cursor: pointer;
}
.config-help {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
margin-top: 5px;
}
.config-help a {
color: #feebea;
text-decoration: underline;
}
.config-help code {
background-color: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 11px;
color: #fff;
}
.config-help strong {
color: #feebea;
}
.config-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 20px 30px;
background-color: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(254, 235, 234, 0.2);
border-radius: 0 0 16px 16px;
}
.config-btn {
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.config-btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.config-btn:hover::before {
width: 300px;
height: 300px;
}
.config-btn-primary {
background: linear-gradient(135deg, #feebea 0%, #2d2d2d 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(254, 235, 234, 0.3);
}
.config-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(254, 235, 234, 0.4);
}
.config-btn-primary:active {
transform: translateY(0);
}
.config-btn-secondary {
background-color: #f3f4f6;
color: #6b7280;
border: 2px solid #e5e7eb;
}
.config-btn-secondary:hover {
background-color: #e5e7eb;
color: #374151;
border-color: #d1d5db;
}
.config-btn-danger {
background-color: #fee2e2;
color: #dc2626;
border: 2px solid #fecaca;
}
.config-btn-danger:hover {
background-color: #dc2626;
color: white;
border-color: #dc2626;
}
.config-status {
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
margin-top: 10px;
}
.config-status.success {
background-color: #d4edda;
color: #155724;
}
.config-status.error {
background-color: #f8d7da;
color: #721c24;
}
/* ==================== AI配置列表样式 ==================== */
.ai-config-list {
margin-bottom: 25px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.ai-config-item {
padding: 10px 14px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 10px;
margin-bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(255, 255, 255, 0.05);
position: relative;
overflow: hidden;
}
.ai-config-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 4px;
background: linear-gradient(135deg, #feebea 0%, #ffdbdb 100%);
transform: scaleY(0);
transition: transform 0.3s ease;
}
.ai-config-item:hover {
background: rgba(254, 235, 234, 0.15);
border-color: #feebea;
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(254, 235, 234, 0.2);
}
.ai-config-item:hover::before {
transform: scaleY(1);
}
.ai-config-item.selected {
border-color: #feebea;
background: rgba(254, 235, 234, 0.2);
box-shadow: 0 4px 16px rgba(254, 235, 234, 0.3);
}
.ai-config-item.selected::before {
transform: scaleY(1);
width: 4px;
}
.ai-config-item-name {
font-weight: 600;
font-size: 14px;
color: #e5e7eb;
}
.ai-config-item.selected .ai-config-item-name {
color: #fff;
font-weight: 700;
}
.ai-config-item-actions {
display: flex;
gap: 8px;
z-index: 1;
}
.ai-config-btn-small {
padding: 4px 12px;
font-size: 12px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.ai-config-btn-small:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.ai-config-btn-small.config-btn-primary {
background: linear-gradient(135deg, #feebea 0%, #2d2d2d 100%);
color: white;
}
.ai-config-btn-small.config-btn-secondary {
background-color: #f3f4f6;
color: #6b7280;
}
.ai-config-btn-small.config-btn-secondary:hover {
background-color: #fee2e2;
color: #dc2626;
}
.ai-config-form {
border-top: 1px solid rgba(254, 235, 234, 0.2);
padding-top: 25px;
margin-top: 10px;
background: rgba(0, 0, 0, 0.3);
padding: 25px;
border-radius: 12px;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.ai-config-form.hidden {
display: none;
}
.ai-config-form .config-field {
margin-bottom: 20px;
}
/* ==================== 模型选择器样式 ==================== */
.model-select-wrapper {
margin-top: 8px;
position: relative;
}
.model-search-input {
width: 100%;
padding: 10px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.model-search-input:focus {
outline: none;
border-color: #feebea;
box-shadow: 0 0 0 3px rgba(254, 235, 234, 0.15);
background: rgba(255, 255, 255, 0.15);
}
.model-search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.model-select-wrapper select {
width: 100%;
padding: 10px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
max-height: 200px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.model-select-wrapper select option {
padding: 8px;
background: rgba(0, 0, 0, 0.9);
color: #fff;
}
.model-count-badge {
display: inline-block;
background: #feebea;
color: #1a1a1a;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
margin-left: 8px;
font-weight: 600;
}
.model-field-with-button {
display: flex;
gap: 8px;
align-items: center;
}
.model-field-with-button input {
flex: 1;
}
.fetch-models-btn {
padding: 12px 20px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #feebea 0%, #2d2d2d 100%);
color: white;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(254, 235, 234, 0.3);
}
.fetch-models-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(254, 235, 234, 0.4);
}
.fetch-models-btn:active {
transform: translateY(0);
}
.fetch-models-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
box-shadow: none;
}
/* ==================== 速度控制样式 ==================== */
.speed-control-section {
padding: 12px;
margin-bottom: 15px;
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-radius: 12px;
border: 2px solid rgba(254, 235, 234, 0.5);
}
.speed-control-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 2px solid rgba(254, 235, 234, 0.5);
}
.speed-control-title {
font-size: 14px;
font-weight: 700;
color: #2d2d2d;
}
.speed-control-display {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
font-family: monospace;
}
.speed-control-buttons {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.speed-btn {
flex: 1;
padding: 8px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
color: #1a1a1a;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.speed-btn:hover {
background: #feebea;
border-color: #feebea;
transform: translateY(-1px);
}
.speed-btn-small {
flex: 0 0 40px;
font-size: 18px;
}
.speed-control-advanced {
margin-top: 8px;
}
.speed-toggle-volume-btn {
width: 100%;
padding: 8px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
color: #6b7280;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.speed-toggle-volume-btn:hover {
background: #fff5f5;
border-color: #ffe5e5;
}
/* ==================== 笔记面板样式 ==================== */
.notes-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
max-width: 90%;
max-height: 80vh;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
z-index: 2147483640;
display: none;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.notes-panel.show {
display: flex;
}
.notes-panel-content {
display: flex;
flex-direction: column;
height: 100%;
}
.notes-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(254, 235, 234, 0.2);
background: rgba(254, 235, 234, 0.15);
}
.notes-panel-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #fff;
}
.notes-panel-close {
background: none;
border: none;
font-size: 24px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.notes-panel-close:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.notes-panel-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.notes-empty-state {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.6);
}
.notes-empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.notes-empty-hint {
font-size: 14px;
margin-top: 8px;
}
.note-group {
margin-bottom: 24px;
}
.note-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(254, 235, 234, 0.2);
}
.note-group-title {
font-size: 14px;
font-weight: 600;
color: #e5e7eb;
}
.note-group-actions {
display: flex;
gap: 8px;
}
.note-group-copy-btn,
.note-group-delete-btn {
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
border: 1px solid;
}
.note-group-copy-btn {
background: none;
border-color: #4A90E2;
color: #4A90E2;
}
.note-group-copy-btn:hover {
background: #4A90E2;
color: white;
}
.note-group-delete-btn {
background: none;
border-color: #e74c3c;
color: #e74c3c;
}
.note-group-delete-btn:hover {
background: #e74c3c;
color: white;
}
.note-item {
background: rgba(255, 255, 255, 0.05);
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
transition: background-color 0.2s;
border: 1px solid rgba(254, 235, 234, 0.1);
}
.note-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(254, 235, 234, 0.3);
}
.note-content {
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
margin-bottom: 8px;
word-break: break-word;
white-space: pre-wrap;
}
.note-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.note-time {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.note-actions {
display: flex;
gap: 8px;
}
.note-copy-btn,
.note-delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
transition: color 0.2s;
}
.note-copy-btn {
color: #4A90E2;
}
.note-copy-btn:hover {
color: #357ABD;
}
.note-delete-btn {
color: #e74c3c;
}
.note-delete-btn:hover {
color: #c0392b;
}
/* ==================== 字幕项保存按钮样式 ==================== */
.subtitle-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.save-subtitle-note-btn {
background: linear-gradient(135deg, #feebea 0%, #2d2d2d 100%);
color: white;
border: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
opacity: 0;
}
.subtitle-item:hover .save-subtitle-note-btn {
opacity: 1;
}
.save-subtitle-note-btn:hover {
transform: scale(1.05);
}
/* ==================== 快捷键配置样式 ==================== */
.shortcut-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.shortcut-label {
font-size: 14px;
color: #e5e7eb;
font-weight: 500;
}
.shortcut-input-wrapper {
display: flex;
gap: 8px;
align-items: center;
}
.shortcut-input {
padding: 6px 12px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 6px;
font-size: 13px;
min-width: 180px;
text-align: center;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
transition: all 0.2s;
}
.shortcut-input:focus {
outline: none;
border-color: #feebea;
box-shadow: 0 0 0 3px rgba(254, 235, 234, 0.15);
background: rgba(255, 255, 255, 0.15);
}
.shortcut-input.capturing {
border-color: #feebea;
background: rgba(254, 235, 234, 0.2);
animation: pulse-border 1s infinite;
}
@keyframes pulse-border {
0%, 100% {
border-color: #feebea;
box-shadow: 0 0 0 3px rgba(254, 235, 234, 0.15);
}
50% {
border-color: #ffc9c9;
box-shadow: 0 0 0 3px rgba(254, 235, 234, 0.3);
}
}
.shortcut-clear-btn {
background: none;
border: none;
color: #999;
font-size: 18px;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.shortcut-clear-btn:hover {
background: #fee2e2;
color: #dc2626;
}
/* ==================== 调整大小手柄样式 ==================== */
.subtitle-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
z-index: 10;
}
.subtitle-resize-handle::after {
content: '';
position: absolute;
bottom: 4px;
right: 4px;
width: 12px;
height: 12px;
border-right: 3px solid rgba(254, 235, 234, 0.6);
border-bottom: 3px solid rgba(254, 235, 234, 0.6);
border-radius: 0 0 4px 0;
}
.subtitle-resize-handle:hover::after {
border-color: #feebea;
}
/* ==================== 速度控制模态框样式 ==================== */
.speed-control-section-large {
padding: 20px;
background: rgba(254, 235, 234, 0.1);
border-radius: 12px;
border: 1px solid rgba(254, 235, 234, 0.3);
margin-bottom: 20px;
}
.speed-control-header-large {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(254, 235, 234, 0.3);
}
.speed-control-display-large {
font-size: 32px;
font-weight: 700;
color: #fff;
font-family: monospace;
}
.speed-control-buttons-large {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.speed-btn-large {
padding: 16px;
border: 1px solid rgba(254, 235, 234, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.speed-btn-large:hover {
background: rgba(254, 235, 234, 0.2);
border-color: #feebea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(254, 235, 234, 0.3);
}
.speed-btn-large:active {
transform: translateY(0);
}
.speed-status-info {
margin-top: 12px;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
min-height: 40px;
border: 1px solid rgba(254, 235, 234, 0.2);
}
.speed-status-item {
font-size: 12px;
color: #4CAF50;
font-weight: 600;
padding: 4px 0;
}
.sponsor-switch {
position: relative;
width: 48px;
height: 24px;
}
.sponsor-switch input {
opacity: 0;
width: 0;
height: 0;
}
.sponsor-switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #cbd5e1;
transition: 0.3s;
border-radius: 24px;
}
.sponsor-switch-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.sponsor-switch input:checked + .sponsor-switch-slider {
background-color: #feebea;
}
.sponsor-switch input:checked + .sponsor-switch-slider:before {
transform: translateX(24px);
}
/* ==================== SponsorBlock 标签样式 ==================== */
.bili-quality-tag, .bili-ad-tag {
display: inline-flex !important;
align-items: center;
color: white !important;
padding: 3px 10px !important;
border-radius: 15px !important;
margin-right: 6px !important;
font-size: 12px !important;
animation: badgeSlideIn 0.3s ease-out !important;
position: relative;
z-index: 2;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* 视频卡片标签位置 */
.video-page-card-small .bili-quality-tag,
.video-page-card-small .bili-ad-tag,
.bili-video-card__wrap .bili-quality-tag,
.bili-video-card__wrap .bili-ad-tag {
position: absolute;
left: 8px;
top: 8px;
transform: scale(0.9);
}
/* UP主主页视频卡片 */
.up-main-video-card .bili-quality-tag,
.up-main-video-card .bili-ad-tag,
.small-item .bili-quality-tag,
.small-item .bili-ad-tag {
position: absolute !important;
left: 8px !important;
top: 8px !important;
z-index: 10 !important;
transform: scale(0.9);
}
.up-main-video-card .cover-container,
.up-main-video-card .cover,
.small-item .cover {
position: relative !important;
}
/* 多标签容器 */
.bili-tags-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
@keyframes badgeSlideIn {
0% { opacity: 0; transform: translateX(-15px) scale(0.9); }
100% { opacity: 1; transform: translateX(0) scale(0.9); }
}
/* 跳过提示Toast - 视频右下角,绿色 */
.skip-toast {
position: absolute;
bottom: 60px;
right: 20px;
background: rgba(0, 212, 0, 0.15);
color: #00d400;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
font-weight: 500;
backdrop-filter: blur(4px);
pointer-events: auto !important;
user-select: none;
}
.skip-toast.hiding {
animation: fadeOut 0.3s ease-out forwards;
}
/* 手动跳过提示 - 视频右下角 */
.skip-prompt {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.9);
color: white;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 10000;
min-width: 280px;
animation: fadeIn 0.3s ease-out;
pointer-events: auto !important;
user-select: none;
}
.skip-prompt-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 14px;
font-weight: 500;
}
.skip-prompt-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.skip-prompt-icon svg {
width: 100%;
height: 100%;
}
.skip-prompt-message {
flex: 1;
}
.skip-prompt-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.skip-prompt-btn {
padding: 6px 14px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.skip-prompt-btn-primary {
background: #00a1d6;
color: white;
}
.skip-prompt-btn-primary:hover {
background: #0087b3;
}
.skip-prompt-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.skip-prompt-btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.skip-prompt-close {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 18px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 8px;
}
.skip-prompt-close:hover {
color: white;
}
.skip-prompt.hiding {
animation: fadeOut 0.3s ease-out forwards;
}
/* 进度条片段标记 */
#sponsorblock-preview-bar {
overflow: hidden;
padding: 0;
margin: 0;
position: absolute;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.sponsorblock-segment {
display: inline-block;
height: 100%;
position: absolute;
min-width: 1px;
opacity: 0.7;
transition: all 0.2s ease;
pointer-events: auto;
cursor: pointer;
}
.sponsorblock-segment:hover {
opacity: 0.95;
transform: scaleY(1.5);
z-index: 100;
}
/* 片段详情弹窗 */
.segment-details-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.95);
color: white;
border-radius: 12px;
padding: 24px;
min-width: 350px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
z-index: 10002;
animation: popupFadeIn 0.2s ease-out;
}
@keyframes popupFadeIn {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.segment-details-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.2);
}
.segment-details-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 500;
}
.segment-details-close {
background: none;
border: none;
color: #999;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.segment-details-close:hover {
background: rgba(255,255,255,0.1);
color: white;
}
.segment-details-content {
margin-bottom: 16px;
}
.segment-details-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.segment-details-label {
color: #999;
}
.segment-details-value {
color: white;
font-weight: 500;
}
.segment-details-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.segment-details-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.segment-details-btn-primary {
background: #00a1d6;
color: white;
}
.segment-details-btn-primary:hover {
background: #0087b3;
}
.segment-details-btn-secondary {
background: rgba(255,255,255,0.1);
color: white;
}
.segment-details-btn-secondary:hover {
background: rgba(255,255,255,0.2);
}
.segment-details-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.3);
z-index: 10001;
}
/* SponsorBlock 设置面板样式 */
.sponsor-settings-section {
margin-bottom: 24px;
}
.sponsor-settings-section h3 {
font-size: 16px;
color: #e5e7eb;
margin: 0 0 12px 0;
}
.sponsor-checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.sponsor-checkbox-item {
display: flex;
align-items: center;
padding: 8px;
border-radius: 6px;
transition: background 0.2s;
}
.sponsor-checkbox-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.sponsor-checkbox-item input[type="checkbox"] {
margin-right: 10px;
cursor: pointer;
width: 18px;
height: 18px;
}
.sponsor-checkbox-item label {
cursor: pointer;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
color: #e5e7eb;
}
.category-color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.sponsor-switch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(254, 235, 234, 0.2);
margin-bottom: 8px;
color: #e5e7eb;
}
`;
/**
* 注入样式到页面
*/
function injectStyles() {
const style = document.createElement('style');
style.textContent = CSS_STYLES;
document.head.appendChild(style);
}
// SVG图标
const ICONS = {
AI: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 21L12 12L12.2 6.2L11 5M15 4V2M15 16V14M8 9H10M20 9H22M17.8 11.8L19 13M17.8 6.2L19 5" stroke="#2d2d2d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="1.5" fill="#2d2d2d"/>
<path d="M17 7L12 12L7 7" stroke="#2d2d2d" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
</svg>`,
DOWNLOAD: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V16M12 16L7 11M12 16L17 11" stroke="#2d2d2d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 17V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V17" stroke="#2d2d2d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
NOTION: `<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.724 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
</svg>`};
/**
* 验证工具模块
* 提供各种输入验证和格式检查功能
*/
/**
* 验证Notion Page ID格式
* @param {string} pageId - Page ID
* @returns {{valid: boolean, cleaned: string|null, error: string|null}}
*/
function validateNotionPageId(pageId) {
if (!pageId || typeof pageId !== 'string') {
return { valid: false, cleaned: null, error: 'Page ID不能为空' };
}
// 移除URL,只保留ID
let cleanedId = pageId.split('?')[0].split('#')[0];
// 提取32位ID
const match = cleanedId.match(REGEX.NOTION_PAGE_ID);
if (!match) {
return { valid: false, cleaned: null, error: 'Page ID格式错误,应为32位十六进制字符' };
}
// 移除横线,统一格式
cleanedId = match[1].replace(/-/g, '');
// 验证长度
if (cleanedId.length !== LIMITS.NOTION_PAGE_ID_LENGTH) {
return { valid: false, cleaned: null, error: `Page ID长度错误,需要${LIMITS.NOTION_PAGE_ID_LENGTH}位字符` };
}
return { valid: true, cleaned: cleanedId, error: null };
}
/**
* 验证API URL格式
* @param {string} url - API URL
* @returns {{valid: boolean, error: string|null}}
*/
function validateApiUrl(url) {
if (!url || typeof url !== 'string') {
return { valid: false, error: 'URL不能为空' };
}
// 检查是否以http或https开头
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { valid: false, error: 'URL必须以 http:// 或 https:// 开头' };
}
// 尝试解析URL
try {
new URL(url);
return { valid: true, error: null };
} catch (e) {
return { valid: false, error: 'URL格式无效' };
}
}
/**
* 验证API Key格式
* @param {string} apiKey - API Key
* @returns {{valid: boolean, error: string|null}}
*/
function validateApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
return { valid: false, error: 'API Key不能为空' };
}
if (apiKey.trim().length === 0) {
return { valid: false, error: 'API Key不能为空' };
}
// 基本长度检查(大多数API Key至少10个字符)
if (apiKey.length < 10) {
return { valid: false, error: 'API Key长度过短,请检查是否完整' };
}
return { valid: true, error: null };
}
/**
* 验证视频信息
* @param {{bvid: string, cid: string|number}} videoInfo - 视频信息
* @returns {{valid: boolean, error: string|null}}
*/
function validateVideoInfo(videoInfo) {
if (!videoInfo) {
return { valid: false, error: '视频信息为空' };
}
if (!videoInfo.bvid || !videoInfo.bvid.match(/^BV[1-9A-Za-z]{10}$/)) {
return { valid: false, error: 'BV号格式错误' };
}
if (!videoInfo.cid) {
return { valid: false, error: 'CID为空' };
}
return { valid: true, error: null };
}
/**
* 验证字幕数据
* @param {Array} subtitleData - 字幕数据数组
* @returns {{valid: boolean, error: string|null}}
*/
function validateSubtitleData(subtitleData) {
if (!Array.isArray(subtitleData)) {
return { valid: false, error: '字幕数据格式错误' };
}
if (subtitleData.length === 0) {
return { valid: false, error: '字幕数据为空' };
}
// 检查第一条字幕的格式
const first = subtitleData[0];
if (!first.from || !first.to || !first.content) {
return { valid: false, error: '字幕数据格式不完整' };
}
return { valid: true, error: null };
}
/**
* 安全地生成缓存键
* @param {{bvid: string, cid: string|number}} videoInfo - 视频信息
* @returns {string|null} - 缓存键,如果无效返回null
*/
function generateCacheKey(videoInfo) {
const validation = validateVideoInfo(videoInfo);
if (!validation.valid) {
return null;
}
return `${videoInfo.bvid}-${videoInfo.cid}`;
}
/**
* 事件总线模块
* 用于解耦不同模块之间的通信
*/
class EventBus {
constructor() {
this.events = new Map();
}
/**
* 订阅事件
* @param {string} event - 事件名称
* @param {Function} handler - 事件处理函数
* @returns {Function} - 取消订阅的函数
*/
on(event, handler) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(handler);
// 返回取消订阅的函数
return () => this.off(event, handler);
}
/**
* 订阅一次性事件
* @param {string} event - 事件名称
* @param {Function} handler - 事件处理函数
*/
once(event, handler) {
const onceHandler = (...args) => {
handler(...args);
this.off(event, onceHandler);
};
this.on(event, onceHandler);
}
/**
* 取消订阅事件
* @param {string} event - 事件名称
* @param {Function} handler - 事件处理函数
*/
off(event, handler) {
if (!this.events.has(event)) return;
const handlers = this.events.get(event);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
// 如果没有处理函数了,删除整个事件
if (handlers.length === 0) {
this.events.delete(event);
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {...any} args - 传递给处理函数的参数
*/
emit(event, ...args) {
if (!this.events.has(event)) return;
const handlers = [...this.events.get(event)]; // 复制数组,避免在遍历时被修改
for (const handler of handlers) {
try {
handler(...args);
} catch (error) {
console.error(`[EventBus] 事件 "${event}" 处理出错:`, error);
}
}
}
/**
* 清空所有事件监听器
*/
clear() {
this.events.clear();
}
/**
* 获取某个事件的监听器数量
* @param {string} event - 事件名称
* @returns {number}
*/
listenerCount(event) {
return this.events.has(event) ? this.events.get(event).length : 0;
}
}
// 创建全局单例
const eventBus = new EventBus();
/**
* 状态管理模块
* 集中管理应用的所有状态,解决全局变量散乱和竞态条件问题
*/
class StateManager {
constructor() {
this.reset();
}
/**
* 重置所有状态
* 解决"状态重置不完整"的问题
*/
reset() {
// 字幕相关状态
this.subtitle = {
data: null, // 当前字幕数据
cache: {}, // 字幕缓存 {videoKey: subtitleData}
capturedUrl: null, // 捕获到的字幕URL
};
// 请求相关状态(解决竞态条件)
this.request = {
isRequesting: false, // 是否正在请求
currentRequestKey: null, // 当前请求的视频key
requestPromise: null, // 当前请求的Promise
abortController: null, // 用于取消请求
};
// AI相关状态
this.ai = {
isSummarizing: false, // 是否正在生成总结
currentSummary: null, // 当前总结内容
summaryPromise: null, // 总结Promise
abortController: null, // 用于取消AI总结
};
// Notion相关状态
this.notion = {
isSending: false, // 是否正在发送
sendPromise: null, // 发送Promise
};
// UI相关状态
this.ui = {
ballStatus: BALL_STATUS.IDLE, // 小球状态
panelVisible: false, // 面板是否可见
isDragging: false, // 是否正在拖拽
dragStart: { x: 0, y: 0 }, // 拖拽起始位置
panelStart: { x: 0, y: 0 }, // 面板起始位置
};
// 视频相关状态
this.video = {
bvid: null, // 当前视频BV号
cid: null, // 当前视频CID
aid: null, // 当前视频AID
};
}
/**
* 更新视频信息
* @param {{bvid: string, cid: string|number, aid: string|number}} videoInfo
*/
setVideoInfo(videoInfo) {
const validation = validateVideoInfo(videoInfo);
if (!validation.valid) {
return false;
}
this.video.bvid = videoInfo.bvid;
this.video.cid = videoInfo.cid;
this.video.aid = videoInfo.aid;
return true;
}
/**
* 获取当前视频信息
* @returns {{bvid: string, cid: string|number, aid: string|number}}
*/
getVideoInfo() {
return { ...this.video };
}
/**
* 生成当前视频的缓存键
* @returns {string|null}
*/
getVideoKey() {
return generateCacheKey(this.video);
}
/**
* 设置字幕数据(同时更新缓存)
* @param {Array} data - 字幕数据
*/
setSubtitleData(data) {
this.subtitle.data = data;
// 更新缓存
const videoKey = this.getVideoKey();
if (videoKey) {
this.subtitle.cache[videoKey] = data;
}
// 触发事件
if (data && data.length > 0) {
eventBus.emit(EVENTS.SUBTITLE_LOADED, data, videoKey);
}
}
/**
* 获取字幕数据(优先从缓存)
* @param {string|null} videoKey - 视频键,不传则使用当前视频
* @returns {Array|null}
*/
getSubtitleData(videoKey = null) {
const key = videoKey || this.getVideoKey();
if (!key) {
return this.subtitle.data;
}
// 优先从缓存获取
if (this.subtitle.cache[key]) {
return this.subtitle.cache[key];
}
// 如果是当前视频,返回当前数据
if (key === this.getVideoKey()) {
return this.subtitle.data;
}
return null;
}
/**
* 开始请求(原子操作,解决竞态条件)
* @returns {{success: boolean, reason: string|null}}
*/
startRequest() {
const videoKey = this.getVideoKey();
if (!videoKey) {
return { success: false, reason: '视频信息无效' };
}
// 检查是否正在请求相同的视频
if (this.request.isRequesting && this.request.currentRequestKey === videoKey) {
return { success: false, reason: '已有相同视频的请求在进行中' };
}
// 检查缓存
if (this.subtitle.cache[videoKey]) {
return { success: false, reason: '已有缓存' };
}
// 如果正在请求其他视频,取消旧请求
if (this.request.isRequesting) {
this.cancelRequest();
}
// 开始新请求
this.request.isRequesting = true;
this.request.currentRequestKey = videoKey;
return { success: true, reason: null };
}
/**
* 完成请求
*/
finishRequest() {
this.request.isRequesting = false;
this.request.currentRequestKey = null;
this.request.requestPromise = null;
this.request.abortController = null;
}
/**
* 取消当前请求
*/
cancelRequest() {
if (this.request.abortController) {
this.request.abortController.abort();
}
this.finishRequest();
}
/**
* 开始AI总结
* @returns {boolean}
*/
startAISummary() {
if (this.ai.isSummarizing) {
return false;
}
this.ai.isSummarizing = true;
this.ai.abortController = new AbortController();
eventBus.emit(EVENTS.AI_SUMMARY_START);
return true;
}
/**
* 完成AI总结
* @param {string} summary - 总结内容
*/
finishAISummary(summary) {
this.ai.isSummarizing = false;
this.ai.currentSummary = summary;
this.ai.summaryPromise = null;
this.ai.abortController = null;
// 保存到sessionStorage
const videoKey = this.getVideoKey();
if (videoKey && summary) {
sessionStorage.setItem(`ai-summary-${videoKey}`, summary);
}
eventBus.emit(EVENTS.AI_SUMMARY_COMPLETE, summary, videoKey);
}
/**
* 取消AI总结
*/
cancelAISummary() {
if (this.ai.abortController) {
this.ai.abortController.abort();
}
this.ai.isSummarizing = false;
this.ai.summaryPromise = null;
this.ai.abortController = null;
}
/**
* 获取AI总结(优先从缓存)
* @param {string|null} videoKey - 视频键
* @returns {string|null}
*/
getAISummary(videoKey = null) {
const key = videoKey || this.getVideoKey();
if (!key) {
return this.ai.currentSummary;
}
// 从sessionStorage获取
const cached = sessionStorage.getItem(`ai-summary-${key}`);
if (cached) {
return cached;
}
// 如果是当前视频,返回当前总结
if (key === this.getVideoKey()) {
return this.ai.currentSummary;
}
return null;
}
/**
* 更新小球状态
* @param {string} status - 状态值
*/
setBallStatus(status) {
if (this.ui.ballStatus !== status) {
this.ui.ballStatus = status;
eventBus.emit(EVENTS.UI_BALL_STATUS_CHANGE, status);
}
}
/**
* 获取小球状态
* @returns {string}
*/
getBallStatus() {
return this.ui.ballStatus;
}
/**
* 切换面板显示状态
*/
togglePanel() {
this.ui.panelVisible = !this.ui.panelVisible;
eventBus.emit(EVENTS.UI_PANEL_TOGGLE, this.ui.panelVisible);
}
/**
* 设置面板显示状态
* @param {boolean} visible
*/
setPanelVisible(visible) {
if (this.ui.panelVisible !== visible) {
this.ui.panelVisible = visible;
eventBus.emit(EVENTS.UI_PANEL_TOGGLE, visible);
}
}
}
// 创建全局单例
const state = new StateManager();
/**
* 配置管理模块
* 统一管理AI和Notion的配置,避免重复代码
*/
class ConfigManager {
/**
* 获取AI配置列表
* @returns {Array}
*/
getAIConfigs() {
const configs = GM_getValue(STORAGE_KEYS.AI_CONFIGS, []);
if (configs.length === 0) {
return [...AI_DEFAULT_CONFIGS]; // 返回默认配置的副本
}
return configs;
}
/**
* 保存AI配置列表
* @param {Array} configs
*/
saveAIConfigs(configs) {
GM_setValue(STORAGE_KEYS.AI_CONFIGS, configs);
}
/**
* 获取当前选中的AI配置ID
* @returns {string}
*/
getSelectedAIConfigId() {
return GM_getValue(STORAGE_KEYS.AI_SELECTED_ID, 'openrouter');
}
/**
* 设置当前选中的AI配置ID
* @param {string} id
*/
setSelectedAIConfigId(id) {
GM_setValue(STORAGE_KEYS.AI_SELECTED_ID, id);
}
/**
* 获取当前选中的AI配置
* @returns {Object|null}
*/
getSelectedAIConfig() {
const configs = this.getAIConfigs();
const selectedId = this.getSelectedAIConfigId();
return configs.find(c => c.id === selectedId) || configs[0] || null;
}
/**
* 添加AI配置
* @param {Object} config
* @returns {{success: boolean, error: string|null}}
*/
addAIConfig(config) {
// 验证必填字段
if (!config.name || !config.url || !config.apiKey || !config.model) {
return { success: false, error: '所有字段都是必填的' };
}
// 验证URL
const urlValidation = validateApiUrl(config.url);
if (!urlValidation.valid) {
return { success: false, error: urlValidation.error };
}
// 验证API Key
const keyValidation = validateApiKey(config.apiKey);
if (!keyValidation.valid) {
return { success: false, error: keyValidation.error };
}
const configs = this.getAIConfigs();
const newConfig = {
id: Date.now().toString(),
name: config.name.trim(),
url: config.url.trim(),
apiKey: config.apiKey.trim(),
model: config.model.trim(),
prompt: config.prompt || '根据以下视频字幕,用中文总结视频内容:\n\n',
isOpenRouter: config.isOpenRouter || false
};
configs.push(newConfig);
this.saveAIConfigs(configs);
this.setSelectedAIConfigId(newConfig.id);
return { success: true, error: null, config: newConfig };
}
/**
* 更新AI配置
* @param {string} id
* @param {Object} updates
* @returns {{success: boolean, error: string|null}}
*/
updateAIConfig(id, updates) {
const configs = this.getAIConfigs();
const index = configs.findIndex(c => c.id === id);
if (index === -1) {
return { success: false, error: '配置不存在' };
}
// 验证更新的字段
if (updates.url) {
const urlValidation = validateApiUrl(updates.url);
if (!urlValidation.valid) {
return { success: false, error: urlValidation.error };
}
}
if (updates.apiKey) {
const keyValidation = validateApiKey(updates.apiKey);
if (!keyValidation.valid) {
return { success: false, error: keyValidation.error };
}
}
configs[index] = { ...configs[index], ...updates };
this.saveAIConfigs(configs);
return { success: true, error: null };
}
/**
* 删除AI配置
* @param {string} id
* @returns {{success: boolean, error: string|null}}
*/
deleteAIConfig(id) {
// 不允许删除预设配置
if (id === 'openrouter' || id === 'openai') {
return { success: false, error: '预设配置不能删除' };
}
let configs = this.getAIConfigs();
configs = configs.filter(c => c.id !== id);
this.saveAIConfigs(configs);
// 如果删除的是当前选中的配置,切换到默认配置
if (this.getSelectedAIConfigId() === id) {
this.setSelectedAIConfigId('openrouter');
}
return { success: true, error: null };
}
/**
* 获取AI自动总结开关状态
* @returns {boolean}
*/
getAIAutoSummaryEnabled() {
return GM_getValue(STORAGE_KEYS.AI_AUTO_SUMMARY, true);
}
/**
* 设置AI自动总结开关状态
* @param {boolean} enabled
*/
setAIAutoSummaryEnabled(enabled) {
GM_setValue(STORAGE_KEYS.AI_AUTO_SUMMARY, enabled);
}
/**
* 获取Notion配置
* @returns {{apiKey: string, parentPageId: string, databaseId: string}}
*/
getNotionConfig() {
return {
apiKey: GM_getValue(STORAGE_KEYS.NOTION_API_KEY, ''),
parentPageId: GM_getValue(STORAGE_KEYS.NOTION_PARENT_PAGE_ID, ''),
databaseId: GM_getValue(STORAGE_KEYS.NOTION_DATABASE_ID, '')
};
}
/**
* 保存Notion配置
* @param {Object} config
* @returns {{success: boolean, error: string|null}}
*/
saveNotionConfig(config) {
// 验证API Key
if (config.apiKey) {
const keyValidation = validateApiKey(config.apiKey);
if (!keyValidation.valid) {
return { success: false, error: keyValidation.error };
}
GM_setValue(STORAGE_KEYS.NOTION_API_KEY, config.apiKey.trim());
}
// 验证Page ID
if (config.parentPageId) {
const pageIdValidation = validateNotionPageId(config.parentPageId);
if (!pageIdValidation.valid) {
return { success: false, error: pageIdValidation.error };
}
GM_setValue(STORAGE_KEYS.NOTION_PARENT_PAGE_ID, pageIdValidation.cleaned);
}
// 保存Database ID
if (config.databaseId !== undefined) {
GM_setValue(STORAGE_KEYS.NOTION_DATABASE_ID, config.databaseId);
}
return { success: true, error: null };
}
/**
* 获取Notion自动发送开关状态
* @returns {boolean}
*/
getNotionAutoSendEnabled() {
return GM_getValue(STORAGE_KEYS.NOTION_AUTO_SEND, false);
}
/**
* 设置Notion自动发送开关状态
* @param {boolean} enabled
*/
setNotionAutoSendEnabled(enabled) {
GM_setValue(STORAGE_KEYS.NOTION_AUTO_SEND, enabled);
}
}
// 创建全局单例
const config = new ConfigManager();
/**
* 辅助函数模块
* 提供各种通用的辅助功能
*/
/**
* 格式化时间(秒转为 MM:SS 格式)
* @param {number} seconds - 秒数
* @returns {string} - 格式化后的时间
*/
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* 从URL中提取BV号
* @param {string} url - URL字符串
* @returns {string|null} - BV号或null
*/
function extractBvidFromUrl(url = window.location.href) {
// 方法1: 从路径中精确提取
const pathMatch = url.match(REGEX.BVID_FROM_PATH);
if (pathMatch) {
return pathMatch[1];
}
// 方法2: 使用通用正则
const bvMatch = url.match(REGEX.BVID_FROM_URL);
return bvMatch ? bvMatch[0] : null;
}
/**
* 获取视频信息
* @returns {{bvid: string|null, cid: string|number|null, aid: string|number|null}}
*/
function getVideoInfo() {
let bvid = null;
let cid = null;
let aid = null;
// 从URL提取BV号
bvid = extractBvidFromUrl();
// 尝试从页面数据中获取CID和AID
try {
const initialState = unsafeWindow.__INITIAL_STATE__;
if (initialState && initialState.videoData) {
bvid = bvid || initialState.videoData.bvid;
cid = initialState.videoData.cid || initialState.videoData.pages?.[0]?.cid;
aid = initialState.videoData.aid;
}
} catch (e) {
// Silently ignore
}
return { bvid, cid, aid };
}
/**
* 获取视频标题
* @returns {string} - 视频标题
*/
function getVideoTitle() {
let title = '';
// 方法1: 从__INITIAL_STATE__获取
try {
const initialState = unsafeWindow.__INITIAL_STATE__;
if (initialState && initialState.videoData && initialState.videoData.title) {
title = initialState.videoData.title;
}
} catch (e) {
// Silently ignore
}
// 方法2: 从h1标签获取
if (!title) {
const h1 = document.querySelector(SELECTORS.VIDEO_TITLE_H1);
if (h1) {
title = h1.textContent.trim();
}
}
// 方法3: 从document.title提取
if (!title) {
title = document.title
.replace(/_哔哩哔哩.*$/, '')
.replace(/_bilibili.*$/i, '')
.trim();
}
return title || '未知视频';
}
/**
* 获取视频创作者信息
* @returns {string} - 创作者名称
*/
function getVideoCreator() {
try {
const initialState = unsafeWindow.__INITIAL_STATE__;
if (initialState && initialState.videoData && initialState.videoData.owner) {
return initialState.videoData.owner.name;
}
} catch (e) {
// Silently ignore
}
return '未知';
}
/**
* 获取视频URL(去除查询参数)
* @returns {string} - 清理后的视频URL
*/
function getVideoUrl() {
return window.location.href.split('?')[0];
}
/**
* 延迟执行
* @param {number} ms - 延迟时间(毫秒)
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 带超时的Promise
* @param {Promise} promise - 原始Promise
* @param {number} timeout - 超时时间(毫秒)
* @param {string} errorMessage - 超时错误信息
* @returns {Promise}
*/
function withTimeout(promise, timeout, errorMessage = '操作超时') {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(errorMessage)), timeout)
)
]);
}
/**
* 下载文本文件
* @param {string} content - 文件内容
* @param {string} filename - 文件名
* @param {string} mimeType - MIME类型
*/
function downloadFile(content, filename, mimeType = 'text/plain;charset=utf-8') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* 字幕服务模块
* 处理字幕获取、拦截、下载等逻辑
*/
class SubtitleService {
constructor() {
this.capturedSubtitleUrl = null;
this.setupInterceptor();
}
/**
* 设置字幕请求拦截器
*/
setupInterceptor() {
const originalOpen = unsafeWindow.XMLHttpRequest.prototype.open;
const originalSend = unsafeWindow.XMLHttpRequest.prototype.send;
unsafeWindow.XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
return originalOpen.apply(this, arguments);
};
unsafeWindow.XMLHttpRequest.prototype.send = function() {
if (this._url && this._url.includes('aisubtitle.hdslb.com')) {
subtitleService.capturedSubtitleUrl = this._url;
state.subtitle.capturedUrl = this._url;
// 捕获到请求后尝试下载
setTimeout(() => {
subtitleService.downloadCapturedSubtitle();
}, TIMING.SUBTITLE_CAPTURE_DELAY);
}
return originalSend.apply(this, arguments);
};
}
/**
* 下载捕获到的字幕
*/
async downloadCapturedSubtitle() {
if (!this.capturedSubtitleUrl) {
return;
}
const videoInfo = getVideoInfo();
state.setVideoInfo(videoInfo);
// 开始请求(使用状态管理器的原子操作)
const result = state.startRequest();
if (!result.success) {
// 如果是因为已有缓存,直接使用缓存
if (result.reason === '已有缓存') {
const cachedData = state.getSubtitleData();
if (cachedData) {
state.setBallStatus(BALL_STATUS.ACTIVE);
eventBus.emit(EVENTS.SUBTITLE_LOADED, cachedData, state.getVideoKey());
}
}
return;
}
state.setBallStatus(BALL_STATUS.LOADING);
eventBus.emit(EVENTS.SUBTITLE_REQUESTED, videoInfo);
try {
const subtitleData = await this._fetchSubtitle(this.capturedSubtitleUrl, videoInfo);
// 验证字幕数据
const validation = validateSubtitleData(subtitleData);
if (!validation.valid) {
throw new Error(validation.error);
}
// 保存字幕数据(自动更新缓存)
state.setSubtitleData(subtitleData);
state.setBallStatus(BALL_STATUS.ACTIVE);
} catch (error) {
console.error('[SubtitleService] 字幕获取失败:', error);
state.setBallStatus(BALL_STATUS.ERROR);
eventBus.emit(EVENTS.SUBTITLE_FAILED, error.message);
} finally {
state.finishRequest();
}
}
/**
* 获取字幕内容
* @private
* @param {string} url - 字幕URL
* @param {Object} videoInfo - 视频信息
* @returns {Promise<Array>}
*/
_fetchSubtitle(url, videoInfo) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Origin': 'https://www.bilibili.com',
'Referer': `https://www.bilibili.com/video/${videoInfo.bvid}/`,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'User-Agent': navigator.userAgent
},
anonymous: false,
onload: (response) => {
// 验证视频是否切换
const currentVideoInfo = getVideoInfo();
if (currentVideoInfo.bvid !== videoInfo.bvid || currentVideoInfo.cid !== videoInfo.cid) {
reject(new Error('视频已切换'));
return;
}
if (response.status !== 200) {
reject(new Error(`请求失败: ${response.status}`));
return;
}
// 检查是否返回HTML而非JSON
if (response.responseText.trim().startsWith('<!DOCTYPE') ||
response.responseText.trim().startsWith('<html')) {
reject(new Error('服务器返回HTML而非JSON,可能被重定向'));
return;
}
try {
const data = JSON.parse(response.responseText);
if (data.body && data.body.length > 0) {
resolve(data.body);
} else {
reject(new Error('字幕内容为空'));
}
} catch (e) {
reject(new Error('解析字幕数据失败'));
}
},
onerror: () => {
reject(new Error('网络请求失败'));
}
});
});
}
/**
* 检测字幕按钮
*/
async checkSubtitleButton() {
let checkCount = 0;
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
checkCount++;
const subtitleButton = document.querySelector(SELECTORS.SUBTITLE_BUTTON);
if (subtitleButton) {
clearInterval(checkInterval);
this.tryActivateSubtitle();
resolve(true);
} else if (checkCount >= TIMING.CHECK_MAX_ATTEMPTS) {
clearInterval(checkInterval);
state.setBallStatus(BALL_STATUS.NO_SUBTITLE);
resolve(false);
}
}, TIMING.CHECK_SUBTITLE_INTERVAL);
});
}
/**
* 尝试激活字幕
*/
async tryActivateSubtitle() {
await delay(TIMING.SUBTITLE_ACTIVATION_DELAY);
if (this.capturedSubtitleUrl) {
this.downloadCapturedSubtitle();
} else {
this.triggerSubtitleSelection();
}
}
/**
* 触发字幕选择
*/
async triggerSubtitleSelection() {
const subtitleResultBtn = document.querySelector(SELECTORS.SUBTITLE_BUTTON);
if (!subtitleResultBtn) {
state.setBallStatus(BALL_STATUS.NO_SUBTITLE);
return;
}
// 点击字幕按钮
subtitleResultBtn.click();
await delay(TIMING.MENU_OPEN_DELAY);
// 查找中文字幕选项
let chineseOption = document.querySelector('.bpx-player-ctrl-subtitle-language-item[data-lan="ai-zh"]');
if (!chineseOption) {
chineseOption = document.querySelector('.bpx-player-ctrl-subtitle-language-item[data-lan*="zh"]');
}
if (!chineseOption) {
const allOptions = document.querySelectorAll('.bpx-player-ctrl-subtitle-language-item');
for (let option of allOptions) {
const text = option.querySelector('.bpx-player-ctrl-subtitle-language-item-text');
if (text && text.textContent.includes('中文')) {
chineseOption = option;
break;
}
}
}
if (chineseOption) {
chineseOption.click();
// 立即关闭字幕显示(无感操作)
await delay(TIMING.CLOSE_SUBTITLE_DELAY);
const closeBtn = document.querySelector(SELECTORS.SUBTITLE_CLOSE_SWITCH);
if (closeBtn) {
closeBtn.click();
}
// 等待字幕请求被捕获
await delay(TIMING.SUBTITLE_ACTIVATION_DELAY);
if (this.capturedSubtitleUrl) {
this.downloadCapturedSubtitle();
} else {
state.setBallStatus(BALL_STATUS.ERROR);
}
} else {
// 尝试第一个选项
const firstOption = document.querySelector('.bpx-player-ctrl-subtitle-language-item');
if (firstOption) {
firstOption.click();
await delay(TIMING.CLOSE_SUBTITLE_DELAY);
const closeBtn = document.querySelector(SELECTORS.SUBTITLE_CLOSE_SWITCH);
if (closeBtn) closeBtn.click();
await delay(TIMING.SUBTITLE_ACTIVATION_DELAY);
if (this.capturedSubtitleUrl) {
this.downloadCapturedSubtitle();
} else {
state.setBallStatus(BALL_STATUS.ERROR);
}
} else {
subtitleResultBtn.click();
state.setBallStatus(BALL_STATUS.NO_SUBTITLE);
}
}
}
/**
* 下载字幕文件
*/
downloadSubtitleFile() {
const subtitleData = state.getSubtitleData();
if (!subtitleData || subtitleData.length === 0) {
throw new Error('没有字幕数据可下载');
}
const videoInfo = state.getVideoInfo();
const videoTitle = getVideoTitle();
const content = subtitleData.map(item => item.content).join('\n');
const filename = `${videoTitle}_${videoInfo.bvid}_字幕.txt`;
downloadFile(content, filename);
}
/**
* 重置状态(用于视频切换)
*/
reset() {
this.capturedSubtitleUrl = null;
state.subtitle.capturedUrl = null;
}
}
// 创建全局单例
const subtitleService = new SubtitleService();
/**
* AI服务模块
* 处理AI总结相关的所有逻辑,修复内存泄漏问题
*/
class AIService {
/**
* 获取OpenRouter模型列表
* @param {string} apiKey - API Key
* @param {string} url - API URL
* @returns {Promise<Array>}
*/
async fetchOpenRouterModels(apiKey, url) {
const modelsUrl = url.replace('/chat/completions', '/models');
const response = await fetch(modelsUrl, {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (!response.ok) {
throw new Error(`获取模型列表失败: ${response.status}`);
}
const data = await response.json();
return data.data || [];
}
/**
* 生成AI总结
* @param {Array} subtitleData - 字幕数据
* @param {boolean} isAuto - 是否自动触发
* @returns {Promise<string>}
*/
async summarize(subtitleData, isAuto = false) {
// 检查是否正在总结
if (!state.startAISummary()) {
throw new Error('已有总结任务在进行中');
}
try {
const aiConfig = config.getSelectedAIConfig();
if (!aiConfig || !aiConfig.apiKey) {
throw new Error('请先配置 AI API Key');
}
// 验证配置
if (!aiConfig.url || !aiConfig.url.startsWith('http')) {
throw new Error('API URL格式错误');
}
if (!aiConfig.model) {
throw new Error('未配置模型');
}
// 生成字幕文本
const subtitleText = subtitleData.map(item => item.content).join('\n');
// 构建请求头
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${aiConfig.apiKey}`
};
// OpenRouter需要额外的headers
if (aiConfig.isOpenRouter) {
headers['HTTP-Referer'] = window.location.origin;
headers['X-Title'] = 'Bilibili Subtitle Extractor';
}
const requestBody = {
model: aiConfig.model,
messages: [
{
role: 'user',
content: aiConfig.prompt + subtitleText
}
],
stream: true
};
// 使用超时机制发起请求(修复内存泄漏问题)
const summaryPromise = this._streamingRequest(aiConfig.url, headers, requestBody);
// 添加超时保护
const summary = await withTimeout(
summaryPromise,
TIMING.AI_SUMMARY_TIMEOUT,
'AI总结超时,请稍后重试'
);
// 完成总结
state.finishAISummary(summary);
return summary;
} catch (error) {
// 发生错误时,确保状态正确重置
state.cancelAISummary();
eventBus.emit(EVENTS.AI_SUMMARY_FAILED, error.message);
throw error;
}
}
/**
* 流式请求处理
* @private
* @param {string} url - API URL
* @param {Object} headers - 请求头
* @param {Object} body - 请求体
* @returns {Promise<string>}
*/
async _streamingRequest(url, headers, body) {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
signal: state.ai.abortController?.signal
});
if (!response.ok) {
const errorText = await response.text();
console.error('[AIService] API错误响应:', errorText);
throw new Error(`API请求失败: ${response.status} ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulatedText = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content;
if (content) {
accumulatedText += content;
// 触发chunk事件,供UI实时更新
eventBus.emit(EVENTS.AI_SUMMARY_CHUNK, accumulatedText);
}
} catch (e) {
// 跳过解析错误
}
}
}
}
return accumulatedText;
} finally {
reader.releaseLock();
}
}
/**
* 取消当前的AI总结
*/
cancelCurrentSummary() {
state.cancelAISummary();
}
}
// 创建全局单例
const aiService = new AIService();
/**
* Notion服务模块
* 处理Notion集成相关的所有逻辑,使用Promise替代回调地狱
*/
class NotionService {
/**
* 发送字幕到Notion
* @param {Array} subtitleData - 字幕数据
* @param {boolean} isAuto - 是否自动发送
* @returns {Promise<void>}
*/
async sendSubtitle(subtitleData, isAuto = false) {
const notionConfig = config.getNotionConfig();
if (!notionConfig.apiKey) {
throw new Error('请先配置 Notion API Key');
}
if (!subtitleData || subtitleData.length === 0) {
throw new Error('没有字幕数据可发送');
}
state.notion.isSending = true;
eventBus.emit(EVENTS.NOTION_SEND_START);
try {
const videoInfo = state.getVideoInfo();
const videoTitle = getVideoTitle();
const videoUrl = getVideoUrl();
const creator = getVideoCreator();
// 构建页面内容
const pageChildren = this._buildPageContent(videoInfo, videoTitle, videoUrl, subtitleData);
// 根据配置决定使用数据库ID还是页面ID
let databaseId = notionConfig.databaseId;
if (!databaseId) {
// 首次使用,尝试识别是Database ID还是Page ID
if (!notionConfig.parentPageId) {
throw new Error('请先配置目标位置(Page ID 或 Database ID)');
}
// 尝试作为Database ID使用
databaseId = notionConfig.parentPageId;
}
// 获取数据库结构并填充数据
const schema = await this._getDatabaseSchema(notionConfig.apiKey, databaseId);
const properties = this._buildProperties(schema, videoInfo, videoTitle, videoUrl, creator, subtitleData);
// 创建页面
await this._createPage(notionConfig.apiKey, databaseId, properties, pageChildren);
// 保存database ID(如果是首次使用)
if (!notionConfig.databaseId) {
config.saveNotionConfig({ databaseId });
}
state.notion.isSending = false;
eventBus.emit(EVENTS.NOTION_SEND_COMPLETE);
} catch (error) {
state.notion.isSending = false;
eventBus.emit(EVENTS.NOTION_SEND_FAILED, error.message);
throw error;
}
}
/**
* 创建Bilibili数据库
* @param {string} apiKey - API Key
* @param {string} parentPageId - 父页面ID
* @returns {Promise<string>} - 返回创建的数据库ID
*/
async createDatabase(apiKey, parentPageId) {
const databaseData = {
parent: {
type: 'page_id',
page_id: parentPageId
},
title: [
{
type: 'text',
text: { content: '📺 Bilibili 字幕收藏' }
}
],
properties: {
'标题': { title: {} },
'BV号': { rich_text: {} },
'创作者': { rich_text: {} },
'视频链接': { url: {} },
'收藏时间': { date: {} },
'字幕条数': { number: {} },
'状态': { select: { options: [
{ name: '未总结', color: 'gray' },
{ name: '已总结', color: 'green' }
]}},
'总结': { rich_text: {} }
}
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${API.NOTION_BASE_URL}/databases`,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Notion-Version': API.NOTION_VERSION
},
data: JSON.stringify(databaseData),
onload: (response) => {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
resolve(data.id);
} else {
const error = this._parseNotionError(response);
reject(error);
}
},
onerror: (error) => {
reject(new Error('网络请求失败'));
}
});
});
}
/**
* 获取数据库结构
* @private
* @param {string} apiKey - API Key
* @param {string} databaseId - 数据库ID
* @returns {Promise<Object>}
*/
_getDatabaseSchema(apiKey, databaseId) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${API.NOTION_BASE_URL}/databases/${databaseId}`,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Notion-Version': API.NOTION_VERSION
},
onload: (response) => {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
resolve(data.properties);
} else {
const error = this._parseNotionError(response);
reject(error);
}
},
onerror: () => {
reject(new Error('获取数据库结构失败'));
}
});
});
}
/**
* 创建页面
* @private
* @param {string} apiKey - API Key
* @param {string} databaseId - 数据库ID
* @param {Object} properties - 页面属性
* @param {Array} children - 页面内容
* @returns {Promise<Object>}
*/
_createPage(apiKey, databaseId, properties, children) {
const pageData = {
parent: { database_id: databaseId },
properties: properties,
children: children
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${API.NOTION_BASE_URL}/pages`,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Notion-Version': API.NOTION_VERSION
},
data: JSON.stringify(pageData),
onload: (response) => {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
resolve(data);
} else {
const error = this._parseNotionError(response);
reject(error);
}
},
onerror: () => {
reject(new Error('创建页面失败'));
}
});
});
}
/**
* 构建页面内容
* @private
* @param {Object} videoInfo - 视频信息
* @param {string} videoTitle - 视频标题
* @param {string} videoUrl - 视频URL
* @param {Array} subtitleData - 字幕数据
* @returns {Array}
*/
_buildPageContent(videoInfo, videoTitle, videoUrl, subtitleData) {
const children = [
{
object: 'block',
type: 'heading_2',
heading_2: {
rich_text: [{ type: 'text', text: { content: '📹 视频信息' } }]
}
},
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: `视频标题:${videoTitle}` } }]
}
},
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: `BV号:${videoInfo.bvid}` } }]
}
},
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: `视频链接:${videoUrl}` } }]
}
},
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: `字幕总数:${subtitleData.length} 条` } }]
}
},
{
object: 'block',
type: 'divider',
divider: {}
},
{
object: 'block',
type: 'heading_2',
heading_2: {
rich_text: [{ type: 'text', text: { content: '📝 字幕内容' } }]
}
}
];
// 构建字幕rich_text数组
const subtitleRichTextArray = [];
let currentText = '';
const maxTextLength = LIMITS.NOTION_TEXT_CHUNK;
for (let item of subtitleData) {
const line = `${item.content}\n`;
if (currentText.length + line.length > maxTextLength) {
if (currentText) {
subtitleRichTextArray.push({
type: 'text',
text: { content: currentText }
});
}
currentText = line;
} else {
currentText += line;
}
}
// 添加最后一段
if (currentText) {
subtitleRichTextArray.push({
type: 'text',
text: { content: currentText }
});
}
// 添加字幕代码块
children.push({
object: 'block',
type: 'code',
code: {
rich_text: subtitleRichTextArray,
language: 'plain text'
}
});
return children;
}
/**
* 构建数据库属性
* @private
* @param {Object} schema - 数据库结构
* @param {Object} videoInfo - 视频信息
* @param {string} videoTitle - 视频标题
* @param {string} videoUrl - 视频URL
* @param {string} creator - 创作者
* @param {Array} subtitleData - 字幕数据
* @returns {Object}
*/
_buildProperties(schema, videoInfo, videoTitle, videoUrl, creator, subtitleData) {
const properties = {};
// 查找title类型的字段(必须存在)
const titleField = Object.keys(schema).find(key => schema[key].type === 'title');
if (titleField) {
properties[titleField] = {
title: [{ text: { content: videoTitle } }]
};
}
// 智能匹配其他字段
Object.keys(schema).forEach(fieldName => {
const fieldType = schema[fieldName].type;
const lowerFieldName = fieldName.toLowerCase().replace(/\s+/g, '');
// BV号字段
if (lowerFieldName.includes('bv') && (fieldType === 'rich_text' || fieldType === 'text')) {
properties[fieldName] = {
rich_text: [{ text: { content: videoInfo.bvid || '' } }]
};
}
// 创作者字段
if ((lowerFieldName.includes('创作') || lowerFieldName.includes('作者') ||
lowerFieldName.includes('creator') || lowerFieldName.includes('up主')) &&
(fieldType === 'rich_text' || fieldType === 'text')) {
properties[fieldName] = {
rich_text: [{ text: { content: creator } }]
};
}
// 视频链接字段
if (lowerFieldName.includes('链接') && fieldType === 'url') {
properties[fieldName] = { url: videoUrl };
}
// 日期字段
if (fieldType === 'date' && (
lowerFieldName === '日期' ||
lowerFieldName.includes('收藏') ||
lowerFieldName.includes('添加') ||
lowerFieldName.includes('创建'))) {
properties[fieldName] = {
date: { start: new Date().toISOString() }
};
}
// 数量字段
if ((lowerFieldName.includes('条数') || lowerFieldName.includes('数量')) &&
fieldType === 'number') {
properties[fieldName] = { number: subtitleData.length };
}
// 状态字段
if (lowerFieldName === '状态' || lowerFieldName === 'status') {
const videoKey = state.getVideoKey();
const hasSummary = videoKey ? state.getAISummary(videoKey) : null;
if (fieldType === 'select' || fieldType === 'status') {
properties[fieldName] = {
[fieldType]: { name: hasSummary ? '已总结' : '未总结' }
};
} else if (fieldType === 'rich_text') {
properties[fieldName] = {
rich_text: [{ text: { content: hasSummary ? '已总结' : '未总结' } }]
};
}
}
// 总结字段
if (lowerFieldName === '总结' || lowerFieldName === 'summary') {
const videoKey = state.getVideoKey();
const summary = videoKey ? state.getAISummary(videoKey) : null;
if (fieldType === 'rich_text' && summary) {
properties[fieldName] = {
rich_text: [{ text: { content: summary.substring(0, LIMITS.NOTION_TEXT_MAX) } }]
};
}
}
});
return properties;
}
/**
* 解析Notion错误响应
* @private
* @param {Object} response - 响应对象
* @returns {Error}
*/
_parseNotionError(response) {
try {
const error = JSON.parse(response.responseText);
// 特殊处理常见错误
if (error.code === 'object_not_found' || error.message?.includes('Could not find')) {
return new Error('找不到指定的Notion页面或数据库,请检查:\n1. ID是否正确\n2. 是否已在Notion中授权该Integration');
}
return new Error(error.message || '未知错误');
} catch (e) {
return new Error(`请求失败: ${response.status}`);
}
}
}
// 创建全局单例
const notionService = new NotionService();
/**
* 笔记服务模块
* 管理用户选中文字的笔记保存和管理
*/
const NOTES_CONFIG = {
STORAGE_KEY: 'bilibili_subtitle_notes',
BLUE_DOT_SIZE: 14,
BLUE_DOT_COLOR: '#feebea',
BLUE_DOT_HIDE_TIMEOUT: 5000,
};
class NotesService {
constructor() {
this.blueDot = null;
this.blueDotHideTimeout = null;
this.savedSelectionText = '';
this.selectionTimeout = null;
}
/**
* 初始化笔记服务
*/
init() {
this.createBlueDot();
this.initSelectionListener();
}
/**
* 获取所有笔记数据
* @returns {Array} 笔记数组
*/
getAllNotes() {
try {
const data = localStorage.getItem(NOTES_CONFIG.STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('读取笔记数据失败:', error);
return [];
}
}
/**
* 保存笔记数据
* @param {Array} notes - 笔记数组
*/
saveNotes(notes) {
try {
localStorage.setItem(NOTES_CONFIG.STORAGE_KEY, JSON.stringify(notes));
} catch (error) {
console.error('保存笔记数据失败:', error);
}
}
/**
* 添加新笔记
* @param {string} content - 笔记内容
* @param {string} url - 来源URL
* @returns {Object} 新添加的笔记对象
*/
addNote(content, url) {
const note = {
id: Date.now() + Math.random().toString(36).substr(2, 9),
content: content.trim(),
url: url,
timestamp: Date.now()
};
const notes = this.getAllNotes();
notes.unshift(note);
this.saveNotes(notes);
return note;
}
/**
* 删除指定笔记
* @param {string} noteId - 笔记ID
*/
deleteNote(noteId) {
const notes = this.getAllNotes();
const filtered = notes.filter(note => note.id !== noteId);
this.saveNotes(filtered);
}
/**
* 批量删除笔记
* @param {Array<string>} noteIds - 笔记ID数组
*/
deleteNotes(noteIds) {
const notes = this.getAllNotes();
const filtered = notes.filter(note => !noteIds.includes(note.id));
this.saveNotes(filtered);
}
/**
* 按日期分组笔记
* @returns {Array} 分组后的笔记数组 [{date, notes}, ...]
*/
getGroupedNotes() {
const notes = this.getAllNotes();
const groups = {};
notes.forEach(note => {
const date = this.formatDate(note.timestamp);
if (!groups[date]) {
groups[date] = [];
}
groups[date].push(note);
});
return Object.keys(groups)
.sort((a, b) => {
const dateA = groups[a][0].timestamp;
const dateB = groups[b][0].timestamp;
return dateB - dateA;
})
.map(date => ({
date,
notes: groups[date]
}));
}
/**
* 格式化时间戳为日期字符串
* @param {number} timestamp - 时间戳
* @returns {string} 格式化的日期字符串
*/
formatDate(timestamp) {
const date = new Date(timestamp);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return '今天';
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天';
} else {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
/**
* 格式化时间戳为完整时间字符串
* @param {number} timestamp - 时间戳
* @returns {string} 格式化的时间字符串
*/
formatTime(timestamp) {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* 创建钢笔保存点元素
*/
createBlueDot() {
if (this.blueDot) {
return this.blueDot;
}
this.blueDot = document.createElement('div');
this.blueDot.id = 'note-saver-blue-dot';
this.blueDot.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20">
<path d="M20.7 5.2c.4.4.4 1.1 0 1.6l-1 1-3.3-3.3 1-1c.4-.4 1.1-.4 1.6 0l1.7 1.7zm-3.3 2.3L6.7 18.2c-.2.2-.4.3-.7.3H3c-.6 0-1-.4-1-1v-3c0-.3.1-.5.3-.7L13 3.1l3.3 3.3z" stroke="#feebea" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="rgba(0,0,0,0.7)"/>
</svg>
`;
this.blueDot.style.cssText = `
position: absolute;
cursor: pointer;
z-index: 999999;
display: none;
transition: transform 0.2s, filter 0.2s;
`;
this.blueDot.addEventListener('mouseenter', () => {
this.blueDot.style.transform = 'scale(1.15)';
this.blueDot.style.filter = 'drop-shadow(0 2px 4px rgba(254, 235, 234, 0.5))';
});
this.blueDot.addEventListener('mouseleave', () => {
this.blueDot.style.transform = 'scale(1)';
this.blueDot.style.filter = 'none';
});
this.blueDot.addEventListener('click', (e) => this.handleBlueDotClick(e));
document.body.appendChild(this.blueDot);
return this.blueDot;
}
/**
* 显示蓝点在指定位置
* @param {number} x - X坐标
* @param {number} y - Y坐标
*/
showBlueDot(x, y) {
const dot = this.createBlueDot();
dot.style.left = `${x}px`;
dot.style.top = `${y}px`;
dot.style.display = 'block';
if (this.blueDotHideTimeout) {
clearTimeout(this.blueDotHideTimeout);
}
this.blueDotHideTimeout = setTimeout(() => {
this.hideBlueDot();
this.savedSelectionText = '';
}, NOTES_CONFIG.BLUE_DOT_HIDE_TIMEOUT);
}
/**
* 隐藏蓝点
*/
hideBlueDot() {
if (this.blueDot) {
this.blueDot.style.display = 'none';
}
if (this.blueDotHideTimeout) {
clearTimeout(this.blueDotHideTimeout);
this.blueDotHideTimeout = null;
}
}
/**
* 处理蓝点点击事件
*/
handleBlueDotClick(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (this.savedSelectionText) {
this.addNote(this.savedSelectionText, window.location.href);
this.savedSelectionText = '';
const selection = window.getSelection();
if (selection.rangeCount > 0) {
selection.removeAllRanges();
}
}
this.hideBlueDot();
}
/**
* 监听文本选择事件
*/
initSelectionListener() {
document.addEventListener('mouseup', (e) => {
if (this.selectionTimeout) {
clearTimeout(this.selectionTimeout);
}
this.selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText && selection.rangeCount > 0) {
this.savedSelectionText = selectedText;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
if (rects.length === 0) {
this.hideBlueDot();
return;
}
// 判断选择方向
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
const anchorOffset = selection.anchorOffset;
const focusOffset = selection.focusOffset;
let isForward = true;
if (anchorNode === focusNode) {
isForward = anchorOffset <= focusOffset;
} else {
const position = anchorNode.compareDocumentPosition(focusNode);
isForward = (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
}
let x, y;
if (isForward) {
// 从上往下选中 → 显示在最后一个字右下角
const lastRect = rects[rects.length - 1];
x = lastRect.right + window.scrollX + 5;
y = lastRect.bottom + window.scrollY + 5;
} else {
// 从下往上选中 → 显示在第一个字左上角
const firstRect = rects[0];
x = firstRect.left + window.scrollX - 35;
y = firstRect.top + window.scrollY - 35;
}
this.showBlueDot(x, y);
} else {
this.savedSelectionText = '';
this.hideBlueDot();
}
}, 100);
});
document.addEventListener('mousedown', (e) => {
// 如果点击的是蓝点或其子元素,不清空
if (this.blueDot && (e.target === this.blueDot || this.blueDot.contains(e.target))) {
return;
}
this.savedSelectionText = '';
this.hideBlueDot();
});
}
/**
* 保存当前选中的字幕文本
* @param {string} content - 字幕内容
*/
saveSubtitleNote(content) {
const note = this.addNote(content, window.location.href);
return note;
}
}
// 创建全局单例
const notesService = new NotesService();
/**
* 媒体速度控制服务模块
* 提供媒体播放速度控制和响度检测功能
*/
const SPEED_CONFIG = {
speedStep: 0.1,
boostMultiplier: 1.5,
doubleClickDelay: 200,
displayDuration: 1000,
maxSpeed: 10,
volumeThresholdStep: 1,
volumeCheckInterval: 100,
};
class SpeedControlService {
constructor() {
this.state = {
baseSpeed: 1.0,
isRightOptionPressed: false,
isTempBoosted: false,
lastKeyPressTime: { comma: 0, period: 0 },
lastOptionPressTime: 0,
optionDoubleClickTimer: null,
volumeDetectionEnabled: false,
currentVolumeThreshold: -40,
isVolumeBoosted: false,
mediaAnalyzers: new Map(),
commaPressed: false,
periodPressed: false,
volumeHistory: [],
maxHistoryLength: 100,
volumeChart: null,
};
this.observer = null;
}
/**
* 初始化速度控制服务
*/
init() {
this.bindKeyboardEvents();
this.observeMediaElements();
this.applySpeedToExistingMedia();
}
/**
* 获取当前所有媒体元素
*/
getMediaElements() {
return Array.from(document.querySelectorAll('video, audio'));
}
/**
* 应用速度到所有媒体元素
*/
applySpeed(speed) {
const mediaElements = this.getMediaElements();
mediaElements.forEach(media => {
media.playbackRate = speed;
this.showSpeedIndicator(media, speed);
});
}
/**
* 显示速度指示器
*/
showSpeedIndicator(media, speed) {
const oldIndicator = media.parentElement?.querySelector('.speed-indicator');
if (oldIndicator) {
oldIndicator.remove();
}
const indicator = document.createElement('div');
indicator.className = 'speed-indicator';
indicator.textContent = `${speed.toFixed(2)}x`;
indicator.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
font-family: monospace;
z-index: 999999;
pointer-events: none;
transition: opacity 0.3s;
`;
if (media.parentElement) {
const parentPosition = window.getComputedStyle(media.parentElement).position;
if (parentPosition === 'static') {
media.parentElement.style.position = 'relative';
}
media.parentElement.appendChild(indicator);
setTimeout(() => {
indicator.style.opacity = '0';
setTimeout(() => {
indicator.remove();
}, 300);
}, SPEED_CONFIG.displayDuration);
}
}
/**
* 调整基础速度
*/
adjustBaseSpeed(delta) {
this.state.baseSpeed = Math.max(0.1, Math.min(SPEED_CONFIG.maxSpeed, this.state.baseSpeed + delta));
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 设置基础速度
*/
setBaseSpeed(speed) {
this.state.baseSpeed = Math.max(0.1, Math.min(SPEED_CONFIG.maxSpeed, speed));
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 应用临时加速(长按option)
*/
applyTemporaryBoost() {
this.state.isTempBoosted = true;
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 移除临时加速(松开option)
*/
removeTemporaryBoost() {
this.state.isTempBoosted = false;
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 应用永久加速(双击option)
*/
applyPermanentBoost() {
this.state.baseSpeed = Math.min(SPEED_CONFIG.maxSpeed, this.state.baseSpeed * SPEED_CONFIG.boostMultiplier);
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 重置为1倍速
*/
resetToNormalSpeed() {
this.state.baseSpeed = 1.0;
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 设置为2倍速
*/
setToDoubleSpeed() {
this.state.baseSpeed = 2.0;
this.applySpeed(this.calculateFinalSpeed());
}
/**
* 检测双击
*/
detectDoubleClick(keyType) {
const now = Date.now();
const lastTime = this.state.lastKeyPressTime[keyType];
this.state.lastKeyPressTime[keyType] = now;
if (now - lastTime < SPEED_CONFIG.doubleClickDelay) {
return true;
}
return false;
}
/**
* 计算最终速度(考虑所有加速因素)
*/
calculateFinalSpeed() {
let speed = this.state.baseSpeed;
if (this.state.isTempBoosted) {
speed *= SPEED_CONFIG.boostMultiplier;
}
if (this.state.isVolumeBoosted) {
speed *= SPEED_CONFIG.boostMultiplier;
}
return Math.min(SPEED_CONFIG.maxSpeed, speed);
}
/**
* 为媒体元素创建音频分析器
*/
setupVolumeAnalyzer(media) {
try {
if (this.state.mediaAnalyzers.has(media)) {
return this.state.mediaAnalyzers.get(media);
}
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const source = audioContext.createMediaElementSource(media);
source.connect(analyser);
analyser.connect(audioContext.destination);
const analyzer = {
context: audioContext,
analyser: analyser,
dataArray: new Uint8Array(analyser.frequencyBinCount),
intervalId: null
};
this.state.mediaAnalyzers.set(media, analyzer);
return analyzer;
} catch (error) {
console.error('创建音频分析器失败:', error);
return null;
}
}
/**
* 计算当前响度(dB)
*/
getVolumeLevel(analyzer) {
analyzer.analyser.getByteFrequencyData(analyzer.dataArray);
let sum = 0;
for (let i = 0; i < analyzer.dataArray.length; i++) {
sum += analyzer.dataArray[i];
}
const average = sum / analyzer.dataArray.length;
if (average === 0) return -Infinity;
const db = 20 * Math.log10(average / 255);
return db;
}
/**
* 开始监测特定媒体元素的响度
*/
startVolumeDetection(media) {
const analyzer = this.setupVolumeAnalyzer(media);
if (!analyzer) return;
if (analyzer.intervalId) {
clearInterval(analyzer.intervalId);
}
this.createVolumeChart(media);
analyzer.intervalId = setInterval(() => {
if (!this.state.volumeDetectionEnabled || media.paused) {
return;
}
const volumeDb = this.getVolumeLevel(analyzer);
const shouldBoost = volumeDb < this.state.currentVolumeThreshold;
this.updateVolumeChart(volumeDb);
if (shouldBoost && !this.state.isVolumeBoosted) {
this.state.isVolumeBoosted = true;
this.applySpeed(this.calculateFinalSpeed());
} else if (!shouldBoost && this.state.isVolumeBoosted) {
this.state.isVolumeBoosted = false;
this.applySpeed(this.calculateFinalSpeed());
}
}, SPEED_CONFIG.volumeCheckInterval);
}
/**
* 停止监测并清理资源
*/
stopVolumeDetection(media) {
const analyzer = this.state.mediaAnalyzers.get(media);
if (!analyzer) return;
if (analyzer.intervalId) {
clearInterval(analyzer.intervalId);
analyzer.intervalId = null;
}
if (analyzer.context) {
analyzer.context.close();
}
this.state.mediaAnalyzers.delete(media);
if (this.state.volumeChart) {
this.state.volumeChart.remove();
this.state.volumeChart = null;
}
this.state.volumeHistory = [];
}
/**
* 切换响度检测功能
*/
toggleVolumeDetection() {
this.state.volumeDetectionEnabled = !this.state.volumeDetectionEnabled;
if (this.state.volumeDetectionEnabled) {
const mediaElements = this.getMediaElements();
mediaElements.forEach(media => {
this.startVolumeDetection(media);
});
} else {
const mediaElements = this.getMediaElements();
mediaElements.forEach(media => {
this.stopVolumeDetection(media);
});
if (this.state.isVolumeBoosted) {
this.state.isVolumeBoosted = false;
this.applySpeed(this.calculateFinalSpeed());
}
}
}
/**
* 调整响度阈值
*/
adjustVolumeThreshold(delta) {
this.state.currentVolumeThreshold += delta;
this.state.currentVolumeThreshold = Math.max(-100, Math.min(0, this.state.currentVolumeThreshold));
// 显示图表
if (this.state.volumeChart) {
this.state.volumeChart.style.opacity = '1';
// 清除旧定时器
if (this.hideChartTimer) {
clearTimeout(this.hideChartTimer);
}
// 5秒后重新隐藏
this.hideChartTimer = setTimeout(() => {
if (this.state.volumeChart) {
this.state.volumeChart.style.opacity = '0';
}
}, 5000);
}
}
/**
* 创建响度图表
*/
createVolumeChart(media) {
if (this.state.volumeChart) {
this.state.volumeChart.remove();
}
const canvas = document.createElement('canvas');
canvas.className = 'volume-chart';
canvas.width = 300;
canvas.height = 150;
canvas.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
border: 2px solid #feebea;
border-radius: 8px;
z-index: 999999;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s;
`;
if (media.parentElement) {
const parentPosition = window.getComputedStyle(media.parentElement).position;
if (parentPosition === 'static') {
media.parentElement.style.position = 'relative';
}
media.parentElement.appendChild(canvas);
}
this.state.volumeChart = canvas;
this.state.volumeHistory = [];
// 5秒后隐藏
this.hideChartTimer = setTimeout(() => {
if (canvas) {
canvas.style.opacity = '0';
}
}, 5000);
return canvas;
}
/**
* 更新响度图表
*/
updateVolumeChart(volumeDb) {
if (!this.state.volumeChart) return;
const canvas = this.state.volumeChart;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
this.state.volumeHistory.push(volumeDb);
if (this.state.volumeHistory.length > this.state.maxHistoryLength) {
this.state.volumeHistory.shift();
}
ctx.clearRect(0, 0, width, height);
const padding = 30;
const chartWidth = width - 2 * padding;
const chartHeight = height - 2 * padding;
const minDb = -60;
const maxDb = 0;
const dbToY = (db) => {
const clampedDb = Math.max(minDb, Math.min(maxDb, db));
const ratio = (clampedDb - minDb) / (maxDb - minDb);
return height - padding - ratio * chartHeight;
};
// 绘制坐标轴
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.stroke();
// 绘制刻度和标签
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
for (let db = minDb; db <= maxDb; db += 20) {
const y = dbToY(db);
ctx.fillText(`${db}dB`, padding - 5, y + 3);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
}
// 绘制红色阈值线
ctx.strokeStyle = '#FF5252';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
const thresholdY = dbToY(this.state.currentVolumeThreshold);
ctx.beginPath();
ctx.moveTo(padding, thresholdY);
ctx.lineTo(width - padding, thresholdY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#FF5252';
ctx.textAlign = 'left';
ctx.fillText(`阈值: ${this.state.currentVolumeThreshold.toFixed(0)}dB`, width - padding + 5, thresholdY + 3);
// 绘制绿色响度曲线
if (this.state.volumeHistory.length > 1) {
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.beginPath();
const xStep = chartWidth / (this.state.maxHistoryLength - 1);
this.state.volumeHistory.forEach((db, index) => {
const x = padding + index * xStep;
const y = dbToY(db);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
const lastDb = this.state.volumeHistory[this.state.volumeHistory.length - 1];
const lastX = padding + (this.state.volumeHistory.length - 1) * xStep;
const lastY = dbToY(lastDb);
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
ctx.arc(lastX, lastY, 3, 0, 2 * Math.PI);
ctx.fill();
ctx.fillText(`${lastDb.toFixed(1)}dB`, lastX + 5, lastY - 5);
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText('响度检测', width / 2, 15);
}
/**
* 绑定键盘事件
*/
bindKeyboardEvents() {
document.addEventListener('keydown', (event) => this.handleKeyDown(event), true);
document.addEventListener('keyup', (event) => this.handleKeyUp(event), true);
}
/**
* 键盘按下事件处理
*/
handleKeyDown(event) {
// 检测右侧Option键
if (event.code === 'AltRight' && event.location === 2) {
if (!this.state.isRightOptionPressed) {
this.state.isRightOptionPressed = true;
const now = Date.now();
if (now - this.state.lastOptionPressTime < SPEED_CONFIG.doubleClickDelay) {
this.applyPermanentBoost();
if (this.state.optionDoubleClickTimer) {
clearTimeout(this.state.optionDoubleClickTimer);
this.state.optionDoubleClickTimer = null;
}
} else {
this.applyTemporaryBoost();
}
this.state.lastOptionPressTime = now;
}
return;
}
// 忽略在输入框中的按键
if (event.target.tagName === 'INPUT' ||
event.target.tagName === 'TEXTAREA' ||
event.target.isContentEditable) {
return;
}
// 检测句号键 (.)
if (event.code === 'Period') {
if (event.altKey) {
event.preventDefault();
this.adjustVolumeThreshold(SPEED_CONFIG.volumeThresholdStep);
return;
}
if (!this.state.periodPressed) {
this.state.periodPressed = true;
if (this.state.commaPressed) {
event.preventDefault();
this.toggleVolumeDetection();
return;
}
}
event.preventDefault();
if (this.detectDoubleClick('period')) {
this.setToDoubleSpeed();
} else {
this.adjustBaseSpeed(SPEED_CONFIG.speedStep);
}
return;
}
// 检测逗号键 (,)
if (event.code === 'Comma') {
if (event.altKey) {
event.preventDefault();
this.adjustVolumeThreshold(-1);
return;
}
if (!this.state.commaPressed) {
this.state.commaPressed = true;
if (this.state.periodPressed) {
event.preventDefault();
this.toggleVolumeDetection();
return;
}
}
event.preventDefault();
if (this.detectDoubleClick('comma')) {
this.resetToNormalSpeed();
} else {
this.adjustBaseSpeed(-0.1);
}
return;
}
}
/**
* 键盘释放事件处理
*/
handleKeyUp(event) {
if (event.code === 'AltRight' && event.location === 2) {
if (this.state.isRightOptionPressed) {
this.state.isRightOptionPressed = false;
this.state.optionDoubleClickTimer = setTimeout(() => {
if (!this.state.isRightOptionPressed && this.state.isTempBoosted) {
this.removeTemporaryBoost();
}
}, SPEED_CONFIG.doubleClickDelay);
}
return;
}
if (event.code === 'Period') {
this.state.periodPressed = false;
return;
}
if (event.code === 'Comma') {
this.state.commaPressed = false;
return;
}
}
/**
* 监听新添加的媒体元素
*/
observeMediaElements() {
this.observer = new MutationObserver(() => {
const mediaElements = this.getMediaElements();
mediaElements.forEach(media => {
const currentSpeed = this.calculateFinalSpeed();
if (Math.abs(media.playbackRate - currentSpeed) > 0.01) {
media.playbackRate = currentSpeed;
}
if (this.state.volumeDetectionEnabled && !this.state.mediaAnalyzers.has(media)) {
this.startVolumeDetection(media);
}
});
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
/**
* 对已存在的媒体元素应用初始速度
*/
applySpeedToExistingMedia() {
const mediaElements = this.getMediaElements();
mediaElements.forEach(media => {
media.playbackRate = this.state.baseSpeed;
});
}
/**
* 获取当前速度
*/
getCurrentSpeed() {
return this.calculateFinalSpeed();
}
/**
* 获取当前状态(用于UI显示)
*/
getState() {
return {
baseSpeed: this.state.baseSpeed,
finalSpeed: this.calculateFinalSpeed(),
isTempBoosted: this.state.isTempBoosted,
isVolumeBoosted: this.state.isVolumeBoosted,
volumeDetectionEnabled: this.state.volumeDetectionEnabled,
currentVolumeThreshold: this.state.currentVolumeThreshold,
};
}
/**
* 清理资源
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
}
this.getMediaElements().forEach(media => {
this.stopVolumeDetection(media);
});
}
}
// 创建全局单例
const speedControlService = new SpeedControlService();
/**
* SponsorBlock配置管理模块
* 管理SponsorBlock相关的所有配置
*/
const STORAGE_KEY$1 = 'sponsorblock_settings';
class SponsorBlockConfigManager {
constructor() {
this.settings = this.loadSettings();
}
/**
* 加载设置
* @returns {Object}
*/
loadSettings() {
const saved = GM_getValue(STORAGE_KEY$1, null);
return saved ? JSON.parse(saved) : { ...SPONSORBLOCK.DEFAULT_SETTINGS };
}
/**
* 保存设置
* @param {Object} settings
*/
saveSettings(settings) {
this.settings = settings;
GM_setValue(STORAGE_KEY$1, JSON.stringify(settings));
}
/**
* 获取单个设置
* @param {string} key
* @returns {any}
*/
get(key) {
return this.settings[key];
}
/**
* 设置单个值
* @param {string} key
* @param {any} value
*/
set(key, value) {
this.settings[key] = value;
this.saveSettings(this.settings);
}
/**
* 获取所有设置
* @returns {Object}
*/
getAll() {
return { ...this.settings };
}
/**
* 设置所有设置
* @param {Object} settings
*/
setAll(settings) {
this.saveSettings(settings);
}
/**
* 重置为默认设置
*/
resetToDefaults() {
this.saveSettings({ ...SPONSORBLOCK.DEFAULT_SETTINGS });
}
}
// 创建全局单例
const sponsorBlockConfig = new SponsorBlockConfigManager();
/**
* SponsorBlock服务模块
* 处理视频片段跳过、进度条标记、提示框等核心功能
*/
/**
* SponsorBlock API类
* 负责API请求和缓存管理
*/
class SponsorBlockAPI {
constructor() {
this.cache = new Map();
this.pendingRequests = new Map();
}
/**
* 获取视频片段数据
* @param {string} bvid - 视频BV号
* @returns {Promise<Array>}
*/
async fetchSegments(bvid) {
// 检查缓存
const cached = this.cache.get(bvid);
if (cached && Date.now() - cached.timestamp < SPONSORBLOCK.CACHE_EXPIRY) {
return cached.data;
}
// 检查是否有正在进行的请求
if (this.pendingRequests.has(bvid)) {
return this.pendingRequests.get(bvid);
}
const promise = new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: `${SPONSORBLOCK.API_URL}?videoID=${bvid}`,
headers: {
"origin": "userscript-bilibili-sponsor-skip",
"x-ext-version": "1.0.0"
},
timeout: 5000,
onload: (response) => {
try {
if (response.status === 404) {
const result = [];
this.cache.set(bvid, { data: result, timestamp: Date.now() });
resolve(result);
} else if (response.status === 200) {
const data = JSON.parse(response.responseText);
this.cache.set(bvid, { data, timestamp: Date.now() });
resolve(data);
} else if (response.status === 400) {
console.error('[SponsorBlock] 参数错误 (400)');
reject(new Error('Bad request'));
} else if (response.status === 429) {
console.error('[SponsorBlock] 请求频繁 (429)');
reject(new Error('Rate limited'));
} else {
reject(new Error(`HTTP ${response.status}`));
}
} catch (error) {
reject(error);
}
},
onerror: reject,
ontimeout: () => reject(new Error('Timeout'))
});
});
this.pendingRequests.set(bvid, promise);
promise.finally(() => {
this.pendingRequests.delete(bvid);
});
return promise;
}
/**
* 检查是否有片段
* @param {string} bvid
* @returns {boolean|null}
*/
hasSegments(bvid) {
const cached = this.cache.get(bvid);
if (cached && Date.now() - cached.timestamp < SPONSORBLOCK.CACHE_EXPIRY) {
return cached.data.length > 0;
}
return null;
}
/**
* 清除缓存
*/
clearCache() {
this.cache.clear();
}
}
/**
* 视频播放器控制器类
* 负责片段跳过、进度条标记、提示框显示
*/
class VideoPlayerController {
constructor(api, config) {
this.api = api;
this.config = config;
this.video = null;
this.segments = [];
this.currentBVID = null;
this.lastSkipTime = 0;
this.checkInterval = null;
this.currentPrompt = null;
this.promptedSegments = new Set();
this.ignoredSegments = new Set();
this.progressBar = null;
this.markerContainer = null;
this.playerObserver = null;
}
/**
* 初始化播放器控制器
*/
async init() {
// 检查是否在视频播放页
if (!location.pathname.includes('/video/')) {
return;
}
// 提取BVID
this.currentBVID = location.pathname.match(/video\/(BV\w+)/)?.[1];
if (!this.currentBVID) {
return;
}
// 等待视频元素加载
await this.waitForVideo();
// 获取片段数据
try {
this.segments = await this.api.fetchSegments(this.currentBVID);
if (this.segments.length > 0) {
// 渲染进度条标记
this.renderProgressMarkers();
}
} catch (error) {
console.error('[SponsorBlock] 获取片段失败:', error);
this.segments = [];
}
// 开始监听
this.startMonitoring();
// 添加播放器观察器
this.setupPlayerObserver();
}
/**
* 设置播放器观察器
*/
setupPlayerObserver() {
const playerContainer = document.querySelector('.bpx-player-video-wrap') ||
document.querySelector('.bpx-player-container');
if (!playerContainer) return;
this.playerObserver = new MutationObserver(() => {
if (this.segments.length > 0 && !document.querySelector('#sponsorblock-preview-bar')) {
this.renderProgressMarkers();
}
});
this.playerObserver.observe(playerContainer, {
childList: true,
subtree: true
});
}
/**
* 等待视频元素加载
*/
async waitForVideo() {
return new Promise((resolve) => {
const check = () => {
this.video = document.querySelector(SELECTORS.VIDEO);
if (this.video) {
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
}
/**
* 渲染进度条标记
*/
renderProgressMarkers() {
if (!this.config.get('showProgressMarkers')) {
return;
}
const tryRender = (retryCount = 0) => {
const targetContainer = document.querySelector('.bpx-player-progress-schedule');
if (!targetContainer) {
if (retryCount < 10) {
setTimeout(() => tryRender(retryCount + 1), 1000);
}
return;
}
this.progressBar = targetContainer;
// 移除旧标记
document.querySelectorAll('#sponsorblock-preview-bar').forEach(el => el.remove());
// 创建标记容器
this.markerContainer = document.createElement('ul');
this.markerContainer.id = 'sponsorblock-preview-bar';
targetContainer.prepend(this.markerContainer);
// 等待视频时长
if (this.video.duration && this.video.duration > 0) {
this.createSegmentMarkers();
} else {
this.video.addEventListener('loadedmetadata', () => {
this.createSegmentMarkers();
}, { once: true });
}
};
tryRender();
}
/**
* 创建片段标记
*/
createSegmentMarkers() {
if (!this.markerContainer || !this.video.duration || this.video.duration <= 0) {
return;
}
this.markerContainer.innerHTML = '';
const videoDuration = this.video.duration;
// 排序:长片段先渲染
const sortedSegments = [...this.segments].sort((a, b) => {
return (b.segment[1] - b.segment[0]) - (a.segment[1] - a.segment[0]);
});
// 为每个片段创建标记
sortedSegments.forEach((segment, index) => {
const startTime = Math.min(videoDuration, segment.segment[0]);
const endTime = Math.min(videoDuration, segment.segment[1]);
const leftPercent = (startTime / videoDuration) * 100;
const rightPercent = (1 - endTime / videoDuration) * 100;
const marker = document.createElement('li');
marker.className = 'sponsorblock-segment';
marker.dataset.segmentIndex = index.toString();
const categoryInfo = SPONSORBLOCK.CATEGORIES[segment.category] ||
{ name: segment.category, color: '#999' };
marker.style.position = 'absolute';
marker.style.left = `${leftPercent}%`;
marker.style.right = `${rightPercent}%`;
marker.style.backgroundColor = categoryInfo.color;
const duration = endTime - startTime;
marker.title = `${categoryInfo.name}\n${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s (${duration.toFixed(1)}s)`;
// 点击事件
marker.addEventListener('click', (e) => {
e.stopPropagation();
this.showSegmentDetails(segment);
});
this.markerContainer.appendChild(marker);
});
}
/**
* 显示片段详情
*/
showSegmentDetails(segment) {
// 移除已有弹窗
const existingPopup = document.querySelector('.segment-details-popup');
if (existingPopup) {
existingPopup.remove();
document.querySelector('.segment-details-overlay')?.remove();
}
const categoryInfo = SPONSORBLOCK.CATEGORIES[segment.category] ||
{ name: segment.category, color: '#999' };
const duration = segment.segment[1] - segment.segment[0];
const startTime = formatTime(segment.segment[0]);
const endTime = formatTime(segment.segment[1]);
// 创建遮罩层
const overlay = document.createElement('div');
overlay.className = 'segment-details-overlay';
overlay.onclick = () => this.closeSegmentDetails();
// 创建弹窗
const popup = document.createElement('div');
popup.className = 'segment-details-popup';
popup.onclick = (e) => e.stopPropagation();
popup.innerHTML = `
<div class="segment-details-header">
<div class="segment-details-title">
<div style="width: 16px; height: 16px; background: ${categoryInfo.color}; border-radius: 3px;"></div>
<span>${categoryInfo.name}</span>
</div>
<button class="segment-details-close">×</button>
</div>
<div class="segment-details-content">
<div class="segment-details-row">
<span class="segment-details-label">开始时间</span>
<span class="segment-details-value">${startTime}</span>
</div>
<div class="segment-details-row">
<span class="segment-details-label">结束时间</span>
<span class="segment-details-value">${endTime}</span>
</div>
<div class="segment-details-row">
<span class="segment-details-label">时长</span>
<span class="segment-details-value">${duration.toFixed(1)} 秒</span>
</div>
<div class="segment-details-row">
<span class="segment-details-label">投票数</span>
<span class="segment-details-value">${segment.votes}</span>
</div>
<div class="segment-details-row">
<span class="segment-details-label">UUID</span>
<span class="segment-details-value" style="font-size: 11px; font-family: monospace;">${segment.UUID.substring(0, 20)}...</span>
</div>
</div>
<div class="segment-details-actions">
<button class="segment-details-btn segment-details-btn-secondary" data-action="close">
关闭
</button>
<button class="segment-details-btn segment-details-btn-primary" data-action="jump">
跳转到此片段
</button>
</div>
`;
document.body.appendChild(overlay);
document.body.appendChild(popup);
// 绑定事件
popup.querySelector('.segment-details-close').onclick = () => this.closeSegmentDetails();
popup.querySelector('[data-action="close"]').onclick = () => this.closeSegmentDetails();
popup.querySelector('[data-action="jump"]').onclick = () => {
if (this.video) {
this.video.currentTime = segment.segment[0];
}
this.closeSegmentDetails();
};
// Esc键关闭
const keyHandler = (e) => {
if (e.key === 'Escape') {
this.closeSegmentDetails();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
}
/**
* 关闭片段详情
*/
closeSegmentDetails() {
document.querySelector('.segment-details-popup')?.remove();
document.querySelector('.segment-details-overlay')?.remove();
}
/**
* 开始监控
*/
startMonitoring() {
if (!this.video) {
return;
}
// 使用轮询方式检查
this.checkInterval = setInterval(() => {
this.checkAndSkip();
}, 200);
// 页面卸载时清理
window.addEventListener('beforeunload', () => {
if (this.checkInterval) {
clearInterval(this.checkInterval);
}
});
}
/**
* 检查并跳过
*/
checkAndSkip() {
if (!this.video || this.video.paused) {
return;
}
const currentTime = this.video.currentTime;
const skipCategories = this.config.get('skipCategories') || [];
for (const segment of this.segments) {
// 检查是否在片段范围内
if (currentTime >= segment.segment[0] && currentTime < segment.segment[1]) {
const segmentKey = `${segment.UUID}`;
// 如果用户选择不跳过此片段,则忽略
if (this.ignoredSegments.has(segmentKey)) {
continue;
}
// 判断是否勾选了此类别
if (skipCategories.includes(segment.category)) {
// 自动跳过
if (Date.now() - this.lastSkipTime < 1000) {
continue;
}
const skipTo = segment.segment[1];
this.video.currentTime = skipTo;
this.lastSkipTime = Date.now();
// 显示Toast提示
this.showSkipToast(segment);
break;
} else {
// 显示手动提示
if (!this.promptedSegments.has(segmentKey)) {
this.showSkipPrompt(segment);
this.promptedSegments.add(segmentKey);
}
continue;
}
}
}
}
/**
* 显示跳过Toast
*/
showSkipToast(segment) {
const categoryInfo = SPONSORBLOCK.CATEGORIES[segment.category] ||
{ name: segment.category};
const toast = document.createElement('div');
toast.className = 'skip-toast';
toast.textContent = `已跳过 ${categoryInfo.name}`;
toast.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
});
toast.addEventListener('mousedown', (e) => {
e.stopPropagation();
e.preventDefault();
});
const playerContainer = document.querySelector('.bpx-player-video-wrap') ||
document.querySelector('.bpx-player-container') ||
document.body;
playerContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
}
/**
* 显示跳过提示
*/
showSkipPrompt(segment) {
// 如果已有提示,先清理
this.closePrompt();
const categoryInfo = SPONSORBLOCK.CATEGORIES[segment.category] ||
{ name: segment.category, color: '#999' };
const prompt = document.createElement('div');
prompt.className = 'skip-prompt';
const duration = segment.segment[1] - segment.segment[0];
const startTime = formatTime(segment.segment[0]);
const endTime = formatTime(segment.segment[1]);
prompt.innerHTML = `
<div class="skip-prompt-header">
<div class="skip-prompt-icon">
<svg viewBox="0 0 24 24" fill="${categoryInfo.color}">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
<div class="skip-prompt-message">
跳过${categoryInfo.name}?<br>
<small style="color: #999; font-size: 11px;">${startTime} - ${endTime}</small>
</div>
<button class="skip-prompt-close" title="关闭">×</button>
</div>
<div class="skip-prompt-buttons">
<button class="skip-prompt-btn skip-prompt-btn-secondary" data-action="ignore">
不跳过
</button>
<button class="skip-prompt-btn skip-prompt-btn-primary" data-action="skip">
跳过 (${duration.toFixed(0)}秒)
</button>
</div>
`;
prompt.addEventListener('click', (e) => {
e.stopPropagation();
});
prompt.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
const playerContainer = document.querySelector('.bpx-player-video-wrap') ||
document.querySelector('.bpx-player-container') ||
document.body;
playerContainer.appendChild(prompt);
this.currentPrompt = prompt;
// 绑定事件
const skipBtn = prompt.querySelector('[data-action="skip"]');
const ignoreBtn = prompt.querySelector('[data-action="ignore"]');
const closeBtn = prompt.querySelector('.skip-prompt-close');
const handleSkip = () => {
this.video.currentTime = segment.segment[1];
this.lastSkipTime = Date.now();
this.closePrompt();
};
const handleIgnore = () => {
const segmentKey = `${segment.UUID}`;
this.ignoredSegments.add(segmentKey);
this.closePrompt();
};
const handleClose = () => {
this.closePrompt();
};
skipBtn.onclick = handleSkip;
ignoreBtn.onclick = handleIgnore;
closeBtn.onclick = handleClose;
// 键盘快捷键
const keyHandler = (e) => {
if (e.key === 'Enter') {
handleSkip();
document.removeEventListener('keydown', keyHandler);
} else if (e.key === 'Escape') {
handleClose();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
// 片段结束后自动关闭提示
const checkEnd = () => {
if (this.video && this.video.currentTime >= segment.segment[1]) {
this.closePrompt();
clearInterval(endCheckInterval);
}
};
const endCheckInterval = setInterval(checkEnd, 500);
// 5秒后自动淡出关闭
const autoCloseTimer = setTimeout(() => {
if (this.currentPrompt === prompt) {
this.closePrompt();
}
}, 5000);
// 保存清理函数
prompt._cleanup = () => {
clearInterval(endCheckInterval);
clearTimeout(autoCloseTimer);
document.removeEventListener('keydown', keyHandler);
};
}
/**
* 关闭提示
*/
closePrompt() {
if (this.currentPrompt) {
if (this.currentPrompt._cleanup) {
this.currentPrompt._cleanup();
}
this.currentPrompt.classList.add('hiding');
setTimeout(() => {
if (this.currentPrompt) {
this.currentPrompt.remove();
this.currentPrompt = null;
}
}, 300);
}
}
/**
* 销毁控制器
*/
destroy() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
}
this.closePrompt();
this.closeSegmentDetails();
if (this.markerContainer) {
this.markerContainer.remove();
this.markerContainer = null;
}
if (this.playerObserver) {
this.playerObserver.disconnect();
this.playerObserver = null;
}
}
}
/**
* SponsorBlock服务类
* 统一管理API和播放器控制器
*/
class SponsorBlockService {
constructor() {
this.api = new SponsorBlockAPI();
this.playerController = null;
this.currentURL = location.href;
}
/**
* 初始化服务
*/
async init() {
// 初始化播放器控制器(仅视频页)
if (location.pathname.includes('/video/')) {
this.playerController = new VideoPlayerController(this.api, sponsorBlockConfig);
await this.playerController.init();
}
// 监听URL变化
this.setupURLMonitor();
}
/**
* 设置URL监听
*/
setupURLMonitor() {
// 监听popstate事件
window.addEventListener('popstate', () => {
this.handleURLChange();
});
// 监听pushState和replaceState
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
this.handleURLChange();
};
history.replaceState = (...args) => {
originalReplaceState.apply(history, args);
this.handleURLChange();
};
}
/**
* 处理URL变化
*/
handleURLChange() {
const newURL = location.href;
if (newURL !== this.currentURL) {
this.currentURL = newURL;
// 清理旧的控制器
this.playerController?.destroy();
this.playerController = null;
// 如果是视频页,重新初始化
if (location.pathname.includes('/video/')) {
setTimeout(async () => {
this.playerController = new VideoPlayerController(this.api, sponsorBlockConfig);
await this.playerController.init();
}, 1000);
}
}
}
/**
* 获取API实例
*/
getAPI() {
return this.api;
}
}
// 创建全局单例
const sponsorBlockService = new SponsorBlockService();
/**
* 视频质量服务模块
* 负责视频卡片的质量标记和片段标签显示
*/
class VideoQualityService {
constructor(sponsorBlockAPI) {
this.sponsorAPI = sponsorBlockAPI;
this.observer = null;
this.statsCache = new Map();
this.pendingRequests = new Map();
this.abortController = new AbortController();
this.processQueue = new Set();
this.isProcessing = false;
}
/**
* 启动服务
*/
start() {
setTimeout(() => {
this.initScrollHandler();
this.initObserver();
this.checkNewCards();
}, 800);
}
/**
* 初始化滚动处理器
*/
initScrollHandler() {
let timeout;
window.addEventListener('scroll', () => {
clearTimeout(timeout);
timeout = setTimeout(() => this.checkNewCards(), 200);
}, { signal: this.abortController.signal });
}
/**
* 检查新卡片
*/
checkNewCards() {
if (document.visibilityState === 'hidden') return;
const cards = document.querySelectorAll(`
.bili-video-card:not([data-quality-checked]),
.video-page-card-small:not([data-quality-checked]),
.video-page-card:not([data-quality-checked]),
.up-main-video-card:not([data-quality-checked]),
.small-item:not([data-quality-checked])
`);
cards.forEach(card => {
if (!card.dataset.qualityChecked) {
this.processQueue.add(card);
}
});
this.processNextBatch();
}
/**
* 处理下一批卡片
*/
async processNextBatch() {
if (this.isProcessing || this.processQueue.size === 0) return;
this.isProcessing = true;
const batchSize = 5;
const batch = Array.from(this.processQueue).slice(0, batchSize);
try {
await Promise.all(batch.map(card => this.processCard(card)));
} catch (error) {
// 静默处理错误
}
batch.forEach(card => this.processQueue.delete(card));
this.isProcessing = false;
if (this.processQueue.size > 0) {
setTimeout(() => this.processNextBatch(), 100);
}
}
/**
* 处理单个卡片
*/
async processCard(card) {
if (card.dataset.qualityChecked === 'true') return;
if (!document.body.contains(card)) return;
card.dataset.qualityChecked = 'processing';
const link = card.querySelector('a[href*="/video/BV"]');
if (!link) {
card.dataset.qualityChecked = 'true';
return;
}
const bvid = this.extractBVID(link.href);
if (!bvid) {
card.dataset.qualityChecked = 'true';
return;
}
const container = this.findBadgeContainer(card);
if (!container) {
card.dataset.qualityChecked = 'true';
return;
}
try {
// 并行获取视频统计和广告片段
const [stats, segments] = await Promise.all([
this.fetchVideoStats(bvid).catch(() => null),
this.sponsorAPI.fetchSegments(bvid).catch(() => [])
]);
if (!document.body.contains(card)) return;
// 创建标签容器
const existingContainer = container.querySelector('.bili-tags-container');
if (existingContainer) {
existingContainer.remove();
}
const tagsContainer = document.createElement('div');
tagsContainer.className = 'bili-tags-container';
// 添加优质视频标签
if (sponsorBlockConfig.get('showQualityBadge') && stats && this.isHighQuality(stats)) {
const qualityBadge = this.createQualityBadge(stats);
tagsContainer.appendChild(qualityBadge);
}
// 添加片段标签
if (sponsorBlockConfig.get('showAdBadge') && segments && segments.length > 0) {
const badges = this.createSegmentBadges(segments);
badges.forEach(badge => tagsContainer.appendChild(badge));
}
// 如果有标签,插入到容器中
if (tagsContainer.children.length > 0) {
if (container.firstChild) {
container.insertBefore(tagsContainer, container.firstChild);
} else {
container.appendChild(tagsContainer);
}
}
} catch (error) {
// 静默处理错误
} finally {
if (document.body.contains(card)) {
card.dataset.qualityChecked = 'true';
}
}
}
/**
* 查找标签容器
*/
findBadgeContainer(card) {
// UP主主页视频卡片
if (card.classList.contains('up-main-video-card') || card.classList.contains('small-item')) {
return card.querySelector('.cover-container, .cover, .pic-box') || card;
}
// 其他页面视频卡片
if (card.classList.contains('video-page-card-small')) {
return card.querySelector('.pic-box');
}
if (card.classList.contains('video-page-card')) {
return card.querySelector('.pic');
}
return card.querySelector('.bili-video-card__cover, .cover, .pic, .bili-video-card__info') ||
card.closest('.bili-video-card')?.querySelector('.bili-video-card__cover');
}
/**
* 判断是否高质量
*/
isHighQuality(stats) {
return stats?.view >= SPONSORBLOCK.MIN_VIEWS &&
stats.like / stats.view >= SPONSORBLOCK.MIN_SCORE;
}
/**
* 判断是否顶级质量
*/
isTopQuality(stats) {
return stats?.coin >= stats?.like;
}
/**
* 创建质量标签
*/
createQualityBadge(stats) {
const badge = document.createElement('span');
badge.className = 'bili-quality-tag';
if (this.isTopQuality(stats)) {
badge.style.background = SPONSORBLOCK.TOP_TAG_COLOR;
badge.textContent = SPONSORBLOCK.TOP_TAG_TEXT;
} else {
badge.style.background = SPONSORBLOCK.TAG_COLOR;
badge.textContent = SPONSORBLOCK.TAG_TEXT;
}
return badge;
}
/**
* 创建片段标签
*/
createSegmentBadges(segments) {
// 统计各类别的片段
const categoryCount = {};
segments.forEach(seg => {
categoryCount[seg.category] = (categoryCount[seg.category] || 0) + 1;
});
// 为每个类别创建标签
const badges = [];
// 定义类别图标和颜色映射
const categoryStyles = {
'sponsor': { icon: '⚠️', text: '广告', color: 'linear-gradient(135deg, #FF8C00, #FF6347)' },
'selfpromo': { icon: '📢', text: '推广', color: 'linear-gradient(135deg, #FFD700, #FFA500)' },
'interaction': { icon: '👆', text: '三连', color: 'linear-gradient(135deg, #9C27B0, #E91E63)' },
'poi_highlight': { icon: '⭐', text: '高光', color: 'linear-gradient(135deg, #FF1493, #FF69B4)' },
'intro': { icon: '▶️', text: '开场', color: 'linear-gradient(135deg, #00CED1, #00BFFF)' },
'outro': { icon: '🎬', text: '结尾', color: 'linear-gradient(135deg, #1E90FF, #4169E1)' },
'preview': { icon: '🔄', text: '回顾', color: 'linear-gradient(135deg, #00A1D6, #0087B3)' },
'filler': { icon: '💬', text: '闲聊', color: 'linear-gradient(135deg, #9370DB, #8A2BE2)' },
'music_offtopic': { icon: '🎵', text: '非音乐', color: 'linear-gradient(135deg, #FF8C00, #FF7F50)' },
'exclusive_access': { icon: '🤝', text: '合作', color: 'linear-gradient(135deg, #2E8B57, #3CB371)' },
'mute': { icon: '🔇', text: '静音', color: 'linear-gradient(135deg, #DC143C, #C71585)' }
};
Object.entries(categoryCount).forEach(([category, count]) => {
const style = categoryStyles[category] ||
{ icon: '📍', text: category, color: 'linear-gradient(135deg, #888, #666)' };
const badge = document.createElement('span');
badge.className = 'bili-ad-tag';
badge.style.background = style.color;
badge.textContent = `${style.icon} ${style.text}`;
if (count > 1) {
badge.textContent += ` (${count})`;
}
badge.title = `包含 ${count} 个${style.text}片段`;
badges.push(badge);
});
return badges;
}
/**
* 提取BVID
*/
extractBVID(url) {
try {
return new URL(url).pathname.match(/video\/(BV\w+)/)?.[1];
} catch {
return null;
}
}
/**
* 获取视频统计
*/
async fetchVideoStats(bvid) {
// 检查缓存
if (this.statsCache.has(bvid)) {
return this.statsCache.get(bvid);
}
if (this.pendingRequests.has(bvid)) {
return this.pendingRequests.get(bvid);
}
const promise = new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
timeout: 5000,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data?.code === 0 && data?.data?.stat) {
this.statsCache.set(bvid, data.data.stat);
resolve(data.data.stat);
} else {
reject(new Error('Invalid API response'));
}
} catch (error) {
reject(error);
}
},
onerror: reject,
ontimeout: () => reject(new Error('Timeout'))
});
});
this.pendingRequests.set(bvid, promise);
return promise.finally(() => {
this.pendingRequests.delete(bvid);
});
}
/**
* 初始化观察器
*/
initObserver() {
this.observer = new MutationObserver((mutations) => {
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
shouldCheck = true;
break;
}
}
if (shouldCheck) {
this.checkNewCards();
}
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
/**
* 销毁服务
*/
destroy() {
this.observer?.disconnect();
this.abortController.abort();
this.processQueue.clear();
this.pendingRequests.clear();
this.statsCache.clear();
}
}
// 创建全局单例(需要传入API实例)
let videoQualityServiceInstance = null;
function createVideoQualityService(sponsorBlockAPI) {
if (!videoQualityServiceInstance) {
videoQualityServiceInstance = new VideoQualityService(sponsorBlockAPI);
}
return videoQualityServiceInstance;
}
/**
* 通知模块
* 统一的错误处理和用户提示机制
*/
class Notification {
constructor() {
this.toastElement = null;
this.init();
}
/**
* 初始化Toast元素
*/
init() {
this.toastElement = document.createElement('div');
this.toastElement.className = 'notion-toast';
}
/**
* 显示Toast提示
* @param {string} message - 消息内容
* @param {number} duration - 显示时长(毫秒)
*/
showToast(message, duration = TIMING.TOAST_DURATION) {
this.toastElement.textContent = message;
document.body.appendChild(this.toastElement);
setTimeout(() => this.toastElement.classList.add('show'), 10);
setTimeout(() => {
this.toastElement.classList.remove('show');
setTimeout(() => {
if (this.toastElement.parentNode) {
document.body.removeChild(this.toastElement);
}
}, 300);
}, duration);
}
/**
* 显示成功消息
* @param {string} message
*/
success(message) {
this.showToast(message);
}
/**
* 显示警告消息
* @param {string} message
*/
warning(message) {
this.showToast(message);
}
/**
* 显示错误消息
* @param {string} message
* @param {boolean} useAlert - 是否同时使用alert(用于重要错误)
*/
error(message, useAlert = false) {
this.showToast(message, 3000);
if (useAlert) {
alert(message);
}
}
/**
* 显示信息消息
* @param {string} message
*/
info(message) {
this.showToast(message);
}
/**
* 处理错误(统一的错误处理逻辑)
* @param {Error|string} error - 错误对象或错误信息
* @param {string} context - 错误上下文(用于日志)
* @param {boolean} silent - 是否静默处理(不显示给用户)
* @param {boolean} useAlert - 是否使用alert
*/
handleError(error, context = '', silent = false, useAlert = false) {
const errorMessage = error instanceof Error ? error.message : String(error);
// 记录到控制台
console.error(`[Error] ${context}:`, error);
// 显示给用户(如果不是静默模式)
if (!silent) {
this.error(errorMessage, useAlert);
}
}
/**
* 确认对话框
* @param {string} message - 确认消息
* @returns {boolean}
*/
confirm(message) {
return window.confirm(message);
}
}
// 创建全局单例
const notification = new Notification();
/**
* UI渲染模块
* 负责生成所有UI元素的HTML
*/
class UIRenderer {
/**
* 渲染字幕面板
* @param {Array} subtitleData - 字幕数据
* @returns {string} - HTML字符串
*/
renderSubtitlePanel(subtitleData) {
const videoKey = state.getVideoKey();
videoKey ? state.getAISummary(videoKey) : null;
let html = `
<div class="subtitle-header">
<span>视频字幕</span>
<div class="subtitle-header-actions">
<span class="ai-icon ${state.ai.isSummarizing ? 'loading' : ''}" title="AI 总结">
${ICONS.AI}
</span>
<span class="download-icon" title="下载字幕">
${ICONS.DOWNLOAD}
</span>
<span class="notion-icon ${state.notion.isSending ? 'loading' : ''}" title="发送到 Notion">
${ICONS.NOTION}
</span>
<span class="subtitle-close">×</span>
</div>
</div>
<div class="subtitle-content">
<button class="subtitle-toggle-btn" id="subtitle-toggle-btn" title="展开/收起字幕列表 (${subtitleData.length}条)">
<span class="subtitle-toggle-icon">►</span>
</button>
<div class="subtitle-list-container" id="subtitle-list-container">
`;
// 渲染字幕列表
subtitleData.forEach((item, index) => {
const startTime = formatTime(item.from);
html += `
<div class="subtitle-item" data-index="${index}" data-from="${item.from}" data-to="${item.to}">
<div class="subtitle-item-header">
<div class="subtitle-time">${startTime}</div>
<button class="save-subtitle-note-btn" data-content="${this.escapeHtml(item.content)}" title="保存为笔记">保存</button>
</div>
<div class="subtitle-text">${item.content}</div>
</div>
`;
});
html += `
</div>
</div>
`;
return html;
}
/**
* HTML转义
* @param {string} text - 要转义的文本
* @returns {string} 转义后的文本
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 渲染AI总结区域
* @param {string} summary - 总结内容(Markdown格式)
* @param {boolean} isLoading - 是否正在加载
* @returns {HTMLElement} - DOM元素
*/
renderAISummarySection(summary = null, isLoading = false) {
const section = document.createElement('div');
section.className = 'ai-summary-section';
if (isLoading) {
section.innerHTML = `
<div class="ai-summary-title">
<span>✨ AI 视频总结</span>
</div>
<div class="ai-summary-content ai-summary-loading">正在生成总结...</div>
`;
} else if (summary) {
section.innerHTML = `
<div class="ai-summary-title">
<span>✨ AI 视频总结</span>
</div>
<div class="ai-summary-content">${marked.parse(summary)}</div>
`;
}
return section;
}
/**
* 更新AI总结内容
* @param {HTMLElement} container - 字幕容器元素
* @param {string} summary - 总结内容
*/
updateAISummary(container, summary) {
const contentDiv = container.querySelector('.subtitle-content');
if (!contentDiv) return;
let summarySection = contentDiv.querySelector('.ai-summary-section');
if (!summarySection) {
summarySection = this.renderAISummarySection(summary);
contentDiv.insertBefore(summarySection, contentDiv.firstChild);
} else {
const summaryContent = summarySection.querySelector('.ai-summary-content');
if (summaryContent) {
summaryContent.classList.remove('ai-summary-loading');
summaryContent.innerHTML = marked.parse(summary);
}
}
}
/**
* 创建Notion配置模态框
* @returns {HTMLElement}
*/
createNotionConfigModal() {
const modal = document.createElement('div');
modal.id = 'notion-config-modal';
modal.className = 'config-modal';
modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>Notion 集成配置</span>
</div>
<div class="config-modal-body">
<div class="config-field">
<label>1️⃣ Notion API Key</label>
<input type="password" id="notion-api-key" placeholder="输入你的 Integration Token">
<div class="config-help">
访问 <a href="https://www.notion.so/my-integrations" target="_blank">Notion Integrations</a> 创建 Integration 并复制 Token
</div>
</div>
<div class="config-field">
<label>2️⃣ 目标位置(二选一)</label>
<input type="text" id="notion-parent-page-id" placeholder="Page ID 或 Database ID">
<div class="config-help">
<strong>方式A - 使用已有数据库:</strong><br>
从数据库 URL 中获取:<code>notion.so/<strong>abc123...</strong>?v=...</code><br>
脚本会直接向该数据库添加记录
</div>
<div class="config-help" style="margin-top: 8px;">
<strong>方式B - 自动创建数据库:</strong><br>
从页面 URL 中获取:<code>notion.so/My-Page-<strong>abc123...</strong></code><br>
首次使用会在此页面下创建数据库
</div>
<div class="config-help" style="margin-top: 8px; color: #f59e0b;">
⚠️ 重要:需要在「Share」中邀请你的 Integration
</div>
</div>
<div class="config-field">
<label>
<input type="checkbox" id="notion-auto-send-enabled">
自动发送(获取字幕后自动发送到Notion)
</label>
</div>
<div id="notion-status-message"></div>
</div>
<div class="config-footer">
<button class="config-btn config-btn-secondary" id="notion-cancel-btn">取消</button>
<button class="config-btn config-btn-primary" id="notion-save-btn">保存配置</button>
</div>
</div>
`;
return modal;
}
/**
* 创建AI配置模态框
* @returns {HTMLElement}
*/
createAIConfigModal() {
const modal = document.createElement('div');
modal.id = 'ai-config-modal';
modal.className = 'config-modal';
modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>AI 配置管理</span>
</div>
<div class="config-modal-body">
<div style="margin-bottom: 20px; padding: 15px; background: rgba(254, 235, 234, 0.1); border-radius: 10px; border-left: 4px solid #feebea;">
<div style="font-size: 13px; color: #fff; font-weight: 600; margin-bottom: 4px;">使用提示</div>
<div style="font-size: 12px; color: rgba(255, 255, 255, 0.7); line-height: 1.5;">
点击配置卡片直接查看和编辑,修改后保存即更新。点击「新建配置」创建新配置。
</div>
</div>
<div class="ai-config-list" id="ai-config-list"></div>
<div style="margin-bottom: 15px; text-align: center;">
<button class="config-btn config-btn-secondary" id="ai-new-config-btn" style="padding: 8px 16px; font-size: 13px;">新建配置</button>
</div>
<div class="ai-config-form hidden">
<div class="config-field">
<label>配置名称</label>
<input type="text" id="ai-config-name" placeholder="例如:OpenAI GPT-4">
</div>
<div class="config-field">
<label>API URL</label>
<input type="text" id="ai-config-url" placeholder="https://api.openai.com/v1/chat/completions">
</div>
<div class="config-field">
<label>API Key</label>
<input type="password" id="ai-config-apikey" placeholder="sk-...">
</div>
<div class="config-field">
<label>模型</label>
<div class="model-field-with-button">
<input type="text" id="ai-config-model" placeholder="手动输入或点击获取模型">
<button class="fetch-models-btn" id="fetch-models-btn">获取模型</button>
</div>
<div class="model-select-wrapper" id="model-select-wrapper" style="display:none;">
<input type="text" id="model-search-input" class="model-search-input" placeholder="🔍 搜索模型...">
<select id="model-select" size="8"></select>
</div>
</div>
<div class="config-field">
<label>
<input type="checkbox" id="ai-config-is-openrouter">
使用OpenRouter (支持获取模型列表)
</label>
</div>
<div class="config-field">
<label>提示词 (Prompt)</label>
<textarea id="ai-config-prompt" placeholder="根据以下视频字幕,用中文总结视频内容:"></textarea>
</div>
<div class="config-field">
<label>
<input type="checkbox" id="ai-auto-summary-enabled">
自动总结(获取字幕后自动触发AI总结)
</label>
</div>
</div>
</div>
<div class="config-footer">
<button class="config-btn config-btn-danger" id="ai-delete-current-btn" style="display:none;">删除此配置</button>
<div style="flex: 1;"></div>
<button class="config-btn config-btn-secondary" id="ai-cancel-btn">取消</button>
<button class="config-btn config-btn-primary" id="ai-save-new-btn">添加新配置</button>
<button class="config-btn config-btn-primary" id="ai-update-btn" style="display:none;">更新配置</button>
</div>
</div>
`;
return modal;
}
/**
* 渲染AI配置列表
* @param {HTMLElement} listElement - 列表容器元素
*/
renderAIConfigList(listElement) {
const configs = config.getAIConfigs();
const selectedId = config.getSelectedAIConfigId();
listElement.innerHTML = configs.map(cfg => `
<div class="ai-config-item ${cfg.id === selectedId ? 'selected' : ''}" data-id="${cfg.id}">
<div class="ai-config-item-name">${cfg.name}</div>
<div class="ai-config-item-actions">
<button class="ai-config-btn-small config-btn-primary ai-edit-btn" data-id="${cfg.id}">编辑</button>
</div>
</div>
`).join('');
}
/**
* 显示Notion配置状态
* @param {string} message - 消息内容
* @param {boolean} isError - 是否为错误
*/
showNotionStatus(message, isError = false) {
const statusEl = document.getElementById('notion-status-message');
if (statusEl) {
statusEl.className = `config-status ${isError ? 'error' : 'success'}`;
statusEl.textContent = message;
}
}
}
// 创建全局单例
const uiRenderer = new UIRenderer();
/**
* 笔记面板UI模块
* 负责渲染笔记管理界面
*/
class NotesPanel {
constructor() {
this.panel = null;
this.isPanelVisible = false;
}
/**
* 创建笔记面板元素
*/
createPanel() {
if (this.panel) {
return this.panel;
}
this.panel = document.createElement('div');
this.panel.id = 'notes-panel';
this.panel.className = 'notes-panel';
document.body.appendChild(this.panel);
return this.panel;
}
/**
* 显示笔记面板
*/
showPanel() {
const panel = this.createPanel();
this.renderPanel();
panel.classList.add('show');
this.isPanelVisible = true;
}
/**
* 隐藏笔记面板
*/
hidePanel() {
if (this.panel) {
this.panel.classList.remove('show');
}
this.isPanelVisible = false;
}
/**
* 切换笔记面板显示/隐藏
*/
togglePanel() {
if (this.isPanelVisible) {
this.hidePanel();
} else {
this.showPanel();
}
}
/**
* 渲染笔记面板内容
*/
renderPanel() {
const panel = this.createPanel();
const groupedNotes = notesService.getGroupedNotes();
const html = `
<div class="notes-panel-content">
<div class="notes-panel-header">
<h2>我的笔记</h2>
<button class="notes-panel-close">×</button>
</div>
<div class="notes-panel-body">
${groupedNotes.length === 0 ? this.renderEmptyState() : groupedNotes.map(group => this.renderGroup(group)).join('')}
</div>
</div>
`;
panel.innerHTML = html;
this.bindPanelEvents();
}
/**
* 渲染空状态
*/
renderEmptyState() {
return `
<div class="notes-empty-state">
<div class="notes-empty-icon">📝</div>
<div>还没有保存任何笔记</div>
<div class="notes-empty-hint">选中文字后点击粉色点即可保存</div>
</div>
`;
}
/**
* 渲染笔记分组
* @param {Object} group - 分组对象 {date, notes}
*/
renderGroup(group) {
return `
<div class="note-group">
<div class="note-group-header">
<div class="note-group-title">
${group.date} (${group.notes.length}条)
</div>
<div class="note-group-actions">
<button class="note-group-copy-btn" data-date="${group.date}">
批量复制
</button>
<button class="note-group-delete-btn" data-date="${group.date}">
批量删除
</button>
</div>
</div>
<div class="note-group-items">
${group.notes.map(note => this.renderNote(note)).join('')}
</div>
</div>
`;
}
/**
* 渲染单条笔记
* @param {Object} note - 笔记对象
*/
renderNote(note) {
const displayContent = note.content.length > 200
? note.content.substring(0, 200) + '...'
: note.content;
return `
<div class="note-item" data-note-id="${note.id}">
<div class="note-content">${this.escapeHtml(displayContent)}</div>
<div class="note-footer">
<div class="note-time">${notesService.formatTime(note.timestamp)}</div>
<div class="note-actions">
<button class="note-copy-btn" data-note-id="${note.id}">复制</button>
<button class="note-delete-btn" data-note-id="${note.id}">删除</button>
</div>
</div>
</div>
`;
}
/**
* HTML转义
* @param {string} text - 要转义的文本
* @returns {string} 转义后的文本
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
*/
async copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
} catch (error) {
console.error('复制失败:', error);
}
}
/**
* 绑定面板事件
*/
bindPanelEvents() {
// 关闭按钮
const closeBtn = this.panel.querySelector('.notes-panel-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.hidePanel());
}
// 复制单条笔记
this.panel.querySelectorAll('.note-copy-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const noteId = e.target.getAttribute('data-note-id');
const note = notesService.getAllNotes().find(n => n.id === noteId);
if (note) {
await this.copyToClipboard(note.content);
const originalText = e.target.textContent;
e.target.textContent = '✓';
setTimeout(() => {
e.target.textContent = originalText;
}, 1000);
}
});
});
// 删除单条笔记
this.panel.querySelectorAll('.note-delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const noteId = e.target.getAttribute('data-note-id');
notesService.deleteNote(noteId);
this.renderPanel();
});
});
// 批量复制
this.panel.querySelectorAll('.note-group-copy-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const date = e.target.getAttribute('data-date');
const groupedNotes = notesService.getGroupedNotes();
const group = groupedNotes.find(g => g.date === date);
if (group) {
const contents = group.notes.map(note => note.content).join('\n\n');
await this.copyToClipboard(contents);
const originalText = e.target.textContent;
e.target.textContent = '✓';
setTimeout(() => {
e.target.textContent = originalText;
}, 1000);
}
});
});
// 批量删除
this.panel.querySelectorAll('.note-group-delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const date = e.target.getAttribute('data-date');
const groupedNotes = notesService.getGroupedNotes();
const group = groupedNotes.find(g => g.date === date);
if (group && confirm(`确定要删除 ${date} 的 ${group.notes.length} 条笔记吗?`)) {
const noteIds = group.notes.map(note => note.id);
notesService.deleteNotes(noteIds);
this.renderPanel();
}
});
});
}
/**
* 在字幕项中添加保存按钮
* @param {HTMLElement} subtitleItem - 字幕项元素
*/
addSaveButton(subtitleItem) {
if (subtitleItem.querySelector('.save-subtitle-note-btn')) {
return;
}
const content = subtitleItem.querySelector('.subtitle-text')?.textContent;
if (!content) return;
const saveBtn = document.createElement('button');
saveBtn.className = 'save-subtitle-note-btn';
saveBtn.textContent = '保存';
saveBtn.title = '保存此字幕为笔记';
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
notesService.saveSubtitleNote(content);
saveBtn.textContent = '✓';
setTimeout(() => {
saveBtn.textContent = '保存';
}, 1000);
});
const footer = subtitleItem.querySelector('.subtitle-time');
if (footer) {
footer.appendChild(saveBtn);
}
}
/**
* 为所有字幕项添加保存按钮
* @param {HTMLElement} container - 字幕容器
*/
addSaveButtonsToSubtitles(container) {
const subtitleItems = container.querySelectorAll('.subtitle-item');
subtitleItems.forEach(item => this.addSaveButton(item));
}
}
// 创建全局单例
const notesPanel = new NotesPanel();
/**
* 事件处理模块
* 负责所有UI事件的绑定和处理
*/
class EventHandlers {
constructor() {
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.translateX = 0;
this.translateY = 0;
this.isResizing = false;
this.resizeStartX = 0;
this.resizeStartY = 0;
this.resizeStartWidth = 0;
this.resizeStartHeight = 0;
}
/**
* 绑定字幕面板事件
* @param {HTMLElement} container - 字幕容器
*/
bindSubtitlePanelEvents(container) {
// 关闭按钮
const closeBtn = container.querySelector('.subtitle-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
state.setPanelVisible(false);
container.classList.remove('show');
});
}
// AI总结按钮
const aiIcon = container.querySelector('.ai-icon');
if (aiIcon) {
aiIcon.addEventListener('click', async (e) => {
e.stopPropagation();
const subtitleData = state.getSubtitleData();
if (subtitleData) {
try {
await aiService.summarize(subtitleData, false);
} catch (error) {
notification.handleError(error, 'AI总结');
}
}
});
}
// 下载按钮
const downloadIcon = container.querySelector('.download-icon');
if (downloadIcon) {
downloadIcon.addEventListener('click', (e) => {
e.stopPropagation();
try {
subtitleService.downloadSubtitleFile();
notification.success('字幕文件已下载');
} catch (error) {
notification.handleError(error, '下载字幕');
}
});
}
// Notion发送按钮
const notionIcon = container.querySelector('.notion-icon');
if (notionIcon) {
notionIcon.addEventListener('click', async (e) => {
e.stopPropagation();
const subtitleData = state.getSubtitleData();
if (subtitleData) {
try {
await notionService.sendSubtitle(subtitleData, false);
} catch (error) {
notification.handleError(error, 'Notion发送');
}
}
});
}
// 展开/收起按钮
const toggleBtn = container.querySelector('#subtitle-toggle-btn');
const listContainer = container.querySelector('#subtitle-list-container');
if (toggleBtn && listContainer) {
toggleBtn.addEventListener('click', () => {
listContainer.classList.toggle('expanded');
toggleBtn.classList.toggle('expanded');
});
}
// 字幕项点击跳转
const subtitleItems = container.querySelectorAll('.subtitle-item');
subtitleItems.forEach(item => {
item.addEventListener('click', () => {
const video = document.querySelector(SELECTORS.VIDEO);
if (video) {
const startTime = parseFloat(item.dataset.from);
// 先移除所有高亮
container.querySelectorAll('.subtitle-item').forEach(i => {
i.classList.remove('current');
});
// 只高亮当前点击的
item.classList.add('current');
// 跳转视频
video.currentTime = startTime;
}
});
});
// 保存笔记按钮
const saveButtons = container.querySelectorAll('.save-subtitle-note-btn');
saveButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const content = btn.getAttribute('data-content');
if (content) {
notesService.saveSubtitleNote(content);
btn.textContent = '✓';
setTimeout(() => {
btn.textContent = '保存';
}, 1000);
}
});
});
// 同步字幕高亮
this.syncSubtitleHighlight(container);
}
/**
* 设置拖拽功能
* @param {HTMLElement} container - 字幕容器
*/
setupDragging(container) {
const header = container.querySelector('.subtitle-header');
if (!header) return;
header.addEventListener('mousedown', (e) => {
// 如果点击的是按钮,不触发拖拽
if (e.target.closest('.subtitle-close') ||
e.target.closest('.ai-icon') ||
e.target.closest('.download-icon') ||
e.target.closest('.notion-icon')) {
return;
}
this.isDragging = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
// 启用GPU加速
container.style.willChange = 'transform';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
requestAnimationFrame(() => {
const deltaX = e.clientX - this.dragStartX;
const deltaY = e.clientY - this.dragStartY;
this.translateX += deltaX;
this.translateY += deltaY;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
container.style.transform = `translate(${this.translateX}px, ${this.translateY}px)`;
});
});
document.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
container.style.willChange = 'auto';
this.savePanelPosition(container);
}
});
}
/**
* 设置大小调整功能
* @param {HTMLElement} container - 字幕容器
*/
setupResize(container) {
const resizeHandle = container.querySelector('.subtitle-resize-handle');
if (!resizeHandle) return;
resizeHandle.addEventListener('mousedown', (e) => {
this.isResizing = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartWidth = container.offsetWidth;
this.resizeStartHeight = container.offsetHeight;
e.preventDefault();
e.stopPropagation();
});
document.addEventListener('mousemove', (e) => {
if (!this.isResizing) return;
requestAnimationFrame(() => {
const deltaX = e.clientX - this.resizeStartX;
const deltaY = e.clientY - this.resizeStartY;
const newWidth = this.resizeStartWidth + deltaX;
const newHeight = this.resizeStartHeight + deltaY;
// 限制尺寸范围
const constrainedWidth = Math.max(300, Math.min(800, newWidth));
const maxHeight = window.innerHeight * 0.9;
const constrainedHeight = Math.max(400, Math.min(maxHeight, newHeight));
container.style.width = `${constrainedWidth}px`;
container.style.maxHeight = `${constrainedHeight}px`;
});
});
document.addEventListener('mouseup', () => {
if (this.isResizing) {
this.isResizing = false;
this.savePanelDimensions(container);
}
});
}
/**
* 保存面板位置
*/
savePanelPosition(container) {
try {
localStorage.setItem('subtitle_panel_position', JSON.stringify({
translateX: this.translateX,
translateY: this.translateY
}));
} catch (error) {
console.error('保存面板位置失败:', error);
}
}
/**
* 保存面板尺寸
*/
savePanelDimensions(container) {
try {
localStorage.setItem('subtitle_panel_dimensions', JSON.stringify({
width: container.offsetWidth,
height: container.offsetHeight
}));
} catch (error) {
console.error('保存面板尺寸失败:', error);
}
}
/**
* 加载面板尺寸和位置
*/
loadPanelDimensions(container) {
try {
// 加载尺寸
const savedDimensions = localStorage.getItem('subtitle_panel_dimensions');
if (savedDimensions) {
const { width, height } = JSON.parse(savedDimensions);
container.style.width = `${width}px`;
container.style.maxHeight = `${height}px`;
}
// 加载位置
const savedPosition = localStorage.getItem('subtitle_panel_position');
if (savedPosition) {
const { translateX, translateY } = JSON.parse(savedPosition);
this.translateX = translateX;
this.translateY = translateY;
container.style.transform = `translate(${translateX}px, ${translateY}px)`;
}
} catch (error) {
console.error('加载面板设置失败:', error);
}
}
/**
* 同步字幕高亮
* @param {HTMLElement} container - 字幕容器
*/
syncSubtitleHighlight(container) {
const video = document.querySelector(SELECTORS.VIDEO);
if (video) {
video.addEventListener('timeupdate', () => {
const currentTime = video.currentTime;
const items = container.querySelectorAll('.subtitle-item');
// 找到第一个匹配的字幕(按顺序)
let foundMatch = false;
items.forEach(item => {
const from = parseFloat(item.dataset.from);
const to = parseFloat(item.dataset.to);
if (!foundMatch && currentTime >= from && currentTime <= to) {
item.classList.add('current');
foundMatch = true;
} else {
item.classList.remove('current');
}
});
});
}
}
/**
* 显示AI配置模态框
*/
showAIConfigModal() {
const modal = document.getElementById('ai-config-modal');
if (!modal) return;
// 渲染配置列表
const listEl = document.getElementById('ai-config-list');
if (listEl) {
uiRenderer.renderAIConfigList(listEl);
}
// 清空表单并隐藏
this.clearAIConfigForm();
const formEl = modal.querySelector('.ai-config-form');
if (formEl) {
formEl.classList.add('hidden');
}
// 加载自动总结开关
document.getElementById('ai-auto-summary-enabled').checked = config.getAIAutoSummaryEnabled();
modal.classList.add('show');
}
/**
* 隐藏AI配置模态框
*/
hideAIConfigModal() {
const modal = document.getElementById('ai-config-modal');
if (!modal) return;
// 保存自动总结开关
const autoSummaryEnabled = document.getElementById('ai-auto-summary-enabled').checked;
config.setAIAutoSummaryEnabled(autoSummaryEnabled);
modal.classList.remove('show');
this.clearAIConfigForm();
}
/**
* 清空AI配置表单
*/
clearAIConfigForm() {
const nameEl = document.getElementById('ai-config-name');
const urlEl = document.getElementById('ai-config-url');
const apikeyEl = document.getElementById('ai-config-apikey');
const modelEl = document.getElementById('ai-config-model');
const promptEl = document.getElementById('ai-config-prompt');
const openrouterEl = document.getElementById('ai-config-is-openrouter');
const saveNewBtn = document.getElementById('ai-save-new-btn');
const updateBtn = document.getElementById('ai-update-btn');
const modelSelectWrapper = document.getElementById('model-select-wrapper');
if (nameEl) nameEl.value = '';
if (urlEl) urlEl.value = 'https://openrouter.ai/api/v1/chat/completions';
if (apikeyEl) apikeyEl.value = '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';
if (modelEl) modelEl.value = 'alibaba/tongyi-deepresearch-30b-a3b:free';
if (promptEl) promptEl.value = `请用中文总结以下视频字幕内容,使用Markdown格式输出。
要求:
1. 在开头提供TL;DR(不超过50字的核心摘要)
2. 使用标题、列表等Markdown格式组织内容
3. 突出关键信息和要点
字幕内容:
`;
if (openrouterEl) openrouterEl.checked = true;
if (saveNewBtn) saveNewBtn.style.display = '';
if (updateBtn) updateBtn.style.display = 'none';
if (modelSelectWrapper) modelSelectWrapper.style.display = 'none';
}
/**
* 显示Notion配置模态框
*/
showNotionConfigModal() {
const modal = document.getElementById('notion-config-modal');
if (!modal) return;
const notionConfig = config.getNotionConfig();
document.getElementById('notion-api-key').value = notionConfig.apiKey;
document.getElementById('notion-parent-page-id').value = notionConfig.parentPageId;
document.getElementById('notion-auto-send-enabled').checked = config.getNotionAutoSendEnabled();
const statusEl = document.getElementById('notion-status-message');
if (statusEl) statusEl.innerHTML = '';
modal.classList.add('show');
}
/**
* 隐藏Notion配置模态框
*/
hideNotionConfigModal() {
const modal = document.getElementById('notion-config-modal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* 绑定AI配置模态框事件
* @param {HTMLElement} modal - AI配置模态框
*/
bindAIConfigModalEvents(modal) {
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideAIConfigModal();
}
});
// 绑定配置列表事件(选择、编辑)
const listEl = document.getElementById('ai-config-list');
if (listEl) {
listEl.addEventListener('click', (e) => {
const item = e.target.closest('.ai-config-item');
const editBtn = e.target.closest('.ai-edit-btn');
if (editBtn) {
const id = editBtn.dataset.id;
// 显示表单并加载配置
const formEl = modal.querySelector('.ai-config-form');
if (formEl) {
formEl.classList.remove('hidden');
}
this.loadConfigToForm(id);
} else if (item && !editBtn) {
const id = item.dataset.id;
config.setSelectedAIConfigId(id);
uiRenderer.renderAIConfigList(listEl);
const cfg = config.getAIConfigs().find(c => c.id === id);
notification.success(`已选择配置: ${cfg.name}`);
// 显示表单并加载配置
const formEl = modal.querySelector('.ai-config-form');
if (formEl) {
formEl.classList.remove('hidden');
}
this.loadConfigToForm(id);
}
});
}
// 新建配置按钮
document.getElementById('ai-new-config-btn').addEventListener('click', () => {
this.clearAIConfigForm();
// 显示表单
const formEl = modal.querySelector('.ai-config-form');
if (formEl) {
formEl.classList.remove('hidden');
// 滚动到表单
setTimeout(() => {
formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
notification.info('请填写新配置信息');
});
// 保存/添加按钮
document.getElementById('ai-save-new-btn').addEventListener('click', () => {
this.saveNewAIConfig();
});
document.getElementById('ai-update-btn').addEventListener('click', () => {
this.updateAIConfig();
});
// 取消按钮
document.getElementById('ai-cancel-btn').addEventListener('click', () => {
this.hideAIConfigModal();
});
// 删除配置按钮
document.getElementById('ai-delete-current-btn').addEventListener('click', () => {
const deleteBtn = document.getElementById('ai-delete-current-btn');
const id = deleteBtn?.dataset.deleteId;
if (!id) return;
if (notification.confirm('确定要删除这个配置吗?')) {
const result = config.deleteAIConfig(id);
if (result.success) {
notification.success('配置已删除');
const listEl = document.getElementById('ai-config-list');
if (listEl) uiRenderer.renderAIConfigList(listEl);
// 隐藏表单
const formEl = document.querySelector('.ai-config-form');
if (formEl) {
formEl.classList.add('hidden');
}
// 隐藏删除按钮
deleteBtn.style.display = 'none';
} else {
notification.error(result.error);
}
}
});
// 获取模型按钮
document.getElementById('fetch-models-btn').addEventListener('click', async () => {
await this.fetchModels();
});
}
/**
* 加载配置到表单(选择配置时使用)
* @param {string} id - 配置ID
*/
loadConfigToForm(id) {
const configs = config.getAIConfigs();
const cfg = configs.find(c => c.id === id);
if (!cfg) return;
const nameEl = document.getElementById('ai-config-name');
const urlEl = document.getElementById('ai-config-url');
const apikeyEl = document.getElementById('ai-config-apikey');
const modelEl = document.getElementById('ai-config-model');
const promptEl = document.getElementById('ai-config-prompt');
const openrouterEl = document.getElementById('ai-config-is-openrouter');
const saveNewBtn = document.getElementById('ai-save-new-btn');
const updateBtn = document.getElementById('ai-update-btn');
const modelSelectWrapper = document.getElementById('model-select-wrapper');
if (nameEl) nameEl.value = cfg.name;
if (urlEl) urlEl.value = cfg.url;
if (apikeyEl) apikeyEl.value = cfg.apiKey;
if (modelEl) modelEl.value = cfg.model;
if (promptEl) promptEl.value = cfg.prompt;
if (openrouterEl) openrouterEl.checked = cfg.isOpenRouter || false;
// 显示更新按钮
if (saveNewBtn) saveNewBtn.style.display = 'none';
if (updateBtn) {
updateBtn.style.display = '';
updateBtn.dataset.editId = id;
}
if (modelSelectWrapper) modelSelectWrapper.style.display = 'none';
// 显示/隐藏删除按钮(非预设配置显示)
const deleteBtn = document.getElementById('ai-delete-current-btn');
if (deleteBtn) {
if (id === 'openrouter' || id === 'openai' || id === 'siliconflow' ||
id === 'deepseek' || id === 'moonshot' || id === 'zhipu' ||
id === 'yi' || id === 'dashscope' || id === 'gemini') {
deleteBtn.style.display = 'none';
} else {
deleteBtn.style.display = '';
deleteBtn.dataset.deleteId = id;
}
}
// 滚动到表单
setTimeout(() => {
const formEl = document.querySelector('.ai-config-form');
if (formEl) {
formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
/**
* 编辑AI配置(与loadConfigToForm相同,保持兼容)
* @param {string} id - 配置ID
*/
editAIConfig(id) {
this.loadConfigToForm(id);
}
/**
* 保存新的AI配置
*/
saveNewAIConfig() {
const newConfig = {
name: document.getElementById('ai-config-name').value.trim(),
url: document.getElementById('ai-config-url').value.trim(),
apiKey: document.getElementById('ai-config-apikey').value.trim(),
model: document.getElementById('ai-config-model').value.trim(),
prompt: document.getElementById('ai-config-prompt').value,
isOpenRouter: document.getElementById('ai-config-is-openrouter').checked
};
const result = config.addAIConfig(newConfig);
if (result.success) {
notification.success(`配置"${newConfig.name}"已添加`);
const listEl = document.getElementById('ai-config-list');
if (listEl) uiRenderer.renderAIConfigList(listEl);
this.clearAIConfigForm();
} else {
notification.error(result.error);
}
}
/**
* 更新AI配置
*/
updateAIConfig() {
const id = document.getElementById('ai-update-btn').dataset.editId;
if (!id) return;
const updates = {
name: document.getElementById('ai-config-name').value.trim(),
url: document.getElementById('ai-config-url').value.trim(),
apiKey: document.getElementById('ai-config-apikey').value.trim(),
model: document.getElementById('ai-config-model').value.trim(),
prompt: document.getElementById('ai-config-prompt').value,
isOpenRouter: document.getElementById('ai-config-is-openrouter').checked
};
const result = config.updateAIConfig(id, updates);
if (result.success) {
notification.success(`配置"${updates.name}"已更新`);
const listEl = document.getElementById('ai-config-list');
if (listEl) uiRenderer.renderAIConfigList(listEl);
this.clearAIConfigForm();
} else {
notification.error(result.error);
}
}
/**
* 获取OpenRouter模型列表
*/
async fetchModels() {
const apiKey = document.getElementById('ai-config-apikey').value.trim();
const url = document.getElementById('ai-config-url').value.trim();
const isOpenRouter = document.getElementById('ai-config-is-openrouter').checked;
if (!apiKey) {
notification.error('请先填写 API Key');
return;
}
if (!isOpenRouter) {
notification.error('仅OpenRouter支持获取模型列表');
return;
}
const btn = document.getElementById('fetch-models-btn');
btn.disabled = true;
btn.textContent = '获取中...';
try {
const models = await aiService.fetchOpenRouterModels(apiKey, url);
const selectWrapper = document.getElementById('model-select-wrapper');
const select = document.getElementById('model-select');
const searchInput = document.getElementById('model-search-input');
if (!select) {
notification.error('模型选择器未找到');
return;
}
// 保存完整模型列表
this.allModels = models;
// 渲染所有模型
select.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = `${model.name || model.id} (${model.context_length || 'N/A'} tokens)`;
option.title = model.id;
select.appendChild(option);
});
if (selectWrapper) selectWrapper.style.display = 'block';
// 绑定选择事件
select.onchange = () => {
document.getElementById('ai-config-model').value = select.value;
};
// 双击选择事件
select.ondblclick = () => {
document.getElementById('ai-config-model').value = select.value;
notification.success('已选择模型');
};
// 绑定搜索事件
if (searchInput) {
searchInput.value = '';
searchInput.oninput = (e) => {
this.filterModels(e.target.value);
};
searchInput.onkeydown = (e) => {
if (e.key === 'Enter' && select.options.length > 0) {
select.selectedIndex = 0;
document.getElementById('ai-config-model').value = select.options[0].value;
notification.success('已选择: ' + select.options[0].text);
}
};
}
notification.success(`已获取 ${models.length} 个模型`);
} catch (error) {
notification.error(`获取模型列表失败: ${error.message}`);
} finally {
btn.disabled = false;
btn.textContent = '获取模型';
}
}
/**
* 过滤模型列表(模糊搜索)
* @param {string} searchTerm - 搜索词
*/
filterModels(searchTerm) {
if (!this.allModels) return;
const select = document.getElementById('model-select');
if (!select) return;
const term = searchTerm.toLowerCase().trim();
if (!term) {
// 搜索为空,显示所有模型
select.innerHTML = '';
this.allModels.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = `${model.name || model.id} (${model.context_length || 'N/A'} tokens)`;
option.title = model.id;
select.appendChild(option);
});
return;
}
// 模糊搜索
const filtered = this.allModels.filter(model => {
const id = (model.id || '').toLowerCase();
const name = (model.name || '').toLowerCase();
return id.includes(term) || name.includes(term);
});
select.innerHTML = '';
filtered.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = `${model.name || model.id} (${model.context_length || 'N/A'} tokens)`;
option.title = model.id;
select.appendChild(option);
});
const searchInput = document.getElementById('model-search-input');
if (searchInput) {
searchInput.placeholder = filtered.length > 0
? `找到 ${filtered.length} 个模型`
: `未找到匹配的模型`;
}
}
/**
* 绑定Notion配置模态框事件
* @param {HTMLElement} modal - Notion配置模态框
*/
bindNotionConfigModalEvents(modal) {
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideNotionConfigModal();
}
});
// 保存按钮
document.getElementById('notion-save-btn').addEventListener('click', () => {
const apiKey = document.getElementById('notion-api-key').value.trim();
const parentPageId = document.getElementById('notion-parent-page-id').value.trim();
const autoSendEnabled = document.getElementById('notion-auto-send-enabled').checked;
if (!apiKey) {
uiRenderer.showNotionStatus('请输入 API Key', true);
return;
}
if (!parentPageId) {
uiRenderer.showNotionStatus('请输入目标位置(Page ID 或 Database ID)', true);
return;
}
const result = config.saveNotionConfig({ apiKey, parentPageId });
if (result.success) {
config.setNotionAutoSendEnabled(autoSendEnabled);
uiRenderer.showNotionStatus('配置已保存');
setTimeout(() => {
this.hideNotionConfigModal();
}, 1500);
} else {
uiRenderer.showNotionStatus(result.error, true);
}
});
// 取消按钮
document.getElementById('notion-cancel-btn').addEventListener('click', () => {
this.hideNotionConfigModal();
});
}
}
// 创建全局单例
const eventHandlers = new EventHandlers();
/**
* 快捷键管理模块
* 管理全局快捷键配置和绑定
*/
const STORAGE_KEY = 'bilibili_shortcuts_config';
// 默认快捷键配置
const DEFAULT_SHORTCUTS = {
toggleSubtitlePanel: { key: 'b', ctrl: true, alt: false, shift: false, description: '切换字幕面板' },
toggleNotesPanel: { key: 'l', ctrl: true, alt: false, shift: false, description: '切换笔记面板' },
saveNote: { key: 's', ctrl: true, alt: false, shift: false, description: '保存选中文本为笔记' },
speedIncrease: { key: 'Period', ctrl: false, alt: false, shift: false, description: '增加播放速度' },
speedDecrease: { key: 'Comma', ctrl: false, alt: false, shift: false, description: '减少播放速度' },
speedReset: { key: 'Comma', ctrl: false, alt: false, shift: false, doubleClick: true, description: '重置播放速度(双击)' },
speedDouble: { key: 'Period', ctrl: false, alt: false, shift: false, doubleClick: true, description: '2倍速(双击)' },
};
class ShortcutManager {
constructor() {
this.shortcuts = this.loadShortcuts();
this.handlers = new Map();
this.isListening = false;
}
/**
* 加载快捷键配置
*/
loadShortcuts() {
try {
const saved = GM_getValue(STORAGE_KEY, null);
return saved ? JSON.parse(saved) : { ...DEFAULT_SHORTCUTS };
} catch (error) {
console.error('加载快捷键配置失败:', error);
return { ...DEFAULT_SHORTCUTS };
}
}
/**
* 保存快捷键配置
*/
saveShortcuts(shortcuts) {
try {
this.shortcuts = shortcuts;
GM_setValue(STORAGE_KEY, JSON.stringify(shortcuts));
return { success: true, error: null };
} catch (error) {
console.error('保存快捷键配置失败:', error);
return { success: false, error: error.message };
}
}
/**
* 重置为默认快捷键
*/
resetToDefaults() {
this.shortcuts = { ...DEFAULT_SHORTCUTS };
return this.saveShortcuts(this.shortcuts);
}
/**
* 获取所有快捷键
*/
getAllShortcuts() {
return { ...this.shortcuts };
}
/**
* 更新单个快捷键
*/
updateShortcut(name, config) {
if (!this.shortcuts[name]) {
return { success: false, error: '快捷键不存在' };
}
// 检查冲突
const conflict = this.checkConflict(name, config);
if (conflict) {
return { success: false, error: `与"${conflict}"冲突` };
}
this.shortcuts[name] = { ...this.shortcuts[name], ...config };
return this.saveShortcuts(this.shortcuts);
}
/**
* 检查快捷键冲突
*/
checkConflict(excludeName, config) {
for (const [name, shortcut] of Object.entries(this.shortcuts)) {
if (name === excludeName) continue;
if (shortcut.key === config.key &&
shortcut.ctrl === config.ctrl &&
shortcut.alt === config.alt &&
shortcut.shift === config.shift &&
shortcut.doubleClick === config.doubleClick) {
return shortcut.description;
}
}
return null;
}
/**
* 注册(不可用)快捷键处理器
*/
register(name, handler) {
this.handlers.set(name, handler);
}
/**
* 检查事件是否匹配快捷键
*/
matches(event, shortcut) {
const ctrlPressed = event.ctrlKey || event.metaKey;
return event.code === shortcut.key &&
ctrlPressed === shortcut.ctrl &&
event.altKey === shortcut.alt &&
event.shiftKey === shortcut.shift;
}
/**
* 开始监听快捷键
*/
startListening() {
if (this.isListening) return;
document.addEventListener('keydown', (e) => this.handleKeyDown(e), true);
this.isListening = true;
}
/**
* 处理键盘事件
*/
handleKeyDown(event) {
// 忽略在输入框中的按键(除了特定的全局快捷键)
const isInputField = event.target.tagName === 'INPUT' ||
event.target.tagName === 'TEXTAREA' ||
event.target.isContentEditable;
for (const [name, shortcut] of Object.entries(this.shortcuts)) {
// 跳过双击类型的快捷键(由SpeedControlService处理)
if (shortcut.doubleClick) continue;
// 全局快捷键(Ctrl/Cmd组合键)允许在任何地方触发
const isGlobalShortcut = shortcut.ctrl || shortcut.alt;
if (this.matches(event, shortcut)) {
// 如果是输入框且不是全局快捷键,跳过
if (isInputField && !isGlobalShortcut) {
continue;
}
const handler = this.handlers.get(name);
if (handler) {
event.preventDefault();
handler(event);
}
}
}
}
/**
* 格式化快捷键为显示文本
*/
formatShortcut(shortcut) {
const parts = [];
if (shortcut.ctrl) {
parts.push(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl');
}
if (shortcut.alt) {
parts.push('Alt');
}
if (shortcut.shift) {
parts.push('Shift');
}
// 格式化按键名
let keyName = shortcut.key;
if (keyName === 'Period') keyName = '.';
if (keyName === 'Comma') keyName = ',';
if (keyName.length === 1) keyName = keyName.toUpperCase();
parts.push(keyName);
if (shortcut.doubleClick) {
parts.push('(双击)');
}
return parts.join(' + ');
}
/**
* 验证快捷键配置
*/
validateConfig(config) {
if (!config.key || typeof config.key !== 'string') {
return { valid: false, error: '按键不能为空' };
}
if (typeof config.ctrl !== 'boolean' ||
typeof config.alt !== 'boolean' ||
typeof config.shift !== 'boolean') {
return { valid: false, error: '修饰键配置错误' };
}
return { valid: true, error: null };
}
}
// 创建全局单例
const shortcutManager = new ShortcutManager();
/**
* 快捷键配置模态框模块
* 提供快捷键自定义界面
*/
class ShortcutConfigModal {
constructor() {
this.modal = null;
this.isCapturing = false;
this.currentCapturingField = null;
}
/**
* 创建快捷键配置模态框
*/
createModal() {
if (this.modal) {
return this.modal;
}
this.modal = document.createElement('div');
this.modal.id = 'shortcut-config-modal';
this.modal.className = 'config-modal';
document.body.appendChild(this.modal);
return this.modal;
}
/**
* 显示模态框
*/
show() {
const modal = this.createModal();
this.renderModal();
modal.classList.add('show');
}
/**
* 隐藏模态框
*/
hide() {
if (this.modal) {
this.modal.classList.remove('show');
}
this.isCapturing = false;
this.currentCapturingField = null;
}
/**
* 渲染模态框内容
*/
renderModal() {
const shortcuts = shortcutManager.getAllShortcuts();
this.modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>快捷键设置</span>
</div>
<div class="config-modal-body">
<div style="margin-bottom: 20px; padding: 15px; background: rgba(254, 235, 234, 0.1); border-radius: 10px; border-left: 4px solid #feebea;">
<div style="font-size: 13px; color: #fff; font-weight: 600; margin-bottom: 4px;">使用说明</div>
<div style="font-size: 12px; color: rgba(255, 255, 255, 0.7); line-height: 1.5;">
点击快捷键输入框,然后按下你想要的按键组合。支持 Ctrl/Cmd、Alt、Shift 修饰键。
</div>
</div>
<div class="shortcut-list">
${Object.entries(shortcuts).map(([name, config]) =>
this.renderShortcutItem(name, config)
).join('')}
</div>
</div>
<div class="config-footer">
<button class="config-btn config-btn-secondary" id="shortcut-reset-btn">重置默认</button>
<button class="config-btn config-btn-secondary" id="shortcut-cancel-btn">取消</button>
<button class="config-btn config-btn-primary" id="shortcut-save-btn">保存</button>
</div>
</div>
`;
this.bindEvents();
}
/**
* 渲染单个快捷键项
*/
renderShortcutItem(name, config) {
const displayText = shortcutManager.formatShortcut(config);
return `
<div class="shortcut-item">
<div class="shortcut-label">${config.description}</div>
<div class="shortcut-input-wrapper">
<input type="text"
class="shortcut-input"
data-shortcut-name="${name}"
value="${displayText}"
readonly
placeholder="点击设置快捷键">
<button class="shortcut-clear-btn" data-shortcut-name="${name}" title="清除">×</button>
</div>
</div>
`;
}
/**
* 绑定事件
*/
bindEvents() {
// 点击背景关闭
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
// 快捷键输入框点击事件
const inputs = this.modal.querySelectorAll('.shortcut-input');
inputs.forEach(input => {
input.addEventListener('click', () => {
this.startCapture(input);
});
});
// 清除按钮
const clearButtons = this.modal.querySelectorAll('.shortcut-clear-btn');
clearButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const name = btn.getAttribute('data-shortcut-name');
const input = this.modal.querySelector(`input[data-shortcut-name="${name}"]`);
if (input) {
input.value = '';
}
});
});
// 保存按钮
document.getElementById('shortcut-save-btn')?.addEventListener('click', () => {
this.saveShortcuts();
});
// 取消按钮
document.getElementById('shortcut-cancel-btn')?.addEventListener('click', () => {
this.hide();
});
// 重置按钮
document.getElementById('shortcut-reset-btn')?.addEventListener('click', () => {
if (confirm('确定要重置为默认快捷键吗?')) {
const result = shortcutManager.resetToDefaults();
if (result.success) {
notification.success('已重置为默认快捷键');
this.renderModal();
} else {
notification.error('重置失败');
}
}
});
}
/**
* 开始捕获快捷键
*/
startCapture(input) {
if (this.currentCapturingField) {
this.currentCapturingField.classList.remove('capturing');
}
this.isCapturing = true;
this.currentCapturingField = input;
input.classList.add('capturing');
input.value = '请按下快捷键...';
const keydownHandler = (e) => {
e.preventDefault();
e.stopPropagation();
// 忽略单独的修饰键
if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) {
return;
}
// 构建快捷键配置
const config = {
key: e.code || e.key,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
shift: e.shiftKey,
doubleClick: false
};
// 显示快捷键
const displayText = this.formatCapturedKey(config);
input.value = displayText;
// 清理
input.classList.remove('capturing');
this.isCapturing = false;
this.currentCapturingField = null;
document.removeEventListener('keydown', keydownHandler, true);
};
document.addEventListener('keydown', keydownHandler, true);
// 失焦时取消捕获
input.addEventListener('blur', () => {
if (this.isCapturing && this.currentCapturingField === input) {
input.classList.remove('capturing');
this.isCapturing = false;
this.currentCapturingField = null;
document.removeEventListener('keydown', keydownHandler, true);
// 恢复原值
const name = input.getAttribute('data-shortcut-name');
const shortcut = shortcutManager.getAllShortcuts()[name];
if (shortcut) {
input.value = shortcutManager.formatShortcut(shortcut);
}
}
}, { once: true });
}
/**
* 格式化捕获的按键
*/
formatCapturedKey(config) {
const parts = [];
if (config.ctrl) {
parts.push(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl');
}
if (config.alt) {
parts.push('Alt');
}
if (config.shift) {
parts.push('Shift');
}
let keyName = config.key;
if (keyName === 'Period') keyName = '.';
if (keyName === 'Comma') keyName = ',';
if (keyName.length === 1) keyName = keyName.toUpperCase();
parts.push(keyName);
return parts.join(' + ');
}
/**
* 保存所有快捷键
*/
saveShortcuts() {
const inputs = this.modal.querySelectorAll('.shortcut-input');
const newShortcuts = {};
for (const input of inputs) {
const name = input.getAttribute('data-shortcut-name');
const value = input.value.trim();
if (!value || value === '请按下快捷键...') {
notification.error(`请为"${shortcutManager.getAllShortcuts()[name].description}"设置快捷键`);
return;
}
// 解析快捷键
const config = this.parseShortcutString(value);
if (!config) {
notification.error(`快捷键"${value}"格式错误`);
return;
}
// 保留原有的description和doubleClick设置
const originalConfig = shortcutManager.getAllShortcuts()[name];
newShortcuts[name] = {
...config,
description: originalConfig.description,
doubleClick: originalConfig.doubleClick || false
};
}
// 检查冲突
const conflicts = this.findConflicts(newShortcuts);
if (conflicts.length > 0) {
notification.error(`快捷键冲突: ${conflicts.join(', ')}`);
return;
}
// 保存
const result = shortcutManager.saveShortcuts(newShortcuts);
if (result.success) {
notification.success('快捷键已保存');
setTimeout(() => this.hide(), 1000);
} else {
notification.error(`保存失败: ${result.error}`);
}
}
/**
* 解析快捷键字符串
*/
parseShortcutString(str) {
const parts = str.split('+').map(p => p.trim());
const config = {
key: '',
ctrl: false,
alt: false,
shift: false
};
for (const part of parts) {
const lower = part.toLowerCase();
if (lower === 'ctrl' || lower === 'cmd') {
config.ctrl = true;
} else if (lower === 'alt') {
config.alt = true;
} else if (lower === 'shift') {
config.shift = true;
} else {
// 这是按键
if (part === '.') {
config.key = 'Period';
} else if (part === ',') {
config.key = 'Comma';
} else if (part.length === 1) {
config.key = part.toLowerCase();
} else {
config.key = part;
}
}
}
if (!config.key) {
return null;
}
return config;
}
/**
* 查找所有冲突
*/
findConflicts(shortcuts) {
const conflicts = [];
const keys = Object.keys(shortcuts);
for (let i = 0; i < keys.length; i++) {
for (let j = i + 1; j < keys.length; j++) {
const name1 = keys[i];
const name2 = keys[j];
const sc1 = shortcuts[name1];
const sc2 = shortcuts[name2];
if (sc1.key === sc2.key &&
sc1.ctrl === sc2.ctrl &&
sc1.alt === sc2.alt &&
sc1.shift === sc2.shift &&
sc1.doubleClick === sc2.doubleClick) {
conflicts.push(`${sc1.description} 与 ${sc2.description}`);
}
}
}
return conflicts;
}
}
// 创建全局单例
const shortcutConfigModal = new ShortcutConfigModal();
/**
* 速度控制模态框模块
* 提供播放速度控制的独立界面
*/
class SpeedControlModal {
constructor() {
this.modal = null;
this.updateInterval = null;
}
/**
* 创建模态框
*/
createModal() {
if (this.modal) {
return this.modal;
}
this.modal = document.createElement('div');
this.modal.id = 'speed-control-modal';
this.modal.className = 'config-modal';
document.body.appendChild(this.modal);
return this.modal;
}
/**
* 显示模态框
*/
show() {
const modal = this.createModal();
this.renderModal();
modal.classList.add('show');
// 开始定期更新速度显示
this.startUpdateLoop();
}
/**
* 隐藏模态框
*/
hide() {
if (this.modal) {
this.modal.classList.remove('show');
}
// 停止更新
this.stopUpdateLoop();
}
/**
* 渲染模态框内容
*/
renderModal() {
const state = speedControlService.getState();
this.modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>播放速度控制</span>
</div>
<div class="config-modal-body">
<div style="margin-bottom: 20px; padding: 15px; background: rgba(254, 235, 234, 0.1); border-radius: 10px; border-left: 4px solid #feebea;">
<div style="font-size: 13px; color: #fff; font-weight: 600; margin-bottom: 4px;">快捷键说明</div>
<div style="font-size: 12px; color: rgba(255, 255, 255, 0.7); line-height: 1.5;">
<strong>,</strong> 减速 | <strong>.</strong> 加速 | <strong>,,</strong> 重置1x | <strong>..</strong> 2倍速<br>
<strong>右Option</strong> 临时加速 | <strong>右Option双击</strong> 永久加速<br>
<strong>, + .</strong> 同时按切换响度检测
</div>
</div>
<div class="speed-control-section-large">
<div class="speed-control-header-large">
<span class="speed-control-title">当前速度</span>
<span class="speed-control-display-large" id="speed-display-modal">${state.finalSpeed.toFixed(2)}x</span>
</div>
<div class="speed-control-buttons-large">
<button class="speed-btn-large" data-action="decrease">
<span style="font-size: 24px;">−</span>
<span style="font-size: 11px;">减速</span>
</button>
<button class="speed-btn-large" data-action="reset">
<span style="font-size: 18px;">1x</span>
<span style="font-size: 11px;">重置</span>
</button>
<button class="speed-btn-large" data-action="double">
<span style="font-size: 18px;">2x</span>
<span style="font-size: 11px;">2倍速</span>
</button>
<button class="speed-btn-large" data-action="increase">
<span style="font-size: 24px;">+</span>
<span style="font-size: 11px;">加速</span>
</button>
</div>
<div class="speed-status-info">
${state.isTempBoosted ? '<div class="speed-status-item">临时加速中 (右Option)</div>' : ''}
${state.isVolumeBoosted ? '<div class="speed-status-item">响度加速中</div>' : ''}
</div>
</div>
<div class="config-field" style="margin-top: 20px;">
<label style="display: flex; align-items: center; justify-content: space-between;">
<span>响度检测自动加速</span>
<label class="sponsor-switch">
<input type="checkbox" id="volume-detection-toggle" ${state.volumeDetectionEnabled ? 'checked' : ''}>
<span class="sponsor-switch-slider"></span>
</label>
</label>
<div class="config-help" style="margin-top: 8px;">
开启后,当检测到音量低于阈值时自动提速 ${speedControlService.state.boostMultiplier}x
</div>
</div>
${state.volumeDetectionEnabled ? `
<div class="config-field">
<label>响度阈值 (dB)</label>
<div style="display: flex; gap: 8px; align-items: center;">
<button class="config-btn config-btn-secondary" style="padding: 8px 16px;" id="threshold-decrease">-</button>
<input type="number"
id="volume-threshold-input"
value="${state.currentVolumeThreshold}"
min="-100"
max="0"
step="1"
style="flex: 1; text-align: center;">
<button class="config-btn config-btn-secondary" style="padding: 8px 16px;" id="threshold-increase">+</button>
</div>
<div class="config-help">
当前阈值: ${state.currentVolumeThreshold}dB (低于此值触发加速)
</div>
</div>
` : ''}
</div>
<div class="config-footer">
<button class="config-btn config-btn-secondary" id="speed-close-btn">关闭</button>
</div>
</div>
`;
this.bindEvents();
}
/**
* 绑定事件
*/
bindEvents() {
// 点击背景关闭
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
// 速度按钮
const speedButtons = this.modal.querySelectorAll('.speed-btn-large');
speedButtons.forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-action');
this.handleSpeedAction(action);
});
});
// 响度检测开关
const volumeToggle = document.getElementById('volume-detection-toggle');
if (volumeToggle) {
volumeToggle.addEventListener('change', () => {
speedControlService.toggleVolumeDetection();
this.renderModal();
});
}
// 阈值调整
const thresholdDecrease = document.getElementById('threshold-decrease');
const thresholdIncrease = document.getElementById('threshold-increase');
const thresholdInput = document.getElementById('volume-threshold-input');
if (thresholdDecrease) {
thresholdDecrease.addEventListener('click', () => {
speedControlService.adjustVolumeThreshold(-1);
this.updateThresholdDisplay();
});
}
if (thresholdIncrease) {
thresholdIncrease.addEventListener('click', () => {
speedControlService.adjustVolumeThreshold(1);
this.updateThresholdDisplay();
});
}
if (thresholdInput) {
thresholdInput.addEventListener('change', (e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) {
speedControlService.state.currentVolumeThreshold = Math.max(-100, Math.min(0, value));
this.updateThresholdDisplay();
}
});
}
// 关闭按钮
const closeBtn = document.getElementById('speed-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.hide());
}
}
/**
* 处理速度操作
*/
handleSpeedAction(action) {
switch (action) {
case 'increase':
speedControlService.adjustBaseSpeed(0.1);
break;
case 'decrease':
speedControlService.adjustBaseSpeed(-0.1);
break;
case 'reset':
speedControlService.resetToNormalSpeed();
break;
case 'double':
speedControlService.setToDoubleSpeed();
break;
}
this.updateSpeedDisplay();
}
/**
* 更新速度显示
*/
updateSpeedDisplay() {
const speedDisplay = document.getElementById('speed-display-modal');
if (speedDisplay) {
const speed = speedControlService.getCurrentSpeed();
speedDisplay.textContent = `${speed.toFixed(2)}x`;
}
}
/**
* 更新阈值显示
*/
updateThresholdDisplay() {
const input = document.getElementById('volume-threshold-input');
if (input) {
input.value = speedControlService.state.currentVolumeThreshold;
}
}
/**
* 开始更新循环
*/
startUpdateLoop() {
this.updateInterval = setInterval(() => {
this.updateSpeedDisplay();
}, 200);
}
/**
* 停止更新循环
*/
stopUpdateLoop() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
}
// 创建全局单例
const speedControlModal = new SpeedControlModal();
/**
* 使用帮助模态框模块
* 显示工具的使用说明和快捷键
*/
class HelpModal {
constructor() {
this.modal = null;
}
/**
* 创建帮助模态框
*/
createModal() {
if (this.modal) {
return this.modal;
}
this.modal = document.createElement('div');
this.modal.id = 'help-modal';
this.modal.className = 'config-modal';
document.body.appendChild(this.modal);
return this.modal;
}
/**
* 显示模态框
*/
show() {
const modal = this.createModal();
this.renderModal();
modal.classList.add('show');
}
/**
* 隐藏模态框
*/
hide() {
if (this.modal) {
this.modal.classList.remove('show');
}
}
/**
* 渲染模态框内容
*/
renderModal() {
this.modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>使用帮助</span>
</div>
<div class="config-modal-body">
<div style="margin-bottom: 20px;">
<h3 style="color: #fff; margin-bottom: 10px; font-size: 16px;">功能特性</h3>
<ul style="line-height: 1.8; color: #e5e7eb;">
<li><strong>字幕提取</strong> - 自动检测并提取B站AI字幕和人工字幕</li>
<li><strong>AI智能总结</strong> - 支持OpenAI、OpenRouter等多种AI服务</li>
<li><strong>Notion集成</strong> - 一键发送字幕和总结到Notion数据库</li>
<li><strong>笔记保存</strong> - 选中任意文字显示粉色钢笔图标保存笔记</li>
<li><strong>播放速度控制</strong> - 键盘快捷键控制速度和响度检测自动加速</li>
</ul>
</div>
<div style="margin-bottom: 20px;">
<h3 style="color: #fff; margin-bottom: 10px; font-size: 16px;">快捷键</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(254, 235, 234, 0.2);">
<th style="padding: 8px; text-align: left; font-weight: 600; color: #fff;">功能</th>
<th style="padding: 8px; text-align: left; font-weight: 600; color: #fff;">快捷键</th>
<th style="padding: 8px; text-align: left; font-weight: 600; color: #fff;">说明</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid rgba(254, 235, 234, 0.1);">
<td style="padding: 8px; color: #e5e7eb;">切换字幕面板</td>
<td style="padding: 8px;"><code style="background: rgba(255, 255, 255, 0.1); padding: 2px 6px; border-radius: 3px; color: #feebea;">Cmd/Ctrl + B</code></td>
<td style="padding: 8px; color: rgba(255, 255, 255, 0.7);">显示/隐藏字幕面板</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">切换笔记面板</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">Cmd/Ctrl + L</code></td>
<td style="padding: 8px; color: #6b7280;">显示/隐藏笔记管理</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">保存笔记</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">Cmd/Ctrl + S</code></td>
<td style="padding: 8px; color: #6b7280;">保存选中文字或打开笔记面板</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">增加速度</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">.</code></td>
<td style="padding: 8px; color: #6b7280;">每次增加0.1x</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">减少速度</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">,</code></td>
<td style="padding: 8px; color: #6b7280;">每次减少0.1x</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">2倍速</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">.. (双击)</code></td>
<td style="padding: 8px; color: #6b7280;">直接设为2倍速</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">重置速度</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">,, (双击)</code></td>
<td style="padding: 8px; color: #6b7280;">重置为1倍速</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">临时加速</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">右Option键</code></td>
<td style="padding: 8px; color: #6b7280;">按住时1.5x加速</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px;">响度检测</td>
<td style="padding: 8px;"><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">, + . (同时按)</code></td>
<td style="padding: 8px; color: #6b7280;">开启/关闭自动加速</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-bottom: 20px;">
<h3 style="color: #2d2d2d; margin-bottom: 10px; font-size: 16px;">使用说明</h3>
<div style="line-height: 1.8; color: #374151;">
<p style="margin: 8px 0;"><strong>字幕提取:</strong>打开B站视频,等待几秒,字幕面板自动出现在右侧</p>
<p style="margin: 8px 0;"><strong>AI总结:</strong>配置AI服务(菜单 → AI配置),点击魔法棒图标 ✨</p>
<p style="margin: 8px 0;"><strong>笔记保存:</strong>选中任意文字,点击粉色钢笔图标</p>
<p style="margin: 8px 0;"><strong>速度控制:</strong>使用 , 和 . 键调整速度,同时按切换响度检测</p>
<p style="margin: 8px 0;"><strong>快捷键自定义:</strong>菜单 → 快捷键设置,点击输入框后按下想要的按键组合</p>
</div>
</div>
<div style="padding: 15px; background: rgba(254, 235, 234, 0.1); border-radius: 10px; border-left: 4px solid #feebea;">
<div style="font-size: 13px; color: #fff; font-weight: 600; margin-bottom: 4px;">提示</div>
<div style="font-size: 12px; color: rgba(255, 255, 255, 0.7); line-height: 1.5;">
• 所有快捷键均可通过"快捷键设置"自定义<br>
• AI配置支持多个提供商,可自由切换<br>
• 笔记保存在本地,按日期自动分组
</div>
</div>
</div>
<div class="config-footer">
<button class="config-btn config-btn-primary" id="help-close-btn">知道了</button>
</div>
</div>
`;
this.bindEvents();
}
/**
* 绑定事件
*/
bindEvents() {
// 点击背景关闭
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
// 关闭按钮
const closeBtn = document.getElementById('help-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.hide());
}
}
}
// 创建全局单例
const helpModal = new HelpModal();
/**
* SponsorBlock配置模态框模块
* 提供SponsorBlock设置界面
*/
class SponsorBlockModal {
constructor() {
this.modal = null;
}
/**
* 创建模态框
*/
createModal() {
if (this.modal) {
return this.modal;
}
this.modal = document.createElement('div');
this.modal.id = 'sponsorblock-modal';
this.modal.className = 'config-modal';
document.body.appendChild(this.modal);
return this.modal;
}
/**
* 显示模态框
*/
show() {
const modal = this.createModal();
this.renderModal();
modal.classList.add('show');
}
/**
* 隐藏模态框
*/
hide() {
if (this.modal) {
this.modal.classList.remove('show');
}
}
/**
* 渲染模态框内容
*/
renderModal() {
const currentSettings = sponsorBlockConfig.getAll();
this.modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<span>SponsorBlock 设置</span>
</div>
<div class="config-modal-body">
<div style="margin-bottom: 20px; padding: 15px; background: rgba(254, 235, 234, 0.1); border-radius: 10px; border-left: 4px solid #feebea;">
<div style="font-size: 13px; color: #fff; font-weight: 600; margin-bottom: 4px;">使用说明</div>
<div style="font-size: 12px; color: rgba(255, 255, 255, 0.7); line-height: 1.5;">
<strong>勾选的类别</strong> → 自动跳过<br>
<strong>未勾选的类别</strong> → 显示手动提示(5秒后自动消失)<br>
在进度条上会显示彩色标记,点击可查看详情
</div>
</div>
<div class="sponsor-settings-section">
<h3>片段类别(勾选=自动跳过,未勾选=手动提示)</h3>
<div class="sponsor-checkbox-group">
${Object.entries(SPONSORBLOCK.CATEGORIES).map(([key, info]) => `
<div class="sponsor-checkbox-item">
<input type="checkbox"
id="category-${key}"
value="${key}"
${currentSettings.skipCategories.includes(key) ? 'checked' : ''}>
<label for="category-${key}">
<span class="category-color-dot" style="background: ${info.color}"></span>
<span>${info.name}</span>
</label>
</div>
`).join('')}
</div>
</div>
<div class="sponsor-settings-section">
<h3>显示选项</h3>
<div class="sponsor-switch-item">
<span>显示片段标签(视频卡片)</span>
<label class="sponsor-switch">
<input type="checkbox" id="showAdBadge"
${currentSettings.showAdBadge ? 'checked' : ''}>
<span class="sponsor-switch-slider"></span>
</label>
</div>
<div class="sponsor-switch-item">
<span>显示优质视频标签</span>
<label class="sponsor-switch">
<input type="checkbox" id="showQualityBadge"
${currentSettings.showQualityBadge ? 'checked' : ''}>
<span class="sponsor-switch-slider"></span>
</label>
</div>
<div class="sponsor-switch-item">
<span>进度条显示片段标记</span>
<label class="sponsor-switch">
<input type="checkbox" id="showProgressMarkers"
${currentSettings.showProgressMarkers ? 'checked' : ''}>
<span class="sponsor-switch-slider"></span>
</label>
</div>
</div>
</div>
<div class="config-footer">
<button class="config-btn config-btn-secondary" id="sponsorblock-cancel-btn">取消</button>
<button class="config-btn config-btn-primary" id="sponsorblock-save-btn">保存</button>
</div>
</div>
`;
this.bindEvents();
}
/**
* 绑定事件
*/
bindEvents() {
// 点击背景关闭
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
// 保存按钮
const saveBtn = document.getElementById('sponsorblock-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', () => this.saveSettings());
}
// 取消按钮
const cancelBtn = document.getElementById('sponsorblock-cancel-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.hide());
}
}
/**
* 保存设置
*/
saveSettings() {
const newSettings = {
skipCategories: Array.from(
this.modal.querySelectorAll('.sponsor-checkbox-item input[type="checkbox"]:checked')
).map(cb => cb.value),
showAdBadge: this.modal.querySelector('#showAdBadge').checked,
showQualityBadge: this.modal.querySelector('#showQualityBadge').checked,
showProgressMarkers: this.modal.querySelector('#showProgressMarkers').checked
};
sponsorBlockConfig.setAll(newSettings);
this.hide();
// 提示保存成功并刷新页面
notification.info('设置已保存!\n\n✅ 勾选的类别 → 自动跳过\n⏸️ 未勾选的类别 → 手动提示(5秒)\n\n页面将刷新以应用新设置。');
setTimeout(() => {
location.reload();
}, 2000);
}
}
// 创建全局单例
const sponsorBlockModal = new SponsorBlockModal();
/**
* B站字幕提取器 - 主入口文件
* 模块化重构版本 v4.0.0
*/
/**
* 应用主类
*/
class BilibiliSubtitleExtractor {
constructor() {
this.initialized = false;
this.ball = null;
this.container = null;
this.videoQualityService = null;
}
/**
* 初始化应用
*/
async init() {
if (this.initialized) return;
// 注入样式
injectStyles();
// 等待页面加载
await this.waitForPageReady();
// 初始化笔记服务
notesService.init();
// 初始化速度控制服务
speedControlService.init();
// 初始化 SponsorBlock 服务
await sponsorBlockService.init();
// 初始化视频质量服务
this.videoQualityService = createVideoQualityService(sponsorBlockService.getAPI());
this.videoQualityService.start();
// 创建UI元素
this.createUI();
// 绑定事件
this.bindEvents();
// 设置自动化逻辑
this.setupAutomation();
// 注册(不可用)油猴菜单
this.registerMenuCommands();
// 注册(不可用)快捷键
this.registerShortcuts();
// 开始检测字幕
subtitleService.checkSubtitleButton();
// 监听视频切换
this.observeVideoChange();
this.initialized = true;
}
/**
* 注册(不可用)全局快捷键
*/
registerShortcuts() {
// 切换字幕面板
shortcutManager.register('toggleSubtitlePanel', () => {
state.togglePanel();
});
// 切换笔记面板
shortcutManager.register('toggleNotesPanel', () => {
notesPanel.togglePanel();
});
// 保存选中文本为笔记
shortcutManager.register('saveNote', () => {
if (notesService.savedSelectionText) {
notesService.addNote(notesService.savedSelectionText, window.location.href);
const selection = window.getSelection();
if (selection.rangeCount > 0) {
selection.removeAllRanges();
}
notesService.hideBlueDot();
notesService.savedSelectionText = '';
if (notesPanel.isPanelVisible) {
notesPanel.renderPanel();
}
} else {
notesPanel.togglePanel();
}
});
// 开始监听
shortcutManager.startListening();
}
/**
* 注册(不可用)油猴菜单命令
*/
registerMenuCommands() {
if (typeof GM_registerMenuCommand === 'undefined') {
return;
}
GM_registerMenuCommand('AI配置', () => {
eventHandlers.showAIConfigModal();
});
GM_registerMenuCommand('Notion配置', () => {
eventHandlers.showNotionConfigModal();
});
GM_registerMenuCommand('笔记管理', () => {
notesPanel.togglePanel();
});
GM_registerMenuCommand('速度控制', () => {
speedControlModal.show();
});
GM_registerMenuCommand('SponsorBlock 设置', () => {
sponsorBlockModal.show();
});
GM_registerMenuCommand('快捷键设置', () => {
shortcutConfigModal.show();
});
GM_registerMenuCommand('使用帮助', () => {
helpModal.show();
});
GM_registerMenuCommand('关于', () => {
notification.info('Bilibili Tools v6.0.0 - by geraldpeng & claude 4.5 sonnet');
});
}
/**
* 等待页面元素加载完成
*/
async waitForPageReady() {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const videoContainer = document.querySelector(SELECTORS.VIDEO_CONTAINER);
if (videoContainer) {
clearInterval(checkInterval);
resolve();
}
}, TIMING.CHECK_SUBTITLE_INTERVAL);
});
}
/**
* 创建UI元素
*/
createUI() {
// 创建小球
this.ball = document.createElement('div');
this.ball.id = 'subtitle-ball';
this.ball.title = '字幕提取器';
const videoContainer = document.querySelector(SELECTORS.VIDEO_CONTAINER);
if (videoContainer) {
if (videoContainer.style.position !== 'relative' &&
videoContainer.style.position !== 'absolute') {
videoContainer.style.position = 'relative';
}
videoContainer.appendChild(this.ball);
}
// 创建字幕容器并嵌入到页面
this.createEmbeddedContainer();
// 创建Notion配置模态框
const notionModal = uiRenderer.createNotionConfigModal();
document.body.appendChild(notionModal);
eventHandlers.bindNotionConfigModalEvents(notionModal);
// 创建AI配置模态框
const aiModal = uiRenderer.createAIConfigModal();
document.body.appendChild(aiModal);
eventHandlers.bindAIConfigModalEvents(aiModal);
}
/**
* 创建嵌入式字幕容器
*/
createEmbeddedContainer() {
// 创建字幕容器
this.container = document.createElement('div');
this.container.id = 'subtitle-container';
// 添加到视频容器
const videoContainer = document.querySelector(SELECTORS.VIDEO_CONTAINER);
if (videoContainer) {
// 确保视频容器使用相对定位
if (videoContainer.style.position !== 'relative' &&
videoContainer.style.position !== 'absolute') {
videoContainer.style.position = 'relative';
}
videoContainer.appendChild(this.container);
} else {
// 降级方案:添加到body
document.body.appendChild(this.container);
}
}
/**
* 绑定事件监听器
*/
bindEvents() {
// 监听字幕加载完成事件
eventBus.on(EVENTS.SUBTITLE_LOADED, (data, videoKey) => {
this.renderSubtitles(data);
});
// 监听AI总结chunk更新
eventBus.on(EVENTS.AI_SUMMARY_CHUNK, (summary) => {
if (this.container) {
uiRenderer.updateAISummary(this.container, summary);
}
});
// 监听AI总结完成事件
eventBus.on(EVENTS.AI_SUMMARY_COMPLETE, (summary, videoKey) => {
notification.success('AI总结完成');
if (this.container) {
uiRenderer.updateAISummary(this.container, summary);
}
// 更新AI图标状态
const aiIcon = this.container?.querySelector('.ai-icon');
if (aiIcon) {
aiIcon.classList.remove('loading');
}
});
// 监听Notion发送完成事件
eventBus.on(EVENTS.NOTION_SEND_COMPLETE, () => {
notification.success('字幕已成功发送到 Notion');
// 更新Notion图标状态
const notionIcon = this.container?.querySelector('.notion-icon');
if (notionIcon) {
notionIcon.classList.remove('loading');
}
});
// 监听错误事件
eventBus.on(EVENTS.SUBTITLE_FAILED, (error) => {
notification.handleError(error, '字幕获取');
});
eventBus.on(EVENTS.AI_SUMMARY_FAILED, (error) => {
notification.handleError(error, 'AI总结');
});
eventBus.on(EVENTS.NOTION_SEND_FAILED, (error) => {
notification.handleError(error, 'Notion发送');
});
// 监听小球状态变化
eventBus.on(EVENTS.UI_BALL_STATUS_CHANGE, (status) => {
this.updateBallStatus(status);
});
// 监听面板显示/隐藏
eventBus.on(EVENTS.UI_PANEL_TOGGLE, (visible) => {
if (this.container) {
if (visible) {
this.container.classList.add('show');
} else {
this.container.classList.remove('show');
}
}
});
// 键盘快捷键(Command+B 或 Ctrl+B)
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault();
state.togglePanel();
}
});
}
/**
* 渲染字幕面板
* @param {Array} subtitleData - 字幕数据
*/
renderSubtitles(subtitleData) {
if (!this.container || !subtitleData) return;
// 渲染HTML
this.container.innerHTML = uiRenderer.renderSubtitlePanel(subtitleData);
// 检查是否有缓存的AI总结
const videoKey = state.getVideoKey();
const cachedSummary = videoKey ? state.getAISummary(videoKey) : null;
if (cachedSummary) {
uiRenderer.updateAISummary(this.container, cachedSummary);
} else if (state.ai.isSummarizing) {
// 如果正在总结,显示加载状态
const contentDiv = this.container.querySelector('.subtitle-content');
if (contentDiv) {
const summarySection = uiRenderer.renderAISummarySection(null, true);
contentDiv.insertBefore(summarySection, contentDiv.firstChild);
}
}
// 绑定事件
eventHandlers.bindSubtitlePanelEvents(this.container);
console.log('[App] 字幕面板已渲染');
}
/**
* 设置自动化逻辑(解耦AI和Notion)
*/
setupAutomation() {
// 字幕加载完成后,检查是否需要自动总结
eventBus.on(EVENTS.SUBTITLE_LOADED, async (data) => {
await delay(TIMING.AUTO_ACTIONS_DELAY);
const aiAutoEnabled = config.getAIAutoSummaryEnabled();
const aiConfig = config.getSelectedAIConfig();
const videoKey = state.getVideoKey();
const cachedSummary = videoKey ? state.getAISummary(videoKey) : null;
// 如果启用自动总结,且有API Key,且没有缓存
if (aiAutoEnabled && aiConfig && aiConfig.apiKey && !cachedSummary) {
try {
await aiService.summarize(data, true);
} catch (error) {
console.error('[App] 自动总结失败:', error);
}
}
});
// AI总结完成后,检查是否需要自动发送Notion
eventBus.on(EVENTS.AI_SUMMARY_COMPLETE, async () => {
const notionAutoEnabled = config.getNotionAutoSendEnabled();
const notionConfig = config.getNotionConfig();
if (notionAutoEnabled && notionConfig.apiKey) {
const subtitleData = state.getSubtitleData();
if (subtitleData) {
try {
await notionService.sendSubtitle(subtitleData, true);
} catch (error) {
console.error('[App] 自动发送失败:', error);
}
}
}
});
// 字幕加载完成后,如果没有启用AI自动总结,直接检查Notion自动发送
eventBus.on(EVENTS.SUBTITLE_LOADED, async (data) => {
await delay(TIMING.AUTO_ACTIONS_DELAY);
const aiAutoEnabled = config.getAIAutoSummaryEnabled();
const notionAutoEnabled = config.getNotionAutoSendEnabled();
const notionConfig = config.getNotionConfig();
// 如果没有启用AI自动总结,但启用了Notion自动发送
if (!aiAutoEnabled && notionAutoEnabled && notionConfig.apiKey) {
try {
await notionService.sendSubtitle(data, true);
} catch (error) {
console.error('[App] 自动发送失败:', error);
}
}
});
}
/**
* 更新小球状态
*/
updateBallStatus(status) {
if (!this.ball) return;
// 移除所有状态类
this.ball.classList.remove('loading', 'active', 'no-subtitle', 'error');
switch (status) {
case BALL_STATUS.ACTIVE:
this.ball.classList.add('active');
this.ball.style.cursor = 'pointer';
this.ball.onclick = () => state.togglePanel();
this.ball.title = '字幕提取器 - 点击查看字幕';
break;
case BALL_STATUS.NO_SUBTITLE:
this.ball.classList.add('no-subtitle');
this.ball.style.cursor = 'default';
this.ball.onclick = null;
this.ball.title = '该视频无字幕';
break;
case BALL_STATUS.ERROR:
this.ball.classList.add('error');
this.ball.style.cursor = 'default';
this.ball.onclick = null;
this.ball.title = '字幕加载失败';
break;
case BALL_STATUS.LOADING:
this.ball.classList.add('loading');
this.ball.style.cursor = 'default';
this.ball.onclick = null;
this.ball.title = '正在加载字幕...';
break;
}
}
/**
* 监听视频切换
*/
observeVideoChange() {
if (!document.body) {
setTimeout(() => this.observeVideoChange(), 100);
return;
}
let lastUrl = location.href;
let lastBvid = location.href.match(/BV[1-9A-Za-z]{10}/)?.[0];
let lastCid = null;
// 获取当前CID
const getCurrentCid = () => {
try {
const initialState = unsafeWindow.__INITIAL_STATE__;
return initialState?.videoData?.cid || initialState?.videoData?.pages?.[0]?.cid;
} catch (e) {
return null;
}
};
lastCid = getCurrentCid();
new MutationObserver(() => {
const url = location.href;
const currentBvid = url.match(/BV[1-9A-Za-z]{10}/)?.[0];
const currentCid = getCurrentCid();
// 当BV号或CID改变时重新初始化
if (url !== lastUrl && (currentBvid !== lastBvid || currentCid !== lastCid)) {
lastUrl = url;
lastBvid = currentBvid;
lastCid = currentCid;
// 重置所有状态
state.reset();
subtitleService.reset();
// 触发视频切换事件
eventBus.emit(EVENTS.VIDEO_CHANGED, { bvid: currentBvid, cid: currentCid });
// 等待后重新检测字幕
setTimeout(() => {
const videoInfo = getVideoInfo();
state.setVideoInfo(videoInfo);
subtitleService.checkSubtitleButton();
}, TIMING.VIDEO_SWITCH_DELAY);
}
}).observe(document.body, { subtree: true, childList: true });
}
}
// 创建应用实例并初始化
const app = new BilibiliSubtitleExtractor();
// 等待DOM加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
} else {
app.init();
}
})();