Skip to content

Commit 7f40dfe

Browse files
committed
✨ feat: 新增本地歌曲匹配在线歌词
1 parent c995ae0 commit 7f40dfe

6 files changed

Lines changed: 139 additions & 76 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "splayer",
33
"productName": "SPlayer",
4-
"version": "3.0.0-beta.8",
4+
"version": "3.0.0-beta.9",
55
"description": "A minimalist music player",
66
"main": "./out/main/index.js",
77
"author": "imsyy",

src/components/Player/PlayerControl.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<!-- 下载 -->
2424
<div
2525
class="menu-icon"
26-
v-if="!musicStore.playSong.path"
26+
v-if="!musicStore.playSong.path && statusStore.isDeveloperMode"
2727
@click.stop="openDownloadSong(musicStore.playSong)"
2828
>
2929
<SvgIcon name="Download" />

src/components/Setting/LyricsSetting.vue

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,23 @@
207207
<n-collapse-transition :show="settingStore.showYrc">
208208
<n-card class="set-item">
209209
<div class="label">
210-
<n-text class="name">优先使用 QQ 音乐歌词</n-text>
210+
<n-text class="name">优先使用 QM 歌词</n-text>
211+
<n-text class="tip" :depth="3"> 优先从 QM 获取逐字歌词,模糊搜索,可能不准确 </n-text>
212+
</div>
213+
<n-switch v-model:value="settingStore.preferQQMusicLyric" class="set" :round="false" />
214+
</n-card>
215+
<n-card class="set-item">
216+
<div class="label">
217+
<n-text class="name">本地歌曲使用 QM 歌词</n-text>
211218
<n-text class="tip" :depth="3">
212-
优先从 QQ 音乐获取逐字歌词,模糊搜索,可能不准确
219+
为本地歌曲从 QM 匹配逐字歌词,如已有 TTML 歌词则跳过
213220
</n-text>
214221
</div>
215-
<n-switch v-model:value="settingStore.preferQQMusicLyric" class="set" :round="false" />
222+
<n-switch
223+
v-model:value="settingStore.localLyricQQMusicMatch"
224+
class="set"
225+
:round="false"
226+
/>
216227
</n-card>
217228
<n-card class="set-item">
218229
<div class="label">

src/core/player/LyricManager.ts

Lines changed: 118 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isElectron } from "@/utils/env";
88
import { stripLyricMetadata } from "@/utils/lyricStripper";
99
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
1010
import { escapeRegExp, isEmpty } from "lodash-es";
11+
import { SongType } from "@/types/main";
1112

