mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 03:14:57 +08:00
✨ feat: 完善歌词模块
This commit is contained in:
@@ -199,20 +199,50 @@ const initFileIpc = (): void => {
|
||||
// 读取本地歌词
|
||||
ipcMain.handle(
|
||||
"read-local-lyric",
|
||||
async (_, lyricDir: string, id: number, ext: string): Promise<string> => {
|
||||
const pattern = `**/{,*.}${id}.${ext}`;
|
||||
async (_, lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> => {
|
||||
const result = { lrc: "", ttml: "" };
|
||||
|
||||
try {
|
||||
const files = await FastGlob(pattern, { cwd: lyricDir });
|
||||
if (files.length > 0) {
|
||||
const firstMatch = join(lyricDir, files[0]);
|
||||
await access(firstMatch);
|
||||
const lyric = await readFile(firstMatch, "utf-8");
|
||||
if (lyric) return lyric;
|
||||
// 定义需要查找的模式
|
||||
const patterns = {
|
||||
ttml: `**/${id}.ttml`,
|
||||
lrc: `**/${id}.lrc`,
|
||||
};
|
||||
|
||||
// 遍历每一个目录
|
||||
for (const dir of lyricDirs) {
|
||||
try {
|
||||
// 查找 ttml
|
||||
if (!result.ttml) {
|
||||
const ttmlFiles = await FastGlob(patterns.ttml, { cwd: dir });
|
||||
if (ttmlFiles.length > 0) {
|
||||
const filePath = join(dir, ttmlFiles[0]);
|
||||
await access(filePath);
|
||||
result.ttml = await readFile(filePath, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// 查找 lrc
|
||||
if (!result.lrc) {
|
||||
const lrcFiles = await FastGlob(patterns.lrc, { cwd: dir });
|
||||
if (lrcFiles.length > 0) {
|
||||
const filePath = join(dir, lrcFiles[0]);
|
||||
await access(filePath);
|
||||
result.lrc = await readFile(filePath, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果两种文件都找到了就提前结束搜索
|
||||
if (result.ttml && result.lrc) break;
|
||||
} catch {
|
||||
// 某个路径异常,跳过
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
/* 忽略错误 */
|
||||
}
|
||||
return "";
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@imsyy/color-utils": "^1.0.2",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@neteasecloudmusicapienhanced/api": "^4.29.16",
|
||||
"@neteasecloudmusicapienhanced/api": "^4.29.17",
|
||||
"@pixi/app": "^7.4.3",
|
||||
"@pixi/core": "^7.4.3",
|
||||
"@pixi/display": "^7.4.3",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -34,8 +34,8 @@ importers:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
'@neteasecloudmusicapienhanced/api':
|
||||
specifier: ^4.29.16
|
||||
version: 4.29.16
|
||||
specifier: ^4.29.17
|
||||
version: 4.29.17
|
||||
'@pixi/app':
|
||||
specifier: ^7.4.3
|
||||
version: 7.4.3(@pixi/core@7.4.3)(@pixi/display@7.4.3(@pixi/core@7.4.3))
|
||||
@@ -946,8 +946,8 @@ packages:
|
||||
'@material/material-color-utilities@0.3.0':
|
||||
resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==}
|
||||
|
||||
'@neteasecloudmusicapienhanced/api@4.29.16':
|
||||
resolution: {integrity: sha512-ml6cTErjJ/fn+E0wvlNGGoW74rDj3mfogO85xySuTCuX/Mdhuayv+pPtoSm5YDM6j26hHmS8+rV2ma4o16pUiw==}
|
||||
'@neteasecloudmusicapienhanced/api@4.29.17':
|
||||
resolution: {integrity: sha512-zKqmA7NoP+H3dK0b4/1K7SkxAYz69z9zwPd6+9wXcQNA42EO4AK/rDZ0ZPC9bonQjigHrvK4beFMaIl01S1iig==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5262,7 +5262,7 @@ snapshots:
|
||||
|
||||
'@material/material-color-utilities@0.3.0': {}
|
||||
|
||||
'@neteasecloudmusicapienhanced/api@4.29.16':
|
||||
'@neteasecloudmusicapienhanced/api@4.29.17':
|
||||
dependencies:
|
||||
'@unblockneteasemusic/server': 0.28.0
|
||||
axios: 1.13.2
|
||||
|
||||
2
src/types/lyric.d.ts
vendored
2
src/types/lyric.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
/**
|
||||
* 歌词数据类型
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useStatusStore, useMusicStore } from "@/stores";
|
||||
import { SongLyric } from "@/types/lyric";
|
||||
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
|
||||
import { songLyric, songLyricTTML } from "@/api/song";
|
||||
import { type SongLyric } from "@/types/lyric";
|
||||
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
|
||||
import { isElectron } from "./env";
|
||||
import { isEmpty } from "lodash-es";
|
||||
|
||||
// TODO: 实现歌词统一管理类
|
||||
// 先区分是在线还是本地
|
||||
@@ -12,6 +16,17 @@ class LyricManager {
|
||||
// Store
|
||||
private musicStore = useMusicStore();
|
||||
private statusStore = useStatusStore();
|
||||
private settingStore = useSettingStore();
|
||||
/**
|
||||
* 在线歌词请求序列
|
||||
* 每次发起新请求递增
|
||||
*/
|
||||
private lyricReqSeq = 0;
|
||||
/**
|
||||
* 当前有效的请求序列
|
||||
* 用于校验返回是否属于当前歌曲的最新请求
|
||||
*/
|
||||
private activeLyricReq = 0;
|
||||
/**
|
||||
* 重置当前歌曲的歌词数据
|
||||
* 包括清空歌词数据、重置歌词索引、关闭 TTMLL 歌词等
|
||||
@@ -23,50 +38,249 @@ class LyricManager {
|
||||
// 重置歌词索引
|
||||
this.statusStore.lyricIndex = -1;
|
||||
}
|
||||
/**
|
||||
* 歌词内容对齐
|
||||
* @param lyrics 歌词数据
|
||||
* @param otherLyrics 其他歌词数据
|
||||
* @param key 对齐类型
|
||||
* @returns 对齐后的歌词数据
|
||||
*/
|
||||
private alignLyrics(
|
||||
lyrics: LyricLine[],
|
||||
otherLyrics: LyricLine[],
|
||||
key: "translatedLyric" | "romanLyric",
|
||||
): LyricLine[] {
|
||||
const lyricsData = lyrics;
|
||||
if (lyricsData.length && otherLyrics.length) {
|
||||
lyricsData.forEach((v: LyricLine) => {
|
||||
otherLyrics.forEach((x: LyricLine) => {
|
||||
if (v.startTime === x.startTime || Math.abs(v.startTime - x.startTime) < 0.6) {
|
||||
v[key] = x.words.map((word) => word.word).join("");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return lyricsData;
|
||||
}
|
||||
/**
|
||||
* 对齐本地歌词
|
||||
* @param lyrics 本地歌词数据
|
||||
* @param otherLyrics 其他歌词数据
|
||||
* @returns 对齐后的本地歌词数据
|
||||
*/
|
||||
private alignLocalLyrics(lyricData: SongLyric): SongLyric {
|
||||
// 同一时间的两/三行分别作为主句、翻译、音译
|
||||
const toTime = (line: LyricLine) => Number(line?.startTime ?? line?.words?.[0]?.startTime ?? 0);
|
||||
// 取内容
|
||||
const toText = (line: LyricLine) => String(line?.words?.[0]?.word || "").trim();
|
||||
const lrc = lyricData.lrcData || [];
|
||||
if (!lrc.length) return lyricData;
|
||||
// 按开始时间分组,时间差 < 0.6s 视为同组
|
||||
const sorted = [...lrc].sort((a, b) => toTime(a) - toTime(b));
|
||||
const groups: LyricLine[][] = [];
|
||||
for (const line of sorted) {
|
||||
const st = toTime(line);
|
||||
const last = groups[groups.length - 1]?.[0];
|
||||
if (last && Math.abs(st - toTime(last)) < 0.6) groups[groups.length - 1].push(line);
|
||||
else groups.push([line]);
|
||||
}
|
||||
// 组装:第 1 行主句;第 2 行翻译;第 3 行音译;不调整时长
|
||||
const aligned = groups.map((group) => {
|
||||
const base = { ...group[0] } as LyricLine;
|
||||
const tran = group[1] ? toText(group[1]) : "";
|
||||
const roma = group[2] ? toText(group[2]) : "";
|
||||
if (!base.translatedLyric) base.translatedLyric = tran;
|
||||
if (!base.romanLyric) base.romanLyric = roma;
|
||||
return base;
|
||||
});
|
||||
return { lrcData: aligned, yrcData: lyricData.yrcData };
|
||||
}
|
||||
/**
|
||||
* 处理在线歌词
|
||||
* @param id 歌曲 ID
|
||||
* @returns 歌词数据
|
||||
*/
|
||||
private async handleOnlineLyric(id: number): Promise<SongLyric> {
|
||||
console.log("处理在线歌词", id);
|
||||
return {
|
||||
lrcData: [],
|
||||
yrcData: [],
|
||||
const req = this.activeLyricReq;
|
||||
const settingStore = this.settingStore;
|
||||
// 请求是否成功
|
||||
let adopted = false;
|
||||
let result: SongLyric = { lrcData: [], yrcData: [] };
|
||||
// 过期判断
|
||||
const isStale = () => this.activeLyricReq !== req || this.musicStore.playSong?.id !== id;
|
||||
// 处理 TTML 歌词
|
||||
const adoptTTML = async () => {
|
||||
try {
|
||||
if (!settingStore.enableTTMLLyric) return;
|
||||
const ttmlContent = await songLyricTTML(id);
|
||||
if (isStale()) return;
|
||||
if (!ttmlContent || typeof ttmlContent !== "string") return;
|
||||
const parsed = parseTTML(ttmlContent);
|
||||
const lines = parsed?.lines || [];
|
||||
if (!lines.length) return;
|
||||
result = { lrcData: [], yrcData: lines };
|
||||
adopted = true;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
// 处理 LRC 歌词
|
||||
const adoptLRC = async () => {
|
||||
try {
|
||||
const data = await songLyric(id);
|
||||
if (isStale()) return;
|
||||
if (!data || data.code !== 200) return;
|
||||
let lrcLines: LyricLine[] = [];
|
||||
let yrcLines: LyricLine[] = [];
|
||||
// 普通歌词
|
||||
if (data?.lrc?.lyric) {
|
||||
lrcLines = parseLrc(data.lrc.lyric) || [];
|
||||
// 普通歌词翻译
|
||||
if (data?.tlyric?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.tlyric.lyric), "translatedLyric");
|
||||
// 普通歌词音译
|
||||
if (data?.romalrc?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.romalrc.lyric), "romanLyric");
|
||||
}
|
||||
// 逐字歌词
|
||||
if (data?.yrc?.lyric) {
|
||||
yrcLines = parseYrc(data.yrc.lyric) || [];
|
||||
// 逐字歌词翻译
|
||||
if (data?.ytlrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.ytlrc.lyric), "translatedLyric");
|
||||
// 逐字歌词音译
|
||||
if (data?.yromalrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
|
||||
}
|
||||
if (adopted) return;
|
||||
result = { lrcData: lrcLines, yrcData: yrcLines };
|
||||
adopted = true;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
// 统一判断与设置 TTML
|
||||
await Promise.allSettled([adoptTTML(), adoptLRC()]);
|
||||
this.statusStore.usingTTMLLyric = Boolean(
|
||||
settingStore.enableTTMLLyric && result.yrcData?.length && !result.lrcData?.length,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 处理本地歌词
|
||||
* @param id 歌曲 ID
|
||||
* @param path 本地歌词路径
|
||||
* @returns 歌词数据
|
||||
*/
|
||||
private async handleLocalLyric(id: number, path: string): Promise<SongLyric> {
|
||||
console.log("处理本地歌词", id, path);
|
||||
private async handleLocalLyric(path: string): Promise<SongLyric> {
|
||||
try {
|
||||
const { lyric, format }: { lyric?: string; format?: "lrc" | "ttml" } =
|
||||
await window.electron.ipcRenderer.invoke("get-music-lyric", path);
|
||||
if (!lyric) return { lrcData: [], yrcData: [] };
|
||||
// TTML 直接返回
|
||||
if (format === "ttml") {
|
||||
const ttml = parseTTML(lyric);
|
||||
const lines = ttml?.lines || [];
|
||||
this.statusStore.usingTTMLLyric = true;
|
||||
return { lrcData: [], yrcData: lines };
|
||||
}
|
||||
// 解析本地歌词并对其
|
||||
const lrcLines = parseLrc(lyric);
|
||||
const aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
|
||||
this.statusStore.usingTTMLLyric = false;
|
||||
return aligned;
|
||||
} catch {
|
||||
return { lrcData: [], yrcData: [] };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 检测本地歌词覆盖
|
||||
* @param id 歌曲 ID
|
||||
* @returns 歌词数据
|
||||
*/
|
||||
private async checkLocalLyricOverride(id: number): Promise<SongLyric> {
|
||||
console.log("检测本地歌词覆盖", id);
|
||||
const { localLyricPath } = this.settingStore;
|
||||
if (!isElectron || !localLyricPath.length) return { lrcData: [], yrcData: [] };
|
||||
// 从本地遍历
|
||||
const { lrc, ttml } = await window.electron.ipcRenderer.invoke(
|
||||
"read-local-lyric",
|
||||
localLyricPath,
|
||||
id,
|
||||
);
|
||||
this.statusStore.usingTTMLLyric = Boolean(ttml);
|
||||
return { lrcData: parseLrc(lrc || ""), yrcData: parseTTML(ttml || "").lines || [] };
|
||||
}
|
||||
/**
|
||||
* 处理歌词排除
|
||||
* @param lyricData 歌词数据
|
||||
* @returns 处理后的歌词数据
|
||||
*/
|
||||
private handleLyricExclude(lyricData: SongLyric): SongLyric {
|
||||
const { enableExcludeLyrics, excludeKeywords, excludeRegexes } = this.settingStore;
|
||||
// 未开启排除
|
||||
if (!enableExcludeLyrics) return lyricData;
|
||||
// 处理正则表达式
|
||||
const regexes = (excludeRegexes || []).map((r: string) => new RegExp(r));
|
||||
/**
|
||||
* 判断歌词是否被排除
|
||||
* @param line 歌词行
|
||||
* @returns 是否被排除
|
||||
*/
|
||||
const isExcluded = (line: LyricLine) => {
|
||||
const content = (line?.words || [])
|
||||
.map((w) => String(w.word || ""))
|
||||
.join("")
|
||||
.trim();
|
||||
if (!content) return true;
|
||||
return (
|
||||
(excludeKeywords || []).some((k: string) => content.includes(k)) ||
|
||||
regexes.some((re) => re.test(content))
|
||||
);
|
||||
};
|
||||
/**
|
||||
* 过滤排除的歌词行
|
||||
* @param lines 歌词行数组
|
||||
* @returns 过滤后的歌词行数组
|
||||
*/
|
||||
const filterLines = (lines: LyricLine[]) => (lines || []).filter((l) => !isExcluded(l));
|
||||
return {
|
||||
lrcData: [],
|
||||
yrcData: [],
|
||||
lrcData: filterLines(lyricData.lrcData || []),
|
||||
yrcData: filterLines(lyricData.yrcData || []),
|
||||
};
|
||||
}
|
||||
// 处理歌词
|
||||
/**
|
||||
* 处理歌词
|
||||
* @param id 歌曲 ID
|
||||
* @param path 本地歌词路径(可选)
|
||||
*/
|
||||
public async handleLyric(id: number, path?: string) {
|
||||
try {
|
||||
// 歌词加载状态
|
||||
this.statusStore.lyricLoading = true;
|
||||
// 重置歌词
|
||||
this.resetSongLyric();
|
||||
// 标记当前歌词请求(避免旧请求覆盖新请求)
|
||||
this.activeLyricReq = ++this.lyricReqSeq;
|
||||
// 检查歌词覆盖
|
||||
let lyricData = await this.checkLocalLyricOverride(id);
|
||||
// 开始获取歌词
|
||||
let lyricData: Partial<SongLyric> = {};
|
||||
if (path) {
|
||||
lyricData = await this.handleLocalLyric(id, path);
|
||||
if (isEmpty(lyricData.lrcData) || isEmpty(lyricData.yrcData)) {
|
||||
// 进行本地歌词对齐
|
||||
lyricData = this.alignLocalLyrics(lyricData);
|
||||
} else if (path) {
|
||||
lyricData = await this.handleLocalLyric(path);
|
||||
} else {
|
||||
lyricData = await this.handleOnlineLyric(id);
|
||||
}
|
||||
console.log("歌词数据", lyricData);
|
||||
// 排除内容
|
||||
lyricData = this.handleLyricExclude(lyricData);
|
||||
console.log("最终歌词数据", lyricData);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
// 歌词加载状态
|
||||
this.statusStore.lyricLoading = false;
|
||||
if (this.musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {
|
||||
this.statusStore.lyricLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user