// ==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