239 lines
7.6 KiB
JavaScript
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;
|
|
}
|
|
};
|