您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
🚀 终极YouTube截图工具套件!按照平行或者重叠结构合并截图
// ==UserScript== // @name 🎬 YouTube&Bilibili FrameMaster Pro - Ultimate Video Capture Suite // @name:zh-TW 🎬 YouTube&Bilibili 影格大師 Pro - 終極影片擷取套件 // @name:zh-CN 🎬 YouTube&Bilibili 帧师傅 Pro - 终极视频捕获套件 // @namespace org.jw23.framemaster // @version 4.3 // @description 🚀 The ultimate YouTube screenshot toolkit! It can merge the multiple screenshot by a specific way! // @description:zh-TW 🚀 終極YouTube截圖工具套件! // @description:zh-CN 🚀 终极YouTube截图工具套件!按照平行或者重叠结构合并截图 // @author ChatGPT & Community // @grant GM_registerMenuCommand // @match https://www.youtube.com/* // @match https://www.bilibili.com/video/* // @grant GM_getValue // @grant GM_setValue // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iMCIgeTE9IjAiIHgyPSIxMDAiIHkyPSIxMDAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZjAwMDAiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmZjYwMDAiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSIxMiIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0yMCAxNmgxNmEyIDIgMCAwIDEgMiAydjEwYTIgMiAwIDAgMS0yIDJIMjBhMiAyIDIgMCAwIDEgMi0yeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yOCAyMnY2bDQtM3oiIGZpbGw9IiNmZjAwMDAiLz48cGF0aCBkPSJNMTYgMzZoMzJhMiAyIDAgMCAxIDIgMnY4YTIgMiAwIDAgMS0yIDJIMTZhMiAyIDAgMCAxLTItMnYtOGEyIDIgMCAwIDEgMi0yeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yMCA0MGgzdjJ2M2gtM3YtNXptNCAwaDN2Mmg0djNIMjR2LTV6bTggMGgzdjJoNHYzSDMydC01em04IDBoM3YydjNoLTN2LTV6IiBmaWxsPSIjMzMzIi8+PC9zdmc+ // @supportURL https://github.com/example/youtube-framemaster/issues // @homepageURL https://github.com/example/youtube-framemaster // ==/UserScript== (function () { 'use strict'; // 等待DOM完全加载 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } function init() { console.log('FrameMaster Pro initializing...'); console.log('DOM ready state:', document.readyState); console.log('Document body:', document.body); /** * 语言管理系统 */ class LanguageManager { constructor() { this.currentLang = GM_getValue('lang', 'EN'); this.translations = { EN: { // 面板标题 title: '🎬 FrameMaster Pro', subtitle: 'Ultimate Video Capture Suite', // 快速操作 quickActions: '⚡ Quick Actions', takeScreenshot: '📸 Take Screenshot', burstMode: '🔥 Burst Mode', currentHotkey: 'Current Hotkey: ', // 基础设置 basicSettings: '⚙️ Basic Settings', screenshotHotkey: 'Screenshot Hotkey', apply: 'Apply', burstInterval: 'Burst Interval: ', interfaceLanguage: 'Interface Language', // 字幕设置 subtitleSettings: '💬 Subtitle Settings', subtitleFile: 'Subtitle File', enableSubtitle: 'Enable Subtitle Overlay', fontSize: 'Font Size: ', maxLines: 'Max Lines: ', status: 'Status: ', notLoaded: 'Not Loaded', // 批量截图 batchScreenshot: '🎯 Batch Screenshot', compositeMode: 'Composite Mode', parallelMode: 'Parallel Mode', overlapMode: 'Overlap Mode', overlapHeight: 'Overlap Height: ', timeRange: 'Time Range', startBatch: '🚀 Start Batch Screenshot', inProgress: '📸 In Progress...', // 底部按钮 resetConfig: 'Reset Config', saveConfig: 'Save Config', // 通知消息 hotkeyUpdated: 'Hotkey updated to: ', invalidHotkey: 'Please enter a valid single letter as hotkey', languageUpdated: 'Language setting updated', subtitleLoaded: 'Subtitle file loaded successfully', subtitleError: 'Invalid subtitle file format', subtitleEnabled: 'Subtitle function enabled', subtitleDisabled: 'Subtitle function disabled', compositeModeChanged: 'Composite mode changed to', screenshotSaved: 'Screenshot saved', useHotkey: 'Please hold hotkey for burst mode', enterTimeRange: 'Please enter time range', batchInProgress: 'Batch screenshot in progress, please wait', batchComplete: 'Batch screenshot completed, generated {count} images', batchFailed: 'Batch screenshot failed: {error}', configReset: 'Configuration reset, please refresh page', configSaved: 'Configuration saved', confirmReset: 'Are you sure you want to reset all configurations?', complete: 'Complete!', // 帮助文本 formatHelp: '💡 Supported formats: Time range (01:00-02:00), Second range (60-120), or Subtitle grouping (01:00-02:12,10 means divide subtitles into 10 groups, generate 10 composite images)', placeholderTimeRange: 'e.g.: 01:00-02:00 or 60-120 or 01:00-02:12,10', // 错误消息 noVideo: 'Video element not found', noSubtitle: 'Please load subtitle file and enable subtitle function first', invalidTimeFormat: 'Invalid time range format', noSubtitlesInRange: 'No subtitles found in the specified time range', loadedSubtitles: 'Loaded ({count} subtitles)', adapter: 'Adapter: {name}', unknown: 'Unknown', // 浮动按钮提示 frameMasterLoaded: '🎬 FrameMaster Pro loaded! Press {shortcut} to open configuration panel' }, ZH: { // 面板标题 title: '🎬 FrameMaster Pro', subtitle: 'Ultimate Video Capture Suite', // 快速操作 quickActions: '⚡ 快速操作', takeScreenshot: '📸 立即截图', burstMode: '🔥 连拍模式', currentHotkey: '当前快捷键: ', // 基础设置 basicSettings: '⚙️ 基础设置', screenshotHotkey: '截图快捷键', apply: '应用', burstInterval: '连拍间隔: ', interfaceLanguage: '界面语言', // 字幕设置 subtitleSettings: '💬 字幕设置', subtitleFile: '字幕文件', enableSubtitle: '启用字幕叠加', fontSize: '字体大小: ', maxLines: '最大行数: ', status: '状态: ', notLoaded: '未加载', // 批量截图 batchScreenshot: '🎯 批量截图', compositeMode: '拼接模式', parallelMode: '平行模式', overlapMode: '重叠模式', overlapHeight: '重叠高度: ', timeRange: '时间范围', startBatch: '🚀 开始批量截图', inProgress: '📸 正在截图...', // 底部按钮 resetConfig: '重置配置', saveConfig: '保存配置', // 通知消息 hotkeyUpdated: '快捷键已更新为: ', invalidHotkey: '请输入有效的单个字母作为快捷键', languageUpdated: '语言设置已更新', subtitleLoaded: '字幕文件加载成功', subtitleError: '字幕文件格式错误', subtitleEnabled: '字幕功能已开启', subtitleDisabled: '字幕功能已关闭', compositeModeChanged: '拼接模式已切换为', screenshotSaved: '截图已保存', useHotkey: '请按住快捷键进行连拍', enterTimeRange: '请输入时间范围', batchInProgress: '批量截图正在进行中,请稍候', batchComplete: '批量截图完成,生成了 {count} 张图片', batchFailed: '批量截图失败: {error}', configReset: '配置已重置,请刷新页面', configSaved: '配置已保存', confirmReset: '确定要重置所有配置吗?', complete: '完成!', // 帮助文本 formatHelp: '💡 支持格式:时间范围(01:00-02:00)、秒数范围(60-120)、字幕分组(01:00-02:12,10 表示将字幕分为10组,生成10张拼接图)、或多时间点叠加(01:00,01:22,01:33 无需字幕)', placeholderTimeRange: '例: 01:00-02:00 或 60-120 或 01:00-02:12,10 或 01:00,01:22,01:33', // 错误消息 noVideo: '找不到视频元素', noSubtitle: '请先加载字幕文件并开启字幕功能', invalidTimeFormat: '时间范围格式错误', noSubtitlesInRange: '指定时间范围内没有找到字幕', loadedSubtitles: '已加载 ({count} 条字幕)', adapter: '适配器: {name}', unknown: '未知' } }; } setLanguage(lang) { this.currentLang = lang; GM_setValue('lang', lang); } // 翻译方法 t(key, replacements = {}) { const currentLang = GM_getValue('lang', 'ZH'); const translations = { EN: { title: '🎬 FrameMaster Pro', subtitle: 'Ultimate Video Capture Suite', adapter: 'Adapter: {name}', unknown: 'Unknown', quickActions: '⚡ Quick Actions', takeScreenshot: '📸 Take Screenshot', burstMode: '🔥 Burst Mode', currentHotkey: 'Current Hotkey: ', basicSettings: '⚙️ Basic Settings', screenshotHotkey: 'Screenshot Hotkey', apply: 'Apply', interfaceLanguage: 'Interface Language', subtitleSettings: '💬 Subtitle Settings', subtitleFile: 'Subtitle File', enableSubtitle: 'Enable Subtitle Overlay', fontSize: 'Font Size: ', maxLines: 'Max Lines: ', status: 'Status: ', notLoaded: 'Not Loaded', batchScreenshot: '🎯 Batch Screenshot', compositeMode: 'Composite Mode', parallelMode: 'Parallel Mode', overlapMode: 'Overlap Mode', overlapHeight: 'Overlap Height: ', timeRange: 'Time Range', startBatch: '🚀 Start Batch Screenshot', resetConfig: 'Reset Config', saveConfig: 'Save Config', screenshotSaved: 'Screenshot saved', useHotkey: 'Please hold hotkey for burst mode', enterTimeRange: 'Please enter time range', batchInProgress: 'Batch screenshot in progress, please wait', languageUpdated: 'Language setting updated', showSubtitlesInComposite: 'Show subtitles in composite images', burstInterval: 'Burst Interval: ', formatHelp: '💡 Supported formats: Time range (01:00-02:00), Second range (60-120), Subtitle grouping (01:00-02:12,10), or Multi-time overlay (01:00,01:22,01:33 no subtitles required)', placeholderTimeRange: 'e.g.: 01:00-02:00 or 60-120 or 01:00-02:12,10 or 01:00,01:22,01:33', showSubtitlesInComposite: 'Show subtitles in composite images' }, ZH: { title: '🎬 FrameMaster Pro', subtitle: 'Ultimate Video Capture Suite', adapter: '适配器: {name}', unknown: '未知', quickActions: '⚡ 快速操作', takeScreenshot: '📸 立即截图', burstMode: '🔥 连拍模式', currentHotkey: '当前快捷键: ', basicSettings: '⚙️ 基础设置', screenshotHotkey: '截图快捷键', apply: '应用', interfaceLanguage: '界面语言', subtitleSettings: '💬 字幕设置', subtitleFile: '字幕文件', enableSubtitle: '启用字幕叠加', fontSize: '字体大小: ', maxLines: '最大行数: ', status: '状态: ', notLoaded: '未加载', batchScreenshot: '🎯 批量截图', compositeMode: '拼接模式', parallelMode: '平行模式', overlapMode: '重叠模式', overlapHeight: '重叠高度: ', timeRange: '时间范围', startBatch: '🚀 开始批量截图', resetConfig: '重置配置', saveConfig: '保存配置', screenshotSaved: '截图已保存', useHotkey: '请按住快捷键进行连拍', enterTimeRange: '请输入时间范围', batchInProgress: '批量截图正在进行中,请稍候', languageUpdated: '语言设置已更新', showSubtitlesInComposite: '在拼接图片中显示字幕', burstInterval: '连拍间隔: ', formatHelp: '💡 支持格式:时间范围(01:00-02:00)、秒数范围(60-120)、字幕分组(01:00-02:12,10)、或多时间点叠加(01:00,01:22,01:33 无需字幕)', placeholderTimeRange: '例: 01:00-02:00 或 60-120 或 01:00-02:12,10 或 01:00,01:22,01:33' } }; let text = translations[currentLang][key] || translations['ZH'][key] || key; // 处理占位符替换 Object.keys(replacements).forEach(placeholder => { text = text.replace(`{${placeholder}}`, replacements[placeholder]); }); return text; } // 更新界面语言 updateInterfaceLanguage() { const currentLang = GM_getValue('lang', 'ZH'); // 更新标题 const titleElement = document.querySelector('#ytFrameMasterConfig h3'); if (titleElement) titleElement.textContent = this.t('title'); // 更新副标题 const subtitleElement = document.querySelector('#ytFrameMasterConfig p'); if (subtitleElement) subtitleElement.textContent = this.t('subtitle'); // 更新按钮文本 const takeScreenshotBtn = document.getElementById('takeScreenshotBtn'); if (takeScreenshotBtn) takeScreenshotBtn.textContent = this.t('takeScreenshot'); const burstModeBtn = document.getElementById('burstModeBtn'); if (burstModeBtn) burstModeBtn.textContent = this.t('burstMode'); const setHotkeyBtn = document.getElementById('setHotkey'); if (setHotkeyBtn) setHotkeyBtn.textContent = this.t('apply'); const batchScreenshotBtn = document.getElementById('batchScreenshot'); if (batchScreenshotBtn && !batchScreenshotBtn.disabled) { batchScreenshotBtn.textContent = this.t('startBatch'); } const resetConfigBtn = document.getElementById('resetConfig'); if (resetConfigBtn) resetConfigBtn.textContent = this.t('resetConfig'); const saveConfigBtn = document.getElementById('saveConfig'); if (saveConfigBtn) saveConfigBtn.textContent = this.t('saveConfig'); // 更新帮助文本 const helpText = document.querySelector('#ytFrameMasterConfig .helpText'); if (helpText) helpText.textContent = this.t('formatHelp'); // 更新placeholder const timeRangeInput = document.getElementById('timeRangeInput'); if (timeRangeInput) timeRangeInput.setAttribute('placeholder', this.t('placeholderTimeRange')); // 更新标签文本 this.updateLabels(); } // 更新标签文本 updateLabels() { const labels = { '快速操作': 'quickActions', '基础设置': 'basicSettings', '截图快捷键': 'screenshotHotkey', '连拍间隔': 'burstInterval', '界面语言': 'interfaceLanguage', '字幕设置': 'subtitleSettings', '字幕文件': 'subtitleFile', '启用字幕叠加': 'enableSubtitle', '字体大小': 'fontSize', '最大行数': 'maxLines', '状态': 'status', '批量截图': 'batchScreenshot', '拼接模式': 'compositeMode', '时间范围': 'timeRange' }; Object.entries(labels).forEach(([chinese, key]) => { const elements = document.querySelectorAll('#ytFrameMasterConfig *'); elements.forEach(element => { if (element.textContent && element.textContent.includes(chinese)) { element.textContent = element.textContent.replace(chinese, this.t(key)); } }); }); } } // 创建全局语言管理器实例 const langManager = new LanguageManager(); /** * 视频截图工具 - 重构版本 * 支持快捷键截图、批量截图、字幕叠加等功能 */ class VideoScreenshotTool { constructor() { this.config = { defaultHotkey: 's', defaultInterval: 1000, minInterval: 100, defaultLang: 'EN', }; this.state = { keyDown: false, intervalId: null, subtitleData: null, subtitleEnabled: false, screenshotKey: 's', interval: 1000, lang: 'EN', subtitleFontSize: 48, subtitleMaxLines: 2, compositeMode: 'parallel' }; this.init(); } /** * 初始化工具 */ init() { this.setupEventListeners(); this.videoManager = new VideoManager(); this.subtitleManager = new SubtitleManager(); this.screenshotManager = new ScreenshotManager(this.videoManager, this.subtitleManager); this.imageComposer = new ImageComposer(this.subtitleManager); // 初始化重叠高度设置 this.imageComposer.updateOverlapHeight(GM_getValue('overlapHeight', 150)); this.taskManager = new TaskManager(this.videoManager, this.subtitleManager, this.screenshotManager, this.imageComposer); } /** * 设置事件监听 */ setupEventListeners() { document.addEventListener('keydown', (e) => this.handleKeyDown(e)); document.addEventListener('keyup', (e) => this.handleKeyUp(e)); } /** * 处理按键按下 */ handleKeyDown(e) { if ( e.key.toLowerCase() === this.state.screenshotKey && !this.state.keyDown && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) ) { this.state.keyDown = true; this.screenshotManager.takeScreenshot(); this.state.intervalId = setInterval(() => { this.screenshotManager.takeScreenshot(); }, this.state.interval); } } /** * 处理按键抬起 */ handleKeyUp(e) { if (e.key.toLowerCase() === this.state.screenshotKey) { this.state.keyDown = false; clearInterval(this.state.intervalId); } } } /** * 视频适配器接口 */ class VideoAdapter { /** * 获取视频元素 */ getVideoElement() { throw new Error('getVideoElement method must be implemented'); } /** * 获取视频标题 */ getVideoTitle() { throw new Error('getVideoTitle method must be implemented'); } /** * 获取视频ID */ getVideoID() { throw new Error('getVideoID method must be implemented'); } /** * 检测当前网站是否支持 */ isSupported() { throw new Error('isSupported method must be implemented'); } /** * 获取网站名称 */ getSiteName() { throw new Error('getSiteName method must be implemented'); } /** * 清理标题中的非法字符 */ sanitizeTitle(title) { return title.replace(/[\\/:*?"<>|]/g, '').trim(); } } /** * YouTube视频适配器 */ class YouTubeAdapter extends VideoAdapter { getVideoElement() { const videos = Array.from(document.querySelectorAll('video')); if (window.location.href.includes('/shorts/')) { return videos.find(v => v.offsetParent !== null); } return videos[0] || null; } getVideoTitle() { if (window.location.href.includes('/shorts/')) { let h2 = document.querySelector('ytd-reel-video-renderer[is-active] h2'); if (h2 && h2.textContent.trim()) return this.sanitizeTitle(h2.textContent.trim()); h2 = document.querySelector('ytd-reel-video-renderer h2'); if (h2 && h2.textContent.trim()) return this.sanitizeTitle(h2.textContent.trim()); let meta = document.querySelector('meta[name="title"]'); if (meta) return this.sanitizeTitle(meta.getAttribute('content')); return this.sanitizeTitle(document.title || 'unknown'); } if (window.location.href.includes('/live/')) { let title = document.querySelector('meta[name="title"]')?.getAttribute('content') || document.title || 'unknown'; return this.sanitizeTitle(title); } let title = document.querySelector('h1.ytd-watch-metadata')?.textContent || document.querySelector('h1.title')?.innerText || document.querySelector('h1')?.innerText || document.querySelector('meta[name="title"]')?.getAttribute('content') || document.title || 'unknown'; return this.sanitizeTitle(title); } getVideoID() { let match = window.location.href.match(/\/shorts\/([a-zA-Z0-9_-]+)/); if (match) return match[1]; match = window.location.href.match(/\/live\/([a-zA-Z0-9_-]+)/); if (match) return match[1]; match = window.location.href.match(/[?&]v=([^&]+)/); return match ? match[1] : 'unknown'; } isSupported() { return window.location.hostname.includes('youtube.com') || window.location.hostname.includes('youtu.be'); } getSiteName() { return 'YouTube'; } } /** * 哔哩哔哩视频适配器 */ class BilibiliAdapter extends VideoAdapter { getVideoElement() { // 优先使用哔哩哔哩特定的选择器 return document.querySelector('.bpx-player-video-wrap>video') || document.querySelector('video'); } getVideoTitle() { const titleElement = document.querySelector('.video-title') || document.querySelector('.media-title') || document.querySelector('h1[title]') || document.querySelector('.video-info-title'); return titleElement ? this.sanitizeTitle(titleElement.textContent || titleElement.title) : this.sanitizeTitle(document.title || 'Bilibili_Video'); } getVideoID() { // 从URL中提取BV号或av号 const url = window.location.href; const bvMatch = url.match(/\/video\/(BV[a-zA-Z0-9]+)/); if (bvMatch) return bvMatch[1]; const avMatch = url.match(/\/video\/av(\d+)/); if (avMatch) return 'av' + avMatch[1]; return 'unknown'; } isSupported() { return window.location.hostname.includes('bilibili.com'); } getSiteName() { return 'Bilibili'; } } /** * 通用视频适配器(兜底方案) */ class GenericAdapter extends VideoAdapter { getVideoElement() { return document.querySelector('video'); } getVideoTitle() { const title = document.title || 'Video'; return this.sanitizeTitle(title); } getVideoID() { return Date.now().toString(); } isSupported() { return document.querySelector('video') !== null; } getSiteName() { return window.location.hostname; } } /** * 视频适配器工厂 */ class VideoAdapterFactory { static adapters = [ new YouTubeAdapter(), new BilibiliAdapter(), new GenericAdapter() // 兜底适配器,必须放在最后 ]; /** * 获取适合当前网站的适配器 */ static getAdapter() { for (const adapter of this.adapters) { if (adapter.isSupported()) { console.log(`使用 ${adapter.getSiteName()} 适配器`); return adapter; } } throw new Error('No suitable video adapter found'); } /** * 添加自定义适配器 */ static addAdapter(adapter) { if (!(adapter instanceof VideoAdapter)) { throw new Error('Adapter must extend VideoAdapter'); } // 插入到通用适配器之前 this.adapters.splice(-1, 0, adapter); } } /** * 视频管理器 */ class VideoManager { constructor() { this.adapter = VideoAdapterFactory.getAdapter(); } /** * 获取视频元素 */ getVideoElement() { return this.adapter.getVideoElement(); } /** * 获取视频ID */ getVideoID() { return this.adapter.getVideoID(); } /** * 获取视频标题 */ getVideoTitle() { return this.adapter.getVideoTitle(); } /** * 获取网站名称 */ getSiteName() { return this.adapter.getSiteName(); } /** * 清理标题中的非法字符 */ sanitizeTitle(title) { return title.replace(/[\\/:*?"<>|]/g, '').trim(); } /** * 跳转到指定时间点 */ goToTime(video, targetTime) { if (!video) return false; if (targetTime < 0 || targetTime > video.duration) { console.error(`Target time ${targetTime}s is out of video range (0-${video.duration}s)`); return false; } video.currentTime = targetTime; return true; } /** * 格式化时间 */ formatTime(seconds) { const h = String(Math.floor(seconds / 3600)).padStart(2, '0'); const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'); const s = String(Math.floor(seconds % 60)).padStart(2, '0'); const ms = String(Math.floor((seconds % 1) * 1000)).padStart(3, '0'); return { h, m, s, ms }; } } /** * 字幕适配器接口 */ class SubtitleAdapter { isFormatSupported(data) { throw new Error('isFormatSupported method must be implemented'); } parseSubtitleData(data) { throw new Error('parseSubtitleData method must be implemented'); } findSubtitleAtTime(data, timeInSeconds) { throw new Error('findSubtitleAtTime method must be implemented'); } findSubtitlesInRange(data, startTime, endTime) { throw new Error('findSubtitlesInRange method must be implemented'); } getSubtitleCount(data) { throw new Error('getSubtitleCount method must be implemented'); } getFormatName() { throw new Error('getFormatName method must be implemented'); } } /** * YouTube字幕适配器(原有格式) */ class YouTubeSubtitleAdapter extends SubtitleAdapter { isFormatSupported(data) { return data && data.events && Array.isArray(data.events); } parseSubtitleData(data) { if (!this.isFormatSupported(data)) { throw new Error('Unsupported YouTube subtitle format'); } return data; } findSubtitleAtTime(data, timeInSeconds) { if (!data.events) return null; const timeInMs = timeInSeconds * 1000; for (const event of data.events) { const startTime = event.tStartMs; const endTime = event.tStartMs + event.dDurationMs; if (timeInMs >= startTime && timeInMs <= endTime) { let text = ''; if (event.segs) { text = event.segs.map(seg => seg.utf8 || '').join(''); } return text.trim(); } } return null; } findSubtitlesInRange(data, startTime, endTime) { if (!data.events) return []; const startMs = startTime * 1000; const endMs = endTime * 1000; const subtitlesInRange = []; for (const event of data.events) { const eventStartTime = event.tStartMs; const eventEndTime = event.tStartMs + event.dDurationMs; if (eventStartTime < endMs && eventEndTime > startMs) { let text = ''; if (event.segs) { text = event.segs.map(seg => seg.utf8 || '').join(''); } const trimmedText = text.trim(); if (trimmedText) { const midTime = (eventStartTime + eventEndTime) / 2 / 1000; subtitlesInRange.push({ startTime: eventStartTime / 1000, endTime: eventEndTime / 1000, midTime: midTime, text: trimmedText }); } } } return subtitlesInRange.sort((a, b) => a.startTime - b.startTime); } getSubtitleCount(data) { return data.events ? data.events.length : 0; } getFormatName() { return 'YouTube'; } } /** * 哔哩哔哩字幕适配器 */ class BilibiliSubtitleAdapter extends SubtitleAdapter { isFormatSupported(data) { return data && data.body && Array.isArray(data.body) && data.type === 'AIsubtitle'; } parseSubtitleData(data) { if (!this.isFormatSupported(data)) { throw new Error('Unsupported Bilibili subtitle format'); } return data; } findSubtitleAtTime(data, timeInSeconds) { if (!data.body) return null; for (const item of data.body) { if (!item.content) continue; const startTime = item.from; const endTime = item.to; if (timeInSeconds >= startTime && timeInSeconds <= endTime) { return item.content.trim(); } } return null; } findSubtitlesInRange(data, startTime, endTime) { if (!data.body) return []; const subtitlesInRange = []; for (const item of data.body) { if (!item.content) continue; const itemStartTime = item.from; const itemEndTime = item.to; if (itemStartTime < endTime && itemEndTime > startTime) { const trimmedText = item.content.trim(); if (trimmedText) { const midTime = (itemStartTime + itemEndTime) / 2; subtitlesInRange.push({ startTime: itemStartTime, endTime: itemEndTime, midTime: midTime, text: trimmedText }); } } } return subtitlesInRange.sort((a, b) => a.startTime - b.startTime); } getSubtitleCount(data) { return data.body ? data.body.filter(item => item.content).length : 0; } getFormatName() { return 'Bilibili'; } } /** * 字幕适配器工厂 */ class SubtitleAdapterFactory { static adapters = [ new BilibiliSubtitleAdapter(), new YouTubeSubtitleAdapter() ]; static getAdapter(data, preferredSite = null) { console.log('SubtitleAdapterFactory.getAdapter called with:', { hasData: !!data, preferredSite: preferredSite, dataType: data?.type, hasBody: !!data?.body, hasEvents: !!data?.events }); // 如果指定了首选网站,先尝试对应的适配器 if (preferredSite) { const preferredAdapter = this.adapters.find(adapter => { const formatName = adapter.getFormatName().toLowerCase(); const matches = formatName.includes(preferredSite.toLowerCase()) && adapter.isFormatSupported(data); console.log(`检查适配器 ${adapter.getFormatName()}: 名称匹配=${formatName.includes(preferredSite.toLowerCase())}, 格式支持=${adapter.isFormatSupported(data)}, 总体匹配=${matches}`); return matches; }); if (preferredAdapter) { console.log(`优先使用 ${preferredAdapter.getFormatName()} 字幕适配器(基于网站:${preferredSite})`); return preferredAdapter; } } // 回退到常规检测 for (const adapter of this.adapters) { if (adapter.isFormatSupported(data)) { console.log(`使用 ${adapter.getFormatName()} 字幕适配器`); return adapter; } } console.error('没有找到支持的字幕适配器'); throw new Error('Unsupported subtitle format'); } static addAdapter(adapter) { if (!(adapter instanceof SubtitleAdapter)) { throw new Error('Adapter must extend SubtitleAdapter'); } this.adapters.unshift(adapter); } } /** * 字幕管理器 */ class SubtitleManager { constructor() { this.subtitleData = null; this.subtitleEnabled = false; this.fontSize = 48; this.maxLines = 2; this.adapter = null; // 当前使用的字幕适配器 this.showSubtitlesInComposite = GM_getValue('showSubtitlesInComposite', true); // 新增:控制是否在拼接图片中显示字幕 this.currentSite = this.detectCurrentSite(); // 检测当前网站 console.log('字幕管理器初始化完成, 当前网站:', this.currentSite); } /** * 检测当前网站 */ detectCurrentSite() { const hostname = window.location.hostname; let site = 'unknown'; if (hostname.includes('bilibili.com')) { site = 'bilibili'; } else if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) { site = 'youtube'; } console.log(`检测到当前网站: ${hostname} -> ${site}`); return site; } /** * 设置是否在拼接图片中显示字幕 */ setShowSubtitlesInComposite(show) { this.showSubtitlesInComposite = show; GM_setValue('showSubtitlesInComposite', show); } /** * 获取是否在拼接图片中显示字幕 */ getShowSubtitlesInComposite() { return GM_getValue('showSubtitlesInComposite', true); } /** * 加载字幕文件 */ loadSubtitleFile() { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt,.json'; input.onchange = (event) => { const file = event.target.files[0]; if (!file) { reject(new Error('No file selected')); return; } const reader = new FileReader(); reader.onload = (e) => { try { const rawData = JSON.parse(e.target.result); // 使用适配器工厂获取合适的适配器,优先使用当前网站的适配器 this.adapter = SubtitleAdapterFactory.getAdapter(rawData, this.currentSite); this.subtitleData = this.adapter.parseSubtitleData(rawData); this.subtitleEnabled = true; const count = this.adapter.getSubtitleCount(this.subtitleData); console.log(`${this.adapter.getFormatName()} 字幕加载成功:`, count, '条字幕'); resolve(this.subtitleData); } catch (error) { console.error('字幕文件解析错误:', error); reject(error); } }; reader.readAsText(file); }; input.click(); }); } /** * 检查字幕文本是否为空或只包含无意义字符 */ isSubtitleEmpty(text) { if (!text) return true; // 清理文本:移除括号内容、换行符、多余空格 const cleanText = text .replace(/\([^)]*\)/g, '') // 移除括号内容 .replace(/\[[^\]]*\]/g, '') // 秘除方括号内容 .replace(/\{[^}]*\}/g, '') // 移除大括号内容 .replace(/\n+/g, ' ') // 换行符替换为空格 .replace(/\s+/g, ' ') // 多个空格替换为单个空格 .trim(); // 检查是否为空或只包含标点符号 return cleanText.length === 0 || /^[.,!?;:\-_\s]*$/.test(cleanText); } /** * 查找指定时间的字幕 */ findSubtitleAtTime(timeInSeconds) { if (!this.subtitleData || !this.subtitleEnabled || !this.adapter) { return null; } const subtitleText = this.adapter.findSubtitleAtTime(this.subtitleData, timeInSeconds); // 过滤空字幕 if (subtitleText && !this.isSubtitleEmpty(subtitleText)) { return subtitleText; } return null; } /** * 在时间范围内查找所有字幕 */ findSubtitlesInRange(startTime, endTime) { if (!this.subtitleData || !this.adapter) { return []; } return this.adapter.findSubtitlesInRange(this.subtitleData, startTime, endTime); } /** * 在画布上绘制字幕 */ drawSubtitleOnCanvas(canvas, text) { if (!text) return; const ctx = canvas.getContext('2d'); ctx.save(); // 清理文本 const cleanText = text.replace(/\([^)]*\)/g, '').replace(/\n+/g, ' ').trim(); const words = cleanText.split(/\s+/).filter(word => word.trim()); if (words.length === 0) { ctx.restore(); return; } // 字体设置 const fontSize = Math.max(36, Math.min(96, this.fontSize)); ctx.font = `bold ${fontSize}px "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif`; // 样式计算 const lineHeight = fontSize * 1.3; const padding = Math.max(12, fontSize * 0.4); const margin = Math.max(25, fontSize * 0.8); const maxWidth = canvas.width * 0.85; // 分行处理 const lines = this.splitTextToLines(words, ctx, maxWidth, this.maxLines); if (lines.length === 0) { ctx.restore(); return; } // 绘制背景 this.drawSubtitleBackground(ctx, canvas, lines, fontSize, lineHeight, padding, margin); // 绘制文本 this.drawSubtitleText(ctx, canvas, lines, fontSize, lineHeight, padding, margin); ctx.restore(); } /** * 将文本分行 */ splitTextToLines(words, ctx, maxWidth, maxLines) { const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; const testWidth = ctx.measureText(testLine).width; if (testWidth <= maxWidth) { currentLine = testLine; } else { if (currentLine) { lines.push(currentLine); currentLine = word; } else { lines.push(word); } if (lines.length >= maxLines) { break; } } } if (currentLine && lines.length < maxLines) { lines.push(currentLine); } return lines; } /** * 绘制字幕背景 */ drawSubtitleBackground(ctx, canvas, lines, fontSize, lineHeight, padding, margin) { const maxLineWidth = Math.max(...lines.map(line => ctx.measureText(line).width)); const totalHeight = lines.length * lineHeight + padding * 2; const bgWidth = maxLineWidth + padding * 2; const bgHeight = totalHeight; const bgX = (canvas.width - bgWidth) / 2; const bgY = canvas.height - bgHeight - margin; // 重置绘制状态 ctx.globalAlpha = 1.0; ctx.globalCompositeOperation = 'source-over'; ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; // 绘制背景 ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; ctx.beginPath(); ctx.rect(bgX, bgY, bgWidth, bgHeight); ctx.fill(); // 绘制边框 ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.rect(bgX, bgY, bgWidth, bgHeight); ctx.stroke(); } /** * 绘制字幕文本 */ drawSubtitleText(ctx, canvas, lines, fontSize, lineHeight, padding, margin) { const maxLineWidth = Math.max(...lines.map(line => ctx.measureText(line).width)); const totalHeight = lines.length * lineHeight + padding * 2; const bgWidth = maxLineWidth + padding * 2; const bgHeight = totalHeight; const bgX = (canvas.width - bgWidth) / 2; const bgY = canvas.height - bgHeight - margin; lines.forEach((line, index) => { const textWidth = ctx.measureText(line).width; const x = (canvas.width - textWidth) / 2; const y = bgY + padding + (index + 1) * lineHeight - lineHeight * 0.25; // 绘制文本描边 ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)'; ctx.lineWidth = Math.max(2, fontSize * 0.08); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.strokeText(line, x, y); // 绘制文本 ctx.fillStyle = '#ffffff'; ctx.fillText(line, x, y); }); } } /** * 截图管理器 */ class ScreenshotManager { constructor(videoManager, subtitleManager) { this.videoManager = videoManager; this.subtitleManager = subtitleManager; } /** * 截取单张图片 */ takeScreenshot() { const video = this.videoManager.getVideoElement(); if (!video || video.videoWidth === 0 || video.videoHeight === 0) { console.warn('Video not available or invalid dimensions'); return; } if (video.readyState < 2) { console.warn(`Video not ready for capture (readyState: ${video.readyState})`); return; } const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // 检查图片亮度 const averageBrightness = this.checkImageBrightness(canvas); if (averageBrightness < 10) { console.warn(`Screenshot appears to be mostly black (brightness: ${averageBrightness.toFixed(2)})`); } // 添加字幕 if (this.subtitleManager.subtitleEnabled && this.subtitleManager.subtitleData) { const subtitleText = this.subtitleManager.findSubtitleAtTime(video.currentTime); if (subtitleText) { this.subtitleManager.drawSubtitleOnCanvas(canvas, subtitleText); } } this.downloadScreenshot(canvas, video.currentTime); } /** * 检查图片亮度 */ checkImageBrightness(canvas) { const ctx = canvas.getContext('2d'); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const pixels = imageData.data; let totalBrightness = 0; let samplePoints = 0; for (let i = 0; i < pixels.length; i += 40) { if (i + 2 < pixels.length) { const r = pixels[i]; const g = pixels[i + 1]; const b = pixels[i + 2]; const brightness = (r + g + b) / 3; totalBrightness += brightness; samplePoints++; } } return totalBrightness / samplePoints; } /** * 下载截图 */ downloadScreenshot(canvas, currentTime) { const link = document.createElement('a'); const timeObj = this.videoManager.formatTime(currentTime); const title = this.videoManager.getVideoTitle(); const id = this.videoManager.getVideoID(); const resolution = `${canvas.width}x${canvas.height}`; const subtitleSuffix = (this.subtitleManager.subtitleEnabled && this.subtitleManager.subtitleData) ? '_sub' : ''; link.download = `${title}_${timeObj.h}_${timeObj.m}_${timeObj.s}_${timeObj.ms}_${id}_${resolution}${subtitleSuffix}.png`; link.href = canvas.toDataURL('image/png'); link.click(); } /** * 在指定时间点截图 */ captureFrameAtTime(video, targetTime) { return new Promise((resolve) => { const originalTime = video.currentTime; if (targetTime < 0 || targetTime > video.duration) { console.error(`Target time ${targetTime}s is out of video range`); resolve(null); return; } let seekAttempts = 0; const maxSeekAttempts = 3; const attemptCapture = () => { const onSeeked = () => { video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onSeekedError); const delay = 200 + (seekAttempts * 100); setTimeout(() => { try { if (video.readyState < 2) { console.warn(`Video not ready (readyState: ${video.readyState}), retrying...`); if (seekAttempts < maxSeekAttempts - 1) { seekAttempts++; setTimeout(attemptCapture, 300); return; } } const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; if (canvas.width === 0 || canvas.height === 0) { console.error(`Invalid video dimensions: ${canvas.width}x${canvas.height}`); video.currentTime = originalTime; resolve(null); return; } const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const averageBrightness = this.checkImageBrightness(canvas); if (averageBrightness < 10 && seekAttempts < maxSeekAttempts - 1) { console.warn(`Frame appears to be mostly black, retrying...`); seekAttempts++; setTimeout(attemptCapture, 500); return; } console.log(`Captured frame: ${canvas.width}x${canvas.height} at time ${video.currentTime}s`); video.currentTime = originalTime; resolve(canvas); } catch (error) { console.error('Error capturing frame:', error); video.currentTime = originalTime; resolve(null); } }, delay); }; const onSeekedError = () => { console.error('Seek operation failed'); video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onSeekedError); if (seekAttempts < maxSeekAttempts - 1) { seekAttempts++; setTimeout(attemptCapture, 500); } else { resolve(null); } }; video.addEventListener('seeked', onSeeked); video.addEventListener('error', onSeekedError); try { video.currentTime = targetTime; } catch (error) { console.error('Error setting video time:', error); video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onSeekedError); if (seekAttempts < maxSeekAttempts - 1) { seekAttempts++; setTimeout(attemptCapture, 500); } else { resolve(null); } } }; attemptCapture(); }); } } /** * 图片合成器 */ class ImageComposer { constructor(subtitleManager) { this.subtitleManager = subtitleManager; this.overlapHeight = GM_getValue('overlapHeight', 150); } /** * 更新重叠高度设置 */ updateOverlapHeight(height) { this.overlapHeight = height; } /** * 创建合成图片 */ createCompositeImage(screenshots, subtitles, mode = 'parallel') { if (screenshots.length === 0) return null; const frameWidth = screenshots[0].width; const frameHeight = screenshots[0].height; let totalHeight; if (mode === 'overlap') { // 使用可配置的重叠高度 totalHeight = frameHeight + (screenshots.length - 1) * this.overlapHeight; } else { const spacing = 10; totalHeight = screenshots.length * frameHeight + (screenshots.length - 1) * spacing; } const compositeCanvas = document.createElement('canvas'); compositeCanvas.width = frameWidth; compositeCanvas.height = totalHeight; const ctx = compositeCanvas.getContext('2d'); // 初始化画布 ctx.save(); ctx.globalAlpha = 1.0; ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, frameWidth, totalHeight); if (mode === 'overlap') { this.drawOverlapMode(ctx, compositeCanvas, screenshots, subtitles, frameWidth, frameHeight); } else { this.drawParallelMode(ctx, compositeCanvas, screenshots, subtitles, frameWidth, frameHeight); } ctx.restore(); return compositeCanvas; } /** * 重叠模式绘制 */ drawOverlapMode(ctx, compositeCanvas, screenshots, subtitles, frameWidth, frameHeight) { // 使用可配置的重叠高度 const subtitleHeight = this.overlapHeight; let currentY = 0; screenshots.forEach((canvas, index) => { if (!canvas || canvas.width === 0 || canvas.height === 0) return; if (index === 0) { // 第一张图片完整绘制 ctx.drawImage(canvas, 0, currentY, frameWidth, frameHeight); if (subtitles[index] && subtitles[index].text && this.subtitleManager.showSubtitlesInComposite) { this.drawSubtitleOnSpecificArea(compositeCanvas, subtitles[index].text, currentY, frameHeight); } currentY += frameHeight; } else { // 后续图片只绘制字幕区域 const subtitleRegionHeight = subtitleHeight; const sourceY = frameHeight - subtitleRegionHeight; ctx.drawImage(canvas, 0, sourceY, frameWidth, subtitleRegionHeight, 0, currentY, frameWidth, subtitleRegionHeight); if (subtitles[index] && subtitles[index].text && this.subtitleManager.showSubtitlesInComposite) { this.drawSubtitleOnSpecificArea(compositeCanvas, subtitles[index].text, currentY, subtitleRegionHeight); } currentY += subtitleRegionHeight; } }); } /** * 并行模式绘制 */ drawParallelMode(ctx, compositeCanvas, screenshots, subtitles, frameWidth, frameHeight) { const spacing = 10; let currentY = 0; screenshots.forEach((canvas, index) => { if (!canvas || canvas.width === 0 || canvas.height === 0) return; ctx.drawImage(canvas, 0, currentY, frameWidth, frameHeight); if (subtitles[index] && subtitles[index].text && this.subtitleManager.showSubtitlesInComposite) { this.drawSubtitleOnSpecificArea(compositeCanvas, subtitles[index].text, currentY, frameHeight); } currentY += frameHeight + spacing; }); } /** * 在特定区域绘制字幕 */ drawSubtitleOnSpecificArea(canvas, text, yOffset, areaHeight) { if (!text) return; const ctx = canvas.getContext('2d'); ctx.save(); // 文本处理 const cleanText = text.replace(/\([^)]*\)/g, '').replace(/\n+/g, ' ').trim(); const words = cleanText.split(/\s+/).filter(word => word.trim()); if (words.length === 0) { ctx.restore(); return; } // 字体设置 const fontSize = Math.max(36, Math.min(96, this.subtitleManager.fontSize)); ctx.font = `bold ${fontSize}px "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif`; // 样式计算 const lineHeight = fontSize * 1.3; const padding = Math.max(10, fontSize * 0.35); const margin = Math.max(20, fontSize * 0.7); const maxWidth = canvas.width * 0.85; // 分行 const lines = this.subtitleManager.splitTextToLines(words, ctx, maxWidth, this.subtitleManager.maxLines); if (lines.length === 0) { ctx.restore(); return; } // 计算位置 const maxLineWidth = Math.max(...lines.map(line => ctx.measureText(line).width)); const totalHeight = lines.length * lineHeight + padding * 2; const bgWidth = maxLineWidth + padding * 2; const bgHeight = totalHeight; const bgX = (canvas.width - bgWidth) / 2; const bgY = yOffset + areaHeight - bgHeight - margin; // 绘制背景 ctx.globalAlpha = 1.0; ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; ctx.beginPath(); ctx.rect(bgX, bgY, bgWidth, bgHeight); ctx.fill(); // 绘制文本 lines.forEach((line, index) => { const textWidth = ctx.measureText(line).width; const x = (canvas.width - textWidth) / 2; const y = bgY + padding + (index + 1) * lineHeight - lineHeight * 0.25; ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)'; ctx.lineWidth = Math.max(2, fontSize * 0.08); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.strokeText(line, x, y); ctx.fillStyle = '#ffffff'; ctx.fillText(line, x, y); }); ctx.restore(); } } /** * 任务管理器 */ class TaskManager { constructor(videoManager, subtitleManager, screenshotManager, imageComposer) { this.videoManager = videoManager; this.subtitleManager = subtitleManager; this.screenshotManager = screenshotManager; this.imageComposer = imageComposer; } /** * 解析时间输入 */ parseTimeInput(timeString) { const timeMatch = timeString.match(/^(\d{1,2}):(\d{1,3})$/); if (timeMatch) { return parseInt(timeMatch[1]) * 60 + parseInt(timeMatch[2]); } const secondsMatch = timeString.match(/^(\d+(?:\.\d+)?)$/); if (secondsMatch) { return parseFloat(secondsMatch[1]); } return null; } /** * 解析时间范围 */ parseTimeRanges(input) { if (!input) return null; // 检查是否是新的按字幕分组格式: "01:00-02:12,10" const subtitleGroupMatch = input.match(/^(.+),\s*(\d+)$/); if (subtitleGroupMatch) { const rangeInput = subtitleGroupMatch[1]; const groupCount = parseInt(subtitleGroupMatch[2]); const rangeParts = rangeInput.split('-'); if (rangeParts.length === 2) { const startTime = this.parseTimeInput(rangeParts[0]); const endTime = this.parseTimeInput(rangeParts[1]); if (startTime !== null && endTime !== null && startTime < endTime) { // 返回单个范围,但标记为需要按字幕分组 return [{ startTime: startTime, endTime: endTime, isSubtitleGroupBased: true, targetGroupCount: groupCount, isDivided: false }]; } } } // 检查是否是多时间点叠加格式: "01:00,01:22,01:33" if (input.includes(',') && !input.includes('-')) { const timePoints = input.split(',').map(t => t.trim()); const parsedTimes = []; for (const timePoint of timePoints) { const parsedTime = this.parseTimeInput(timePoint); if (parsedTime === null) return null; parsedTimes.push(parsedTime); } return [{ timePoints: parsedTimes, isMultiTimeOverlay: true, isDivided: false }]; } const timePoints = input.split('-'); if (timePoints.length < 2) return null; const parsedTimes = []; for (const timePoint of timePoints) { const parsedTime = this.parseTimeInput(timePoint.trim()); if (parsedTime === null) return null; parsedTimes.push(parsedTime); } for (let i = 1; i < parsedTimes.length; i++) { if (parsedTimes[i] <= parsedTimes[i - 1]) { return null; } } const ranges = []; for (let i = 0; i < parsedTimes.length - 1; i++) { ranges.push({ startTime: parsedTimes[i], endTime: parsedTimes[i + 1], isDivided: false }); } return ranges; } /** * 批量截图 */ async batchScreenshot(timeRangeInput, compositeMode = 'parallel') { const timeRanges = this.parseTimeRanges(timeRangeInput.trim()); if (!timeRanges) { throw new Error('时间范围格式错误'); } const video = this.videoManager.getVideoElement(); if (!video) { throw new Error('找不到视频元素'); } // 处理多时间点叠加模式 if (timeRanges[0].isMultiTimeOverlay) { return await this.handleMultiTimeOverlay(timeRanges[0], compositeMode); } // 处理字幕分组模式 if (timeRanges[0].isSubtitleGroupBased) { if (!this.subtitleManager.subtitleEnabled || !this.subtitleManager.subtitleData) { throw new Error('请先加载字幕文件并开启字幕功能'); } return await this.handleSubtitleGroupMode(timeRanges[0], compositeMode); } // 原有的逻辑处理 return await this.handleRegularMode(timeRanges, compositeMode); } /** * 处理多时间点叠加模式 */ async handleMultiTimeOverlay(timeRange, compositeMode) { const video = this.videoManager.getVideoElement(); const screenshots = []; const subtitles = []; let completedScreenshots = 0; const totalScreenshots = timeRange.timePoints.length; for (const timePoint of timeRange.timePoints) { // 更新进度 completedScreenshots++; const progress = Math.round((completedScreenshots / totalScreenshots) * 100); if (typeof this.onProgress === 'function') { this.onProgress(progress, completedScreenshots, totalScreenshots); } const canvas = await this.screenshotManager.captureFrameAtTime(video, timePoint); if (canvas && canvas.width > 0 && canvas.height > 0) { screenshots.push(canvas); // 不需要字幕,所以添加空字幕 subtitles.push({ text: '', time: timePoint }); } await new Promise(resolve => setTimeout(resolve, 500)); } if (screenshots.length === 0) { throw new Error('没有成功截取到任何图片'); } // 创建合成图片 const compositeCanvas = this.imageComposer.createCompositeImage(screenshots, subtitles, compositeMode); if (compositeCanvas) { const title = this.videoManager.getVideoTitle(); const id = this.videoManager.getVideoID(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const link = document.createElement('a'); link.download = `${title}_multi_overlay_${screenshots.length}pts_${id}_${timestamp}.png`; link.href = compositeCanvas.toDataURL('image/png'); link.click(); } return 1; } /** * 处理字幕分组模式 */ async handleSubtitleGroupMode(timeRange, compositeMode) { const { startTime, endTime, targetGroupCount } = timeRange; const allSubtitlesInRange = this.subtitleManager.findSubtitlesInRange(startTime, endTime); if (allSubtitlesInRange.length === 0) { throw new Error('指定时间范围内没有找到字幕'); } // 计算每组的字幕数量 const subtitlesPerGroup = Math.max(1, Math.floor(allSubtitlesInRange.length / targetGroupCount)); const remainder = allSubtitlesInRange.length % targetGroupCount; // 将字幕分组 const groups = []; let currentIndex = 0; for (let groupIndex = 0; groupIndex < targetGroupCount; groupIndex++) { const currentGroupSize = subtitlesPerGroup + (groupIndex < remainder ? 1 : 0); if (currentIndex >= allSubtitlesInRange.length) { break; } const groupSubtitles = allSubtitlesInRange.slice(currentIndex, currentIndex + currentGroupSize); if (groupSubtitles.length > 0) { groups.push({ groupIndex, subtitles: groupSubtitles }); } currentIndex += currentGroupSize; } // 处理每个组 const video = this.videoManager.getVideoElement(); let completedGroups = 0; for (const group of groups) { const screenshots = []; for (const subtitle of group.subtitles) { const canvas = await this.screenshotManager.captureFrameAtTime(video, subtitle.midTime); if (canvas && canvas.width > 0 && canvas.height > 0) { screenshots.push(canvas); } await new Promise(resolve => setTimeout(resolve, 500)); } if (screenshots.length > 0) { const compositeCanvas = this.imageComposer.createCompositeImage(screenshots, group.subtitles, compositeMode); if (compositeCanvas) { const title = this.videoManager.getVideoTitle(); const id = this.videoManager.getVideoID(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const link = document.createElement('a'); link.download = `${title}_batch_group${group.groupIndex + 1}_of_${targetGroupCount}_${id}_${timestamp}.png`; link.href = compositeCanvas.toDataURL('image/png'); link.click(); await new Promise(resolve => setTimeout(resolve, 500)); } } completedGroups++; const progress = Math.round((completedGroups / groups.length) * 100); if (typeof this.onProgress === 'function') { this.onProgress(progress, completedGroups, groups.length); } } return groups.length; } /** * 处理常规模式 */ async handleRegularMode(timeRanges, compositeMode) { if (!this.subtitleManager.subtitleEnabled || !this.subtitleManager.subtitleData) { throw new Error('请先加载字幕文件并开启字幕功能'); } const video = this.videoManager.getVideoElement(); let processedRanges = 0; for (const range of timeRanges) { const subtitlesInRange = this.subtitleManager.findSubtitlesInRange(range.startTime, range.endTime); if (subtitlesInRange.length > 0) { const screenshots = []; for (const subtitle of subtitlesInRange) { const canvas = await this.screenshotManager.captureFrameAtTime(video, subtitle.midTime); if (canvas && canvas.width > 0 && canvas.height > 0) { screenshots.push(canvas); } await new Promise(resolve => setTimeout(resolve, 500)); } if (screenshots.length > 0) { const compositeCanvas = this.imageComposer.createCompositeImage(screenshots, subtitlesInRange, compositeMode); if (compositeCanvas) { const title = this.videoManager.getVideoTitle(); const id = this.videoManager.getVideoID(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const link = document.createElement('a'); link.download = `${title}_batch_range${processedRanges + 1}_${id}_${timestamp}.png`; link.href = compositeCanvas.toDataURL('image/png'); link.click(); await new Promise(resolve => setTimeout(resolve, 500)); } } } processedRanges++; const progress = Math.round((processedRanges / timeRanges.length) * 100); if (typeof this.onProgress === 'function') { this.onProgress(progress, processedRanges, timeRanges.length); } } return processedRanges; } } /** * 配置面板管理器 */ class ConfigPanelManager { constructor() { this.tool = null; this.panelVisible = false; this.t=new LanguageManager().t this.init(); } init() { console.log('ConfigPanelManager initializing...'); // 创建配置面板 this.createConfigPanel(); // 添加快捷键监听 this.setupShortcuts(); // 添加油猴菜单 this.setupTampermonkeyMenu(); console.log('ConfigPanelManager initialized successfully'); } createConfigPanel() { // 检查面板是否已存在 if (document.getElementById('ytFrameMasterConfig')) { console.log('Panel already exists'); return; } console.log('Creating config panel...'); // 创建主面板容器 const panel = this.createElement('div', { id: 'ytFrameMasterConfig', style: { position: 'fixed', top: '20px', right: '20px', width: '350px', background: 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)', color: '#fff', border: '1px solid #444', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0,0,0,0.3)', zIndex: '10000', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontSize: '14px', display: 'none', maxHeight: '85vh', overflowY: 'auto', backdropFilter: 'blur(10px)' } }); // 添加自定义滚动条样式 const style = document.createElement('style'); style.textContent = ` #ytFrameMasterConfig::-webkit-scrollbar { width: 8px; } #ytFrameMasterConfig::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 4px; } #ytFrameMasterConfig::-webkit-scrollbar-thumb { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; transition: all 0.3s ease; } #ytFrameMasterConfig::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%); } `; document.head.appendChild(style); // 创建头部 const header = this.createHeader(); panel.appendChild(header); // 创建主体内容 const content = this.createContent(); panel.appendChild(content); // 添加到页面 document.body.appendChild(panel); // 设置事件监听器 this.setupPanelEvents(); console.log('Panel created successfully'); } createElement(tag, options = {}) { const element = document.createElement(tag); // 设置属性 if (options.id) element.id = options.id; if (options.className) element.className = options.className; if (options.textContent) element.textContent = options.textContent; if (options.innerHTML) element.innerHTML = options.innerHTML; // 设置样式 if (options.style) { Object.assign(element.style, options.style); } // 设置其他属性 if (options.attributes) { Object.entries(options.attributes).forEach(([key, value]) => { element.setAttribute(key, value); }); } return element; } createHeader() { const header = this.createElement('div', { id: 'configPanelHeader', style: { padding: '20px', background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%)', borderRadius: '12px 12px 0 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'move' } }); // 左侧标题区域 const titleArea = this.createElement('div'); const title = this.createElement('h4', { textContent: this.t('title'), style: { margin: '0', fontSize: '18px', color: '#fff', fontWeight: '600' } }); const subtitle = this.createElement('p', { textContent: this.t('subtitle'), style: { margin: '5px 0 0 0', fontSize: '12px', color: 'rgba(255,255,255,0.8)', fontWeight: '300' } }); // 添加适配器信息 const adapterInfo = this.createElement('p', { textContent: this.t('adapter', { name: this.tool ? this.tool.videoManager.getSiteName() : this.t('unknown') }), style: { margin: '2px 0 0 0', fontSize: '10px', color: 'rgba(255,255,255,0.6)', fontWeight: '300' } }); titleArea.appendChild(title); titleArea.appendChild(subtitle); titleArea.appendChild(adapterInfo); // 关闭按钮 const closeBtn = this.createElement('button', { id: 'closeConfigPanel', textContent: '×', style: { background: 'rgba(255,255,255,0.2)', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer', padding: '0', width: '36px', height: '36px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s ease', flexShrink: '0', lineHeight: '1', fontWeight: 'bold' } }); // 悬停效果 closeBtn.addEventListener('mouseenter', () => { closeBtn.style.background = 'rgba(255,255,255,0.3)'; }); closeBtn.addEventListener('mouseleave', () => { closeBtn.style.background = 'rgba(255,255,255,0.2)'; }); header.appendChild(titleArea); header.appendChild(closeBtn); return header; } createContent() { const content = this.createElement('div', { style: { padding: '25px' } }); // 创建各个区域 content.appendChild(this.createQuickActionsSection()); content.appendChild(this.createBasicSettingsSection()); content.appendChild(this.createSubtitleSection()); content.appendChild(this.createBatchSection()); content.appendChild(this.createBottomButtons()); return content; } createQuickActionsSection() { const section = this.createElement('div', { style: { marginBottom: '25px', padding: '20px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '10px' } }); const title = this.createElement('h4', { textContent: langManager.t('quickActions'), style: { margin: '0 0 15px 0', fontSize: '16px', color: '#fff', fontWeight: '600' } }); const buttonGroup = this.createElement('div', { style: { display: 'flex', gap: '10px', marginBottom: '15px' } }); const screenshotBtn = this.createActionButton('takeScreenshotBtn', langManager.t('takeScreenshot'), '#11998e', '#38ef7d'); const burstBtn = this.createActionButton('burstModeBtn', langManager.t('burstMode'), '#f093fb', '#f5576c'); buttonGroup.appendChild(screenshotBtn); buttonGroup.appendChild(burstBtn); const shortcutInfo = this.createElement('div', { style: { fontSize: '12px', color: 'rgba(255,255,255,0.8)', textAlign: 'center' } }); const shortcutText = this.createElement('span', { textContent: langManager.t('currentHotkey') }); const shortcutKey = this.createElement('span', { id: 'currentHotkey', textContent: 'S', style: { background: 'rgba(255,255,255,0.2)', padding: '2px 6px', borderRadius: '4px', fontWeight: '600' } }); shortcutInfo.appendChild(shortcutText); shortcutInfo.appendChild(shortcutKey); section.appendChild(title); section.appendChild(buttonGroup); section.appendChild(shortcutInfo); return section; } createActionButton(id, text, color1, color2) { const button = this.createElement('button', { id: id, textContent: text, style: { flex: '1', padding: '14px 16px', background: `linear-gradient(135deg, ${color1} 0%, ${color2} 100%)`, color: 'white', border: 'none', borderRadius: '10px', cursor: 'pointer', fontSize: '14px', fontWeight: '600', transition: 'all 0.3s ease', boxShadow: `0 4px 15px rgba(${this.hexToRgb(color1)}, 0.3)`, position: 'relative', overflow: 'hidden' } }); // 悬停效果 button.addEventListener('mouseenter', () => { button.style.transform = 'translateY(-2px) scale(1.02)'; button.style.boxShadow = `0 8px 25px rgba(${this.hexToRgb(color1)}, 0.4)`; }); button.addEventListener('mouseleave', () => { button.style.transform = 'translateY(0) scale(1)'; button.style.boxShadow = `0 4px 15px rgba(${this.hexToRgb(color1)}, 0.3)`; }); // 点击效果 button.addEventListener('mousedown', () => { button.style.transform = 'translateY(0) scale(0.98)'; }); button.addEventListener('mouseup', () => { button.style.transform = 'translateY(-2px) scale(1.02)'; }); return button; } createBasicSettingsSection() { const section = this.createElement('div', { style: { marginBottom: '25px', padding: '20px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.1)' } }); const title = this.createElement('h4', { textContent: langManager.t('basicSettings'), style: { margin: '0 0 15px 0', fontSize: '16px', color: '#fff', fontWeight: '600' } }); section.appendChild(title); section.appendChild(this.createHotkeyControl()); section.appendChild(this.createIntervalControl()); section.appendChild(this.createLanguageControl()); return section; } createHotkeyControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { textContent: langManager.t('screenshotHotkey'), style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const inputGroup = this.createElement('div', { style: { display: 'flex', gap: '10px', alignItems: 'center' } }); const input = this.createElement('input', { id: 'hotkeyInput', attributes: { type: 'text', value: 's', maxlength: '1' }, style: { width: '60px', padding: '10px', border: '1px solid #555', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', color: '#fff', textAlign: 'center', fontSize: '16px', fontWeight: '600', textTransform: 'uppercase' } }); const applyBtn = this.createElement('button', { id: 'setHotkey', textContent: langManager.t('apply'), style: { padding: '10px 16px', background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: '600', transition: 'all 0.3s ease', boxShadow: '0 2px 8px rgba(79, 172, 254, 0.3)' } }); // 应用按钮悬停效果 applyBtn.addEventListener('mouseenter', () => { applyBtn.style.transform = 'translateY(-1px)'; applyBtn.style.boxShadow = '0 4px 12px rgba(79, 172, 254, 0.4)'; }); applyBtn.addEventListener('mouseleave', () => { applyBtn.style.transform = 'translateY(0)'; applyBtn.style.boxShadow = '0 2px 8px rgba(79, 172, 254, 0.3)'; }); inputGroup.appendChild(input); inputGroup.appendChild(applyBtn); container.appendChild(label); container.appendChild(inputGroup); return container; } createIntervalControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const labelText = this.createElement('span', { textContent: langManager.t('burstInterval') }); const valueSpan = this.createElement('span', { id: 'intervalValue', textContent: '1000' }); const unitSpan = this.createElement('span', { textContent: 'ms' }); label.appendChild(labelText); label.appendChild(valueSpan); label.appendChild(unitSpan); const slider = this.createElement('input', { id: 'intervalSlider', attributes: { type: 'range', min: '100', max: '3000', value: '1000', step: '100' }, style: { width: '100%', height: '6px', borderRadius: '3px', background: '#555', outline: 'none', marginBottom: '10px' } }); container.appendChild(label); container.appendChild(slider); return container; } createLanguageControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { textContent: langManager.t('interfaceLanguage'), style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const select = this.createElement('select', { id: 'langSelect', style: { width: '100%', padding: '10px', border: '1px solid #555', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: '14px' } }); const option1 = this.createElement('option', { textContent: 'English', attributes: { value: 'EN' } }); const option2 = this.createElement('option', { textContent: '中文', attributes: { value: 'ZH' } }); select.appendChild(option1); select.appendChild(option2); container.appendChild(label); container.appendChild(select); return container; } createSubtitleSection() { const section = this.createElement('div', { style: { marginBottom: '25px', padding: '20px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.1)' } }); const title = this.createElement('h4', { textContent: langManager.t('subtitleSettings'), style: { margin: '0 0 15px 0', fontSize: '16px', color: '#fff', fontWeight: '600' } }); section.appendChild(title); section.appendChild(this.createSubtitleFileControl()); section.appendChild(this.createSubtitleToggle()); section.appendChild(this.createSubtitleInCompositeToggle()); section.appendChild(this.createFontSizeControl()); section.appendChild(this.createMaxLinesControl()); section.appendChild(this.createSubtitleStatus()); return section; } createSubtitleFileControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { textContent: langManager.t('subtitleFile'), style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const input = this.createElement('input', { id: 'subtitleFile', attributes: { type: 'file', accept: '.txt,.json' }, style: { width: '100%', padding: '10px', border: '1px solid #555', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: '12px' } }); container.appendChild(label); container.appendChild(input); return container; } createSubtitleToggle() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { style: { display: 'flex', alignItems: 'center', cursor: 'pointer' } }); const checkbox = this.createElement('input', { id: 'subtitleToggle', attributes: { type: 'checkbox' }, style: { marginRight: '10px', width: '18px', height: '18px', accentColor: '#4facfe' } }); const span = this.createElement('span', { textContent: langManager.t('enableSubtitle'), style: { fontWeight: '500', color: '#ccc' } }); label.appendChild(checkbox); label.appendChild(span); container.appendChild(label); return container; } createSubtitleInCompositeToggle() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { style: { display: 'flex', alignItems: 'center', cursor: 'pointer' } }); const checkbox = this.createElement('input', { id: 'subtitleInCompositeToggle', attributes: { type: 'checkbox', checked: true // Default to true }, style: { marginRight: '10px', width: '18px', height: '18px', accentColor: '#4facfe' } }); const span = this.createElement('span', { textContent: this.t('showSubtitlesInComposite'), style: { fontWeight: '500', color: '#ccc' } }); label.appendChild(checkbox); label.appendChild(span); container.appendChild(label); return container; } createFontSizeControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const labelText = this.createElement('span', { textContent: langManager.t('fontSize') }); const valueSpan = this.createElement('span', { id: 'fontSizeValue', textContent: '48' }); const unitSpan = this.createElement('span', { textContent: 'px' }); label.appendChild(labelText); label.appendChild(valueSpan); label.appendChild(unitSpan); const slider = this.createElement('input', { id: 'fontSizeSlider', attributes: { type: 'range', min: '24', max: '96', value: '48' }, style: { width: '100%', height: '6px', borderRadius: '3px', background: '#555', outline: 'none', marginBottom: '10px' } }); container.appendChild(label); container.appendChild(slider); return container; } createMaxLinesControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const labelText = this.createElement('span', { textContent: langManager.t('maxLines') }); const valueSpan = this.createElement('span', { id: 'maxLinesValue', textContent: '2' }); label.appendChild(labelText); label.appendChild(valueSpan); const slider = this.createElement('input', { id: 'maxLinesSlider', attributes: { type: 'range', min: '1', max: '5', value: '2' }, style: { width: '100%', height: '6px', borderRadius: '3px', background: '#555', outline: 'none', marginBottom: '10px' } }); container.appendChild(label); container.appendChild(slider); return container; } createSubtitleStatus() { const container = this.createElement('div', { style: { fontSize: '12px', color: '#999', textAlign: 'center', padding: '8px', background: 'rgba(0,0,0,0.2)', borderRadius: '6px' } }); const statusText = this.createElement('span', { textContent: langManager.t('status') }); const statusSpan = this.createElement('span', { id: 'subtitleStatus', textContent: langManager.t('notLoaded') }); container.appendChild(statusText); container.appendChild(statusSpan); return container; } createBatchSection() { const section = this.createElement('div', { style: { marginBottom: '25px', padding: '20px', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.1)' } }); const title = this.createElement('h4', { textContent: langManager.t('batchScreenshot'), style: { margin: '0 0 15px 0', fontSize: '16px', color: '#fff', fontWeight: '600' } }); section.appendChild(title); section.appendChild(this.createCompositeModeControl()); section.appendChild(this.createOverlapHeightControl()); section.appendChild(this.createTimeRangeControl()); section.appendChild(this.createBatchButton()); return section; } createCompositeModeControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { textContent: langManager.t('compositeMode'), style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const select = this.createElement('select', { id: 'compositeModeSelect', style: { width: '100%', padding: '10px', border: '1px solid #555', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: '14px' } }); const option1 = this.createElement('option', { textContent: langManager.t('parallelMode'), attributes: { value: 'parallel' } }); const option2 = this.createElement('option', { textContent: langManager.t('overlapMode'), attributes: { value: 'overlap' } }); select.appendChild(option1); select.appendChild(option2); container.appendChild(label); container.appendChild(select); return container; } createOverlapHeightControl() { const container = this.createElement('div', { id: 'overlapHeightContainer', style: { marginBottom: '15px', display: 'none' // Initially hidden, show only when overlap mode is selected } }); const label = this.createElement('label', { style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const labelText = this.createElement('span', { textContent: this.t('overlapHeight') }); const valueSpan = this.createElement('span', { id: 'overlapHeightValue', textContent: '150' }); const unitSpan = this.createElement('span', { textContent: 'px' }); label.appendChild(labelText); label.appendChild(valueSpan); label.appendChild(unitSpan); const slider = this.createElement('input', { id: 'overlapHeightSlider', attributes: { type: 'range', min: '50', max: '400', value: '150', step: '10' }, style: { width: '100%', height: '6px', borderRadius: '3px', background: '#555', outline: 'none', marginBottom: '10px' } }); const helpText = this.createElement('div', { textContent: '💡 仅在重叠模式下生效,控制除第一张图片外的其他图片重叠区域高度。取消显示字幕时会自动调整到较小值', style: { fontSize: '11px', color: '#888', marginTop: '5px', lineHeight: '1.3' } }); container.appendChild(label); container.appendChild(slider); container.appendChild(helpText); return container; } createTimeRangeControl() { const container = this.createElement('div', { style: { marginBottom: '15px' } }); const label = this.createElement('label', { textContent: langManager.t('timeRange'), style: { display: 'block', marginBottom: '8px', fontWeight: '500', color: '#ccc' } }); const input = this.createElement('input', { id: 'timeRangeInput', attributes: { type: 'text', placeholder: langManager.t('placeholderTimeRange') }, style: { width: '100%', padding: '10px', border: '1px solid #555', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: '14px' } }); const helpText = this.createElement('div', { textContent: langManager.t('formatHelp'), style: { fontSize: '11px', color: '#888', marginTop: '5px', lineHeight: '1.3' } }); container.appendChild(label); container.appendChild(input); container.appendChild(helpText); return container; } createBatchButton() { const container = this.createElement('div'); const button = this.createElement('button', { id: 'batchScreenshot', textContent: langManager.t('startBatch'), style: { width: '100%', padding: '16px 20px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', border: 'none', borderRadius: '12px', cursor: 'pointer', fontSize: '16px', fontWeight: '600', transition: 'all 0.3s ease', boxShadow: '0 4px 15px rgba(102, 126, 234, 0.3)', marginBottom: '10px', position: 'relative', overflow: 'hidden' } }); // 进度条容器 const progressContainer = this.createElement('div', { id: 'batchProgressContainer', style: { display: 'none', marginTop: '10px' } }); // 进度条 const progressBar = this.createElement('div', { style: { width: '100%', height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', overflow: 'hidden', marginBottom: '8px' } }); const progressFill = this.createElement('div', { id: 'batchProgressFill', style: { width: '0%', height: '100%', background: 'linear-gradient(90deg, #11998e 0%, #38ef7d 100%)', transition: 'width 0.3s ease', borderRadius: '3px' } }); progressBar.appendChild(progressFill); // 进度文字 const progressText = this.createElement('div', { id: 'batchProgressText', textContent: '0%', style: { fontSize: '12px', color: '#ccc', textAlign: 'center' } }); progressContainer.appendChild(progressBar); progressContainer.appendChild(progressText); // 悬停效果 button.addEventListener('mouseenter', () => { if (!button.disabled) { button.style.transform = 'translateY(-2px) scale(1.02)'; button.style.boxShadow = '0 8px 25px rgba(102, 126, 234, 0.4)'; } }); button.addEventListener('mouseleave', () => { if (!button.disabled) { button.style.transform = 'translateY(0) scale(1)'; button.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.3)'; } }); // 点击效果 button.addEventListener('mousedown', () => { if (!button.disabled) { button.style.transform = 'translateY(0) scale(0.98)'; } }); button.addEventListener('mouseup', () => { if (!button.disabled) { button.style.transform = 'translateY(-2px) scale(1.02)'; } }); container.appendChild(button); container.appendChild(progressContainer); return container; } createBottomButtons() { const container = this.createElement('div', { style: { marginTop: '25px', paddingTop: '20px', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '10px' } }); const resetBtn = this.createElement('button', { id: 'resetConfig', textContent: langManager.t('resetConfig'), style: { flex: '1', padding: '12px 16px', background: 'linear-gradient(135deg, #868f96 0%, #596164 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: '600', transition: 'all 0.3s ease', boxShadow: '0 2px 8px rgba(134, 143, 150, 0.3)' } }); const saveBtn = this.createElement('button', { id: 'saveConfig', textContent: langManager.t('saveConfig'), style: { flex: '1', padding: '12px 16px', background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: '600', transition: 'all 0.3s ease', boxShadow: '0 2px 8px rgba(17, 153, 142, 0.3)' } }); // 底部按钮悬停效果 resetBtn.addEventListener('mouseenter', () => { resetBtn.style.transform = 'translateY(-1px)'; resetBtn.style.boxShadow = '0 4px 12px rgba(134, 143, 150, 0.4)'; }); resetBtn.addEventListener('mouseleave', () => { resetBtn.style.transform = 'translateY(0)'; resetBtn.style.boxShadow = '0 2px 8px rgba(134, 143, 150, 0.3)'; }); saveBtn.addEventListener('mouseenter', () => { saveBtn.style.transform = 'translateY(-1px)'; saveBtn.style.boxShadow = '0 4px 12px rgba(17, 153, 142, 0.4)'; }); saveBtn.addEventListener('mouseleave', () => { saveBtn.style.transform = 'translateY(0)'; saveBtn.style.boxShadow = '0 2px 8px rgba(17, 153, 142, 0.3)'; }); container.appendChild(resetBtn); container.appendChild(saveBtn); return container; } hexToRgb(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : '0, 0, 0'; } setupPanelEvents() { const panel = document.getElementById('ytFrameMasterConfig'); const closeBtn = document.getElementById('closeConfigPanel'); const header = document.getElementById('configPanelHeader'); // 关闭面板 closeBtn.addEventListener('click', () => { this.hidePanel(); }); // 拖拽功能 this.setupDragFunctionality(panel, header); // 各种设置事件 this.setupSettingsEvents(); } setupDragFunctionality(panel, header) { let isDragging = false; let dragOffset = { x: 0, y: 0 }; header.addEventListener('mousedown', (e) => { isDragging = true; dragOffset.x = e.clientX - panel.offsetLeft; dragOffset.y = e.clientY - panel.offsetTop; header.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (isDragging) { panel.style.left = (e.clientX - dragOffset.x) + 'px'; panel.style.top = (e.clientY - dragOffset.y) + 'px'; panel.style.right = 'auto'; } }); document.addEventListener('mouseup', () => { isDragging = false; header.style.cursor = 'move'; }); } setupSettingsEvents() { // 快捷键设置 document.getElementById('setHotkey').addEventListener('click', () => { const input = document.getElementById('hotkeyInput').value; if (input && /^[a-zA-Z]$/.test(input)) { GM_setValue('screenshotKey', input.toLowerCase()); document.getElementById('currentHotkey').textContent = input.toUpperCase(); this.showNotification(langManager.t('hotkeyUpdated') + input.toUpperCase()); } else { this.showNotification(langManager.t('invalidHotkey'), 'error'); } }); // 间隔设置 document.getElementById('intervalSlider').addEventListener('input', (e) => { const value = parseInt(e.target.value); document.getElementById('intervalValue').textContent = value; GM_setValue('captureInterval', value); }); // 语言设置 document.getElementById('langSelect').addEventListener('change', (e) => { langManager.setLanguage(e.target.value); this.showNotification(langManager.t('languageUpdated')); // 更新界面语言 this.updateInterfaceLanguage(); }); // 字幕文件上传 document.getElementById('subtitleFile').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { try { const rawData = JSON.parse(event.target.result); if (this.tool && this.tool.subtitleManager) { console.log('开始处理字幕文件...', rawData); // 使用适配器工厂获取合适的适配器 const adapter = SubtitleAdapterFactory.getAdapter(rawData, this.tool.subtitleManager.currentSite); console.log('选择的适配器:', adapter.getFormatName()); const subtitleData = adapter.parseSubtitleData(rawData); console.log('字幕数据解析成功'); // 设置字幕管理器的数据和适配器 this.tool.subtitleManager.adapter = adapter; this.tool.subtitleManager.subtitleData = subtitleData; this.tool.subtitleManager.subtitleEnabled = true; const count = adapter.getSubtitleCount(subtitleData); console.log('字幕数量:', count); document.getElementById('subtitleStatus').textContent = `已加载 (${count} 条字幕) - ${adapter.getFormatName()}`; this.showNotification(`${adapter.getFormatName()}字幕文件加载成功`); } else { console.error('字幕管理器未初始化'); throw new Error('字幕管理器未初始化'); } } catch (error) { console.error('字幕文件解析错误:', error); this.showNotification('字幕文件格式错误: ' + error.message, 'error'); } }; reader.readAsText(file); } }); // 字幕开关 document.getElementById('subtitleToggle').addEventListener('change', (e) => { if (this.tool && this.tool.subtitleManager) { this.tool.subtitleManager.subtitleEnabled = e.target.checked; } this.showNotification(`字幕功能已${e.target.checked ? '开启' : '关闭'}`); }); // 字幕在拼接图中显示开关 document.getElementById('subtitleInCompositeToggle').addEventListener('change', (e) => { if (this.tool && this.tool.subtitleManager) { this.tool.subtitleManager.setShowSubtitlesInComposite(e.target.checked); } // 自动调整重叠高度 const overlapHeightSlider = document.getElementById('overlapHeightSlider'); const overlapHeightValue = document.getElementById('overlapHeightValue'); if (overlapHeightSlider && overlapHeightValue) { if (!e.target.checked) { // 不显示字幕时,减少重叠高度到较小值 const newHeight = 80; // 不显示字幕时使用较小的重叠高度 overlapHeightSlider.value = newHeight; overlapHeightValue.textContent = newHeight; GM_setValue('overlapHeight', newHeight); // 更新 ImageComposer 实例的重叠高度设置 if (this.tool && this.tool.imageComposer) { this.tool.imageComposer.updateOverlapHeight(newHeight); } } else { // 显示字幕时,恢复到默认较大值 const newHeight = 150; // 显示字幕时使用较大的重叠高度 overlapHeightSlider.value = newHeight; overlapHeightValue.textContent = newHeight; GM_setValue('overlapHeight', newHeight); // 更新 ImageComposer 实例的重叠高度设置 if (this.tool && this.tool.imageComposer) { this.tool.imageComposer.updateOverlapHeight(newHeight); } } } this.showNotification(`拼接图中字幕已${e.target.checked ? '显示' : '隐藏'}`); }); // 字体大小 document.getElementById('fontSizeSlider').addEventListener('input', (e) => { const value = parseInt(e.target.value); document.getElementById('fontSizeValue').textContent = value; if (this.tool && this.tool.subtitleManager) { this.tool.subtitleManager.fontSize = value; } GM_setValue('subtitleFontSize', value); }); // 最大行数 document.getElementById('maxLinesSlider').addEventListener('input', (e) => { const value = parseInt(e.target.value); document.getElementById('maxLinesValue').textContent = value; if (this.tool && this.tool.subtitleManager) { this.tool.subtitleManager.maxLines = value; } GM_setValue('subtitleMaxLines', value); }); // 拼接模式 document.getElementById('compositeModeSelect').addEventListener('change', (e) => { GM_setValue('compositeMode', e.target.value); this.showNotification(`拼接模式已切换为${e.target.value === 'parallel' ? '平行' : '重叠'}模式`); // 显示/隐藏重叠高度控件 const overlapHeightContainer = document.getElementById('overlapHeightContainer'); if (overlapHeightContainer) { overlapHeightContainer.style.display = e.target.value === 'overlap' ? 'block' : 'none'; } }); // 重叠高度 document.getElementById('overlapHeightSlider').addEventListener('input', (e) => { const value = parseInt(e.target.value); document.getElementById('overlapHeightValue').textContent = value; GM_setValue('overlapHeight', value); // 更新 ImageComposer 实例的重叠高度设置 if (this.tool && this.tool.imageComposer) { this.tool.imageComposer.updateOverlapHeight(value); } }); // 快速操作按钮 document.getElementById('takeScreenshotBtn').addEventListener('click', () => { if (this.tool && this.tool.screenshotManager) { this.tool.screenshotManager.takeScreenshot(); this.showNotification(this.t('screenshotSaved')); } }); // 连拍模式按钮 document.getElementById('burstModeBtn').addEventListener('click', () => { this.showNotification(this.t('useHotkey')); }); // 批量截图 document.getElementById('batchScreenshot').addEventListener('click', () => { const timeRange = document.getElementById('timeRangeInput').value; const mode = document.getElementById('compositeModeSelect').value; const button = document.getElementById('batchScreenshot'); const progressContainer = document.getElementById('batchProgressContainer'); const progressFill = document.getElementById('batchProgressFill'); const progressText = document.getElementById('batchProgressText'); if (!timeRange) { this.showNotification(langManager.t('enterTimeRange'), 'error'); return; } if (button.disabled) { this.showNotification(langManager.t('batchInProgress'), 'error'); return; } if (this.tool && this.tool.taskManager) { // 禁用按钮和显示进度 button.disabled = true; button.textContent = langManager.t('inProgress'); button.style.background = 'linear-gradient(135deg, #868f96 0%, #596164 100%)'; button.style.cursor = 'not-allowed'; progressContainer.style.display = 'block'; progressFill.style.width = '0%'; progressText.textContent = '0% (0/0)'; // 设置进度回调 this.tool.taskManager.onProgress = (progress, completed, total) => { progressFill.style.width = progress + '%'; progressText.textContent = `${progress}% (${completed}/${total})`; }; this.tool.taskManager.batchScreenshot(timeRange, mode) .then((count) => { this.showNotification(langManager.t('batchComplete', { count: count })); progressFill.style.width = '100%'; progressText.textContent = '100% - ' + langManager.t('complete'); }) .catch((error) => { this.showNotification(langManager.t('batchFailed', { error: error.message }), 'error'); }) .finally(() => { // 恢复按钮状态 setTimeout(() => { button.disabled = false; button.textContent = langManager.t('startBatch'); button.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; button.style.cursor = 'pointer'; progressContainer.style.display = 'none'; // 清除进度回调 if (this.tool && this.tool.taskManager) { this.tool.taskManager.onProgress = null; } }, 2000); // 2秒后隐藏进度条 }); } }); // 重置配置 document.getElementById('resetConfig').addEventListener('click', () => { if (confirm('确定要重置所有配置吗?')) { GM_setValue('screenshotKey', 's'); GM_setValue('captureInterval', 1000); GM_setValue('lang', 'EN'); GM_setValue('subtitleFontSize', 48); GM_setValue('subtitleMaxLines', 2); GM_setValue('compositeMode', 'parallel'); GM_setValue('overlapHeight', 150); this.showNotification('配置已重置,请刷新页面'); } }); // 保存配置 document.getElementById('saveConfig').addEventListener('click', () => { this.showNotification('配置已保存'); }); } updateInterfaceLanguage() { // 重新创建配置面板以使用新语言 setTimeout(() => { const panel = document.getElementById('ytFrameMasterConfig'); if (panel) { const isVisible = panel.style.display !== 'none'; panel.remove(); this.createConfigPanel(); if (isVisible) { this.showPanel(); } } }, 100); } setupShortcuts() { // 检测平台并设置对应的快捷键 // Mac: Cmd+Shift+F (F for FrameMaster),其他平台: Ctrl+Shift+F // 避免与浏览器默认快捷键冲突 document.addEventListener('keydown', (e) => { const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const modifierKey = isMac ? e.metaKey : e.ctrlKey; if (modifierKey && e.shiftKey && e.key === 'F') { console.log('Shortcut key detected!'); e.preventDefault(); this.togglePanel(); } }); console.log('Shortcuts set up'); } setupTampermonkeyMenu() { // 检测平台并显示对应的快捷键提示 const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const shortcutText = isMac ? 'Cmd+Shift+F' : 'Ctrl+Shift+F'; GM_registerMenuCommand(`🎬 打开 FrameMaster 配置面板 (${shortcutText})`, () => { this.togglePanel(); }); } showPanel() { const panel = document.getElementById('ytFrameMasterConfig'); if (panel) { panel.style.display = 'block'; this.panelVisible = true; // 加载当前配置 this.loadCurrentConfig(); } } hidePanel() { const panel = document.getElementById('ytFrameMasterConfig'); if (panel) { panel.style.display = 'none'; this.panelVisible = false; } } togglePanel() { console.log('togglePanel called, current visible:', this.panelVisible); if (this.panelVisible) { this.hidePanel(); } else { this.showPanel(); } } loadCurrentConfig() { // 加载当前配置到面板 const screenshotKey = GM_getValue('screenshotKey', 's'); const interval = GM_getValue('captureInterval', 1000); const lang = GM_getValue('lang', 'EN'); const fontSize = GM_getValue('subtitleFontSize', 48); const maxLines = GM_getValue('subtitleMaxLines', 2); const compositeMode = GM_getValue('compositeMode', 'parallel'); const overlapHeight = GM_getValue('overlapHeight', 150); const showSubtitlesInComposite = GM_getValue('showSubtitlesInComposite', true); const hotkeyInput = document.getElementById('hotkeyInput'); const currentHotkey = document.getElementById('currentHotkey'); const intervalSlider = document.getElementById('intervalSlider'); const intervalValue = document.getElementById('intervalValue'); const langSelect = document.getElementById('langSelect'); const fontSizeSlider = document.getElementById('fontSizeSlider'); const fontSizeValue = document.getElementById('fontSizeValue'); const maxLinesSlider = document.getElementById('maxLinesSlider'); const maxLinesValue = document.getElementById('maxLinesValue'); const compositeModeSelect = document.getElementById('compositeModeSelect'); const overlapHeightSlider = document.getElementById('overlapHeightSlider'); const overlapHeightValue = document.getElementById('overlapHeightValue'); const overlapHeightContainer = document.getElementById('overlapHeightContainer'); const subtitleInCompositeToggle = document.getElementById('subtitleInCompositeToggle'); if (hotkeyInput) hotkeyInput.value = screenshotKey; if (currentHotkey) currentHotkey.textContent = screenshotKey.toUpperCase(); if (intervalSlider) intervalSlider.value = interval; if (intervalValue) intervalValue.textContent = interval; if (langSelect) langSelect.value = lang; if (fontSizeSlider) fontSizeSlider.value = fontSize; if (fontSizeValue) fontSizeValue.textContent = fontSize; if (maxLinesSlider) maxLinesSlider.value = maxLines; if (maxLinesValue) maxLinesValue.textContent = maxLines; if (compositeModeSelect) compositeModeSelect.value = compositeMode; if (subtitleInCompositeToggle) subtitleInCompositeToggle.checked = showSubtitlesInComposite; // 根据字幕显示状态自动调整重叠高度 let adjustedOverlapHeight = overlapHeight; if (!showSubtitlesInComposite) { adjustedOverlapHeight = Math.min(80, overlapHeight); // 不显示字幕时使用较小值 } if (overlapHeightSlider) overlapHeightSlider.value = adjustedOverlapHeight; if (overlapHeightValue) overlapHeightValue.textContent = adjustedOverlapHeight; // 显示/隐藏重叠高度控件 if (overlapHeightContainer) { overlapHeightContainer.style.display = compositeMode === 'overlap' ? 'block' : 'none'; } } showNotification(message, type = 'success') { const notification = document.createElement('div'); notification.textContent = message; const backgroundColor = type === 'error' ? '#ff6b6b' : '#38ef7d'; notification.style.cssText = ` position: fixed; top: 80px; right: 20px; background: linear-gradient(135deg, ${backgroundColor} 0%, ${backgroundColor}dd 100%); color: white; padding: 15px 20px; border-radius: 8px; z-index: 10001; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 500; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transform: translateX(400px); transition: all 0.3s ease; backdrop-filter: blur(10px); `; document.body.appendChild(notification); // 动画显示 setTimeout(() => { notification.style.transform = 'translateX(0)'; }, 10); // 自动消失 setTimeout(() => { notification.style.transform = 'translateX(400px)'; setTimeout(() => { notification.remove(); }, 300); }, 3000); } setTool(tool) { this.tool = tool; } } // 主工具类的改进版本 class EnhancedVideoScreenshotTool extends VideoScreenshotTool { constructor() { super(); this.loadConfig(); } loadConfig() { this.state.screenshotKey = GM_getValue('screenshotKey', 's'); this.state.interval = GM_getValue('captureInterval', 1000); this.state.lang = GM_getValue('lang', 'EN'); this.subtitleManager.fontSize = GM_getValue('subtitleFontSize', 48); this.subtitleManager.maxLines = GM_getValue('subtitleMaxLines', 2); this.state.compositeMode = GM_getValue('compositeMode', 'parallel'); } handleKeyDown(e) { if ( e.key.toLowerCase() === this.state.screenshotKey && !this.state.keyDown && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) ) { this.state.keyDown = true; this.screenshotManager.takeScreenshot(); this.state.intervalId = setInterval(() => { this.screenshotManager.takeScreenshot(); }, this.state.interval); } } } // 初始化工具和配置面板 const tool = new EnhancedVideoScreenshotTool(); const configPanel = new ConfigPanelManager(); // 将工具实例传递给配置面板 configPanel.setTool(tool); // 添加一个可拖拽的浮动按钮 function createFloatingButton() { console.log('Creating floating button...'); console.log('Document ready state:', document.readyState); console.log('Document body exists:', !!document.body); if (!document.body) { console.log('Body not ready, retrying in 500ms...'); setTimeout(createFloatingButton, 500); return; } // 检查是否已存在 const existingButton = document.getElementById('frameMasterFloatingBtn'); if (existingButton) { console.log('Button already exists, removing...'); existingButton.remove(); } const testButton = document.createElement('button'); testButton.textContent = '🎬'; testButton.id = 'frameMasterFloatingBtn'; testButton.title = 'FrameMaster Pro - 点击打开配置面板'; console.log('Button created:', testButton); // 设置按钮样式 testButton.style.cssText = ` position: fixed !important; top: 100px !important; right: 20px !important; width: 50px !important; height: 50px !important; background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%) !important; color: white !important; border: none !important; border-radius: 50% !important; font-size: 20px !important; cursor: grab !important; z-index: 99999 !important; box-shadow: 0 4px 15px rgba(0,0,0,0.3) !important; transition: all 0.2s ease !important; user-select: none !important; -webkit-user-select: none !important; -moz-user-select: none !important; -ms-user-select: none !important; display: block !important; visibility: visible !important; opacity: 1 !important; `; // 拖拽功能变量 let isDragging = false; let dragOffset = { x: 0, y: 0 }; let startPos = { x: 0, y: 0 }; let hasMoved = false; // 鼠标按下事件 testButton.addEventListener('mousedown', (e) => { isDragging = true; hasMoved = false; startPos.x = e.clientX; startPos.y = e.clientY; // 计算鼠标相对于按钮的偏移 const rect = testButton.getBoundingClientRect(); dragOffset.x = e.clientX - rect.left; dragOffset.y = e.clientY - rect.top; // 改变光标和样式 testButton.style.cursor = 'grabbing'; testButton.style.transform = 'scale(1.1)'; testButton.style.transition = 'none'; // 防止选择文本 e.preventDefault(); }); // 鼠标移动事件 document.addEventListener('mousemove', (e) => { if (!isDragging) return; // 计算移动距离 const moveX = Math.abs(e.clientX - startPos.x); const moveY = Math.abs(e.clientY - startPos.y); // 如果移动距离超过阈值,则认为是拖拽 if (moveX > 5 || moveY > 5) { hasMoved = true; } // 计算新位置 const newX = e.clientX - dragOffset.x; const newY = e.clientY - dragOffset.y; // 获取视口边界 const maxX = window.innerWidth - testButton.offsetWidth; const maxY = window.innerHeight - testButton.offsetHeight; // 限制在视口内 const constrainedX = Math.max(0, Math.min(newX, maxX)); const constrainedY = Math.max(0, Math.min(newY, maxY)); // 更新位置 testButton.style.left = constrainedX + 'px'; testButton.style.top = constrainedY + 'px'; testButton.style.right = 'auto'; testButton.style.bottom = 'auto'; }); // 鼠标释放事件 document.addEventListener('mouseup', () => { if (!isDragging) return; isDragging = false; testButton.style.cursor = 'grab'; testButton.style.transform = 'scale(1)'; testButton.style.transition = 'all 0.2s ease'; // 如果没有移动,延迟一点时间再重置hasMoved,避免点击事件被影响 if (!hasMoved) { setTimeout(() => { hasMoved = false; }, 100); } }); // 点击事件(只在没有拖拽时触发) testButton.addEventListener('click', (e) => { if (hasMoved) { e.preventDefault(); e.stopPropagation(); return; } console.log('Test button clicked'); configPanel.togglePanel(); }); // 悬停效果 testButton.addEventListener('mouseenter', () => { if (!isDragging) { testButton.style.transform = 'scale(1.1)'; testButton.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)'; } }); testButton.addEventListener('mouseleave', () => { if (!isDragging) { testButton.style.transform = 'scale(1)'; testButton.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)'; } }); // 添加长按提示 let longPressTimer = null; testButton.addEventListener('mousedown', (e) => { longPressTimer = setTimeout(() => { if (!hasMoved) { // 显示提示 const tooltip = document.createElement('div'); tooltip.textContent = '拖拽移动按钮位置'; tooltip.style.cssText = ` position: fixed; background: rgba(0,0,0,0.8); color: white; padding: 8px 12px; border-radius: 6px; font-size: 12px; z-index: 10000; pointer-events: none; white-space: nowrap; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; // 计算提示框位置 const rect = testButton.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2) + 'px'; tooltip.style.top = (rect.top - 35) + 'px'; tooltip.style.transform = 'translateX(-50%)'; document.body.appendChild(tooltip); // 3秒后自动消失 setTimeout(() => { if (tooltip.parentNode) { tooltip.remove(); } }, 3000); } }, 1000); }); testButton.addEventListener('mouseup', () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }); // 触摸设备支持 testButton.addEventListener('touchstart', (e) => { const touch = e.touches[0]; isDragging = true; hasMoved = false; startPos.x = touch.clientX; startPos.y = touch.clientY; // 计算鼠标相对于按钮的偏移 const rect = testButton.getBoundingClientRect(); dragOffset.x = touch.clientX - rect.left; dragOffset.y = touch.clientY - rect.top; testButton.style.cursor = 'grabbing'; testButton.style.transform = 'scale(1.1)'; testButton.style.transition = 'none'; e.preventDefault(); }); testButton.addEventListener('touchmove', (e) => { if (!isDragging) return; const touch = e.touches[0]; const moveX = Math.abs(touch.clientX - startPos.x); const moveY = Math.abs(touch.clientY - startPos.y); if (moveX > 5 || moveY > 5) { hasMoved = true; } const newX = touch.clientX - dragOffset.x; const newY = touch.clientY - dragOffset.y; const maxX = window.innerWidth - testButton.offsetWidth; const maxY = window.innerHeight - testButton.offsetHeight; const constrainedX = Math.max(0, Math.min(newX, maxX)); const constrainedY = Math.max(0, Math.min(newY, maxY)); testButton.style.left = constrainedX + 'px'; testButton.style.top = constrainedY + 'px'; testButton.style.right = 'auto'; testButton.style.bottom = 'auto'; e.preventDefault(); }); testButton.addEventListener('touchend', () => { if (!isDragging) return; isDragging = false; testButton.style.cursor = 'grab'; testButton.style.transform = 'scale(1)'; testButton.style.transition = 'all 0.2s ease'; if (!hasMoved) { setTimeout(() => { hasMoved = false; }, 100); } }); // 窗口大小改变时重新定位按钮 window.addEventListener('resize', () => { const rect = testButton.getBoundingClientRect(); const maxX = window.innerWidth - testButton.offsetWidth; const maxY = window.innerHeight - testButton.offsetHeight; if (rect.left > maxX) { testButton.style.left = maxX + 'px'; } if (rect.top > maxY) { testButton.style.top = maxY + 'px'; } }); document.body.appendChild(testButton); console.log('Draggable floating button added to body'); console.log('Button element:', testButton); console.log('Button in DOM:', document.getElementById('frameMasterFloatingBtn')); console.log('Body children count:', document.body.children.length); } // 开始创建按钮 createFloatingButton(); // 延迟显示配置面板(让用户知道有这个功能) setTimeout(() => { // 检测平台并显示对应的快捷键提示 const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const shortcutText = isMac ? 'Cmd+Shift+F' : 'Ctrl+Shift+F'; const message = GM_getValue('lang', 'ZH') === 'EN' ? `🎬 FrameMaster Pro loaded! Press ${shortcutText} to open configuration panel` : `🎬 FrameMaster Pro 已加载!按 ${shortcutText} 打开配置面板`; configPanel.showNotification(message, 'success'); }, 2000); } // 结束 init 函数 // 调用 init 函数启动脚本 init(); }) (); // 结束 IIFE