feat: add translated and phonetic lyric previews
This commit is contained in:
1 parent
bfd84ccdb5
commit
783e620214
10 files changed
+680
-44
No files matched your search
+2
-2
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="搜索并下载 LRC、翻译歌词或纯文本歌词。">
|
||||
<meta name="description" content="搜索、预览并下载原文、翻译与音歌词。">
|
||||
<title>LRC Local</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
@@ -14,7 +14,7 @@
|
||||
<a href="https://git.sighs.cc/Flechazo/lrc-local">源码</a>
|
||||
</header>
|
||||
|
||||
<p>搜索歌曲并下载 LRC、翻译 LRC 或纯文本歌词。</p>
|
||||
<p>搜索、完整预览并下载原文、翻译或“音”歌词。翻译与音歌词只在 QQ 音乐提供对应内容时出现。</p>
|
||||
<p id="mode-note" class="note">正在确认运行模式…</p>
|
||||
|
||||
<form id="search-form">
|
||||
|
||||
+226
-32
@@ -7,9 +7,10 @@ const localDownloads = document.querySelector('#local-downloads');
|
||||
const lyricCache = new Map();
|
||||
|
||||
function text(value) { return document.createTextNode(value ?? ''); }
|
||||
function hasText(value) { return typeof value === 'string' && value.trim().length > 0; }
|
||||
function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; }
|
||||
function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; }
|
||||
function plainText(lrc) { return lrc.split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
|
||||
function plainText(lrc) { return (lrc || '').split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
|
||||
|
||||
async function api(path, payload) {
|
||||
const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
|
||||
@@ -36,56 +37,249 @@ async function loadInfo() {
|
||||
function download(name, body, type = 'text/plain;charset=utf-8') {
|
||||
const url = URL.createObjectURL(new Blob([body], {type}));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = name; anchor.hidden = true;
|
||||
document.body.append(anchor); anchor.click(); anchor.remove();
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.hidden = true;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
}
|
||||
|
||||
async function fetchAndSave(song, kind, button) {
|
||||
button.disabled = true;
|
||||
notice.className = 'notice'; notice.textContent = `正在读取《${song.name}》…`;
|
||||
try {
|
||||
if (!lyricCache.has(song.mid)) lyricCache.set(song.mid, api('/api/lyrics', {mid: song.mid}));
|
||||
const data = await lyricCache.get(song.mid).catch(error => { lyricCache.delete(song.mid); throw error; });
|
||||
const base = safeName(`${song.name} - ${song.singer}`);
|
||||
if (kind === 'lrc') download(`${base}.lrc`, data.lyric);
|
||||
if (kind === 'txt') download(`${base}.txt`, plainText(data.lyric));
|
||||
if (kind === 'trans') {
|
||||
if (!data.trans) throw new Error('这首歌没有翻译歌词');
|
||||
download(`${base}.trans.lrc`, data.trans);
|
||||
function parseLRC(lrc) {
|
||||
const lines = [];
|
||||
for (const raw of (lrc || '').split(/\r?\n/)) {
|
||||
const stamps = [...raw.matchAll(/\[(\d+):(\d+)(?:[.:](\d{1,3}))?\]/g)];
|
||||
if (!stamps.length) continue;
|
||||
const content = raw.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (!content) continue;
|
||||
for (const stamp of stamps) {
|
||||
const fraction = stamp[3] || '0';
|
||||
const milliseconds = Number(fraction.padEnd(3, '0').slice(0, 3));
|
||||
lines.push({time: Number(stamp[1]) * 60000 + Number(stamp[2]) * 1000 + milliseconds, content});
|
||||
}
|
||||
notice.textContent = '文件已在浏览器中生成。';
|
||||
} catch (error) {
|
||||
notice.className = 'notice error'; notice.textContent = error.message;
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
function timestamp(milliseconds) {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor(milliseconds % 60000 / 1000);
|
||||
const millis = milliseconds % 1000;
|
||||
return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}]`;
|
||||
}
|
||||
|
||||
function alignTrack(base, extra) {
|
||||
const aligned = new Array(base.length).fill('');
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < base.length && cursor < extra.length; index += 1) {
|
||||
while (cursor + 1 < extra.length && Math.abs(extra[cursor + 1].time - base[index].time) <= Math.abs(extra[cursor].time - base[index].time)) cursor += 1;
|
||||
if (Math.abs(extra[cursor].time - base[index].time) <= 1800) {
|
||||
aligned[index] = extra[cursor].content;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
return aligned;
|
||||
}
|
||||
|
||||
function previewText(data, mode) {
|
||||
const original = parseLRC(data.lyric);
|
||||
if (!original.length) return data.lyric || '';
|
||||
const translations = mode.includes('trans') ? alignTrack(original, parseLRC(data.trans)) : [];
|
||||
const phonetics = mode.includes('roma') ? alignTrack(original, parseLRC(data.roma)) : [];
|
||||
const output = [];
|
||||
original.forEach((line, index) => {
|
||||
output.push(`${timestamp(line.time)}${line.content}`);
|
||||
if (translations[index]) output.push(` 译:${translations[index]}`);
|
||||
if (phonetics[index]) output.push(` 音:${phonetics[index]}`);
|
||||
});
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function cachedLyrics(song) {
|
||||
if (!lyricCache.has(song.mid)) {
|
||||
const request = api('/api/lyrics', {mid: song.mid}).catch(error => {
|
||||
lyricCache.delete(song.mid);
|
||||
throw error;
|
||||
});
|
||||
lyricCache.set(song.mid, request);
|
||||
}
|
||||
return lyricCache.get(song.mid);
|
||||
}
|
||||
|
||||
function actionButton(label, primary, handler) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
if (primary) button.className = 'primary';
|
||||
button.append(text(label));
|
||||
button.addEventListener('click', handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function saveLyrics(song, data, kind) {
|
||||
const base = safeName(`${song.name} - ${song.singer}`);
|
||||
const variants = {
|
||||
lrc: [`${base}.lrc`, data.lyric],
|
||||
txt: [`${base}.txt`, plainText(data.lyric)],
|
||||
transLRC: [`${base}.trans.lrc`, data.trans],
|
||||
transTXT: [`${base}.trans.txt`, plainText(data.trans)],
|
||||
romaLRC: [`${base}.sound.lrc`, data.roma],
|
||||
romaTXT: [`${base}.sound.txt`, plainText(data.roma)]
|
||||
};
|
||||
const selected = variants[kind];
|
||||
if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词');
|
||||
download(selected[0], selected[1]);
|
||||
}
|
||||
|
||||
function populatePreview(select, output, data) {
|
||||
const current = select.value;
|
||||
select.replaceChildren();
|
||||
const choices = [['original', '仅原文']];
|
||||
if (hasText(data.trans)) choices.push(['trans', '原文 + 翻译']);
|
||||
if (hasText(data.roma)) choices.push(['roma', '原文 + 音']);
|
||||
if (hasText(data.trans) && hasText(data.roma)) choices.push(['trans-roma', '原文 + 翻译 + 音']);
|
||||
choices.forEach(([value, label]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.append(text(label));
|
||||
select.append(option);
|
||||
});
|
||||
if ([...select.options].some(option => option.value === current)) select.value = current;
|
||||
output.textContent = previewText(data, select.value);
|
||||
}
|
||||
|
||||
function renderSongActions(song, actions, data) {
|
||||
actions.replaceChildren();
|
||||
const definitions = [['lrc', '下载 LRC', true], ['txt', '原文 TXT', false]];
|
||||
if (hasText(data.trans)) definitions.push(['transLRC', '翻译 LRC', false], ['transTXT', '翻译 TXT', false]);
|
||||
if (hasText(data.roma)) definitions.push(['romaLRC', '音 LRC', false], ['romaTXT', '音 TXT', false]);
|
||||
definitions.forEach(([kind, label, primary]) => {
|
||||
actions.append(actionButton(label, primary, () => {
|
||||
try {
|
||||
saveLyrics(song, data, kind);
|
||||
notice.className = 'notice';
|
||||
notice.textContent = '文件已在浏览器中生成。';
|
||||
} catch (error) {
|
||||
notice.className = 'notice error';
|
||||
notice.textContent = error.message;
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function renderSongs(songs) {
|
||||
results.replaceChildren();
|
||||
songs.forEach((song, index) => {
|
||||
const article = document.createElement('article'); article.className = 'song';
|
||||
const article = document.createElement('article');
|
||||
article.className = 'song';
|
||||
const top = document.createElement('div');
|
||||
top.className = 'song-top';
|
||||
const info = document.createElement('div');
|
||||
const title = document.createElement('h2'); title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`));
|
||||
const meta = document.createElement('div'); meta.className = 'meta';
|
||||
info.className = 'song-info';
|
||||
const title = document.createElement('h2');
|
||||
title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`));
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · ')));
|
||||
info.append(title, meta);
|
||||
const actions = document.createElement('div'); actions.className = 'actions';
|
||||
[['lrc','下载 LRC','primary'],['txt','纯文本'],['trans','翻译 LRC']].forEach(([kind,label,klass]) => {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = klass || ''; button.append(text(label));
|
||||
button.addEventListener('click', () => fetchAndSave(song, kind, button)); actions.append(button);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
const details = document.createElement('details');
|
||||
details.className = 'preview';
|
||||
const summary = document.createElement('summary');
|
||||
summary.append(text('完整歌词预览'));
|
||||
const previewBody = document.createElement('div');
|
||||
previewBody.className = 'preview-body';
|
||||
const previewStatus = document.createElement('p');
|
||||
previewStatus.className = 'preview-status';
|
||||
previewStatus.append(text('展开后读取歌词。'));
|
||||
const controls = document.createElement('label');
|
||||
controls.className = 'preview-controls';
|
||||
controls.hidden = true;
|
||||
controls.append(text('预览内容 '));
|
||||
const select = document.createElement('select');
|
||||
controls.append(select);
|
||||
const output = document.createElement('pre');
|
||||
output.className = 'lyric-preview';
|
||||
output.hidden = true;
|
||||
previewBody.append(previewStatus, controls, output);
|
||||
details.append(summary, previewBody);
|
||||
|
||||
let loadedData = null;
|
||||
const load = async trigger => {
|
||||
if (loadedData) return loadedData;
|
||||
if (trigger) trigger.disabled = true;
|
||||
previewStatus.textContent = `正在读取《${song.name}》…`;
|
||||
notice.className = 'notice';
|
||||
notice.textContent = `正在读取《${song.name}》…`;
|
||||
try {
|
||||
loadedData = await cachedLyrics(song);
|
||||
renderSongActions(song, actions, loadedData);
|
||||
populatePreview(select, output, loadedData);
|
||||
controls.hidden = false;
|
||||
output.hidden = false;
|
||||
previewStatus.hidden = true;
|
||||
notice.textContent = '歌词已读取;翻译和“音”按钮只在存在对应内容时显示。';
|
||||
return loadedData;
|
||||
} catch (error) {
|
||||
previewStatus.hidden = false;
|
||||
previewStatus.textContent = error.message;
|
||||
notice.className = 'notice error';
|
||||
notice.textContent = error.message;
|
||||
if (details.open) details.open = false;
|
||||
throw error;
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const initialLRC = actionButton('下载 LRC', true, async () => {
|
||||
try {
|
||||
const data = await load(initialLRC);
|
||||
saveLyrics(song, data, 'lrc');
|
||||
notice.textContent = '文件已在浏览器中生成。';
|
||||
} catch { /* load already reports the error */ }
|
||||
});
|
||||
article.append(info, actions); results.append(article);
|
||||
const initialTXT = actionButton('原文 TXT', false, async () => {
|
||||
try {
|
||||
const data = await load(initialTXT);
|
||||
saveLyrics(song, data, 'txt');
|
||||
notice.textContent = '文件已在浏览器中生成。';
|
||||
} catch { /* load already reports the error */ }
|
||||
});
|
||||
actions.append(initialLRC, initialTXT);
|
||||
details.addEventListener('toggle', () => {
|
||||
if (details.open && !loadedData) load().catch(() => {});
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
if (loadedData) output.textContent = previewText(loadedData, select.value);
|
||||
});
|
||||
|
||||
top.append(info, actions);
|
||||
article.append(top, details);
|
||||
results.append(article);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true;
|
||||
notice.className = 'notice'; notice.textContent = '正在搜索…'; results.replaceChildren(); lyricCache.clear();
|
||||
event.preventDefault();
|
||||
const submit = form.querySelector('button');
|
||||
submit.disabled = true;
|
||||
notice.className = 'notice';
|
||||
notice.textContent = '正在搜索…';
|
||||
results.replaceChildren();
|
||||
lyricCache.clear();
|
||||
try {
|
||||
const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15});
|
||||
renderSongs(data.songs || []); notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。` : '没有找到相关歌曲。';
|
||||
} catch (error) { notice.className = 'notice error'; notice.textContent = error.message; }
|
||||
finally { submit.disabled = false; }
|
||||
renderSongs(data.songs || []);
|
||||
notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。展开预览或下载时才会读取歌词。` : '没有找到相关歌曲。';
|
||||
} catch (error) {
|
||||
notice.className = 'notice error';
|
||||
notice.textContent = error.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadInfo();
|
||||
+1
-1
@@ -1 +1 @@
|
||||
:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(820px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:650px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}}
|
||||
:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(900px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus,select:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px;min-width:0}.song-top{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center}.song-info{min-width:0}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.preview{margin-top:12px;border-top:1px solid #eaecf0;padding-top:10px}.preview summary{color:#155eef;cursor:pointer;font-size:.9rem;width:max-content}.preview-body{padding-top:10px}.preview-status{color:#667085;font-size:.86rem;margin:0}.preview-controls{font-size:.86rem;margin:0 0 8px}.preview-controls select{border:1px solid #b8c0cc;border-radius:6px;background:#fff;color:#1f2937;padding:6px 28px 6px 8px;font:inherit}.lyric-preview{width:100%;max-height:min(62vh,720px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;border:1px solid #dfe3e8;border-radius:7px;background:#f8fafc;color:#344054;font:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;font-size:.82rem;line-height:1.65}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:700px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song-top{grid-template-columns:1fr}.actions{justify-content:flex-start}.actions button{flex:1 1 auto}.preview summary{width:auto}.lyric-preview{max-height:58vh;padding:10px;font-size:.78rem}}
|
||||
Reference in new issue
Block a user