From 39c35e8a318bf8d3f321f9a237e9fa06fa00b70b Mon Sep 17 00:00:00 2001 From: imsyy Date: Tue, 18 Nov 2025 15:08:15 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=84=20refactor:=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=E6=AF=AB=E7=A7=92=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Player/CountDown.vue | 4 +- src/components/Player/LyricMenu.vue | 12 +- src/components/Player/MainAMLyric.vue | 18 +- src/components/Player/MainLyric.vue | 42 +- src/components/Player/MainPlayer.vue | 6 +- src/components/Player/PlayerControl.vue | 6 +- src/components/Player/PlayerSlider.vue | 4 +- src/stores/status.ts | 28 +- src/utils/format.ts | 14 + src/utils/lyric.ts | 571 ------------------------ src/utils/lyricManager.ts | 53 ++- src/utils/player-utils/lyric.ts | 173 ------- src/utils/player-utils/native.ts | 285 ------------ src/utils/player.ts | 18 +- src/utils/time.ts | 15 - src/views/DesktopLyric/index.vue | 23 +- 16 files changed, 147 insertions(+), 1125 deletions(-) delete mode 100644 src/utils/lyric.ts delete mode 100644 src/utils/player-utils/lyric.ts delete mode 100644 src/utils/player-utils/native.ts diff --git a/src/components/Player/CountDown.vue b/src/components/Player/CountDown.vue index 2df05a7..57189fd 100644 --- a/src/components/Player/CountDown.vue +++ b/src/components/Player/CountDown.vue @@ -36,8 +36,8 @@ const props = defineProps<{ // 是否显示 const isShow = computed(() => { if (!settingStore.countDownShow) return false; - // 计算实时时间 - 0.5是否小于开始 + 持续时间,小于则显示,否则不显示 - return props.seek + 0.5 < props.start + props.duration; + // 计算实时时间 - 0.5s 是否小于开始 + 持续时间,小于则显示,否则不显示 + return props.seek + 500 < props.start + props.duration; }); // 计算每个点的透明度 diff --git a/src/components/Player/LyricMenu.vue b/src/components/Player/LyricMenu.vue index 6e941f3..2dd2ff8 100644 --- a/src/components/Player/LyricMenu.vue +++ b/src/components/Player/LyricMenu.vue @@ -1,12 +1,12 @@ diff --git a/src/stores/status.ts b/src/stores/status.ts index a170209..f462f68 100644 --- a/src/stores/status.ts +++ b/src/stores/status.ts @@ -175,16 +175,26 @@ export const useStatusStore = defineStore("status", { }, }, actions: { - /** 获取指定歌曲的偏移(默认 0) */ + /** + * 获取指定歌曲的偏移 + * 单位:毫秒 + */ getSongOffset(songId?: number): number { if (!songId) return 0; - return this.currentTimeOffsetMap?.[songId] ?? 0; + const offsetTime = this.currentTimeOffsetMap?.[songId] ?? 0; + return Math.floor(offsetTime * 1000); }, - /** 设置指定歌曲的偏移 */ + /** + * 设置指定歌曲的偏移 + * @param songId 歌曲 id + * @param offset 偏移量(单位:毫秒) + */ setSongOffset(songId?: number, offset: number = 0) { if (!songId) return; if (!this.currentTimeOffsetMap) this.currentTimeOffsetMap = {}; - const fixed = Number(offset.toFixed(2)); + // 将毫秒转换为秒存储(保留2位小数) + const offsetSeconds = offset / 1000; + const fixed = Number(offsetSeconds.toFixed(2)); if (fixed === 0) { // 为 0 时移除记录,避免占用空间 delete this.currentTimeOffsetMap[songId]; @@ -192,11 +202,15 @@ export const useStatusStore = defineStore("status", { this.currentTimeOffsetMap[songId] = fixed; } }, - /** 调整指定歌曲的偏移(增量) */ - incSongOffset(songId?: number, delta: number = 0.5) { + /** + * 调整指定歌曲的偏移(增量) + * @param songId 歌曲 id + * @param delta 偏移增量(单位:毫秒,默认 500ms) + */ + incSongOffset(songId?: number, delta: number = 500) { if (!songId) return; const current = this.getSongOffset(songId); - const next = Number((current + delta).toFixed(2)); + const next = current + delta; if (next === 0) { delete this.currentTimeOffsetMap[songId]; } else { diff --git a/src/utils/format.ts b/src/utils/format.ts index e1d2b23..b4e6190 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -267,3 +267,17 @@ const getCoverSizeUrl = (url: string, size: number | null = null) => { return "/images/song.jpg?assest"; } }; + +/** + * 检测歌词语言 + * @param lyric 歌词内容 + * @returns 语言代码("ja" | "zh-CN" | "en") + */ +export const getLyricLanguage = (lyric: string): string => { + // 判断日语 根据平假名和片假名 + if (/[\u3040-\u309f\u30a0-\u30ff]/.test(lyric)) return "ja"; + // 判断简体中文 根据中日韩统一表意文字基本区 + if (/[\u4e00-\u9fa5]/.test(lyric)) return "zh-CN"; + // 默认英语 + return "en"; +}; diff --git a/src/utils/lyric.ts b/src/utils/lyric.ts deleted file mode 100644 index d21f5cc..0000000 --- a/src/utils/lyric.ts +++ /dev/null @@ -1,571 +0,0 @@ -// import { LyricLine, parseLrc, parseTTML, parseYrc, TTMLLyric } from "@applemusic-like-lyrics/lyric"; -// import type { LyricType } from "@/types/main"; -import { useMusicStore, useSettingStore, useStatusStore } from "@/stores"; -// import { SettingState } from "@/stores/setting"; -import { msToS } from "./time"; -import { LyricLine } from "@applemusic-like-lyrics/lyric"; - -// /** 获取排除关键词 */ -// const getExcludeKeywords = (settings: SettingState = useSettingStore()): string[] => { -// if (!settings.enableExcludeLyrics) return []; -// return settings.excludeKeywords; -// }; - -// /** 获取排除正则表达式 */ -// const getExcludeRegexes = (settings: SettingState = useSettingStore()): RegExp[] => { -// if (!settings.enableExcludeLyrics) return []; -// return settings.excludeRegexes.map((regex) => new RegExp(regex)); -// }; - -// /** -// * 检测歌词是否排除 -// * @param line 歌词行 -// * @returns 是否排除 -// */ -// const isLyricExcluded = (line: string): boolean => { -// const settingStore = useSettingStore(); - -// if (!settingStore.enableExcludeLyrics) { -// return false; -// } -// const excludeKeywords = getExcludeKeywords(settingStore); -// const excludeRegexes = getExcludeRegexes(settingStore); -// return ( -// excludeKeywords.some((keyword) => line.includes(keyword)) || -// excludeRegexes.some((regex) => regex.test(line)) -// ); -// }; - -// /** -// * 恢复默认歌词数据 -// */ -// export const resetSongLyric = () => { -// const musicStore = useMusicStore(); -// const statusStore = useStatusStore(); -// // 重置歌词数据 -// musicStore.setSongLyric({}, true); -// statusStore.usingTTMLLyric = false; -// // 标记为加载中(切歌时防止显示上一首歌词) -// statusStore.lyricLoading = true; -// // 重置歌词索引 -// statusStore.lyricIndex = -1; -// }; - -// /** -// * 解析歌词数据 -// * @param lyricData 歌词数据 -// * @param skipExclude 是否跳过排除 -// * @returns 歌词数据 -// */ -// export const parsedLyricsData = (lyricData: any, skipExclude: boolean = false): void => { -// const musicStore = useMusicStore(); -// const statusStore = useStatusStore(); -// if (lyricData.code !== 200) { -// resetSongLyric(); -// return; -// } -// let lrcData: LyricType[] = []; -// let yrcData: LyricType[] = []; -// // 处理后歌词 -// let lrcParseData: LyricLine[] = []; -// let tlyricParseData: LyricLine[] = []; -// let romalrcParseData: LyricLine[] = []; -// let yrcParseData: LyricLine[] = []; -// let ytlrcParseData: LyricLine[] = []; -// let yromalrcParseData: LyricLine[] = []; -// // 普通歌词 -// if (lyricData?.lrc?.lyric) { -// lrcParseData = parseLrc(lyricData.lrc.lyric); -// lrcData = parseLrcData(lrcParseData, skipExclude); -// // 其他翻译 -// if (lyricData?.tlyric?.lyric) { -// tlyricParseData = parseLrc(lyricData.tlyric.lyric); -// lrcData = alignLyrics(lrcData, parseLrcData(tlyricParseData), "tran"); -// } -// if (lyricData?.romalrc?.lyric) { -// romalrcParseData = parseLrc(lyricData.romalrc.lyric); -// lrcData = alignLyrics(lrcData, parseLrcData(romalrcParseData), "roma"); -// } -// } -// // 逐字歌词 -// if (lyricData?.yrc?.lyric) { -// yrcParseData = parseYrc(lyricData.yrc.lyric); -// yrcData = parseYrcData(yrcParseData, skipExclude); -// // 其他翻译 -// if (lyricData?.ytlrc?.lyric) { -// ytlrcParseData = parseLrc(lyricData.ytlrc.lyric); -// yrcData = alignLyrics(yrcData, parseLrcData(ytlrcParseData), "tran"); -// } -// if (lyricData?.yromalrc?.lyric) { -// yromalrcParseData = parseLrc(lyricData.yromalrc.lyric); -// yrcData = alignLyrics(yrcData, parseLrcData(yromalrcParseData), "roma"); -// } -// } -// musicStore.setSongLyric( -// { -// lrcData, -// yrcData, -// lrcAMData: parseAMData(lrcParseData, tlyricParseData, romalrcParseData, skipExclude), -// yrcAMData: parseAMData(yrcParseData, ytlrcParseData, yromalrcParseData, skipExclude), -// }, -// true, -// ); -// // 重置歌词索引 -// statusStore.lyricIndex = -1; -// // 歌词已加载完成 -// statusStore.lyricLoading = false; -// }; - -// /** -// * 解析LRC歌词数据 -// * @param lrcData LRC歌词数据 -// * @param skipExclude 是否跳过排除 -// * @returns LRC歌词数据 -// */ -// export const parseLrcData = (lrcData: LyricLine[], skipExclude: boolean = false): LyricType[] => { -// if (!lrcData) return []; -// // 数据处理 -// const lrcList = lrcData -// .map((line) => { -// const words = line.words; -// const time = msToS(words[0].startTime); -// const content = words[0].word.trim(); -// // 排除内容 -// if (!content || (!skipExclude && isLyricExcluded(content))) { -// return null; -// } -// return { -// time, -// content, -// }; -// }) -// .filter((line): line is LyricType => line !== null); -// // 筛选出非空数据并返回 -// return lrcList; -// }; - -// /** -// * 解析逐字歌词数据 -// * @param yrcData 逐字歌词数据 -// * @param skipExclude 是否跳过排除 -// * @returns 逐字歌词数据 -// */ -// export const parseYrcData = (yrcData: LyricLine[], skipExclude: boolean = false): LyricType[] => { -// if (!yrcData) return []; -// // 数据处理 -// const yrcList = yrcData -// .map((line) => { -// const words = line.words; -// const time = msToS(words[0].startTime); -// const endTime = msToS(words[words.length - 1].endTime); -// const contents = words.map((word) => { -// return { -// time: msToS(word.startTime), -// endTime: msToS(word.endTime), -// duration: msToS(word.endTime - word.startTime), -// content: word.word.trim(), -// endsWithSpace: word.word.endsWith(" "), -// }; -// }); -// // 完整歌词 -// const contentStr = contents -// .map((word) => word.content + (word.endsWithSpace ? " " : "")) -// .join(""); -// // 排除内容 -// if (!contentStr || (!skipExclude && isLyricExcluded(contentStr))) { -// return null; -// } -// return { -// time, -// endTime, -// content: contentStr, -// contents, -// }; -// }) -// .filter((line): line is LyricType => line !== null); -// return yrcList; -// }; - -// /** -// * 歌词内容对齐 -// * @param lyrics 歌词数据 -// * @param otherLyrics 其他歌词数据 -// * @param key 对齐类型 -// * @returns 对齐后的歌词数据 -// */ -// export const alignLyrics = ( -// lyrics: LyricType[], -// otherLyrics: LyricType[], -// key: "tran" | "roma", -// ): LyricType[] => { -// const lyricsData = lyrics; -// if (lyricsData.length && otherLyrics.length) { -// lyricsData.forEach((v: LyricType) => { -// otherLyrics.forEach((x: LyricType) => { -// if (v.time === x.time || Math.abs(v.time - x.time) < 0.6) { -// v[key] = x.content; -// } -// }); -// }); -// } -// return lyricsData; -// }; - -// /** -// * 对齐AM歌词 -// * @param lyrics 歌词数据 -// * @param otherLyrics 其他歌词数据 -// * @param key 对齐类型 -// * @returns 对齐后的歌词数据 -// */ -// export const alignAMLyrics = ( -// 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 lyric 歌词内容 -// * @param format 歌词格式 -// */ -// export const parseLocalLyric = (lyric: string, format: "lrc" | "ttml") => { -// const statusStore = useStatusStore(); - -// if (!lyric) { -// resetSongLyric(); -// return; -// } -// switch (format) { -// case "lrc": -// parseLocalLyricLrc(lyric); -// statusStore.usingTTMLLyric = false; -// break; -// case "ttml": -// parseLocalLyricAM(lyric); -// statusStore.usingTTMLLyric = true; -// break; -// } -// }; - -// /** -// * 解析本地LRC歌词 -// * @param lyric LRC格式的歌词内容 -// */ -// const parseLocalLyricLrc = (lyric: string) => { -// const musicStore = useMusicStore(); -// const statusStore = useStatusStore(); -// const settingStore = useSettingStore(); -// // 解析 -// const lrc: LyricLine[] = parseLrc(lyric); -// const lrcData: LyricType[] = parseLrcData(lrc, !settingStore.enableExcludeLocalLyrics); -// // 处理结果 -// const lrcDataParsed: LyricType[] = []; -// // 翻译提取 -// for (let i = 0; i < lrcData.length; i++) { -// // 当前歌词 -// const lrcItem = lrcData[i]; -// // 是否具有翻译或音译 -// // 根据已解析歌词中是否有时间相同来判断,因此最先遍历的歌词行会被作为主歌词 -// const existingObj = lrcDataParsed.find((v) => v.time === lrcItem.time); -// // 若具有翻译或音译,则判断主歌词中是否有翻译,若没有则将此句作为翻译,音译同理 -// // 如果出现时间相同的歌词行,第一行会被作为主歌词,第二行翻译,第三行音译,其余舍去 -// if (existingObj) { -// if (!existingObj.tran) { -// existingObj.tran = lrcItem.content; -// } else if (!existingObj.roma) { -// existingObj.roma = lrcItem.content; -// } -// } else { -// lrcDataParsed.push(lrcItem); -// } -// } -// // 更新歌词 -// musicStore.setSongLyric( -// { -// lrcData: lrcDataParsed, -// lrcAMData: lrcDataParsed.map((line, index, lines) => ({ -// words: [{ startTime: line.time, endTime: 0, word: line.content }], -// startTime: line.time * 1000, -// endTime: lines[index + 1]?.time * 1000, -// translatedLyric: line.tran ?? "", -// romanLyric: line.roma ?? "", -// isBG: false, -// isDuet: false, -// })), -// yrcData: [], -// yrcAMData: [], -// }, -// true, -// ); -// // 重置歌词索引 -// statusStore.lyricIndex = -1; -// // 歌词已加载完成 -// statusStore.lyricLoading = false; -// }; - -// /** -// * 解析本地AM歌词 -// * @param lyric AM格式的歌词内容 -// */ -// const parseLocalLyricAM = (lyric: string) => { -// const musicStore = useMusicStore(); -// const statusStore = useStatusStore(); -// const settingStore = useSettingStore(); - -// const skipExcludeLocal = !settingStore.enableExcludeLocalLyrics; -// const skipExcludeTTML = !settingStore.enableTTMLLyric; -// const skipExclude = skipExcludeLocal || skipExcludeTTML; - -// const ttml = parseTTML(lyric); -// const yrcAMData = parseTTMLToAMLL(ttml, skipExclude); -// const yrcData = parseTTMLToYrc(ttml, skipExclude); -// musicStore.setSongLyric( -// { -// lrcData: yrcData, -// lrcAMData: yrcAMData, -// yrcAMData, -// yrcData, -// }, -// true, -// ); -// // 重置歌词索引 -// statusStore.lyricIndex = -1; -// // 歌词已加载完成 -// statusStore.lyricLoading = false; -// }; - -// /** -// * 处理 AM 歌词 -// * @param lrcData LRC歌词数据 -// * @param tranData 翻译歌词数据 -// * @param romaData 罗马音歌词数据 -// * @param skipExclude 是否跳过排除 -// * @returns AM歌词数据 -// */ -// const parseAMData = ( -// lrcData: LyricLine[], -// tranData?: LyricLine[], -// romaData?: LyricLine[], -// skipExclude: boolean = false, -// ) => { -// let lyricData = lrcData -// .map((line, index, lines) => { -// // 获取歌词文本内容 -// const content = line.words -// .map((word) => word.word) -// .join("") -// .trim(); -// // 排除包含关键词的内容 -// if (!content || (!skipExclude && isLyricExcluded(content))) { -// return null; -// } -// return { -// words: line.words, -// startTime: line.words[0]?.startTime ?? 0, -// endTime: -// lines[index + 1]?.words?.[0]?.startTime ?? -// line.words?.[line.words.length - 1]?.endTime ?? -// Infinity, -// translatedLyric: "", -// romanLyric: "", -// isBG: line.isBG ?? false, -// isDuet: line.isDuet ?? false, -// }; -// }) -// .filter((line): line is NonNullable => line !== null); -// if (tranData) { -// lyricData = alignAMLyrics(lyricData, tranData, "translatedLyric"); -// } -// if (romaData) { -// lyricData = alignAMLyrics(lyricData, romaData, "romanLyric"); -// } -// return lyricData; -// }; - -// /** -// * 从TTML格式解析歌词并转换为AMLL格式 -// * @param ttmlContent TTML格式的歌词内容 -// * @param skipExclude 是否跳过排除 -// * @returns AMLL格式的歌词行数组 -// */ -// export const parseTTMLToAMLL = ( -// ttmlContent: TTMLLyric, -// skipExclude: boolean = false, -// ): LyricLine[] => { -// if (!ttmlContent) return []; - -// try { -// const validLines = ttmlContent.lines -// .filter((line) => line && typeof line === "object" && Array.isArray(line.words)) -// .map((line) => { -// const words = line.words -// .filter((word) => word && typeof word === "object") -// .map((word) => ({ -// word: String(word.word || " "), -// startTime: Number(word.startTime) || 0, -// endTime: Number(word.endTime) || 0, -// })); - -// if (!words.length) return null; - -// // 获取歌词文本内容 -// const content = words -// .map((word) => word.word) -// .join("") -// .trim(); -// // 排除包含关键词的内容 -// if (!content || (!skipExclude && isLyricExcluded(content))) { -// return null; -// } - -// const startTime = line.startTime || words[0].startTime; -// const endTime = line.endTime || words[words.length - 1].endTime; - -// return { -// words, -// startTime, -// endTime, -// translatedLyric: String(line.translatedLyric || ""), -// romanLyric: String(line.romanLyric || ""), -// isBG: Boolean(line.isBG), -// isDuet: Boolean(line.isDuet), -// }; -// }) -// .filter((line): line is LyricLine => line !== null); - -// return validLines; -// } catch (error) { -// console.error("TTML parsing error:", error); -// return []; -// } -// }; - -// /** -// * 从TTML格式解析歌词并转换为默认Yrc格式 -// * @param ttmlContent TTML格式的歌词内容 -// * @param skipExclude 是否跳过排除 -// * @returns 默认Yrc格式的歌词行数组 -// */ -// export const parseTTMLToYrc = ( -// ttmlContent: TTMLLyric, -// skipExclude: boolean = false, -// ): LyricType[] => { -// if (!ttmlContent) return []; - -// try { -// // 数据处理 -// const yrcList = ttmlContent.lines -// .map((line) => { -// const words = line.words; -// const time = msToS(words[0].startTime); -// const endTime = msToS(words[words.length - 1].endTime); -// const contents = words.map((word) => { -// return { -// time: msToS(word.startTime), -// endTime: msToS(word.endTime), -// duration: msToS(word.endTime - word.startTime), -// content: word.word.trim(), -// endsWithSpace: word.word.endsWith(" "), -// }; -// }); -// // 完整歌词 -// const contentStr = contents -// .map((word) => word.content + (word.endsWithSpace ? " " : "")) -// .join(""); -// // 排除内容 -// if (!contentStr || (!skipExclude && isLyricExcluded(contentStr))) { -// return null; -// } -// return { -// time, -// endTime, -// content: contentStr, -// contents, -// tran: line.translatedLyric || "", -// roma: line.romanLyric || "", -// isBG: line.isBG, -// isDuet: line.isDuet, -// }; -// }) -// .filter((line) => line !== null); -// return yrcList; -// } catch (error) { -// console.error("TTML parsing to yrc error:", error); -// return []; -// } -// }; - -// 检测语言 -export const getLyricLanguage = (lyric: string): string => { - // 判断日语 根据平假名和片假名 - if (/[\u3040-\u309f\u30a0-\u30ff]/.test(lyric)) return "ja"; - // 判断简体中文 根据中日韩统一表意文字基本区 - if (/[\u4e00-\u9fa5]/.test(lyric)) return "zh-CN"; - // 默认英语 - return "en"; -}; - -/** - * 计算歌词索引 - * - 普通歌词(LRC):沿用当前按开始时间定位的算法 - * - 逐字歌词(YRC):当播放时间位于某句 [time, endTime) 区间内时,索引为该句; - * 若下一句开始时间落在上一句区间(对唱重叠),仍保持上一句索引,直到上一句结束。 - */ -export const calculateLyricIndex = (currentTime: number): number => { - const musicStore = useMusicStore(); - const statusStore = useStatusStore(); - const settingStore = useSettingStore(); - // 应用实时偏移(按歌曲 id 记忆) + 0.3s(解决对唱时歌词延迟问题) - const songId = musicStore.playSong?.id; - const offset = statusStore.getSongOffset(songId); - const playSeek = currentTime + offset + 0.3; - // 选择歌词类型 - const useYrc = !!(settingStore.showYrc && musicStore.songLyric.yrcData.length); - const lyrics = useYrc ? musicStore.songLyric.yrcData : musicStore.songLyric.lrcData; - // 无歌词时 - if (!lyrics || !lyrics.length) return -1; - - const getStart = (v: LyricLine) => msToS(v.startTime || 0); - const getEnd = (v: LyricLine) => msToS(v.endTime ?? Infinity); - // 普通歌词:保持原有计算方式 - if (!useYrc) { - const idx = lyrics.findIndex((v) => getStart(v) >= playSeek); - return idx === -1 ? lyrics.length - 1 : idx - 1; - } - // TTML / YRC(支持对唱重叠) - // 在第一句之前 - if (playSeek < getStart(lyrics[0])) return -1; - // 计算在播放进度下处于激活区间的句子集合 activeIndices([time, endTime)) - const activeIndices: number[] = []; - for (let i = 0; i < lyrics.length; i++) { - const start = getStart(lyrics[i]); - const end = getEnd(lyrics[i]); - if (playSeek >= start && playSeek < end) { - activeIndices.push(i); - } - } - // 不在任何区间 → 找最近的上一句 - if (activeIndices.length === 0) { - const next = lyrics.findIndex((v) => getStart(v) > playSeek); - return next === -1 ? lyrics.length - 1 : next - 1; - } - // 1 句激活 → 直接返回 - if (activeIndices.length === 1) return activeIndices[0]; - // 多句激活(对唱) - const keepCount = activeIndices.length >= 3 ? 3 : 2; - const concurrent = activeIndices.slice(-keepCount); - return concurrent[0]; // 保持上一句(重叠时不跳) -}; diff --git a/src/utils/lyricManager.ts b/src/utils/lyricManager.ts index 80a7dc5..36d5c4f 100644 --- a/src/utils/lyricManager.ts +++ b/src/utils/lyricManager.ts @@ -111,7 +111,7 @@ class LyricManager { // 请求序列 const req = this.activeLyricReq; // 最终结果 - let result: SongLyric = { lrcData: [], yrcData: [] }; + const result: SongLyric = { lrcData: [], yrcData: [] }; // 过期判断 const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id; // 处理 TTML 歌词 @@ -343,6 +343,57 @@ class LyricManager { } } } + /** + * 计算歌词索引 + * - 普通歌词(LRC):沿用当前按开始时间定位的算法 + * - 逐字歌词(YRC):当播放时间位于某句 [time, endTime) 区间内时,索引为该句; + * 若下一句开始时间落在上一句区间(对唱重叠),仍保持上一句索引,直到上一句结束。 + */ + public calculateLyricIndex(currentTime: number): number { + const musicStore = useMusicStore(); + const statusStore = useStatusStore(); + const settingStore = useSettingStore(); + // 应用实时偏移(按歌曲 id 记忆) + 0.3s(解决对唱时歌词延迟问题) + const songId = musicStore.playSong?.id; + const offset = statusStore.getSongOffset(songId); + const playSeek = currentTime + offset + 300; + // 选择歌词类型 + const useYrc = !!(settingStore.showYrc && musicStore.songLyric.yrcData.length); + const lyrics = useYrc ? musicStore.songLyric.yrcData : musicStore.songLyric.lrcData; + // 无歌词时 + if (!lyrics || !lyrics.length) return -1; + // 获取开始时间和结束时间 + const getStart = (v: LyricLine) => v.startTime || 0; + const getEnd = (v: LyricLine) => v.endTime ?? Infinity; + // 普通歌词:保持原有计算方式 + if (!useYrc) { + const idx = lyrics.findIndex((v) => getStart(v) >= playSeek); + return idx === -1 ? lyrics.length - 1 : idx - 1; + } + // TTML / YRC(支持对唱重叠) + // 在第一句之前 + if (playSeek < getStart(lyrics[0])) return -1; + // 计算在播放进度下处于激活区间的句子集合 activeIndices([time, endTime)) + const activeIndices: number[] = []; + for (let i = 0; i < lyrics.length; i++) { + const start = getStart(lyrics[i]); + const end = getEnd(lyrics[i]); + if (playSeek >= start && playSeek < end) { + activeIndices.push(i); + } + } + // 不在任何区间 → 找最近的上一句 + if (activeIndices.length === 0) { + const next = lyrics.findIndex((v) => getStart(v) > playSeek); + return next === -1 ? lyrics.length - 1 : next - 1; + } + // 1 句激活 → 直接返回 + if (activeIndices.length === 1) return activeIndices[0]; + // 多句激活(对唱) + const keepCount = activeIndices.length >= 3 ? 3 : 2; + const concurrent = activeIndices.slice(-keepCount); + return concurrent[0]; // 保持上一句(重叠时不跳) + } } export default new LyricManager(); diff --git a/src/utils/player-utils/lyric.ts b/src/utils/player-utils/lyric.ts deleted file mode 100644 index 7de101a..0000000 --- a/src/utils/player-utils/lyric.ts +++ /dev/null @@ -1,173 +0,0 @@ -// import { useMusicStore, useSettingStore, useStatusStore } from "@/stores"; -// import { -// parsedLyricsData, -// parseLocalLyric, -// parseTTMLToAMLL, -// parseTTMLToYrc, -// resetSongLyric, -// } from "../lyric"; -// import { songLyric, songLyricTTML } from "@/api/song"; -// import { parseTTML } from "@applemusic-like-lyrics/lyric"; -// import { LyricLine } from "@applemusic-like-lyrics/core"; -// import { LyricType } from "@/types/main"; - -// /** -// * 获取歌词 -// * @param id 歌曲id -// */ -// export const getLyricData = async (id: number) => { -// const musicStore = useMusicStore(); -// const settingStore = useSettingStore(); -// const statusStore = useStatusStore(); -// // 切歌或重新获取时,先标记为加载中 -// statusStore.lyricLoading = true; - -// if (!id) { -// statusStore.usingTTMLLyric = false; -// resetSongLyric(); -// statusStore.lyricLoading = false; -// return; -// } - -// try { -// // 检测本地歌词覆盖 -// const getLyric = getLyricFun(settingStore.localLyricPath, id); - -// // 并发请求:如果 TTML 先到并且有效,则直接采用 TTML,不再等待或覆盖为 LRC -// const lrcPromise = getLyric("lrc", songLyric); -// // 这里的第二个 getLyric 方法不传入第二个参数(在线获取函数)表明不进行在线获取,仅获取本地 -// const ttmlPromise = settingStore.enableTTMLLyric -// ? getLyric("ttml", songLyricTTML) -// : getLyric("ttml"); - -// let settled = false; // 是否已采用某一种歌词并结束加载状态 -// let ttmlAdopted = false; // 是否已采用 TTML - -// const adoptTTML = async () => { -// if (!ttmlPromise) { -// statusStore.usingTTMLLyric = false; -// return; -// } -// try { -// const { lyric: ttmlContent, isLocal: ttmlLocal } = await ttmlPromise; -// if (!ttmlContent) { -// statusStore.usingTTMLLyric = false; -// return; -// } -// // 本地 TTML 使用 parseLocalLyric,在线 TTML 使用原有解析方式 -// if (ttmlLocal) { -// parseLocalLyric(ttmlContent, "ttml"); -// statusStore.usingTTMLLyric = true; -// ttmlAdopted = true; -// if (!settled) { -// statusStore.lyricLoading = false; -// settled = true; -// } -// console.log("✅ TTML lyrics adopted (prefer TTML)"); -// return; -// } -// // 在线 TTML 解析 -// const parsedResult = parseTTML(ttmlContent); -// if (!parsedResult?.lines?.length) { -// statusStore.usingTTMLLyric = false; -// return; -// } -// const skipExcludeLocal = ttmlLocal && !settingStore.enableExcludeLocalLyrics; -// const skipExcludeTTML = !settingStore.enableExcludeTTML; -// const skipExclude = skipExcludeLocal || skipExcludeTTML; -// const ttmlLyric = parseTTMLToAMLL(parsedResult, skipExclude); -// const ttmlYrcLyric = parseTTMLToYrc(parsedResult, skipExclude); - -// const updates: Partial<{ -// yrcAMData: LyricLine[]; -// yrcData: LyricType[]; -// lrcData: LyricType[]; -// lrcAMData: LyricLine[]; -// }> = {}; -// if (ttmlLyric?.length) { -// updates.yrcAMData = ttmlLyric; -// // 若当前无 LRC-AM 数据,使用 TTML-AM 作为回退 -// if (!musicStore.songLyric.lrcAMData?.length) { -// updates.lrcAMData = ttmlLyric; -// } -// } -// if (ttmlYrcLyric?.length) { -// updates.yrcData = ttmlYrcLyric; -// // 若当前无 LRC 数据,使用 TTML 行级数据作为回退 -// if (!musicStore.songLyric.lrcData?.length) { -// updates.lrcData = ttmlYrcLyric; -// } -// } - -// if (Object.keys(updates).length) { -// musicStore.setSongLyric(updates); -// statusStore.usingTTMLLyric = true; -// ttmlAdopted = true; -// if (!settled) { -// statusStore.lyricLoading = false; -// settled = true; -// } -// console.log("✅ TTML lyrics adopted (prefer TTML)"); -// } else { -// statusStore.usingTTMLLyric = false; -// } -// } catch (err) { -// console.error("❌ Error loading TTML lyrics:", err); -// statusStore.usingTTMLLyric = false; -// } -// }; - -// const adoptLRC = async () => { -// try { -// const { lyric: lyricRes, isLocal: lyricLocal } = await lrcPromise; -// // 如果 TTML 已采用,则忽略 LRC -// if (ttmlAdopted) return; -// // 如果没有歌词内容,直接返回 -// if (!lyricRes) return; -// // 本地歌词使用 parseLocalLyric,在线歌词使用 parsedLyricsData -// if (lyricLocal) { -// parseLocalLyric(lyricRes, "lrc"); -// } else { -// parsedLyricsData(lyricRes, !settingStore.enableExcludeLocalLyrics); -// } -// statusStore.usingTTMLLyric = false; -// if (!settled) { -// statusStore.lyricLoading = false; -// settled = true; -// } -// console.log("✅ LRC lyrics adopted"); -// } catch (err) { -// console.error("❌ Error loading LRC lyrics:", err); -// if (!settled) statusStore.lyricLoading = false; -// } -// }; - -// // 启动并发任务:TTML 与 LRC 同时进行,哪个先成功就先用 -// void adoptLRC(); -// void adoptTTML(); -// } catch (error) { -// console.error("❌ Error loading lyrics:", error); -// statusStore.usingTTMLLyric = false; -// resetSongLyric(); -// statusStore.lyricLoading = false; -// } -// }; - -// /** -// * 获取歌词函数生成器 -// * @param paths 本地歌词路径数组 -// * @param id 歌曲ID -// * @returns 返回一个函数,该函数接受扩展名和在线获取函数作为参数 -// */ -// const getLyricFun = -// (paths: string[], id: number) => -// async ( -// ext: string, -// getOnline?: (id: number) => Promise, -// ): Promise<{ lyric: string | null; isLocal: boolean }> => { -// for (const path of paths) { -// const lyric = await window.electron.ipcRenderer.invoke("read-local-lyric", path, id, ext); -// if (lyric) return { lyric, isLocal: true }; -// } -// return { lyric: getOnline ? await getOnline(id) : null, isLocal: false }; -// }; diff --git a/src/utils/player-utils/native.ts b/src/utils/player-utils/native.ts deleted file mode 100644 index a101237..0000000 --- a/src/utils/player-utils/native.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * 基于 HTMLAudioElement + Web Audio 的播放器引擎 - */ -export class PlayerNative { - private audio: HTMLAudioElement; - private ctx: AudioContext; - private sourceNode: MediaElementAudioSourceNode; - private gainNode: GainNode; - private analyser?: AnalyserNode; - private events: Map void>> = new Map(); - - /** - * 构造播放器引擎 - * @param ctx 可选的外部 `AudioContext`,不传则内部创建 - */ - constructor(ctx?: AudioContext) { - this.audio = new Audio(); - this.audio.preload = "auto"; - this.audio.crossOrigin = "anonymous"; - - this.ctx = ctx ?? new AudioContext(); - this.sourceNode = this.ctx.createMediaElementSource(this.audio); - this.gainNode = this.ctx.createGain(); - this.sourceNode.connect(this.gainNode).connect(this.ctx.destination); - - this.bindDomEvents(); - } - - /** - * 加载指定音频地址,切换歌曲时只需调用此方法即可 - * @param src 音频 URL(需允许跨域以启用频谱/均衡等处理) - */ - load(src: string): void { - this.audio.src = src; - // 重置并触发新加载 - this.audio.load(); - this.emit("loadstart"); - } - - /** - * 开始播放如果 `AudioContext` 处于挂起状态,将自动恢复 - * @returns 播放 Promise,用于捕获自动播放限制等异常 - */ - async play(): Promise { - if (this.ctx.state === "suspended") { - try { - await this.ctx.resume(); - } catch (e) { - this.emit("error", e); - } - } - try { - await this.audio.play(); - this.emit("play"); - } catch (e) { - this.emit("error", e); - throw e; - } - } - - /** - * 暂停播放 - */ - pause(): void { - this.audio.pause(); - this.emit("pause"); - } - - /** - * 停止播放并将进度归零 - */ - stop(): void { - this.audio.pause(); - this.audio.currentTime = 0; - this.emit("stop"); - } - - /** - * 跳转到指定秒数 - * @param seconds 目标时间(秒) - */ - seek(seconds: number): void { - try { - this.audio.currentTime = Math.max(0, seconds); - this.emit("seek", seconds); - } catch (e) { - this.emit("error", e); - } - } - - /** - * 设置音量(0.0 ~ 1.0) - * @param volume 音量值 - */ - setVolume(volume: number): void { - const v = Math.min(1, Math.max(0, volume)); - this.gainNode.gain.setValueAtTime(v, this.ctx.currentTime); - this.emit("volume", v); - } - - /** - * 渐变到目标音量 - * @param target 目标音量(0.0 ~ 1.0) - * @param durationMs 渐变时长(毫秒) - */ - fadeTo(target: number, durationMs: number): void { - const now = this.ctx.currentTime; - const t = Math.min(1, Math.max(0, target)); - this.gainNode.gain.cancelScheduledValues(now); - this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, now); - this.gainNode.gain.linearRampToValueAtTime(t, now + durationMs / 1000); - this.emit("fade", t, durationMs); - } - - /** - * 设置是否循环播放 - * @param loop 是否循环 - */ - setLoop(loop: boolean): void { - this.audio.loop = !!loop; - this.emit("loop", !!loop); - } - - /** - * 设置播放速率 - * @param rate 倍速(例如 1.0 正常速) - */ - setRate(rate: number): void { - const r = Math.max(0.25, Math.min(4, rate)); - this.audio.playbackRate = r; - this.emit("rate", r); - } - - /** - * 将内部音频链路连接到外部 `AnalyserNode`,用于频谱/可视化 - * @param analyser 频谱分析节点 - */ - connectAnalyser(analyser: AnalyserNode): void { - try { - // 重新布线:gain -> analyser -> destination - this.gainNode.disconnect(); - this.gainNode.connect(analyser).connect(this.ctx.destination); - this.analyser = analyser; - this.emit("analyser"); - } catch (e) { - this.emit("error", e); - } - } - - /** - * 获取音频总时长(秒) - */ - getDuration(): number { - return Number.isFinite(this.audio.duration) ? this.audio.duration : 0; - } - - /** - * 获取当前播放进度(秒) - */ - getCurrentTime(): number { - return this.audio.currentTime || 0; - } - - /** - * 获取缓冲范围集合(TimeRanges) - */ - getBuffered(): TimeRanges { - return this.audio.buffered; - } - - /** - * 绑定原生 `HTMLAudioElement` 的事件到引擎事件系统 - */ - private bindDomEvents(): void { - this.audio.addEventListener("loadstart", () => this.emit("loadstart")); - this.audio.addEventListener("canplay", () => this.emit("canplay")); - this.audio.addEventListener("canplaythrough", () => this.emit("loaded")); - this.audio.addEventListener("playing", () => this.emit("playing")); - this.audio.addEventListener("pause", () => this.emit("pause")); - this.audio.addEventListener("ended", () => this.emit("end")); - this.audio.addEventListener("waiting", () => this.emit("waiting")); - this.audio.addEventListener("stalled", () => this.emit("stalled")); - this.audio.addEventListener("error", (e) => this.emit("error", e)); - this.audio.addEventListener("timeupdate", () => this.emit("time", this.audio.currentTime)); - this.audio.addEventListener("progress", () => this.emit("progress", this.audio.buffered)); - } - - /** - * 订阅事件 - * @param event 事件名 - * @param handler 事件处理函数 - */ - on(event: string, handler: (...args: any[]) => void): void { - if (!this.events.has(event)) this.events.set(event, new Set()); - this.events.get(event)!.add(handler); - } - - /** - * 取消订阅事件;不传参数则清空所有事件 - * @param event 事件名(可选) - * @param handler 处理函数(可选) - */ - off(event?: string, handler?: (...args: any[]) => void): void { - if (!event) { - this.events.clear(); - return; - } - if (!handler) { - this.events.get(event)?.clear(); - return; - } - this.events.get(event)?.delete(handler); - } - - /** - * 释放资源与断开音频链路;不会销毁全局 `AudioContext` - */ - destroy(): void { - try { - this.off(); - this.audio.pause(); - this.audio.src = ""; - this.audio.removeAttribute("src"); - // 一般用于释放媒体资源的模式 - this.audio.load(); - } catch {} - try { - this.sourceNode.disconnect(); - } catch {} - try { - this.gainNode.disconnect(); - } catch {} - try { - this.analyser?.disconnect(); - } catch {} - this.emit("destroy"); - } - - /** - * 触发事件 - * @param event 事件名 - * @param args 事件参数 - */ - private emit(event: string, ...args: any[]): void { - const set = this.events.get(event); - if (!set || set.size === 0) return; - set.forEach((fn) => { - try { - fn(...args); - } catch {} - }); - } -} - -/** - * 创建一个 PlayerNative 实例的便捷工厂 - * @param ctx 可选的外部 `AudioContext` - */ -export function createPlayerNative(ctx?: AudioContext): PlayerNative { - return new PlayerNative(ctx); -} - -/** - * 引擎事件类型参考(自由扩展) - */ -export type PlayerNativeEvent = - | "loadstart" - | "canplay" - | "loaded" - | "playing" - | "pause" - | "stop" - | "seek" - | "volume" - | "fade" - | "loop" - | "rate" - | "analyser" - | "time" - | "progress" - | "waiting" - | "stalled" - | "end" - | "error" - | "destroy"; diff --git a/src/utils/player.ts b/src/utils/player.ts index 9ac174e..cba3db0 100644 --- a/src/utils/player.ts +++ b/src/utils/player.ts @@ -3,8 +3,6 @@ import type { MessageReactive } from "naive-ui"; import { Howl, Howler } from "howler"; import { cloneDeep } from "lodash-es"; import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores"; -// import { resetSongLyric, parseLocalLyric, calculateLyricIndex } from "./lyric"; -import { calculateLyricIndex } from "./lyric"; import { calculateProgress } from "./time"; import { shuffleArray, runIdle } from "./helper"; import { heartRateList } from "@/api/playlist"; @@ -108,11 +106,11 @@ class Player { } if (!this.player.playing()) return; const currentTime = this.getSeek(); - const duration = this.player.duration(); + const duration = Math.floor(this.player.duration() * 1000); // 计算进度条距离 const progress = calculateProgress(currentTime, duration); // 计算歌词索引(支持 LRC 与逐字 YRC,对唱重叠处理) - const lyricIndex = calculateLyricIndex(currentTime); + const lyricIndex = lyricManager.calculateLyricIndex(currentTime); // 更新状态 statusStore.$patch({ currentTime, duration, progress, lyricIndex }); // 客户端事件 @@ -300,9 +298,9 @@ class Player { } // 恢复进度(仅在明确指定且大于0时才恢复,避免切换歌曲时意外恢复进度) if (seek && seek > 0) { - const duration = this.player.duration(); + const duration = Math.floor(this.player.duration() * 1000); // 确保恢复的进度有效且距离歌曲结束大于2秒 - if (duration && seek < duration - 2) { + if (duration && seek < duration - 2000) { this.setSeek(seek); } } @@ -907,7 +905,7 @@ class Player { } /** * 设置播放进度 - * @param time 播放进度 + * @param time 播放进度(单位:毫秒) */ setSeek(time: number) { const statusStore = useStatusStore(); @@ -916,17 +914,17 @@ class Player { console.warn("⚠️ Player not ready for seek"); return; } - this.player.seek(time); + this.player.seek(time / 1000); statusStore.currentTime = time; } /** * 获取播放进度 - * @returns 播放进度(单位:秒) + * @returns 播放进度(单位:毫秒) */ getSeek(): number { // 检查播放器状态 if (!this.player || this.player.state() !== "loaded") return 0; - return this.player.seek(); + return Math.floor(this.player.seek() * 1000); } /** * 设置播放速率 diff --git a/src/utils/time.ts b/src/utils/time.ts index 77954d8..59ab069 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -75,25 +75,10 @@ export const formatCommentTime = (timestamp: number): string => { */ export const calculateProgress = (currentTime: number, duration: number): number => { if (duration === 0) return 0; - const progress = (currentTime / duration) * 100; return Math.min(Math.round(progress * 100) / 100, 100); }; -/** - * 根据进度和总时长反推当前时间 - * @param {number} progress 进度百分比,范围通常是0到100 - * @param {number} duration 总时长,单位为秒 - * @returns {number} 当前时间,单位为秒,精确到0.01秒 - */ -export const calculateCurrentTime = (progress: number, duration: number): number => { - // 确保在有效范围内 - progress = Math.min(Math.max(progress, 0), 100); - - const currentTime = (progress / 100) * duration; - return Math.round(currentTime * 100) / 100; -}; - /** * 获取当前时间段的问候语 */ diff --git a/src/views/DesktopLyric/index.vue b/src/views/DesktopLyric/index.vue index ebb958a..cb1b631 100644 --- a/src/views/DesktopLyric/index.vue +++ b/src/views/DesktopLyric/index.vue @@ -335,21 +335,18 @@ const renderLyricLines = computed(() => { const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => { const currentLine = lyricData.yrcData?.[lyricIndex]; if (!currentLine) return { WebkitMaskPositionX: "100%" }; - const seekSec = playSeekMs.value / 1000; - const startSec = (currentLine.startTime || 0) / 1000; - const endSec = (currentLine.endTime || 0) / 1000; + const seekSec = playSeekMs.value; + const startSec = currentLine.startTime || 0; + const endSec = currentLine.endTime || 0; const isLineActive = (seekSec >= startSec && seekSec < endSec) || lyricData.lyricIndex === lyricIndex; if (!isLineActive) { - const hasPlayed = seekSec >= (wordData.endTime || 0) / 1000; + const hasPlayed = seekSec >= (wordData.endTime || 0); return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" }; } - const durationSec = Math.max(((wordData.endTime || 0) - (wordData.startTime || 0)) / 1000, 0.001); - const progress = Math.max( - Math.min((seekSec - (wordData.startTime || 0) / 1000) / durationSec, 1), - 0, - ); + const durationSec = Math.max((wordData.endTime || 0) - (wordData.startTime || 0), 0.001); + const progress = Math.max(Math.min((seekSec - (wordData.startTime || 0)) / durationSec, 1), 0); return { transitionDuration: `0s, 0s, 0.35s`, transitionDelay: `0ms`, @@ -382,11 +379,11 @@ const getScrollStyle = (line: RenderLine) => { const overflow = Math.max(0, content.scrollWidth - container.clientWidth); if (overflow <= 0) return { transform: "translateX(0px)" }; // 计算进度:毫秒锚点插值(`playSeekMs`),并以当前行的 `time` 与有效 `endTime` 计算区间 - const seekSec = playSeekMs.value / 1000; - const start = Number(line.line.startTime ?? 0) / 1000; + const seekSec = playSeekMs.value; + const start = Number(line.line.startTime ?? 0); // 仅在滚动计算中提前 1 秒 const END_MARGIN_SEC = 1; - const endRaw = Number(line.line.endTime) / 1000; + const endRaw = Number(line.line.endTime); // 若 endTime 仍为 0 或不大于 start,视为无时长:不滚动 const hasSafeEnd = Number.isFinite(endRaw) && endRaw > 0 && endRaw > start; if (!hasSafeEnd) return { transform: "translateX(0px)" }; @@ -608,7 +605,7 @@ onMounted(() => { // 更新锚点:以传入的 currentTime + songOffset 建立毫秒级基准,并重置帧时间 if (typeof lyricData.currentTime === "number") { const offset = Number(lyricData.songOffset ?? 0); - baseMs = Math.floor((lyricData.currentTime + offset) * 1000); + baseMs = Math.floor(lyricData.currentTime + offset); anchorTick = performance.now(); } // 按播放状态节能:暂停时暂停 RAF,播放时恢复 RAF