[JavaScript] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LRC歌词套件 - 合并+精准校准工具(支持单文件/多文件)</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
margin: 0;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab-btn {
flex: 1;
padding: 15px;
background: rgba(255,255,255,0.2);
border: none;
border-radius: 12px;
color: white;
font-size: 18px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.tab-btn.active {
background: white;
color: #667eea;
box-shadow: 0 5px 20px rgba(0,0,0,0.2);
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
.card {
background: white;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px 30px;
}
.card-header h2 {
margin: 0 0 5px 0;
}
.card-header p {
margin: 0;
opacity: 0.9;
}
.card-body {
padding: 30px;
}
.file-section {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.file-list {
max-height: 400px;
overflow-y: auto;
}
.file-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 12px 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.file-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-index {
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.file-name {
font-weight: bold;
word-break: break-all;
}
.file-duration {
font-size: 12px;
color: #666;
font-family: monospace;
}
.btn-group {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 5px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.btn-up { background: #6c757d; color: white; }
.btn-down { background: #6c757d; color: white; }
.btn-remove { background: #dc3545; color: white; }
.btn-add { background: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; margin-right: 10px; }
.btn-clear { background: #dc3545; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 16px; }
.btn-success { background: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.btn-info { background: #17a2b8; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.btn-warning { background: #ffc107; color: #333; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.setting-row {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.setting-row label {
font-weight: bold;
}
.setting-row input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
width: 100px;
}
.result-area {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
display: none;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.result-content {
background: #2d2d2d;
color: #f8f8f2;
border-radius: 8px;
padding: 15px;
max-height: 500px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.calibrate-section {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.calibrate-control {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.calibrate-control input {
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
width: 120px;
}
.song-select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
/* 对比视图样式 */
.compare-container {
display: flex;
gap: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.compare-panel {
flex: 1;
min-width: 300px;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.compare-panel h4 {
margin: 0;
padding: 12px 15px;
background: #667eea;
color: white;
font-size: 14px;
}
.compare-panel .diff-content {
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
max-height: 500px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.diff-highlight {
background: #ffeb3b;
color: #333;
display: inline-block;
}
.diff-added {
background: #4caf50;
color: white;
}
.mode-switch {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 10px;
background: #e9ecef;
border-radius: 10px;
}
.mode-btn {
flex: 1;
padding: 10px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
background: white;
color: #667eea;
transition: all 0.3s;
}
.mode-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.info {
background: #d1ecf1;
color: #0c5460;
padding: 12px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
}
.badge-changed {
background: #ffc107;
color: #333;
}
.undo-redo-bar {
display: flex;
gap: 10px;
margin-bottom: 15px;
padding: 10px;
background: #e9ecef;
border-radius: 8px;
align-items: center;
flex-wrap: wrap;
}
.undo-redo-bar span {
font-size: 12px;
color: #666;
margin-left: auto;
}
.file-mode-info {
background: #e7f3ff;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
footer {
text-align: center;
padding: 20px;
color: rgba(255,255,255,0.7);
font-size: 12px;
}
[url=home.php?mod=space&uid=945662]@media[/url] (max-width: 768px) {
.compare-container {
flex-direction: column;
}
.file-item {
flex-direction: column;
align-items: flex-start;
}
.calibrate-control {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body>
<div class="container">
<div class="tabs">
<button class="tab-btn active">🔗 工具1:合并歌词(添加标识符)</button>
<button class="tab-btn">⚡ 工具2:歌词校准器(支持单文件/多文件)</button>
</div>
<!-- 工具1:合并工具 -->
<div id="mergeTab" class="tab-pane active">
<div class="card">
<div class="card-header">
<h2>🎵 多LRC歌词合并工具</h2>
<p>合并多个LRC文件,自动添加标识符 #01#、#02# 等,用于后续精准校准</p>
</div>
<div class="card-body">
<div id="mergeError" class="error"></div>
<div class="file-section">
<div style="margin-bottom: 15px;">
<button class="btn-add" id="addFileBtn">➕ 添加LRC文件</button>
<button class="btn-clear" id="clearAllBtn">🗑️ 清空全部</button>
</div>
<div id="fileListContainer" class="file-list">
<div class="empty-state">📂 暂无文件,点击上方按钮添加LRC歌词文件</div>
</div>
</div>
<div class="setting-row">
<label>⏱️ 歌曲间间隔:</label>
<input type="number" id="gapSeconds" value="0" step="0.1" min="0">
<span class="hint">秒(每两首歌之间的静音间隔)</span>
</div>
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-top: 20px;">
<button class="btn-primary" id="mergeBtn">🔗 合并并添加标识符</button>
</div>
<div class="result-area" id="mergeResultArea">
<div class="result-header">
<h3>📄 合并结果(含标识符)</h3>
<button class="btn-success" id="downloadMergeBtn">💾 下载LRC文件</button>
</div>
<div class="result-content" id="mergeResultContent"></div>
</div>
<div class="info">
💡 说明:<br>
1. 合并后的歌词会自动添加标识符行,格式为 <code>#01#</code>、<code>#02#</code> 等<br>
2. 这些标识符标记每首歌的开始位置,供工具2的多文件校准模式使用<br>
3. 标识符不会影响歌词显示,播放器会忽略无法解析的行<br>
<h3>💡 特别说明:一般歌词中并没有歌曲的最终时间,所以要手动在每首歌词后添加歌曲的时间,这样子才能保证后面的歌词对得上</h3>
</div>
</div>
</div>
</div>
<!-- 工具2:校准工具(支持单文件/多文件) -->
<div id="calibrateTab" class="tab-pane">
<div class="card">
<div class="card-header">
<h2>⚡ 歌词校准器</h2>
<p>支持单文件模式(校准单个LRC)和多文件模式(校准带#01#标识符的合并文件)| 支持撤销/重做</p>
</div>
<div class="card-body">
<div id="calibrateError" class="error"></div>
<!-- 模式切换 -->
<div class="mode-switch">
<button class="mode-btn active" id="singleModeBtn">📄 单文件模式</button>
<button class="mode-btn" id="multiModeBtn">🎵 多文件模式(标识符)</button>
</div>
<div class="calibrate-section">
<div style="margin-bottom: 15px;">
<label style="font-weight: bold;">📂 选择LRC文件:</label>
<input type="file" id="lrcFileInput" accept=".lrc,.txt" style="display:block; margin-top: 10px;">
</div>
<!-- 单文件模式信息 -->
<div id="singleModeInfo" class="file-mode-info">
<span>📄 单文件模式:直接对整个LRC文件进行时间偏移校准</span>
<span class="badge" style="background:#17a2b8; color:white;">整体偏移</span>
</div>
<!-- 多文件模式信息 -->
<div id="multiModeInfo" class="file-mode-info" style="display: none;">
<span>🎵 多文件模式:检测到 <span id="songCount">0</span> 首歌,可选择单首校准</span>
<span class="badge" style="background:#ffc107; color:#333;">逐首偏移</span>
</div>
<!-- 撤销/重做工具栏 -->
<div class="undo-redo-bar">
<button class="btn-sm" id="undoBtn" style="background:#6c757d; color:white;" disabled>↩️ 撤销 (Ctrl+Z)</button>
<button class="btn-sm" id="redoBtn" style="background:#6c757d; color:white;" disabled>↪️ 重做 (Ctrl+Y)</button>
<span id="historyStatus">📝 暂无操作历史</span>
</div>
<!-- 单文件模式控制 -->
<div id="singleControls" class="calibrate-control">
<label>⏱️ 整体时间偏移:</label>
<input type="number" id="singleOffsetValue" value="0" step="0.1" placeholder="秒数">
<span class="hint">正数=向后推迟,负数=向前提前</span>
</div>
<!-- 多文件模式控制 -->
<div id="multiControls" class="calibrate-control" style="display: none;">
<label>🎵 选择要校准的歌曲:</label>
<select id="songSelect" class="song-select" style="min-width: 150px;">
<option value="">请先加载LRC文件</option>
</select>
<label>⏱️ 时间偏移:</label>
<input type="number" id="multiOffsetValue" value="0" step="0.1" placeholder="秒数">
</div>
<div class="calibrate-control">
<button class="btn-info" id="previewBtn">👁️ 预览并对比</button>
<button class="btn-primary" id="applyOffsetBtn">✅ 应用偏移</button>
</div>
<div class="calibrate-control">
<label>⚡ 快捷调整:</label>
<button class="btn-sm" style="background:#17a2b8; color:white;">+0.5秒</button>
<button class="btn-sm" style="background:#17a2b8; color:white;">-0.5秒</button>
<button class="btn-sm" style="background:#17a2b8; color:white;">+5秒</button>
<button class="btn-sm" style="background:#17a2b8; color:white;">-5秒</button>
<button class="btn-sm" style="background:#6c757d; color:white;">重置为0</button>
</div>
</div>
<!-- 左右对比视图 -->
<div id="compareView" style="display: none;">
<div class="compare-container">
<div class="compare-panel">
<h4>📜 修改前(原始歌词)</h4>
<div class="diff-content" id="originalContent"></div>
</div>
<div class="compare-panel">
<h4>✨ 修改后(校准后)<span id="changeBadge" class="badge" style="margin-left: 10px;"></span></h4>
<div class="diff-content" id="modifiedContent"></div>
</div>
</div>
<div style="text-align: center; margin-top: 15px;">
<button class="btn-success" id="downloadCalibrateBtn">💾 下载校准后的LRC文件</button>
</div>
</div>
<div class="info">
💡 使用说明:<br>
<strong>单文件模式:</strong>加载任意LRC文件,对整个歌词文件进行时间偏移校准(适合调整整首歌的歌词同步问题)<br>
<strong>多文件模式:</strong>加载工具1生成的带标识符的LRC文件,可单独校准某一首歌的时间<br>
<strong>撤销/重做:</strong>每次应用偏移都会保存历史,支持 Ctrl+Z 撤销 / Ctrl+Y 重做<br>
<strong>对比视图:</strong>左右窗口实时显示修改前后的差异,黄色/绿色高亮显示变化
</div>
</div>
</div>
</div>
<footer>
纯本地运行,你的文件不会被上传到任何服务器 | 支持标准LRC格式 [mm:ss.xx] 和 [mm:ss:xx]
</footer>
</div>
<script>
// ==================== 工具1:合并工具 ====================
let songsList = [];
function parseTimeToSeconds(timeStr) {
const match = timeStr.match(/\[(\d+):(\d+)(?:[.:](\d+))?\]/);
if (match) {
const minutes = parseInt(match[1]);
const seconds = parseInt(match[2]);
const centiseconds = match[3] ? parseInt(match[3]) : 0;
let fractional = centiseconds >= 100 ? centiseconds / 1000 : centiseconds / 100;
return minutes * 60 + seconds + fractional;
}
return null;
}
function formatSecondsToLrcTime(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(2);
const secsStr = secs.padStart(5, '0');
return `[${minutes.toString().padStart(2, '0')}:${secsStr.replace('.', ':')}]`;
}
function parseLrc(content) {
const lines = content.split(/\r?\n/);
const lyrics = [];
let maxTime = 0;
for (let line of lines) {
line = line.trim();
if (line === '') continue;
const timePattern = /\[\d+:\d+(?:[.:]\d+)?\]/g;
const timeTags = line.match(timePattern);
if (timeTags) {
let text = line;
for (const tag of timeTags) text = text.replace(tag, '');
text = text.trim();
for (const tag of timeTags) {
const seconds = parseTimeToSeconds(tag);
if (seconds !== null) {
if (seconds > maxTime) maxTime = seconds;
lyrics.push({ timeTag: tag, seconds: seconds, text: text });
}
}
} else {
lyrics.push({ timeTag: null, seconds: null, text: line, isMetadata: true });
}
}
return { lyrics, maxTime };
}
function shiftLyrics(lyrics, offsetSeconds) {
const shifted = [];
for (const item of lyrics) {
if (item.isMetadata || item.seconds === null) {
shifted.push(item);
} else {
const newSeconds = item.seconds + offsetSeconds;
shifted.push({ timeTag: formatSecondsToLrcTime(newSeconds), seconds: newSeconds, text: item.text });
}
}
return shifted;
}
function lyricsToLines(lyrics) {
const lines = [];
for (const item of lyrics) {
if (item.isMetadata) lines.push(item.text);
else lines.push(`${item.timeTag}${item.text}`);
}
return lines;
}
async function readFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(e);
reader.readAsText(file, 'UTF-8');
});
}
async function addFiles(files) {
const filesArray = Array.from(files);
for (const file of filesArray) {
if (!file.name.match(/\.(lrc|txt)$/i)) {
showMergeError(`文件 "${file.name}" 不是LRC/TXT格式,已跳过`);
continue;
}
try {
const content = await readFileContent(file);
const { maxTime } = parseLrc(content);
songsList.push({ file, content, name: file.name, duration: maxTime });
} catch (err) {
showMergeError(`读取失败: ${err.message}`);
}
}
renderFileList();
}
function removeFile(index) { songsList.splice(index, 1); renderFileList(); }
function moveUp(index) { if (index > 0) { [songsList[index-1], songsList[index]] = [songsList[index], songsList[index-1]]; renderFileList(); } }
function moveDown(index) { if (index < songsList.length-1) { [songsList[index], songsList[index+1]] = [songsList[index+1], songsList[index]]; renderFileList(); } }
function renderFileList() {
const container = document.getElementById('fileListContainer');
if (songsList.length === 0) {
container.innerHTML = '<div class="empty-state">📂 暂无文件,点击上方按钮添加LRC歌词文件</div>';
return;
}
let html = '';
for (let i = 0; i < songsList.length; i++) {
const song = songsList[i];
const durationStr = formatSecondsToLrcTime(song.duration);
html += `
<div class="file-item">
<div class="file-info">
<span class="file-index">#${i+1}</span>
<span class="file-name">📄 ${escapeHtml(song.name)}</span>
<span class="file-duration">时长: ${durationStr}</span>
</div>
<div class="btn-group">
<button class="btn-sm btn-up" ${i===0?'disabled':''}>▲上移</button>
<button class="btn-sm btn-down" ${i===songsList.length-1?'disabled':''}>▼下移</button>
<button class="btn-sm btn-remove">移除</button>
</div>
</div>
`;
}
container.innerHTML = html;
}
function mergeWithMarkers() {
if (songsList.length === 0) { showMergeError('请先添加LRC文件'); return null; }
const gapSeconds = parseFloat(document.getElementById('gapSeconds').value) || 0;
let currentOffset = 0;
let allLines = [];
let totalDuration = 0;
for (let i = 0; i < songsList.length; i++) {
const song = songsList[i];
const { lyrics } = parseLrc(song.content);
const shifted = shiftLyrics(lyrics, currentOffset);
const lines = lyricsToLines(shifted);
const marker = `#${String(i+1).padStart(2, '0')}#`;
const markerTime = formatSecondsToLrcTime(currentOffset);
allLines.push(`${markerTime} ${marker}`);
allLines.push(...lines);
currentOffset += song.duration + gapSeconds;
totalDuration = currentOffset - gapSeconds;
}
const timed = [], other = [];
for (const line of allLines) {
const match = line.match(/^\[\d+:\d+(?:[.:]\d+)?\]/);
if (match) {
const sec = parseTimeToSeconds(match[0]);
if (sec !== null) timed.push({ line, seconds: sec });
else other.push(line);
} else other.push(line);
}
timed.sort((a,b) => a.seconds - b.seconds);
const finalLines = [...other, ...timed.map(t => t.line)];
const header = [`[ti:合并歌词 (${songsList.length}首)]`, `[by:LRC合并工具]`, `[total:${formatSecondsToLrcTime(totalDuration)}]`, ``];
return header.join('\n') + '\n' + finalLines.join('\n');
}
function showMergeError(msg, type = 'error') {
const div = document.getElementById('mergeError');
div.style.background = type === 'success' ? '#d4edda' : '#f8d7da';
div.style.color = type === 'success' ? '#155724' : '#721c24';
div.textContent = msg;
div.style.display = 'block';
setTimeout(() => div.style.display = 'none', 3000);
}
function escapeHtml(str) { return str.replace(/[&<>]/g, m => ({'&':'&','<':'<','>':'>'}[m])); }
// 工具1事件绑定
document.getElementById('addFileBtn')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.lrc,.txt'; input.multiple = true;
input.onchange = async e => { if(e.target.files.length) await addFiles(e.target.files); };
input.click();
});
document.getElementById('clearAllBtn')?.addEventListener('click', () => {
if(songsList.length && confirm('清空全部?')) { songsList = []; renderFileList(); document.getElementById('mergeResultArea').style.display = 'none'; }
});
document.getElementById('mergeBtn')?.addEventListener('click', () => {
const result = mergeWithMarkers();
if(result) {
document.getElementById('mergeResultArea').style.display = 'block';
document.getElementById('mergeResultContent').textContent = result;
window.mergedResult = result;
showMergeError('合并完成!可下载文件', 'success');
}
});
document.getElementById('downloadMergeBtn')?.addEventListener('click', () => {
if(window.mergedResult) {
const blob = new Blob([window.mergedResult], {type:'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `merged_${Date.now()}.lrc`;
a.click(); URL.revokeObjectURL(url);
} else showMergeError('请先合并');
});
window.moveUp = moveUp; window.moveDown = moveDown; window.removeFile = removeFile;
// ==================== 工具2:校准工具(支持单文件/多文件) ====================
let currentMode = 'single'; // 'single' 或 'multi'
let originalLrcContent = null;
let parsedSongs = [];
let currentCalibratedContent = null;
// 历史记录栈
let historyStack = [];
let historyIndex = -1;
let maxHistorySize = 50;
function saveToHistory(description) {
if (!originalLrcContent) return;
if (historyIndex < historyStack.length - 1) {
historyStack = historyStack.slice(0, historyIndex + 1);
}
historyStack.push({
content: originalLrcContent,
description: description,
timestamp: new Date().toLocaleTimeString()
});
if (historyStack.length > maxHistorySize) {
historyStack.shift();
} else {
historyIndex = historyStack.length - 1;
}
updateHistoryUI();
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
const state = historyStack[historyIndex];
originalLrcContent = state.content;
currentCalibratedContent = state.content;
if (currentMode === 'multi') {
const { songs } = parseMarkedLrc(originalLrcContent);
parsedSongs = songs;
updateSongSelect();
}
refreshCompareView();
showCalibrateError(`↩️ 撤销: ${state.description}`, 'success');
updateHistoryUI();
} else {
showCalibrateError('没有更早的操作记录了', 'error');
}
}
function redo() {
if (historyIndex < historyStack.length - 1) {
historyIndex++;
const state = historyStack[historyIndex];
originalLrcContent = state.content;
currentCalibratedContent = state.content;
if (currentMode === 'multi') {
const { songs } = parseMarkedLrc(originalLrcContent);
parsedSongs = songs;
updateSongSelect();
}
refreshCompareView();
showCalibrateError(`↪️ 重做: ${state.description}`, 'success');
updateHistoryUI();
} else {
showCalibrateError('没有更晚的操作记录了', 'error');
}
}
function updateHistoryUI() {
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
const historyStatus = document.getElementById('historyStatus');
undoBtn.disabled = (historyIndex <= 0);
redoBtn.disabled = (historyIndex >= historyStack.length - 1);
if (historyStack.length === 0) {
historyStatus.innerHTML = '📝 暂无操作历史';
} else {
const current = historyStack[historyIndex];
historyStatus.innerHTML = `📝 当前: ${current.description} (${current.timestamp}) | 共 ${historyStack.length} 条记录`;
}
}
// 对整个LRC文件进行偏移(单文件模式)
function shiftWholeLrc(content, offsetSeconds) {
const { lyrics } = parseLrc(content);
const shifted = shiftLyrics(lyrics, offsetSeconds);
const lines = lyricsToLines(shifted);
// 保留头部元数据(如果有)
const header = [];
for (const line of content.split('\n')) {
if (line.match(/^\[(ti|ar|al|by|offset|total|ve)/i)) {
header.push(line);
} else {
break;
}
}
const result = [...new Set(header), ...lines].join('\n');
return { modified: result, changes: [{ description: `整体偏移 ${offsetSeconds}秒` }] };
}
// 多文件模式:解析带标识符的LRC
function parseMarkedLrc(content) {
const lines = content.split(/\r?\n/);
const songs = [];
let currentSong = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const markerMatch = line.match(/#(\d+)#/);
if (markerMatch) {
if (currentSong) {
currentSong.endLine = i - 1;
songs.push(currentSong);
}
const num = parseInt(markerMatch[1]);
const timeMatch = line.match(/^(\[[\d:.]+])/);
const startTime = timeMatch ? parseTimeToSeconds(timeMatch[1]) : 0;
currentSong = {
index: num,
startLine: i,
endLine: -1,
startTime: startTime,
identifierLine: line,
identifierTimeTag: timeMatch ? timeMatch[1] : null
};
}
}
if (currentSong) {
currentSong.endLine = lines.length - 1;
songs.push(currentSong);
}
return { songs, lines };
}
// 多文件模式:对指定歌曲进行偏移
function applyOffsetToSong(content, songIndex, offsetSeconds) {
const { songs, lines } = parseMarkedLrc(content);
const targetSong = songs.find(s => s.index === songIndex);
if (!targetSong) return { modified: content, changes: [] };
const newLines = [...lines];
const changes = [];
// 调整标识符行
const identifierLine = newLines[targetSong.startLine];
const idTimeMatch = identifierLine.match(/^(\[[\d:.]+])/);
if (idTimeMatch && targetSong.identifierTimeTag) {
const oldTimeTag = idTimeMatch[1];
const oldTime = parseTimeToSeconds(oldTimeTag);
if (oldTime !== null) {
const newTime = formatSecondsToLrcTime(oldTime + offsetSeconds);
newLines[targetSong.startLine] = identifierLine.replace(oldTimeTag, newTime);
changes.push({ line: targetSong.startLine, old: oldTimeTag, new: newTime });
}
}
// 调整歌词行
for (let i = targetSong.startLine + 1; i < (targetSong.endLine + 1) && i < newLines.length; i++) {
let line = newLines[i];
const timePattern = /\[\d+:\d+(?:[.:]\d+)?\]/g;
const times = line.match(timePattern);
if (times) {
let modifiedLine = line;
for (const timeTag of times) {
const oldSec = parseTimeToSeconds(timeTag);
if (oldSec !== null) {
const newSec = oldSec + offsetSeconds;
const newTag = formatSecondsToLrcTime(newSec);
modifiedLine = modifiedLine.replace(timeTag, newTag);
changes.push({ line: i, old: timeTag, new: newTag });
}
}
newLines[i] = modifiedLine;
}
}
return { modified: newLines.join('\n'), changes };
}
function updateSongSelect() {
const songSelect = document.getElementById('songSelect');
if (parsedSongs.length > 0) {
const currentValue = songSelect.value;
songSelect.innerHTML = parsedSongs.map(s => `<option value="${s.index}">🎵 第 ${s.index} 首歌(标识符 #${String(s.index).padStart(2,'0')}#)</option>`).join('');
if (currentValue && parsedSongs.some(s => s.index == currentValue)) {
songSelect.value = currentValue;
}
document.getElementById('songCount').innerHTML = parsedSongs.length;
}
}
function refreshCompareView() {
if (originalLrcContent) {
const { originalHtml } = generateDiffHtml(originalLrcContent, originalLrcContent, []);
document.getElementById('originalContent').innerHTML = originalHtml;
document.getElementById('modifiedContent').innerHTML = originalHtml;
document.getElementById('compareView').style.display = 'block';
document.getElementById('changeBadge').innerHTML = '📊 未修改';
}
}
function generateDiffHtml(original, modified, changes) {
if (!original || !modified) return { originalHtml: '', modifiedHtml: '' };
const originalLines = original.split(/\r?\n/);
const modifiedLines = modified.split(/\r?\n/);
const maxLines = Math.max(originalLines.length, modifiedLines.length);
let originalHtml = '';
let modifiedHtml = '';
const changeMap = new Map();
for (const change of changes) {
if (change.line !== undefined) {
if (!changeMap.has(change.line)) changeMap.set(change.line, []);
changeMap.get(change.line).push(change);
}
}
for (let i = 0; i < maxLines; i++) {
const origLine = originalLines[i] || '';
const modLine = modifiedLines[i] || '';
const lineChanges = changeMap.get(i) || [];
let origDisplay = escapeHtml(origLine);
let modDisplay = escapeHtml(modLine);
for (const change of lineChanges) {
const oldEscaped = escapeHtml(change.old);
const newEscaped = escapeHtml(change.new);
origDisplay = origDisplay.replace(oldEscaped, `<span class="diff-highlight">${oldEscaped}</span>`);
modDisplay = modDisplay.replace(newEscaped, `<span class="diff-added">${newEscaped}</span>`);
}
originalHtml += `<div><span style="color: #888; display: inline-block; width: 40px;">${i+1}</span> ${origDisplay}</div>`;
modifiedHtml += `<div><span style="color: #888; display: inline-block; width: 40px;">${i+1}</span> ${modDisplay}</div>`;
}
return { originalHtml, modifiedHtml };
}
function previewAndCompare() {
if (!originalLrcContent) { showCalibrateError('请先加载LRC文件'); return; }
let modified, changes;
if (currentMode === 'single') {
const offset = parseFloat(document.getElementById('singleOffsetValue').value) || 0;
const result = shiftWholeLrc(originalLrcContent, offset);
modified = result.modified;
changes = result.changes;
} else {
const songIndex = parseInt(document.getElementById('songSelect').value);
const offset = parseFloat(document.getElementById('multiOffsetValue').value) || 0;
if (isNaN(songIndex)) { showCalibrateError('请选择要校准的歌曲'); return; }
const result = applyOffsetToSong(originalLrcContent, songIndex, offset);
modified = result.modified;
changes = result.changes;
}
currentCalibratedContent = modified;
const { originalHtml, modifiedHtml } = generateDiffHtml(originalLrcContent, modified, changes);
document.getElementById('originalContent').innerHTML = originalHtml;
document.getElementById('modifiedContent').innerHTML = modifiedHtml;
document.getElementById('compareView').style.display = 'block';
const changeCount = changes.length;
const badge = document.getElementById('changeBadge');
badge.innerHTML = changeCount > 0 ? `📊 将修改 ${changeCount} 处时间标签` : '无变化';
}
function applyAndSave() {
if (!originalLrcContent) { showCalibrateError('请先加载LRC文件'); return; }
let modified, changes, description;
if (currentMode === 'single') {
const offset = parseFloat(document.getElementById('singleOffsetValue').value) || 0;
const result = shiftWholeLrc(originalLrcContent, offset);
modified = result.modified;
changes = result.changes;
description = `整体偏移 ${offset >= 0 ? '+' : ''}${offset}秒`;
} else {
const songIndex = parseInt(document.getElementById('songSelect').value);
const offset = parseFloat(document.getElementById('multiOffsetValue').value) || 0;
if (isNaN(songIndex)) { showCalibrateError('请选择要校准的歌曲'); return; }
const result = applyOffsetToSong(originalLrcContent, songIndex, offset);
modified = result.modified;
changes = result.changes;
description = `第${songIndex}首歌偏移 ${offset >= 0 ? '+' : ''}${offset}秒`;
}
saveToHistory(description);
originalLrcContent = modified;
currentCalibratedContent = modified;
if (currentMode === 'multi') {
const { songs } = parseMarkedLrc(originalLrcContent);
parsedSongs = songs;
updateSongSelect();
}
const { originalHtml, modifiedHtml } = generateDiffHtml(originalLrcContent, originalLrcContent, []);
document.getElementById('originalContent').innerHTML = originalHtml;
document.getElementById('modifiedContent').innerHTML = modifiedHtml;
showCalibrateError(`✅ 已应用: ${description},修改 ${changes.length} 处`, 'success');
}
function downloadCalibrated() {
if (!currentCalibratedContent) { showCalibrateError('请先预览或应用偏移'); return; }
const blob = new Blob([currentCalibratedContent], {type:'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `calibrated_${Date.now()}.lrc`;
a.click(); URL.revokeObjectURL(url);
showCalibrateError('下载成功!', 'success');
}
// 文件加载
document.getElementById('lrcFileInput')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
originalLrcContent = await readFileContent(file);
currentCalibratedContent = originalLrcContent;
// 检测是否有标识符
const hasMarkers = /#\d+#/.test(originalLrcContent);
if (hasMarkers && currentMode === 'single') {
// 提示用户可以切换到多文件模式
showCalibrateError('检测到标识符 #01# 等,建议切换到「多文件模式」进行逐首校准', 'success');
}
if (currentMode === 'multi') {
const { songs } = parseMarkedLrc(originalLrcContent);
parsedSongs = songs;
if (songs.length === 0) {
showCalibrateError('未检测到标识符 #01#、#02# 等,请使用工具1生成或切换到「单文件模式」', 'error');
} else {
updateSongSelect();
document.getElementById('multiModeInfo').style.display = 'flex';
document.getElementById('songCount').innerHTML = songs.length;
}
}
// 重置历史
historyStack = [];
historyIndex = -1;
saveToHistory('初始加载');
refreshCompareView();
showCalibrateError(`加载成功!${currentMode === 'multi' ? `检测到 ${parsedSongs.length} 首歌` : '单文件模式'}`, 'success');
} catch (err) {
showCalibrateError('读取失败:' + err.message);
}
});
function quickOffset(delta) {
const offsetInput = currentMode === 'single' ? document.getElementById('singleOffsetValue') : document.getElementById('multiOffsetValue');
const current = parseFloat(offsetInput.value) || 0;
offsetInput.value = (current + delta).toFixed(1);
previewAndCompare();
}
function resetOffset() {
const offsetInput = currentMode === 'single' ? document.getElementById('singleOffsetValue') : document.getElementById('multiOffsetValue');
offsetInput.value = '0';
previewAndCompare();
}
function showCalibrateError(msg, type = 'error') {
const div = document.getElementById('calibrateError');
div.style.background = type === 'success' ? '#d4edda' : '#f8d7da';
div.style.color = type === 'success' ? '#155724' : '#721c24';
div.textContent = msg;
div.style.display = 'block';
setTimeout(() => div.style.display = 'none', 3000);
}
// 模式切换
function setMode(mode) {
currentMode = mode;
const singleModeBtn = document.getElementById('singleModeBtn');
const multiModeBtn = document.getElementById('multiModeBtn');
const singleModeInfo = document.getElementById('singleModeInfo');
const multiModeInfo = document.getElementById('multiModeInfo');
const singleControls = document.getElementById('singleControls');
const multiControls = document.getElementById('multiControls');
if (mode === 'single') {
singleModeBtn.classList.add('active');
multiModeBtn.classList.remove('active');
singleModeInfo.style.display = 'flex';
multiModeInfo.style.display = 'none';
singleControls.style.display = 'flex';
multiControls.style.display = 'none';
} else {
singleModeBtn.classList.remove('active');
multiModeBtn.classList.add('active');
singleModeInfo.style.display = 'none';
multiModeInfo.style.display = 'flex';
singleControls.style.display = 'none';
multiControls.style.display = 'flex';
// 如果已有内容,重新检测标识符
if (originalLrcContent) {
const { songs } = parseMarkedLrc(originalLrcContent);
parsedSongs = songs;
if (songs.length > 0) {
updateSongSelect();
document.getElementById('songCount').innerHTML = songs.length;
showCalibrateError(`检测到 ${songs.length} 首歌`, 'success');
} else {
showCalibrateError('当前文件没有标识符,请使用工具1生成或加载带标识符的文件', 'error');
}
}
}
}
// 绑定事件
document.getElementById('previewBtn')?.addEventListener('click', previewAndCompare);
document.getElementById('applyOffsetBtn')?.addEventListener('click', applyAndSave);
document.getElementById('downloadCalibrateBtn')?.addEventListener('click', downloadCalibrated);
document.getElementById('undoBtn')?.addEventListener('click', undo);
document.getElementById('redoBtn')?.addEventListener('click', redo);
document.getElementById('singleModeBtn')?.addEventListener('click', () => setMode('single'));
document.getElementById('multiModeBtn')?.addEventListener('click', () => setMode('multi'));
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
}
if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'Z')) {
e.preventDefault();
redo();
}
});
window.quickOffset = quickOffset;
window.resetOffset = resetOffset;
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
if (tab === 'merge') {
document.querySelector('.tab-btn:first-child').classList.add('active');
document.getElementById('mergeTab').classList.add('active');
} else {
document.querySelector('.tab-btn:last-child').classList.add('active');
document.getElementById('calibrateTab').classList.add('active');
}
}
window.switchTab = switchTab;
</script>
</body>
</html>