<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLAC 歌词嵌入 · LRCLib</title>
<style>
:root {
--bg: #08080f;
--surface: #111118;
--surface2: #181820;
--border: #222230;
--text: #e0e0e8;
--text2: #8888a0;
--accent: #a78bfa;
--accent2: #7c5cfc;
--green: #34d399;
--gold: #fbbf24;
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 28px 16px;
color: var(--text);
}
.container {
width: 100%;
max-width: 960px;
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
text-align: center;
padding: 8px 0 4px;
}
.header h1 {
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.4px;
background: linear-gradient(135deg, #a78bfa, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header .sub {
font-size: 0.82rem;
color: var(--text2);
margin-top: 2px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
}
.card-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text2);
margin-bottom: 12px;
font-weight: 600;
}
.drop-wrapper {
position: relative;
}
.drop-wrapper input[type="file"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.drop-zone {
background: var(--surface2);
border: 2px dashed #2a2a3e;
border-radius: var(--radius);
padding: 32px 20px;
text-align: center;
transition: all 0.2s;
pointer-events: none;
}
.drop-wrapper.drag-over .drop-zone {
border-color: var(--accent);
background: #1a1a28;
box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.15);
}
.drop-wrapper:hover .drop-zone {
border-color: var(--accent);
background: #1a1a28;
box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.06);
}
.drop-zone .dz-icon {
font-size: 2.4rem;
margin-bottom: 8px;
opacity: 0.8;
}
.drop-zone .dz-title {
font-weight: 600;
font-size: 0.95rem;
}
.drop-zone .dz-hint {
font-size: 0.78rem;
color: var(--text2);
margin-top: 4px;
}
.file-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-family: 'SF Mono', 'Consolas', monospace;
color: #c0c0d0;
}
.chip .chip-tag {
font-size: 0.64rem;
background: #1e1e30;
color: var(--accent);
padding: 2px 7px;
border-radius: 10px;
font-weight: 500;
}
.chip .chip-del {
cursor: pointer;
color: #666;
font-weight: 700;
font-size: 1rem;
line-height: 1;
margin-left: 2px;
transition: color 0.15s;
}
.chip .chip-del:hover {
color: #f87171;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.row.mt {
margin-top: 12px;
}
.input {
flex: 1;
min-width: 180px;
padding: 11px 16px;
border-radius: var(--radius-xs);
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
}
.input:focus {
border-color: var(--accent);
}
.input::placeholder {
color: #555;
}
.btn {
padding: 10px 20px;
border-radius: 24px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 5px;
font-family: inherit;
letter-spacing: 0.2px;
}
.btn:hover {
background: #222238;
border-color: #444;
}
.btn-primary {
background: var(--accent2);
border-color: var(--accent2);
color: #fff;
box-shadow: 0 4px 18px rgba(124, 92, 252, 0.25);
}
.btn-primary:hover {
background: #8f6fff;
border-color: #8f6fff;
box-shadow: 0 6px 24px rgba(124, 92, 252, 0.35);
}
.btn-sm {
padding: 6px 14px;
font-size: 0.76rem;
border-radius: 18px;
}
.btn-xs {
padding: 4px 10px;
font-size: 0.7rem;
border-radius: 14px;
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.hint {
font-size: 0.72rem;
color: var(--text2);
margin-top: 6px;
font-style: italic;
}
.results-box {
display: none;
max-height: 280px;
overflow-y: auto;
margin-top: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface2);
}
.results-box.open {
display: block;
}
.result-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
transition: background 0.12s;
gap: 10px;
flex-wrap: wrap;
}
.result-row:hover {
background: #1e1e2c;
}
.result-row+.result-row {
border-top: 1px solid rgba(255, 255, 255, 0.03);
}
.result-row.selected {
background: #1a1830;
border-left: 3px solid var(--accent);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-info .rtrack {
font-weight: 600;
font-size: 0.88rem;
}
.result-info .rartist {
font-size: 0.76rem;
color: var(--text2);
}
.result-meta {
font-size: 0.7rem;
color: #555;
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--text2);
padding: 28px;
font-size: 0.85rem;
}
.spinner-wrap {
text-align: center;
padding: 28px;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.lyrics-panel {
display: none;
margin-top: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.lyrics-panel.open {
display: block;
}
.lyrics-top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
background: #14141e;
border-bottom: 1px solid var(--border);
}
.lyrics-top .ltitle {
font-weight: 700;
font-size: 1rem;
}
.lyrics-top .lartist {
color: var(--accent);
font-size: 0.82rem;
}
.lyrics-top .lalbum {
color: var(--text2);
font-size: 0.76rem;
}
.tabs {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
.tab {
padding: 5px 12px;
border-radius: 16px;
border: 1px solid var(--border);
background: transparent;
color: var(--text2);
cursor: pointer;
font-size: 0.72rem;
font-weight: 500;
transition: 0.2s;
font-family: inherit;
}
.tab.on {
background: var(--accent2);
border-color: var(--accent2);
color: #fff;
}
.lyrics-content {
padding: 16px;
max-height: 340px;
overflow-y: auto;
background: #0c0c16;
font-family: 'SF Mono', 'Fira Code', 'Consolas', 'PingFang SC', monospace;
font-size: 0.8rem;
line-height: 1.75;
white-space: pre-wrap;
color: #c0c0d0;
}
.lyrics-content .hl-tag {
color: var(--accent);
font-weight: 600;
}
.lyrics-content .hl-idx {
color: #666;
}
.lyrics-content .hl-time {
color: var(--green);
}
.lyrics-content .hl-section {
color: var(--gold);
}
.lyrics-content .hl-style {
color: #60a5fa;
}
.check-row {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
padding: 10px 0;
font-size: 0.76rem;
color: var(--text2);
}
.check-row label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
}
.check-row input[type="checkbox"] {
accent-color: var(--accent2);
width: 15px;
height: 15px;
cursor: pointer;
}
.action-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.tag-chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tag-chip {
padding: 4px 10px;
border-radius: 14px;
border: 1px solid var(--border);
background: transparent;
color: var(--text2);
cursor: pointer;
font-size: 0.68rem;
font-weight: 500;
transition: 0.2s;
user-select: none;
font-family: inherit;
}
.tag-chip.on {
background: var(--accent2);
border-color: var(--accent2);
color: #fff;
}
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 10px 22px;
border-radius: 24px;
font-weight: 600;
font-size: 0.82rem;
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
letter-spacing: 0.2px;
}
.toast.show {
opacity: 1;
}
.toast.ok {
background: #065f46;
color: #d1fae5;
box-shadow: 0 8px 28px rgba(5, 150, 105, 0.3);
}
.toast.err {
background: #7f1d1d;
color: #fecaca;
box-shadow: 0 8px 28px rgba(220, 38, 38, 0.3);
}
.footer-note {
text-align: center;
font-size: 0.7rem;
color: #555;
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
@media (max-width: 640px) {
.row {
flex-direction: column;
align-items: stretch;
}
.btn {
justify-content: center;
}
.action-row {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎵 FLAC 歌词嵌入工具</h1>
<div class="sub">LRCLib 歌词搜索 · Vorbis Comment 标签写入 · 元数据覆盖</div>
</div>
<div class="card">
<div class="card-label">📂 选择 FLAC 文件</div>
<div class="drop-wrapper" id="dropWrapper">
<input type="file" id="fileInput" accept=".flac,audio/flac" multiple>
<div class="drop-zone">
<div class="dz-icon">🎶</div>
<div class="dz-title">点击选择或拖拽 FLAC 文件</div>
<div class="dz-hint">支持批量 · 自动读取 TITLE / ARTIST / ALBUM 标签</div>
</div>
</div>
<div class="file-chips" id="fileChips"></div>
<div class="row mt">
<button class="btn btn-sm" id="clearFilesBtn" disabled>🗑 清空</button>
<span style="font-size:0.76rem;color:var(--text2);" id="fileStatus">等待添加 FLAC…</span>
</div>
</div>
<div class="card">
<div class="card-label">🔍 搜索歌词</div>
<div class="row">
<input class="input" id="searchInput" placeholder="歌曲名 或「歌曲名 - 歌手名」" autocomplete="off">
<button class="btn btn-primary" id="searchBtn">搜索</button>
<button class="btn btn-sm" id="directBtn">🎯 精确获取</button>
</div>
<div class="hint" id="autoFillHint"></div>
<div class="results-box" id="resultsBox">
<div id="resultsList"></div>
</div>
</div>
<div class="card" id="lyricsCard" style="display:none;">
<div class="lyrics-panel open" id="lyricsPreview">
<div class="lyrics-top">
<div>
<span class="ltitle" id="songName">—</span>
<span style="margin:0 5px;color:#555;">·</span>
<span class="lartist" id="songArtist">—</span>
<span style="margin:0 5px;color:#555;">·</span>
<span class="lalbum" id="songAlbum">—</span>
</div>
<div class="tabs">
<button class="tab on" data-fmt="lrc">LRC</button>
<button class="tab" data-fmt="srt">SRT</button>
<button class="tab" data-fmt="ass">ASS</button>
<button class="tab" data-fmt="vtt">VTT</button>
</div>
</div>
<div class="lyrics-content" id="lyricsContent"></div>
</div>
<div class="check-row">
<span>🔧 写入时覆盖:</span>
<label><input type="checkbox" id="ovTitle" checked> TITLE</label>
<label><input type="checkbox" id="ovArtist" checked> ARTIST</label>
<label><input type="checkbox" id="ovAlbum" checked> ALBUM</label>
<span style="color:#555;font-size:0.68rem;">用 API 返回信息覆盖 FLAC 标签</span>
</div>
<div class="action-row">
<span style="font-size:0.72rem;color:var(--text2);font-weight:600;">歌词标签</span>
<div class="tag-chips" id="tagChips">
<span class="tag-chip on" data-tag="LYRICS">LYRICS</span>
<span class="tag-chip" data-tag="UNSYNCEDLYRICS">UNSYNCEDLYRICS</span>
<span class="tag-chip" data-tag="LYRICS_LRC">LYRICS_LRC</span>
<span class="tag-chip" data-tag="LYRICS_SRT">LYRICS_SRT</span>
<span class="tag-chip" data-tag="LYRICS_ASS">LYRICS_ASS</span>
<span class="tag-chip" data-tag="LYRICS_VTT">LYRICS_VTT</span>
</div>
<span style="flex:1;"></span>
<button class="btn btn-primary" id="embedBtn" disabled>💾 写入并下载</button>
</div>
</div>
<div class="footer-note">浏览器安全限制:通过下载生成新文件,原文件不被修改</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function() {
var DW = document.getElementById('dropWrapper');
var FI = document.getElementById('fileInput');
var FC = document.getElementById('fileChips');
var CFB = document.getElementById('clearFilesBtn');
var FST = document.getElementById('fileStatus');
var SI = document.getElementById('searchInput');
var SB = document.getElementById('searchBtn');
var DB = document.getElementById('directBtn');
var AFH = document.getElementById('autoFillHint');
var RB = document.getElementById('resultsBox');
var RL = document.getElementById('resultsList');
var LC = document.getElementById('lyricsCard');
var LCT = document.getElementById('lyricsContent');
var SN = document.getElementById('songName');
var SA = document.getElementById('songArtist');
var SAL = document.getElementById('songAlbum');
var EB = document.getElementById('embedBtn');
var OVT = document.getElementById('ovTitle');
var OVA = document.getElementById('ovArtist');
var OVAL = document.getElementById('ovAlbum');
var TO = document.getElementById('toast');
var TABS = document.querySelectorAll('.tab');
var TCHIPS = document.querySelectorAll('.tag-chip');
var files = [];
var song = null;
var fmt = 'lrc';
var tag = 'LYRICS';
var results = [];
var selectedIdx = -1;
var tt;
function toast(m, e) {
clearTimeout(tt);
TO.textContent = m;
TO.className = 'toast show ' + (e ? 'err' : 'ok');
tt = setTimeout(function() { TO.className = 'toast'; }, 2200);
}
function esc(s) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
function p2(n) { return String(n).padStart(2, '0'); }
function p3(n) { return String(n).padStart(3, '0'); }
function readMeta(file, cb) {
var r = new FileReader();
r.onload = function(e) {
try {
var b = e.target.result;
var v = new DataView(b);
if (b.byteLength < 4) return cb({ t: null, a: null, c: null, al: null });
if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !==
'fLaC') return cb({ t: null, a: null, c: null, al: null });
var o = 4,
lb = false,
t = null,
a = null,
c = null,
al = null;
while (o < b.byteLength && !lb) {
if (o + 4 > b.byteLength) break;
var h = v.getUint8(o);
lb = (h & 0x80) !== 0;
var bt = h & 0x7F;
var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3);
o += 4;
if (bt === 4 && o + bs <= b.byteLength) {
var bl = new Uint8Array(b, o, bs);
var dec = new TextDecoder('utf-8');
if (bl.length >= 4) {
var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24);
var p = 4 + vl;
if (p + 4 <= bl.length) {
var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
p += 4;
for (var i = 0; i < nc && p + 4 <= bl.length; i++) {
var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] <<
24);
p += 4;
if (p + cl > bl.length) break;
var cs = dec.decode(bl.slice(p, p + cl));
p += cl;
var ei = cs.indexOf('=');
if (ei > 0) {
var k = cs.substring(0, ei).toUpperCase().trim();
var val = cs.substring(ei + 1).trim();
if (k === 'TITLE' && !t) t = val;
if (k === 'ARTIST' && !a) a = val;
if (k === 'COMPOSER' && !c) c = val;
if (k === 'ALBUM' && !al) al = val;
}
}
}
}
break;
}
o += bs;
}
cb({ t: t, a: a, c: c, al: al });
} catch (x) { cb({ t: null, a: null, c: null, al: null }); }
};
r.onerror = function() { cb({ t: null, a: null, c: null, al: null }); };
r.readAsArrayBuffer(file);
}
function autoFill() {
if (files.length === 0) { AFH.textContent = ''; return; }
var t = files[0].t || '';
var a = files[0].a || files[0].c || '';
if (t) {
var ft = t;
if (a) ft += ' - ' + a;
SI.value = ft;
AFH.textContent = '📋 已从 FLAC 标签自动填充:' + ft;
} else {
AFH.textContent = '⚠️ 未找到 TITLE 标签,请手动输入';
}
}
function updateFiles() {
FC.innerHTML = '';
if (files.length === 0) {
CFB.disabled = true;
FST.textContent = '等待添加 FLAC…';
EB.disabled = true;
AFH.textContent = '';
} else {
CFB.disabled = false;
FST.textContent = files.length + ' 个 FLAC 文件';
if (song) EB.disabled = false;
for (var i = 0; i < files.length; i++) {
var f = files[i];
var el = document.createElement('span');
el.className = 'chip';
var h = '🎵 ' + esc(f.file.name);
if (f.t) h += ' <span class="chip-tag">' + esc(f.t) + '</span>';
h += ' <span class="chip-del" data-idx="' + i + '">×</span>';
el.innerHTML = h;
FC.appendChild(el);
}
var dels = FC.querySelectorAll('.chip-del');
for (var d = 0; d < dels.length; d++) {
dels[d].onclick = function(e) {
e.stopPropagation();
var idx = parseInt(this.getAttribute('data-idx'), 10);
files.splice(idx, 1);
updateFiles();
autoFill();
};
}
}
}
function addFiles(fileList) {
var only = [];
for (var i = 0; i < fileList.length; i++) {
if (!fileList[i].name.toLowerCase().endsWith('.flac')) continue;
var dup = false;
for (var j = 0; j < files.length; j++) {
if (files[j].file.name === fileList[i].name &&
files[j].file.size === fileList[i].size &&
files[j].file.lastModified === fileList[i].lastModified) {
dup = true;
break;
}
}
if (!dup) only.push(fileList[i]);
}
if (only.length === 0) {
if (fileList.length > 0) toast('请选择 .flac 文件', true);
return;
}
FST.textContent = '🔍 读取元数据…';
var done = 0;
var total = only.length;
function handleOne(f, meta) {
files.push({ file: f, t: meta.t, a: meta.a, c: meta.c, al: meta.al });
done++;
if (done >= total) {
updateFiles();
autoFill();
toast('✅ 已添加 ' + total + ' 个 FLAC 文件');
if (files.length > 0 && SI.value.trim()) doSearch(SI.value);
}
}
for (var k = 0; k < only.length; k++) {
(function(fileRef) {
readMeta(fileRef, function(meta) {
handleOne(fileRef, meta);
});
})(only[k]);
}
}
function handleFileSelect(fileList) {
if (fileList && fileList.length) {
addFiles(fileList);
}
}
FI.addEventListener('change', function(e) {
handleFileSelect(e.target.files);
FI.value = '';
});
DW.addEventListener('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
DW.classList.add('drag-over');
});
DW.addEventListener('dragleave', function(e) {
e.preventDefault();
e.stopPropagation();
DW.classList.remove('drag-over');
});
DW.addEventListener('drop', function(e) {
e.preventDefault();
e.stopPropagation();
DW.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length) {
handleFileSelect(e.dataTransfer.files);
}
});
CFB.addEventListener('click', function() {
files = [];
updateFiles();
autoFill();
toast('🧹 已清空');
});
function apiSearch(q) {
return fetch('https://lrclib.net/api/search?q=' + encodeURIComponent(q)).then(function(r) {
if (!r.ok) throw new Error('搜索失败');
return r.json();
});
}
function apiGet(t, a) {
return fetch('https://lrclib.net/api/get?track_name=' + encodeURIComponent(t) + '&artist_name=' +
encodeURIComponent(a)).then(function(r) {
if (!r.ok) throw new Error('获取失败');
return r.text();
}).then(function(t) { if (!t) throw new Error('未找到'); return JSON.parse(t); });
}
function parseLrc(sl) {
var items = [];
if (!sl) return items;
var re = /\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)/g,
m;
while ((m = re.exec(sl)) !== null) {
var ms = m[3].length === 2 ? parseInt(m[3], 10) * 10 : parseInt(m[3], 10);
items.push({ tm: parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + ms, tx: (m[4] || '').trim() });
}
return items;
}
function msLrc(ms) { var mn = Math.floor(ms / 60000); return '[' + p2(mn) + ':' + p2(Math.floor((ms % 60000) /
1000)) + '.' + p2(Math.floor((ms % 1000) / 10)) + ']'; }
function msSrt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) /
60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + ',' + p3(ms % 1000); }
function msAss(ms) { var h = Math.floor(ms / 3600000); return h + ':' + p2(Math.floor((ms % 3600000) / 60000)) +
':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p2(Math.floor((ms % 1000) / 10)); }
function msVtt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) /
60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p3(ms % 1000); }
function genLRC(s) {
var it = parseLrc(s.syncedLyrics);
var l = [];
l.push('[ti:' + (s.trackName || '') + ']');
l.push('[ar:' + (s.artistName || '') + ']');
if (s.albumName) l.push('[al:' + s.albumName + ']');
if (s.duration) { var m = Math.floor(s.duration / 60);
l.push('[length:' + p2(m) + ':' + p2(Math.floor(s.duration % 60)) + ']'); }
l.push('');
if (it.length) { for (var i = 0; i < it.length; i++) l.push(msLrc(it[i].tm) + (it[i].tx || '♪')); } else if (
s.plainLyrics) { var pl = s.plainLyrics.split('\n'); for (var j = 0; j < pl.length; j++) l.push(pl[j]
.trim()); }
return l.join('\n');
}
function genSRT(s) {
var it = parseLrc(s.syncedLyrics);
if (!it.length) return s.plainLyrics ? '1\n00:00:00,000 --> 00:03:00,000\n' + s.plainLyrics.trim() + '\n' :
'';
var l = [];
for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
em = nx ? nx.tm : it[i].tm + 3000;
l.push(String(i + 1));
l.push(msSrt(it[i].tm) + ' --> ' + msSrt(em));
l.push(it[i].tx || '♪');
l.push(''); }
return l.join('\n').trim();
}
function genASS(s) {
var it = parseLrc(s.syncedLyrics);
var l = [];
l.push('[Script Info]');
l.push('Title: ' + (s.trackName || 'Unknown'));
l.push('Original Script: ' + (s.artistName || 'Unknown'));
l.push('ScriptType: v4.00+');
l.push('Collisions: Normal');
l.push('PlayDepth: 0');
l.push('');
l.push('[V4+ Styles]');
l.push(
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding');
l.push(
'Style: Default,Microsoft YaHei,36,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,30,30,30,1');
l.push('');
l.push('[Events]');
l.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text');
if (it.length) {
for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
em = nx ? nx.tm : it[i].tm + 3000;
l.push('Dialogue: 0,' + msAss(it[i].tm) + ',' + msAss(em) + ',Default,,0,0,0,,' + (it[i].tx ||
'♪').replace(/\n/g, '\\N')); }
} else if (s.plainLyrics) {
var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); });
var dur = (s.duration || 180) * 1000;
var each = Math.floor(dur / Math.max(pl.length, 1));
for (var j = 0; j < pl.length; j++) l.push('Dialogue: 0,' + msAss(j * each) + ',' + msAss(j * each +
each) + ',Default,,0,0,0,,' + pl[j].trim().replace(/\n/g, '\\N'));
}
return l.join('\n');
}
function genVTT(s) {
var it = parseLrc(s.syncedLyrics);
var l = [];
l.push('WEBVTT');
l.push('');
if (it.length) {
for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
em = nx ? nx.tm : it[i].tm + 3000;
l.push(msVtt(it[i].tm) + ' --> ' + msVtt(em));
l.push(it[i].tx || '♪');
l.push(''); }
} else if (s.plainLyrics) {
var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); });
var dur = (s.duration || 180) * 1000;
var each = Math.floor(dur / Math.max(pl.length, 1));
for (var j = 0; j < pl.length; j++) { l.push(msVtt(j * each) + ' --> ' + msVtt(j * each + each));
l.push(pl[j].trim());
l.push(''); }
}
return l.join('\n').trim();
}
function getFmt(f, s) {
if (!s) return '';
if (f === 'lrc') return genLRC(s);
if (f === 'srt') return genSRT(s);
if (f === 'ass') return genASS(s);
if (f === 'vtt') return genVTT(s);
return '';
}
function hlHtml(f, s) {
var raw = getFmt(f, s);
if (!raw) return '<span style="color:#555;">暂无内容</span>';
var e = esc(raw);
if (f === 'lrc') { e = e.replace(/^(\[ti:.*\]|\[ar:.*\]|\[al:.*\]|\[length:.*\])$/gm,
'<span class="hl-tag">$1</span>');
e = e.replace(/^(\[\d{2}:\d{2}\.\d{2}\])/gm, '<span class="hl-tag">$1</span>'); } else if (f ===
'srt') { e = e.replace(/^(\d+)$/gm, '<span class="hl-idx">$1</span>');
e = e.replace(/^(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})$/gm,
'<span class="hl-time">$1</span>'); } else if (f === 'ass') { e = e.replace(/^(\[.*\])$/gm,
'<span class="hl-section">$1</span>');
e = e.replace(/^(Style:.*)$/gm, '<span class="hl-style">$1</span>');
e = e.replace(/^(Format:.*)$/gm, '<span class="hl-style">$1</span>'); } else if (f === 'vtt') { e = e
.replace(/^(WEBVTT)$/gm, '<span class="hl-section">$1</span>');
e = e.replace(/^(\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3})$/gm,
'<span class="hl-time">$1</span>'); }
return e;
}
function renderSong(s) {
song = s;
SN.textContent = s.trackName || '未知';
SA.textContent = s.artistName || '未知';
SAL.textContent = s.albumName ? '💿 ' + s.albumName : '';
LC.style.display = 'block';
if (files.length > 0) EB.disabled = false;
updateLyrics();
LC.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function updateLyrics() { LCT.innerHTML = hlHtml(fmt, song); }
for (var t = 0; t < TABS.length; t++) {
TABS[t].addEventListener('click', function() {
for (var i = 0; i < TABS.length; i++) TABS[i].classList.remove('on');
this.classList.add('on');
fmt = this.getAttribute('data-fmt');
updateLyrics();
});
}
for (var c = 0; c < TCHIPS.length; c++) {
TCHIPS[c].addEventListener('click', function() {
for (var i = 0; i < TCHIPS.length; i++) TCHIPS[i].classList.remove('on');
this.classList.add('on');
tag = this.getAttribute('data-tag');
});
}
function doSearch(q) {
if (!q || !q.trim()) { toast('请输入关键词', true); return; }
results = [];
selectedIdx = -1;
RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>搜索中…</div>';
RB.classList.add('open');
apiSearch(q.trim()).then(function(rs) {
results = rs;
if (!rs.length) { RL.innerHTML = '<div class="empty">😕 未找到匹配的歌曲</div>'; return; }
var h = '';
for (var i = 0; i < rs.length; i++) {
var r = rs[i];
h += '<div class="result-row" data-idx="' + i + '">';
h += '<div class="result-info"><div class="rtrack">' + esc(r.trackName || r.name || '未知') +
'</div><div class="rartist">' + esc(r.artistName || '未知') + '</div></div>';
h += '<div class="result-meta">' + fdur(r.duration) + '</div>';
h += '<button class="btn btn-xs pick-btn" data-idx="' + i + '">选择</button>';
h += '</div>';
}
RL.innerHTML = h;
bindResults();
}).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>'; });
}
function bindResults() {
var rows = RL.querySelectorAll('.result-row');
for (var j = 0; j < rows.length; j++) {
(function(idx) {
rows[j].addEventListener('click', function(e) {
if (e.target.closest('.pick-btn')) return;
loadResult(idx);
});
})(j);
}
var btns = RL.querySelectorAll('.pick-btn');
for (var k = 0; k < btns.length; k++) {
(function(idx) {
btns[k].addEventListener('click', function(e) {
e.stopPropagation();
loadResult(idx);
});
})(k);
}
}
function loadResult(idx) {
var s = results[idx];
if (!s) return;
selectedIdx = idx;
var tn = s.trackName || s.name;
var an = s.artistName || '';
var rows = RL.querySelectorAll('.result-row');
for (var i = 0; i < rows.length; i++) {
rows[i].classList.remove('selected');
rows[i].style.opacity = '0.4';
}
var tgt = RL.querySelector('[data-idx="' + idx + '"]');
if (tgt) { tgt.classList.add('selected');
tgt.style.opacity = '1'; }
apiGet(tn, an).then(function(d) {
renderSong(d);
for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1';
}).catch(function(err) {
toast('❌ ' + err.message, true);
for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1';
selectedIdx = -1;
});
}
function fdur(s) { if (!s && s !== 0) return ''; var m = Math.floor(s / 60); return m + ':' + p2(Math.floor(s %
60)); }
SB.addEventListener('click', function() { doSearch(SI.value); });
SI.addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(SI.value); });
DB.addEventListener('click', function() {
var q = SI.value.trim();
if (!q) { toast('请输入「歌曲名 - 歌手名」', true); return; }
var seps = [' - ', '-', ' – ', '–', ' | ', '|', ':', ':'];
var tk = '',
ar = '';
for (var i = 0; i < seps.length; i++) { if (q.indexOf(seps[i]) !== -1) { var pts = q.split(seps[i]);
tk = pts[0].trim();
ar = pts.slice(1).join(seps[i]).trim(); break; } }
if (!tk) { tk = prompt('歌曲名:', q); if (!tk) return;
ar = prompt('歌手名(可选):', '') || ''; }
results = [];
selectedIdx = -1;
RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>获取中…</div>';
RB.classList.add('open');
apiGet(tk, ar).then(function(d) {
results = [d];
selectedIdx = 0;
RL.innerHTML =
'<div class="result-row selected" data-idx="0" style="opacity:1;"><div class="result-info"><div class="rtrack">' +
esc(d.trackName || d.name || '未知') + '</div><div class="rartist">' + esc(d.artistName ||
'未知') + '</div></div><div class="result-meta">' + fdur(d.duration) + '</div></div>';
renderSong(d);
}).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>';
toast('❌ ' + err.message, true); });
});
function readBlocks(ab) {
var v = new DataView(ab);
var b = ab;
if (b.byteLength < 4) throw new Error('太小');
if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !== 'fLaC') throw new Error(
'非FLAC');
var bl = [];
var o = 4,
lb = false;
while (o < b.byteLength && !lb) {
if (o + 4 > b.byteLength) break;
var h = v.getUint8(o);
lb = (h & 0x80) !== 0;
var bt = h & 0x7F;
var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3);
var st = o;
o += 4;
bl.push({ type: bt, last: lb, ho: st, dto: o, size: bs });
o += bs;
}
return { blocks: bl, buf: b };
}
function parseVC(bi, buf) {
var bl = new Uint8Array(buf, bi.dto, bi.size);
var dec = new TextDecoder('utf-8');
if (bl.length < 4) return { cmts: [] };
var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24);
var p = 4 + vl;
if (p + 4 > bl.length) return { cmts: [] };
var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
p += 4;
var cmts = [];
for (var i = 0; i < nc; i++) {
if (p + 4 > bl.length) break;
var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
p += 4;
if (p + cl > bl.length) break;
cmts.push(dec.decode(bl.slice(p, p + cl)));
p += cl;
}
return { cmts: cmts };
}
function buildVC(cmts, isLast) {
var enc = new TextEncoder();
var vb = new Uint8Array(4);
var cb = new Uint8Array(4);
cb[0] = cmts.length & 0xFF;
cb[1] = (cmts.length >> 8) & 0xFF;
cb[2] = (cmts.length >> 16) & 0xFF;
cb[3] = (cmts.length >> 24) & 0xFF;
var arrs = [];
for (var i = 0; i < cmts.length; i++) {
var ed = enc.encode(cmts[i]);
var lb = new Uint8Array(4);
lb[0] = ed.length & 0xFF;
lb[1] = (ed.length >> 8) & 0xFF;
lb[2] = (ed.length >> 16) & 0xFF;
lb[3] = (ed.length >> 24) & 0xFF;
arrs.push(lb);
arrs.push(ed);
}
var tp = 8;
for (var j = 0; j < arrs.length; j++) tp += arrs[j].length;
var bh = new Uint8Array(4);
bh[0] = 4;
if (isLast) bh[0] |= 0x80;
bh[1] = (tp >> 16) & 0xFF;
bh[2] = (tp >> 8) & 0xFF;
bh[3] = tp & 0xFF;
var res = new Uint8Array(4 + tp);
res.set(bh, 0);
var off = 4;
res.set(vb, off);
off += 4;
res.set(cb, off);
off += 4;
for (var k = 0; k < arrs.length; k++) { res.set(arrs[k], off);
off += arrs[k].length; }
return res;
}
function embed(file, lc, tn, sm, ov, cb) {
var r = new FileReader();
r.onload = function(e) {
try {
var info = readBlocks(e.target.result);
var bl = info.blocks;
var buf = info.buf;
var vi = -1;
for (var i = 0; i < bl.length; i++) { if (bl[i].type === 4) { vi = i; break; } }
var nc = [];
var tu = tn.toUpperCase();
var pk = [tu];
if (ov.title) pk.push('TITLE');
if (ov.artist) pk.push('ARTIST');
if (ov.album) pk.push('ALBUM');
if (vi >= 0) {
var parsed = parseVC(bl[vi], buf);
for (var j = 0; j < parsed.cmts.length; j++) {
var eq = parsed.cmts[j].indexOf('=');
if (eq > 0) { var key = parsed.cmts[j].substring(0, eq).toUpperCase().trim(); if (pk.indexOf(
key) === -1) nc.push(parsed.cmts[j]); } else nc.push(parsed.cmts[j]);
}
}
nc.push(tu + '=' + lc);
if (ov.title && sm.trackName) nc.push('TITLE=' + sm.trackName);
if (ov.artist && sm.artistName) nc.push('ARTIST=' + sm.artistName);
if (ov.album && sm.albumName) nc.push('ALBUM=' + sm.albumName);
var isLast = (vi >= 0) ? bl[vi].last : false;
var nb = buildVC(nc, isLast);
var parts = [];
if (vi >= 0) {
parts.push(new Uint8Array(buf, 0, bl[vi].ho));
parts.push(nb);
parts.push(new Uint8Array(buf, bl[vi].dto + bl[vi].size));
} else {
var si = -1;
for (var k = 0; k < bl.length; k++) { if (bl[k].type === 0) { si = k; break; } }
if (si >= 0) {
var sho = bl[si].ho;
var sa = bl[si].dto + bl[si].size;
parts.push(new Uint8Array(buf, 0, sho));
var sh = new Uint8Array(buf, sho, 4);
sh[0] = sh[0] & 0x7F;
parts.push(sh);
parts.push(new Uint8Array(buf, bl[si].dto, bl[si].size));
parts.push(nb);
parts.push(new Uint8Array(buf, sa));
} else {
parts.push(new Uint8Array(buf, 0, 4));
parts.push(nb);
parts.push(new Uint8Array(buf, 4));
}
}
var tl = 0;
for (var p = 0; p < parts.length; p++) tl += parts[p].length;
var result = new Uint8Array(tl);
var off = 0;
for (var q = 0; q < parts.length; q++) { result.set(parts[q], off);
off += parts[q].length; }
cb(null, new Blob([result], { type: 'audio/flac' }));
} catch (x) { cb(x); }
};
r.onerror = function() { cb(new Error('读取失败')); };
r.readAsArrayBuffer(file);
}
EB.addEventListener('click', function() {
if (!files.length) { toast('请先添加 FLAC 文件', true); return; }
if (!song) { toast('请先选择歌词', true); return; }
var lc = getFmt(fmt, song);
if (!lc.trim()) { toast('当前格式无内容', true); return; }
var ov = { title: OVT.checked, artist: OVA.checked, album: OVAL.checked };
EB.disabled = true;
CFB.disabled = true;
var extra = [];
if (ov.title) extra.push('TITLE');
if (ov.artist) extra.push('ARTIST');
if (ov.album) extra.push('ALBUM');
var em = extra.length ? ' + 覆盖 ' + extra.join('/') : '';
toast('⏳ 正在写入 ' + files.length + ' 个文件' + em + '…');
var ok = 0,
fl = 0;
function next(idx) {
if (idx >= files.length) { EB.disabled = false;
CFB.disabled = false; if (fl === 0) toast('🎉 成功!已下载 ' + ok + ' 个 FLAC 文件' + em);
else toast('⚠️ ' + ok + ' 成功, ' + fl + ' 失败', true); return; }
embed(files[idx].file, lc, tag, song, ov, function(err, blob) {
if (err) { fl++;
console.error(err); } else {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = files[idx].file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
ok++;
}
setTimeout(function() { next(idx + 1); }, 200);
});
}
next(0);
});
updateFiles();
SI.focus();
})();
</script>
</body>
</html>
歌词搜索、标签写入和元数据覆盖一次性搞定
2 个帖子 - 2 位参与者