Files
lrc-local-win/web/app.js
T

239 lines
7.6 KiB
JavaScript

const form = document.querySelector('#search'),
keyword = document.querySelector('#keyword'),
notice = document.querySelector('#notice'),
results = document.querySelector('#results');
const cache = new Map();
const node = (tag, cls, label) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (label !== undefined) e.textContent = label;
return e;
};
const has = (v) => typeof v === 'string' && v.trim();
const safe = (v) =>
(v || 'unknown')
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.trim()
.slice(0, 180) || 'unknown';
const duration = (s) => (s ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : '');
const plain = (l) =>
(l || '')
.split(/\r?\n/)
.map((x) => x.replace(/\[[^\]]*\]/g, '').trim())
.filter(Boolean)
.join('\n');
async function api(path, body) {
const r = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
d = await r.json().catch(() => ({}));
if (!r.ok) throw Error(d.error || `请求失败 (${r.status})`);
return d;
}
function save(name, body) {
const u = URL.createObjectURL(new Blob([body], { type: 'text/plain;charset=utf-8' })),
a = node('a');
a.href = u;
a.download = name;
a.hidden = true;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(u), 1500);
}
function parse(lrc) {
const out = [];
for (const raw of (lrc || '').split(/\r?\n/)) {
const stamps = [...raw.matchAll(/\[(\d+):(\d+)(?:[.:](\d{1,3}))?\]/g)],
content = raw.replace(/\[[^\]]*\]/g, '').trim();
if (!content) continue;
for (const s of stamps) {
const f = (s[3] || '0').padEnd(3, '0').slice(0, 3);
out.push({ time: +s[1] * 60000 + +s[2] * 1000 + +f, content });
}
}
return out.sort((a, b) => a.time - b.time);
}
function align(base, extra) {
const out = Array(base.length).fill('');
let j = 0;
for (let i = 0; i < base.length && j < extra.length; i++) {
while (
j + 1 < extra.length &&
Math.abs(extra[j + 1].time - base[i].time) <= Math.abs(extra[j].time - base[i].time)
)
j++;
if (Math.abs(extra[j].time - base[i].time) <= 1800) out[i] = extra[j++].content;
}
return out;
}
function preview(data, mode) {
const base = parse(data.lyric);
if (!base.length) return data.lyric || '';
const tr = mode.includes('trans') ? align(base, parse(data.trans)) : [],
ro = mode.includes('roma') ? align(base, parse(data.roma)) : [];
return base
.flatMap((x, i) => {
const m = Math.floor(x.time / 60000),
s = Math.floor((x.time % 60000) / 1000),
ms = x.time % 1000,
a = [
`[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}]${x.content}`,
];
if (tr[i]) a.push(` 译:${tr[i]}`);
if (ro[i]) a.push(` 音:${ro[i]}`);
return a;
})
.join('\n');
}
function get(song) {
if (!cache.has(song.mid))
cache.set(
song.mid,
api('/api/lyrics', { mid: song.mid }).catch((e) => {
cache.delete(song.mid);
throw e;
}),
);
return cache.get(song.mid);
}
function button(label, primary, fn) {
const b = node('button', primary ? 'primary' : '', label);
b.type = 'button';
b.onclick = fn;
return b;
}
function download(song, data, kind) {
const base = safe(`${song.name} - ${song.singer}`),
v = {
lrc: [`${base}.lrc`, data.lyric],
txt: [`${base}.txt`, plain(data.lyric)],
transLRC: [`${base}.trans.lrc`, data.trans],
transTXT: [`${base}.trans.txt`, plain(data.trans)],
romaLRC: [`${base}.sound.lrc`, data.roma],
romaTXT: [`${base}.sound.txt`, plain(data.roma)],
}[kind];
if (!v || !has(v[1])) throw Error('这首歌没有对应歌词');
save(v[0], v[1]);
notice.textContent = '文件已在浏览器中生成。';
}
function setActions(song, box, data) {
box.replaceChildren();
const defs = [
['lrc', '下载 LRC', 1],
['txt', '原文 TXT', 0],
];
if (has(data.trans)) defs.push(['transLRC', '翻译 LRC', 0], ['transTXT', '翻译 TXT', 0]);
if (has(data.roma)) defs.push(['romaLRC', '音 LRC', 0], ['romaTXT', '音 TXT', 0]);
for (const [k, l, p] of defs)
box.append(
button(l, p, () => {
try {
download(song, data, k);
} catch (e) {
notice.className = 'error';
notice.textContent = e.message;
}
}),
);
}
function render(songs) {
results.replaceChildren();
songs.forEach((song, i) => {
const card = node('article', 'song'),
top = node('div', 'top'),
info = node('div', 'info'),
title = node('h2', '', `${String(i + 1).padStart(2, '0')} ${song.name}`),
meta = node(
'div',
'meta',
[song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)]
.filter(Boolean)
.join(' · '),
),
actions = node('div', 'actions'),
details = node('details', 'preview'),
summary = node('summary', '', '完整歌词预览'),
controls = node('label', 'controls', '预览内容 '),
select = node('select'),
pre = node('pre', 'lyrics'),
status = node('div', 'meta', '展开后读取歌词。');
controls.hidden = pre.hidden = true;
controls.append(select);
details.append(summary, status, controls, pre);
info.append(title, meta);
top.append(info, actions);
card.append(top, details);
results.append(card);
let data = null;
const load = async (b) => {
if (data) return data;
if (b) b.disabled = true;
notice.textContent = `正在读取《${song.name}》…`;
try {
data = await get(song);
setActions(song, actions, data);
const opts = [['original', '仅原文']];
if (has(data.trans)) opts.push(['trans', '原文 + 翻译']);
if (has(data.roma)) opts.push(['roma', '原文 + 音']);
if (has(data.trans) && has(data.roma)) opts.push(['trans-roma', '原文 + 翻译 + 音']);
for (const [v, l] of opts) {
const o = node('option', '', l);
o.value = v;
select.append(o);
}
pre.textContent = preview(data, select.value);
status.hidden = true;
controls.hidden = pre.hidden = false;
notice.textContent = '歌词已读取。';
return data;
} catch (e) {
notice.className = 'error';
notice.textContent = e.message;
details.open = false;
throw e;
} finally {
if (b) b.disabled = false;
}
};
const lrc = button('下载 LRC', 1, async () => {
try {
download(song, await load(lrc), 'lrc');
} catch {}
}),
txt = button('原文 TXT', 0, async () => {
try {
download(song, await load(txt), 'txt');
} catch {}
});
actions.append(lrc, txt);
details.ontoggle = () => {
if (details.open && !data) load().catch(() => {});
};
select.onchange = () => {
pre.textContent = preview(data, select.value);
};
});
}
form.onsubmit = async (e) => {
e.preventDefault();
const b = form.querySelector('button');
b.disabled = true;
notice.className = '';
notice.textContent = '正在搜索…';
results.replaceChildren();
cache.clear();
try {
const d = await api('/api/search', { keyword: keyword.value.trim(), limit: 15 });
render(d.songs || []);
notice.textContent = d.songs?.length ? `找到 ${d.songs.length} 条结果。` : '没有找到相关歌曲。';
} catch (x) {
notice.className = 'error';
notice.textContent = x.message;
} finally {
b.disabled = false;
}
};