1213
class LyricManager {
1314
/**
@@ -146,6 +147,99 @@ class LyricManager {
146147
return { lrcData: aligned, yrcData: lyricData.yrcData };
147148
}
148149

150+
/**
151+
* 从 QQ 音乐获取歌词(封装方法,供在线和本地歌曲使用)
152+
* @param song 歌曲对象,内部自动判断本地/在线并生成缓存 key
153+
* @returns 歌词数据,如果获取失败返回 null
154+
*/
155+
private async fetchQQMusicLyric(song: SongType): Promise<SongLyric | null> {
156+
// 构建歌手字符串
157+
const artistsStr = Array.isArray(song.artists)
158+
? song.artists.map((a) => a.name).join("/")
159+
: String(song.artists || "");
160+
// 判断本地/在线,生成缓存 key
161+
const isLocal = Boolean(song.path);
162+
const cacheKey = isLocal ? `local_${song.id}` : String(song.id);
163+
// 检查缓存
164+
let data: any = null;
165+
try {
166+
const cacheManager = useCacheManager();
167+
const result = await cacheManager.get("lyrics", `${cacheKey}.qrc.json`);
168+
if (result.success && result.data) {
169+
const decoder = new TextDecoder();
170+
const cachedStr = decoder.decode(result.data);
171+
data = JSON.parse(cachedStr);
172+
}
173+
} catch {
174+
data = null;
175+
}
176+
// 如果没有缓存,则请求 API
177+
if (!data) {
178+
const keyword = `${song.name}-${artistsStr}`;
179+
try {
180+
data = await qqMusicMatch(keyword);
181+
} catch (error) {
182+
console.warn("QQ 音乐歌词获取失败:", error);
183+
return null;
184+
}
185+
}
186+
if (!data || data.code !== 200) return null;
187+
// 验证时长匹配(相差超过 5 秒视为不匹配)
188+
if (data.song?.duration && song.duration > 0) {
189+
const durationDiff = Math.abs(data.song.duration - song.duration);
190+
if (durationDiff > 5000) {
191+
console.warn(
192+
`QQ 音乐歌词时长不匹配: ${data.song.duration}ms vs ${song.duration}ms (差异 ${durationDiff}ms)`,
193+
);
194+
return null;
195+
}
196+
}
197+
// 保存到缓存
198+
if (data.code === 200) {
199+
try {
200+
const cacheManager = useCacheManager();
201+
await cacheManager.set("lyrics", `${cacheKey}.qrc.json`, JSON.stringify(data));
202+
} catch (error) {
203+
console.error("写入 QQ 音乐歌词缓存失败:", error);
204+
}
205+
}
206+
// 解析歌词
207+
const result: SongLyric = { lrcData: [], yrcData: [] };
208+
// 解析 QRC 逐字歌词
209+
if (data.qrc) {
210+
const qrcLines = this.parseQRCLyric(data.qrc, data.trans, data.roma);
211+
if (qrcLines.length > 0) {
212+
result.yrcData = qrcLines;
213+
}
214+
}
215+
// 解析 LRC 歌词(如果没有 QRC)
216+
if (!result.yrcData.length && data.lrc) {
217+
let lrcLines = parseLrc(data.lrc) || [];
218+
// 处理翻译
219+
if (data.trans) {
220+
const transLines = parseLrc(data.trans);
221+
if (transLines?.length) {
222+
lrcLines = this.alignLyrics(lrcLines, transLines, "translatedLyric");
223+
}
224+
}
225+
// 处理罗马音
226+
if (data.roma) {
227+
const romaLines = parseLrc(data.roma);
228+
if (romaLines?.length) {
229+
lrcLines = this.alignLyrics(lrcLines, romaLines, "romanLyric");
230+
}
231+
}
232+
if (lrcLines.length > 0) {
233+
result.lrcData = lrcLines;
234+
}
235+
}
236+
// 如果没有任何歌词数据,返回 null
237+
if (!result.lrcData.length && !result.yrcData.length) {
238+
return null;
239+
}
240+
return result;
241+
}
242+
149243
/**
150244
* 解析 QQ 音乐 QRC 格式歌词
151245
* @param qrcContent QRC 原始内容
@@ -254,76 +348,17 @@ class LyricManager {
254348
if (!settingStore.preferQQMusicLyric) return;
255349
const song = musicStore.playSong;
256350
if (!song) return;
257-
// 先检查缓存
258-
const cached = await this.getRawLyricCache(id, "qrc");
259-
let data: any = null;
260-
if (cached) {
261-
try {
262-
data = JSON.parse(cached);
263-
} catch {
264-
data = null;
265-
}
266-
}
267-
// 如果没有缓存,则请求 API
268-
if (!data) {
269-
// 构建搜索关键词
270-
const artistsStr = Array.isArray(song.artists)
271-
? song.artists.map((a) => a.name).join("/")
272-
: song.artists || "";
273-
const keyword = `${song.name}-${artistsStr}`;
274-
try {
275-
data = await qqMusicMatch(keyword);
276-
} catch (error) {
277-
console.warn("QQ 音乐歌词获取失败:", error);
278-
return;
279-
}
280-
}
351+
const qqLyric = await this.fetchQQMusicLyric(song);
281352
if (isStale()) return;
282-
if (!data || data.code !== 200) return;
283-
284-
// 验证时长匹配(相差超过 5 秒视为不匹配)
285-
if (data.song?.duration) {
286-
const durationDiff = Math.abs(data.song.duration - song.duration);
287-
if (durationDiff > 5000) {
288-
console.warn(
289-
`QQ 音乐歌词时长不匹配: ${data.song.duration}ms vs ${song.duration}ms (差异 ${durationDiff}ms)`,
290-
);
291-
return;
292-
}
293-
}
294-
// 保存到缓存
295-
if (!cached && data.code === 200) {
296-
this.saveRawLyricCache(id, "qrc", JSON.stringify(data));
297-
}
298-
// 解析 QRC 逐字歌词
299-
if (data.qrc) {
300-
const qrcLines = this.parseQRCLyric(data.qrc, data.trans, data.roma);
301-
if (qrcLines.length > 0) {
302-
result.yrcData = qrcLines;
303-
qqMusicAdopted = true;
304-
}
353+
if (!qqLyric) return;
354+
// 设置结果
355+
if (qqLyric.yrcData.length > 0) {
356+
result.yrcData = qqLyric.yrcData;
357+
qqMusicAdopted = true;
305358
}
306-
// 解析 LRC 歌词(如果没有 QRC)
307-
if (!qqMusicAdopted && data.lrc) {
308-
let lrcLines = parseLrc(data.lrc) || [];
309-
// 处理翻译
310-
if (data.trans) {
311-
const transLines = parseLrc(data.trans);
312-
if (transLines?.length) {
313-
lrcLines = this.alignLyrics(lrcLines, transLines, "translatedLyric");
314-
}
315-
}
316-
// 处理罗马音
317-
if (data.roma) {
318-
const romaLines = parseLrc(data.roma);
319-
if (romaLines?.length) {
320-
lrcLines = this.alignLyrics(lrcLines, romaLines, "romanLyric");
321-
}
322-
}
323-
if (lrcLines.length > 0) {
324-
result.lrcData = lrcLines;
325-
qqMusicAdopted = true;
326-
}
359+
if (qqLyric.lrcData.length > 0) {
360+
result.lrcData = qqLyric.lrcData;
361+
if (!qqMusicAdopted) qqMusicAdopted = true;
327362
}
328363
};
329364

@@ -414,7 +449,9 @@ class LyricManager {
414449
*/
415450
private async handleLocalLyric(path: string): Promise<SongLyric> {
416451
try {
452+
const musicStore = useMusicStore();
417453
const statusStore = useStatusStore();
454+
const settingStore = useSettingStore();
418455
const { lyric, format }: { lyric?: string; format?: "lrc" | "ttml" } =
419456
await window.electron.ipcRenderer.invoke("get-music-lyric", path);
420457
if (!lyric) return { lrcData: [], yrcData: [] };
@@ -425,10 +462,21 @@ class LyricManager {
425462
statusStore.usingTTMLLyric = true;
426463
return { lrcData: [], yrcData: lines };
427464
}
428-
// 解析本地歌词并对其
465+
// 解析本地歌词
429466
const lrcLines = parseLrc(lyric);
430-
const aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
467+
let aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
431468
statusStore.usingTTMLLyric = false;
469+
// 如果开启了本地歌曲 QQ 音乐匹配,尝试获取逐字歌词
470+
if (settingStore.localLyricQQMusicMatch && musicStore.playSong) {
471+
const qqLyric = await this.fetchQQMusicLyric(musicStore.playSong);
472+
if (qqLyric && qqLyric.yrcData.length > 0) {
473+
// 使用 QQ 音乐的逐字歌词,但保留本地歌词作为 lrcData
474+
aligned = {
475+
lrcData: aligned.lrcData,
476+
yrcData: qqLyric.yrcData,
477+
};
478+
}
479+
}
432480
return aligned;
433481
} catch {
434482
return { lrcData: [], yrcData: [] };

src/stores/setting.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,12 @@ export interface SettingState {
177177
wordFadeWidth: number;
178178
/** 歌词时延调节步长(毫秒) */
179179
lyricOffsetStep: number;
180-
/** 是否启用在线 TTML 歌词 */
180+
/** 启用在线 TTML 歌词 */
181181
enableOnlineTTMLLyric: boolean;
182182
/** 优先使用 QQ 音乐歌词源 */
183183
preferQQMusicLyric: boolean;
184+
/** 本地歌曲使用 QQ 音乐歌词匹配 */
185+
localLyricQQMusicMatch: boolean;
184186
/** AMLL DB 服务地址 */
185187
amllDbServer: string;
186188
/** 菜单显示封面 */
@@ -371,6 +373,7 @@ export const useSettingStore = defineStore("setting", {
371373
lyricOffsetStep: 500,
372374
enableOnlineTTMLLyric: false,
373375
preferQQMusicLyric: false,
376+
localLyricQQMusicMatch: false,
374377
amllDbServer: defaultAMLLDbServer,
375378
showYrc: true,
376379
showYrcAnimation: true,

src/stores/status.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export const useStatusStore = defineStore("status", {
333333
"eqEnabled",
334334
"eqBands",
335335
"eqPreset",
336+
"developerMode",
336337
],
337338
},
338339
});

0 commit comments

Comments
 (0)