// ==UserScript==
// @name 网盘外挂字幕
// @namespace https://www.so1st.com
// @version 0.6.1
// @license GPL-3.0
// @description 网盘播放视频时外挂本地字幕(夸克),按Q键+100ms,按W键-100ms,按E键显示/隐藏字幕
// @author awkee
// @match https://pan.quark.cn/*
// @match https://www.alipan.com/*
// @match https://www.aliyundrive.com/*
// @match https://pan.baidu.com/*
// @match https://pan.xunlei.com/*
// @match https://www.iqiyi.com/*
// @match https://www.ixigua.com/*
// @match https://v.youku.com/*
// @grant none
// @downloadURL https://update.greasyfork.icu/scripts/536720/%E7%BD%91%E7%9B%98%E5%A4%96%E6%8C%82%E5%AD%97%E5%B9%95.user.js
// @updateURL https://update.greasyfork.icu/scripts/536720/%E7%BD%91%E7%9B%98%E5%A4%96%E6%8C%82%E5%AD%97%E5%B9%95.meta.js
// ==/UserScript==
(function () {
'use strict';
// 样式配置
const styleConfig = {
fontFamily: "Microsoft YaHei, SimHei, WenQuanYi Micro Hei, sans-serif",
fontSize: "32px",
foregroundColor: "#00FF00",
backgroundColor: "rgba(0, 0, 0, 0)", // 初始透明背景(rgba 0不透明)
displayMode: "horizontal",
horizontalAlign: "left",
verticalAlign: "bottom",
writingMode: "horizontal-tb",
subtitlePadding: "100px", // 字幕边距
verticalTextSpacing: "0.5em", // 垂直文字间距
verticalTextOrientation: "upright", // 文字方向
verticalLineHeight: "1.8", // 垂直线高度
forceVerticalMode: false, // 强制垂直模式
};
// 创建控制面板
function createControlPanel() {
const panel = document.createElement("div");
panel.id = "subtitle-control-panel";
panel.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
display: flex;
align-items: center;
background-color: ` + styleConfig.backgroundColor + `;
padding: 8px 12px;
border-radius: 6px;
cursor: move; /* 设置鼠标指针样式为移动 */
`;
// 创建设置图标按钮
const settingsIcon = createButton("⚙", "settings-icon", toggleButtonsVisibility);
panel.appendChild(settingsIcon);
// 创建文件选择器
const fileInput = document.createElement("input");
fileInput.type = 'file';
fileInput.accept = '.srt,.vtt,.ass,.ssa';
fileInput.style.cssText = 'display: none;';
fileInput.id = 'subtitle-file-input';
panel.appendChild(fileInput);
// 加载按钮
const loadBtn = createButton("加载字幕", "load-btn");
loadBtn.addEventListener('click', () => fileInput.click());
panel.appendChild(loadBtn);
// 重置按钮
const resetBtn = createButton("重置字幕", "reset-btn");
resetBtn.addEventListener('click', resetSubtitles);
panel.appendChild(resetBtn);
// 样式设置按钮
const styleBtn = createButton("样式设置", "style-btn");
styleBtn.addEventListener('click', toggleStyleMenu);
panel.appendChild(styleBtn);
// 样式菜单
const styleMenu = createStyleMenu();
panel.appendChild(styleMenu);
// 添加鼠标拖拽事件
setupDragging(panel);
return panel;
}
// 设置拖拽功能(当菜单折叠时才支持拖拽)
function setupDragging(element) {
let keyPressed = false;
let offsetX, offsetY;
// 长按3s才会触发拖拽
setTimeout(() => {
element.addEventListener('mousedown', (e) => {
if (document.getElementById('load-btn').style.display!== 'none') {
return;
} // 当菜单折叠时才支持拖拽
keyPressed = true;
offsetX = e.clientX - parseInt(element.style.left);
offsetY = e.clientY - parseInt(element.style.top);
});
}, 3000);
document.addEventListener('mousemove', (e) => {
if (keyPressed) {
element.style.left = (e.clientX - offsetX) + 'px';
element.style.top = (e.clientY - offsetY) + 'px';
}
});
document.addEventListener('mouseup', (e) => {
e.preventDefault(); // 阻止默认行为
keyPressed = false;
})
}
// 创建按钮
function createButton(text, id, clickHandler) {
const container = document.createElement("div");
container.style.cssText = `
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
`;
const btn = document.createElement("button");
btn.id = id;
btn.textContent = text;
btn.style.cssText = `
background-color: #4CAF50;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
`;
btn.addEventListener('mouseover', () => btn.style.backgroundColor = '#45a049');
btn.addEventListener('mouseout', () => btn.style.backgroundColor = '#4CAF50');
if (clickHandler) {
btn.addEventListener('click', clickHandler);
}
container.appendChild(btn);
return container;
}
// 创建样式菜单
function createStyleMenu() {
const menu = document.createElement("div");
menu.id = "subtitle-style-menu";
menu.style.cssText = `
display: none;
position: absolute;
top: 100%;
left: 0;
margin-top: 5px;
background-color: rgba(0, 0, 0, 0.7);
padding: 10px;
border-radius: 6px;
width: 300px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
`;
// 字体选择
const fontFamilySelect = createStyleSelect(
"font-family",
["Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", "Arial", "sans-serif"],
"字体"
);
menu.appendChild(fontFamilySelect);
// 字体大小滑块
const fontSizeSelect = createSlidebar("font-size", "字体大小", "12", "100", "32", "1");
menu.appendChild(fontSizeSelect);
// 边距滑块
const subtitlePaddingSelect = createSlidebar("padding", "边距", "0", "300", "100", "1");
menu.appendChild(subtitlePaddingSelect);
// 前景色
const foregroundColorSelect = createColorPicker("foreground-color", "前景色", "100");
menu.appendChild(foregroundColorSelect);
// 背景色
const backgroundColorSelect = createColorPicker("background-color", "背景色", "0");
menu.appendChild(backgroundColorSelect);
// 显示模式
const displayModeSelect = createStyleSelect(
"display-mode",
["horizontal", "vertical"],
"显示模式"
);
menu.appendChild(displayModeSelect);
// 垂直文字配置组
const verticalGroup = document.createElement("div");
verticalGroup.style.cssText = "margin-top: 15px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.2);";
const verticalLabel = document.createElement("h4");
verticalLabel.textContent = "垂直文字设置";
verticalLabel.style.cssText = "color: white; margin-bottom: 10px;";
verticalGroup.appendChild(verticalLabel);
// 垂直文字间距
const verticalTextSpacing = createSlidebar(
"verticalTextSpacing",
"文字间距",
"0",
"2",
"0.5",
"0.1"
);
verticalGroup.appendChild(verticalTextSpacing);
// 垂直线高
const verticalLineHeight = createSlidebar(
"verticalLineHeight",
"行高",
"1",
"3",
"1.8",
"0.1"
);
verticalGroup.appendChild(verticalLineHeight);
// 文字方向
const textOrientationSelect = createStyleSelect(
"verticalTextOrientation",
["mixed", "upright", "sideways"],
"文字方向"
);
verticalGroup.appendChild(textOrientationSelect);
// 强制垂直模式
const forceVerticalMode = document.createElement("div");
forceVerticalMode.style.cssText = "margin-top: 10px;";
const forceVerticalLabel = document.createElement("label");
forceVerticalLabel.style.cssText = "color: white;";
const forceVerticalCheckbox = document.createElement("input");
forceVerticalCheckbox.type = "checkbox";
forceVerticalCheckbox.id = "subtitle-force-vertical-mode";
forceVerticalCheckbox.checked = styleConfig.forceVerticalMode;
forceVerticalLabel.appendChild(forceVerticalCheckbox);
forceVerticalLabel.appendChild(document.createTextNode(" 强制垂直模式(解决覆盖问题)"));
forceVerticalMode.appendChild(forceVerticalLabel);
verticalGroup.appendChild(forceVerticalMode);
// 添加到菜单
menu.appendChild(verticalGroup);
// 对齐(根据display-mode动态显示)
const alignmentContainer = document.createElement("div");
alignmentContainer.id = "alignment-container";
menu.appendChild(alignmentContainer);
// 水平对齐
const horizontalAlignSelect = createStyleSelect(
"horizontal-align",
["left", "center", "right"],
"水平对齐"
);
horizontalAlignSelect.style.display = 'none'; // 初始隐藏
alignmentContainer.appendChild(horizontalAlignSelect);
// 垂直对齐
const verticalAlignSelect = createStyleSelect(
"vertical-align",
["top", "middle", "bottom"],
"垂直对齐"
);
verticalAlignSelect.style.display = 'none'; // 初始隐藏
alignmentContainer.appendChild(verticalAlignSelect);
// 监听显示模式变化
displayModeSelect.addEventListener('change', () => {
const selectedMode = displayModeSelect.querySelector('select').value;
horizontalAlignSelect.style.display = selectedMode === 'vertical' ? 'block' : 'none';
verticalGroup.style.display = selectedMode ==='vertical'? 'block' : 'none';
verticalAlignSelect.style.display = selectedMode === 'horizontal' ? 'block' : 'none';
});
// 初始设置
displayModeSelect.querySelector('select').value = styleConfig.displayMode;
displayModeSelect.dispatchEvent(new Event('change'));
// 应用初始对齐设置
horizontalAlignSelect.querySelector('select').value = styleConfig.horizontalAlign;
verticalAlignSelect.querySelector('select').value = styleConfig.verticalAlign;
// 应用按钮
const applyBtn = document.createElement("button");
applyBtn.textContent = "应用样式";
applyBtn.style.cssText = `
background-color: #2196F3;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
width: 100%;
transition: background-color 0.3s;
`;
applyBtn.addEventListener('mouseover', () => applyBtn.style.backgroundColor = '#0b7dda');
applyBtn.addEventListener('mouseout', () => applyBtn.style.backgroundColor = '#2196F3');
applyBtn.addEventListener('click', applyStyleSettings);
menu.appendChild(applyBtn);
return menu;
}
// 创建样式选择器
function createStyleSelect(type, options, labelText) {
const container = document.createElement("div");
container.style.cssText = "margin-bottom: 10px;";
const label = document.createElement("label");
label.textContent = labelText;
label.style.cssText = "display: block; margin-bottom: 5px; color: white;";
container.appendChild(label);
const select = document.createElement("select");
select.id = `subtitle-${type}`;
select.style.cssText = "width: 100%; padding: 5px; border-radius: 4px;";
// 将连字符命名转换为驼峰式(如 "font-family" → "fontFamily")
const camelKey = type.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
options.forEach(option => {
const opt = document.createElement("option");
opt.value = option;
opt.textContent = option;
if (styleConfig[camelKey] === option) { // 关键修复:使用驼峰键匹配
opt.selected = true;
}
select.appendChild(opt);
});
container.appendChild(select);
return container;
}
function createSlidebar(id, labelText, min, max, defaultValue, step) {
const container = document.createElement("div");
container.style.cssText = "margin-bottom: 10px;";
// 选择器容器
const slidWrapper = document.createElement("div");
slidWrapper.style.display = "flex";
slidWrapper.style.gap = "10px";
slidWrapper.style.alignItems = "center";
const label = document.createElement("label");
label.textContent = labelText;
label.style.cssText = "display: block; margin-bottom: 5px; color: white;";
container.appendChild(label);
const slidebar = document.createElement("input");
slidebar.type = "range";
slidebar.min = min;
slidebar.max = max;
slidebar.step = step;
slidebar.value = defaultValue;
slidebar.style.cssText = "width: 100%;";
slidebar.id = `subtitle-${id}`;
slidWrapper.appendChild(slidebar);
container.appendChild(slidWrapper);
return container;
}
function createColorPicker(type, labelText, alpha) {
const container = document.createElement("div");
container.style.cssText = "margin-bottom: 10px;";
const label = document.createElement("label");
label.textContent = labelText;
label.style.cssText = "display: block; margin-bottom: 5px; color: white;";
container.appendChild(label);
// 颜色选择器容器
const colorWrapper = document.createElement("div");
colorWrapper.style.display = "flex";
colorWrapper.style.gap = "10px";
colorWrapper.style.alignItems = "center";
// 颜色预览块
const preview = document.createElement("div");
preview.style.width = "30px";
preview.style.height = "30px";
preview.style.borderRadius = "4px";
preview.style.border = "2px solid #fff";
// 颜色选择输入
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.style.flex = "1";
// 透明度滑块
const alphaInput = document.createElement("input");
alphaInput.type = "range";
alphaInput.min = "0";
alphaInput.max = "100";
alphaInput.value = alpha;
alphaInput.style.width = "80px";
alphaInput.style.accentColor = "#4CAF50";
// 初始化颜色值
const initialColor = type === "foreground-color" ? "#00FF00" : "#000000";
colorInput.value = initialColor;
preview.style.backgroundColor = `${initialColor}${Math.round(alphaInput.value * 255 / 100).toString(16).padStart(2, '0')}`;
// 事件监听
const updateColor = () => {
const alpha = parseInt(alphaInput.value) / 100;
preview.style.backgroundColor = `${colorInput.value}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`;
};
colorInput.addEventListener("input", updateColor);
alphaInput.addEventListener("input", updateColor);
colorWrapper.appendChild(preview);
colorWrapper.appendChild(colorInput);
colorWrapper.appendChild(alphaInput);
container.appendChild(colorWrapper);
// 存储元素的引用
container._colorInput = colorInput;
container._alphaInput = alphaInput;
container.id = `subtitle-${type}`;
return container;
}
// 切换加载、重置和样式设置按钮的显示与隐藏
function toggleButtonsVisibility() {
const loadBtn = document.getElementById('load-btn');
const resetBtn = document.getElementById('reset-btn');
const styleBtn = document.getElementById('style-btn');
const subtitleStyleMenu = document.getElementById('subtitle-style-menu');
const isVisible = loadBtn.style.display !== 'none';
loadBtn.style.display = isVisible ? 'none' : 'block';
resetBtn.style.display = isVisible ? 'none' : 'block';
styleBtn.style.display = isVisible ? 'none' : 'block';
if (isVisible) {
subtitleStyleMenu.style.display = 'none';
}
}
// 切换样式菜单显示/隐藏
function toggleStyleMenu() {
const menu = document.getElementById("subtitle-style-menu");
menu.style.display = menu.style.display === "block" ? "none" : "block";
}
// 应用样式设置
function applyStyleSettings() {
const fontFamily = document.getElementById("subtitle-font-family").value;
const fontSize = document.getElementById("subtitle-font-size").value + "px";
const padding = document.getElementById("subtitle-padding").value + "px";
const displayMode = document.getElementById("subtitle-display-mode").value;
const horizontalAlign = document.getElementById("subtitle-horizontal-align").value;
const verticalAlign = document.getElementById("subtitle-vertical-align").value;
console.log("应用样式设置:", styleConfig);
const getRGBA = (colorPicker) => {
const color = colorPicker._colorInput.value;
const alpha = parseInt(colorPicker._alphaInput.value) / 100;
return `rgba(${parseInt(color.substr(1, 2), 16)},${parseInt(color.substr(3, 2), 16)},${parseInt(color.substr(5, 2), 16)},${alpha})`;
};
const foregroundColor = getRGBA(document.getElementById("subtitle-foreground-color"));
const backgroundColor = getRGBA(document.getElementById("subtitle-background-color"));
// 更新配置
styleConfig.fontFamily = fontFamily;
styleConfig.fontSize = fontSize;
styleConfig.foregroundColor = foregroundColor;
styleConfig.backgroundColor = backgroundColor;
styleConfig.displayMode = displayMode;
styleConfig.horizontalAlign = horizontalAlign;
styleConfig.verticalAlign = verticalAlign;
styleConfig.subtitlePadding = padding;
styleConfig.verticalTextSpacing = document.getElementById("subtitle-verticalTextSpacing").value + "em";
styleConfig.verticalTextOrientation = document.getElementById("subtitle-verticalTextOrientation").value;
styleConfig.verticalLineHeight = document.getElementById("subtitle-verticalLineHeight").value;
styleConfig.forceVerticalMode = document.getElementById("subtitle-force-vertical-mode").checked;
// 应用到字幕元素
if (subtitleElement) {
subtitleElement.style.fontFamily = fontFamily;
subtitleElement.style.fontSize = fontSize;
subtitleElement.style.color = foregroundColor;
subtitleElement.style.backgroundColor = backgroundColor;
// 应用显示模式和对齐方式
applyDisplayMode();
}
showSnackbar("字幕样式已更新");
}
// 应用显示模式和对齐方式
function applyDisplayMode() {
if (!subtitleElement || !videoElement) return;
const videoRect = videoElement.getBoundingClientRect();
// 重置样式
subtitleElement.style.removeProperty('writing-mode');
subtitleElement.style.removeProperty('text-orientation');
subtitleElement.style.removeProperty('left');
subtitleElement.style.removeProperty('right');
subtitleElement.style.removeProperty('top');
subtitleElement.style.removeProperty('bottom');
subtitleElement.style.removeProperty('transform');
subtitleElement.style.removeProperty('text-align');
subtitleElement.style.removeProperty('white-space');
subtitleElement.style.removeProperty('letter-spacing');
subtitleElement.style.removeProperty('line-height');
// 根据显示模式调整边距
if (styleConfig.displayMode === 'horizontal') {
if (styleConfig.verticalAlign === 'top') {
subtitleElement.style.top = styleConfig.subtitlePadding;
} else if (styleConfig.verticalAlign === 'bottom') {
subtitleElement.style.bottom = styleConfig.subtitlePadding;
} else {
// 居中对齐
subtitleElement.style.top = '50%';
subtitleElement.style.transform = 'translateY(-50%)';
}
// 水平显示时,居中对齐
subtitleElement.style.left = '50%';
subtitleElement.style.transform = 'translate(-50%, -50%)';
subtitleElement.style.textAlign = 'center';
subtitleElement.style.writingMode = 'horizontal-tb';
subtitleElement.style.whiteSpace = 'pre-wrap';
// 水平文字的默认样式
subtitleElement.style.letterSpacing = 'normal';
subtitleElement.style.lineHeight = 'normal';
subtitleElement.style.display = 'block';
} else { // 垂直显示
if (styleConfig.horizontalAlign === 'left') {
subtitleElement.style.left = styleConfig.subtitlePadding;
} else if (styleConfig.horizontalAlign === 'right') {
// 靠右侧显示时,需要调整边距
subtitleElement.style.right = styleConfig.subtitlePadding;
} else {
// 居中显示时,需要调整边距
subtitleElement.style.left = '50%';
subtitleElement.style.transform = 'translateX(-50%)';
}
// 垂直显示时,居中对齐
subtitleElement.style.top = '50%';
subtitleElement.style.transform = 'translate(-50%, -50%)';
subtitleElement.style.textAlign = 'center';
subtitleElement.style.writingMode = 'vertical-rl';
subtitleElement.style.whiteSpace = 'pre-wrap';
// 针对垂直文字添加特殊样式
subtitleElement.style.textOrientation = styleConfig.verticalTextOrientation;
subtitleElement.style.lineHeight = styleConfig.verticalLineHeight;
subtitleElement.style.letterSpacing = styleConfig.verticalTextSpacing;
// 强制垂直模式(解决某些浏览器的布局问题)
if (styleConfig.forceVerticalMode) {
subtitleElement.style.display = 'flex';
subtitleElement.style.flexDirection = styleConfig.writingMode === 'vertical-rl' ? 'column-reverse' : 'column';
}
}
}
// 重置字幕
function resetSubtitles() {
if (subtitleElement) {
subtitleElement.innerHTML = "";
subtitleElement.style.opacity = "0";
}
subtitleData = [];
timeDelay = 0;
showSnackbar("字幕已重置");
}
// 状态变量
let isFirstFile = false;
let isNewFile = false;
let isSubtitleVisible = true;
let timeDelay = 0;
// DOM 元素
let videoElement, timeDisplayElement, subtitleElement, snackbarElement;
let subtitleObserver;
let subtitleData = [];
// 内联字幕解析器
const SubtitleParser = {
parseSRT: function (content) {
const srtRegex = /(\d+)\r?\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\r?\n([\s\S]*?)(?:\r?\n\r?\n|$)/g;
const subtitles = [];
let match;
while ((match = srtRegex.exec(content)) !== null) {
const index = parseInt(match[1]);
const startTime = this.timeToMS(match[2]);
const endTime = this.timeToMS(match[3]);
const text = match[4].trim();
subtitles.push({
index,
start: startTime,
end: endTime,
text: text.replace(/\n/g, '
')
});
}
return subtitles;
},
parseVTT: function (content) {
// 移除WEBVTT头部
content = content.replace(/^WEBVTT\r?\n/, '');
const vttRegex = /^(\d{2}:\d{2}:\d{2}.\d{3}) --> (\d{2}:\d{2}:\d{2}.\d{3})(?: [^\r\n]*)?\r?\n([\s\S]*?)(?:\r?\n\r?\n|$)/gm;
const subtitles = [];
let index = 1;
let match;
while ((match = vttRegex.exec(content)) !== null) {
const startTime = this.timeToMS(match[1].replace(',', '.'));
const endTime = this.timeToMS(match[2].replace(',', '.'));
const text = match[3].trim();
subtitles.push({
index: index++,
start: startTime,
end: endTime,
text: text.replace(/\n/g, '
')
});
}
return subtitles;
},
parseASS: function (content) {
// 优化正则表达式:支持Dialogue行前空格,更灵活匹配字段
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
// Example: Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,{\\i1}Hello, World!{\\i0}
const assRegex = /Dialogue:\s*[^,]*,([^,]*),([^,]*),[^,]*,[^,]*,[^,]*,[^,]*,[^,]*,[^,]*,(.*)$/gm;
const subtitles = [];
let index = 1;
let match;
while ((match = assRegex.exec(content)) !== null) {
const startTime = this.timeToMS(this.assTimeToSRT(match[1]));
const endTime = this.timeToMS(this.assTimeToSRT(match[2]));
let text = match[3].trim();
// 增强文本处理:支持更多换行符转义,优化标签清理
text = text.replace(/{[^}]*}/g, ''); // 清理ASS样式标签
text = text.replace(/\\[Nn]/g, '
'); // 同时处理\N和\n换行符(大小写不敏感)
text = text.replace(/\\\{/g, '{'); // 恢复转义的{符号(如果有)
text = text.replace(/\\\}/g, '}'); // 恢复转义的}符号(如果有)
subtitles.push({
index: index++,
start: startTime,
end: endTime,
text: text
});
}
return subtitles;
},
timeToMS: function (timeString) {
// 支持两种格式: 00:00:00,000 和 00:00:00.000
const parts = timeString.replace(',', ':').replace('.', ':').split(':');
if (parts.length < 3) return 0;
const hours = parseInt(parts[0]) * 3600000;
const minutes = parseInt(parts[1]) * 60000;
const seconds = parseInt(parts[2]) * 1000;
const milliseconds = parts.length > 3 ? parseInt(parts[3]) : 0;
return hours + minutes + seconds + milliseconds;
},
assTimeToSRT: function (assTime) {
// 将ASS时间格式 (0:00:00.00) 转换为SRT格式 (00:00:00,000)
const parts = assTime.split(':');
if (parts.length < 3) return assTime;
const hours = parts[0].padStart(2, '0');
const minutes = parts[1].padStart(2, '0');
const secondsAndMillis = parts[2].replace('.', ',');
return `${hours}:${minutes}:${secondsAndMillis.padEnd(6, '0')}`;
},
parse: function (content) {
if (/^\s*WEBVTT/.test(content)) {
return this.parseVTT(content);
} else if (/Dialogue:/.test(content)) {
return this.parseASS(content);
} else {
return this.parseSRT(content);
}
}
};
// 初始化
function initialize() {
// 创建控制面板
const controlPanel = createControlPanel();
document.body.prepend(controlPanel);
// 文件选择事件
document.getElementById('subtitle-file-input').onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
if (isFirstFile) {
isNewFile = true;
if (subtitleObserver) subtitleObserver.disconnect();
} else {
setupUIElements();
setupKeyboardShortcuts();
}
isFirstFile = true;
loadSubtitles(file);
toggleButtonsVisibility();
};
// 页面加载完成后检查是否有视频
window.addEventListener('load', () => {
// 如果页面已经有视频,尝试初始化
if (document.querySelector('video')) {
// 延迟一下,确保页面完全加载
setTimeout(() => {
if (!isFirstFile) {
showSnackbar('请选择字幕文件 (.srt, .vtt, .ass)');
}
}, 1000);
}
});
}
// 设置UI元素
function setupUIElements() {
// 查找视频元素
videoElement = document.querySelector('video');
if (!videoElement) {
showSnackbar('未找到视频元素');
return;
}
// 创建字幕元素
subtitleElement = document.createElement('div');
subtitleElement.id = 'quark-custom-subtitle';
subtitleElement.style.cssText = `
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
text-align: center;
font-size: ${styleConfig.fontSize};
font-weight: bold;
color: ${styleConfig.foregroundColor};
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
transition: opacity 0.3s ease;
pointer-events: none;
max-width: 80%;
background-color: ${styleConfig.backgroundColor};
font-family: ${styleConfig.fontFamily};
white-space: pre-wrap;
`;
// 创建提示条
snackbarElement = document.createElement('div');
snackbarElement.id = 'quark-subtitle-snackbar';
snackbarElement.style.cssText = `
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
font-family: ${styleConfig.fontFamily};
`;
// 将元素添加到页面
document.body.appendChild(subtitleElement);
document.body.appendChild(snackbarElement);
// 监听全屏变化
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
// 初始定位
updateSubtitlePosition();
}
// 处理全屏变化
function handleFullscreenChange() {
updateSubtitlePosition();
// 检查是否在全屏模式下
const isFullscreen = document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
// 如果是全屏模式,确保字幕容器在视频上方
if (isFullscreen && videoElement && subtitleElement) {
// 将字幕元素移到视频元素的父容器中,确保与视频在同一层级
const videoParent = videoElement.parentElement;
if (videoParent && subtitleElement.parentElement !== videoParent) {
videoParent.appendChild(subtitleElement);
}
// 更新字幕位置,相对于视频元素
updateSubtitlePosition();
} else if (subtitleElement && subtitleElement.parentElement !== document.body) {
// 如果退出全屏,将字幕元素放回body中
document.body.appendChild(subtitleElement);
updateSubtitlePosition();
}
}
// 设置键盘快捷键
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
const key = e.key.toUpperCase();
// 忽略输入框中的按键
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (key === 'Q') {
timeDelay += 100;
showSnackbar(`时间偏移: +${timeDelay}ms`);
} else if (key === 'W') {
timeDelay -= 100;
showSnackbar(`时间偏移: ${timeDelay}ms`);
} else if (key === 'E') {
isSubtitleVisible = !isSubtitleVisible;
subtitleElement.style.opacity = isSubtitleVisible ? '1' : '0';
showSnackbar(isSubtitleVisible ? '字幕已显示' : '字幕已隐藏');
}
});
}
// 显示提示条
function showSnackbar(message) {
if (!snackbarElement) return;
snackbarElement.textContent = message;
snackbarElement.style.opacity = '1';
setTimeout(() => {
snackbarElement.style.opacity = '0';
}, 2000);
}
// 加载字幕文件 - 优化编码检测
function loadSubtitles(file) {
detectEncoding(file).then(encoding => {
const reader = new FileReader();
reader.onload = (e) => {
try {
subtitleData = SubtitleParser.parse(e.target.result);
if (subtitleData.length === 0) {
throw new Error('未解析到字幕内容');
}
showSnackbar(`字幕加载成功: ${file.name} (${subtitleData.length} 条,编码: ${encoding})`);
startSubtitleDisplay();
} catch (error) {
showSnackbar(`字幕解析失败: ${error.message}`);
console.error('字幕解析错误:', error);
}
};
reader.onerror = () => showSnackbar('文件读取失败');
// 根据检测到的编码读取文件
reader.readAsText(file, encoding);
}).catch(error => {
showSnackbar(`编码检测失败: ${error.message},尝试使用默认编码`);
const reader = new FileReader();
reader.onload = (e) => {
try {
subtitleData = SubtitleParser.parse(e.target.result);
if (subtitleData.length === 0) {
throw new Error('未解析到字幕内容');
}
showSnackbar(`字幕加载成功: ${file.name} (${subtitleData.length} 条,使用默认编码)`);
startSubtitleDisplay();
} catch (error) {
showSnackbar(`字幕解析失败: ${error.message}`);
console.error('字幕解析错误:', error);
}
};
reader.onerror = () => showSnackbar('文件读取失败');
reader.readAsText(file);
});
}
// 改进的编码检测
function detectEncoding(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target.result;
// 检测BOM标记
const view = new DataView(buffer);
if (view.byteLength >= 3 && view.getUint8(0) === 0xEF && view.getUint8(1) === 0xBB && view.getUint8(2) === 0xBF) {
resolve('utf-8');
return;
}
// 转换为文本进行字符分析
let content = '';
try {
content = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
resolve('utf-8');
return;
} catch (e) {
// 不是有效的UTF-8,尝试GBK
}
try {
// 尝试使用GBK解码
content = new TextDecoder('gbk').decode(buffer);
// 检查是否包含中文字符
const chineseChars = /[\u4e00-\u9fa5]/;
if (chineseChars.test(content)) {
resolve('gbk');
return;
}
} catch (e) {
// 不是有效的GBK
}
// 默认使用UTF-8
resolve('utf-8');
};
reader.onerror = () => reject(new Error('无法读取文件'));
// 读取前4096字节进行编码检测
reader.readAsArrayBuffer(file.slice(0, 4096));
});
}
// 开始显示字幕
function startSubtitleDisplay() {
if (!videoElement || subtitleData.length === 0) return;
// 停止之前的观察者
if (subtitleObserver) subtitleObserver.disconnect();
// 创建基于时间的更新机制
let lastTime = -1;
setInterval(() => {
if (!videoElement || !isSubtitleVisible) return;
const currentTime = videoElement.currentTime * 1000 + timeDelay;
// 只有时间变化时才更新字幕
if (Math.abs(currentTime - lastTime) > 50) {
updateSubtitles(currentTime);
lastTime = currentTime;
}
}, 100);
// 初始更新
updateSubtitles(videoElement.currentTime * 1000 + timeDelay);
} 1
// 更新字幕显示
function updateSubtitles(currentTime) {
if (!subtitleElement || subtitleData.length === 0) return;
// 二分查找当前时间对应的字幕
let index = binarySearchSubtitles(currentTime);
if (index >= 0) {
subtitleElement.innerHTML = subtitleData[index].text;
subtitleElement.style.opacity = isSubtitleVisible ? '1' : '0';
} else {
subtitleElement.style.opacity = '0';
}
}
// 二分查找字幕
function binarySearchSubtitles(time) {
let low = 0;
let high = subtitleData.length - 1;
while (low <= high) {
let mid = Math.floor((low + high) / 2);
let subtitle = subtitleData[mid];
if (time >= subtitle.start && time <= subtitle.end) {
return mid; // 找到匹配的字幕
} else if (time < subtitle.start) {
high = mid - 1; // 时间在左侧
} else {
low = mid + 1; // 时间在右侧
}
}
return -1; // 没有找到匹配的字幕
}
// 更新字幕位置
function updateSubtitlePosition() {
if (!subtitleElement || !videoElement) return;
// 应用显示模式和对齐方式
applyDisplayMode();
}
initialize();
})();