From a5d3ff359c1bef592b85b523330938cfeb847c55 Mon Sep 17 00:00:00 2001 From: alger Date: Sun, 12 Oct 2025 17:11:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=80=90=E5=AD=97?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E6=95=88=E6=9E=9C=EF=BC=8C=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E6=B7=BB=E5=8A=A0=E9=80=90=E5=AD=97=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/components/lyric/MusicFull.vue | 42 +++++-- .../components/lyric/MusicFullMobile.vue | 64 +++++++--- src/renderer/hooks/MusicHook.ts | 7 +- src/renderer/layout/components/SearchBar.vue | 1 - src/renderer/store/modules/player.ts | 112 +++++++++++++----- src/renderer/types/music.ts | 1 + src/renderer/utils/yrcParser.ts | 111 ++++++++++++++--- src/renderer/views/lyric/index.vue | 95 ++++++++++++++- 8 files changed, 351 insertions(+), 82 deletions(-) diff --git a/src/renderer/components/lyric/MusicFull.vue b/src/renderer/components/lyric/MusicFull.vue index 9a28768..415938b 100644 --- a/src/renderer/components/lyric/MusicFull.vue +++ b/src/renderer/components/lyric/MusicFull.vue @@ -111,27 +111,31 @@ + +
+ 本歌词不支持自动滚动 +
- + + {{ word.text }}   - {{ word.text }} -
{{ item.text }} @@ -221,6 +225,10 @@ watch( { deep: true } ); +const supportAutoScroll = computed(() => { + return lrcArray.value.length > 0 && lrcArray.value[0].startTime !== -1; +}); + const props = defineProps({ modelValue: { type: Boolean, @@ -246,7 +254,7 @@ const isVisible = computed({ // 歌词滚动方法 const lrcScroll = (behavior: ScrollBehavior = 'smooth', forceTop: boolean = false) => { - if (!isVisible.value || !lrcSider.value) return; + if (!isVisible.value || !lrcSider.value || !supportAutoScroll.value) return; if (forceTop) { lrcSider.value.scrollTo({ @@ -749,6 +757,20 @@ defineExpose({ letter-spacing: var(--lyric-letter-spacing, 0) !important; line-height: var(--lyric-line-height, 2) !important; + &.no-scroll-tip { + @apply text-base opacity-60 cursor-default py-2; + color: var(--text-color-primary); + font-weight: normal; + + span { + padding-right: 0; + } + + &:hover { + background-color: transparent; + } + } + span { background-clip: text !important; -webkit-background-clip: text !important; diff --git a/src/renderer/components/lyric/MusicFullMobile.vue b/src/renderer/components/lyric/MusicFullMobile.vue index 8312161..dd0ff99 100644 --- a/src/renderer/components/lyric/MusicFullMobile.vue +++ b/src/renderer/components/lyric/MusicFullMobile.vue @@ -49,27 +49,31 @@ @scroll="handleScroll" >
+ +
+ 本歌词不支持自动滚动 +
- + + {{ word.text }}   - {{ word.text }} -
{{ item.text }} @@ -247,27 +251,31 @@ @scroll="handleScroll" >
+ +
+ 本歌词不支持自动滚动 +
- + + {{ word.text }}   - {{ word.text }} -
{{ item.text }} @@ -458,6 +466,10 @@ const showFullLyricScreen = () => { }); }; +const supportAutoScroll = computed(() => { + return lrcArray.value.length > 0 && lrcArray.value[0].startTime !== -1; +}); + // 关闭全屏歌词 const closeFullLyrics = () => { showFullLyrics.value = false; @@ -476,6 +488,11 @@ const scrollToCurrentLyric = (immediate = false, customScrollerRef?: HTMLElement return; } + if (!supportAutoScroll.value) { + console.log('歌词不支持自动滚动'); + return; + } + // 如果用户正在手动滚动,不打断他们的操作 if (isTouchScrolling.value && !immediate) { return; @@ -1681,6 +1698,16 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => { color: var(--text-color-primary); opacity: 0.8; + &.no-scroll-tip { + @apply text-base opacity-60 cursor-default py-2; + color: var(--text-color-primary); + font-weight: normal; + + span { + padding-right: 0; + } + } + span { background-clip: text !important; -webkit-background-clip: text !important; @@ -1712,6 +1739,7 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => { line-height: inherit; cursor: inherit; position: relative; + padding-right: 0 !important; &:hover { background-color: rgba(255, 255, 255, 0.1); diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index cbf50fa..8306d1f 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -283,6 +283,7 @@ const parseLyricsString = async ( try { const parseResult = parseLyrics(lyricsStr); + console.log('parseResult', parseResult); if (!parseResult.success) { console.error('歌词解析失败:', parseResult.error.message); @@ -306,9 +307,7 @@ const parseLyricsString = async ( trText: '', // 翻译文本稍后处理 words: hasWords ? line.words.map((word) => ({ - text: word.text, - startTime: word.startTime, - duration: word.duration + ...word })) : undefined, hasWordByWord: hasWords, @@ -318,7 +317,6 @@ const parseLyricsString = async ( lrcTimeArray.push(line.startTime); } - console.log('parseLyricsString', lrcArray); return { lrcArray, lrcTimeArray, hasWordByWord }; } catch (error) { console.error('解析歌词时发生错误:', error); @@ -760,6 +758,7 @@ export const getLrcTimeRange = (index: number) => ({ watch( () => lrcArray.value, (newLrcArray) => { + console.log('lrcArray.value', lrcArray.value); if (newLrcArray.length > 0 && isElectron && isLyricWindowOpen.value) { sendLyricToWin(); } diff --git a/src/renderer/layout/components/SearchBar.vue b/src/renderer/layout/components/SearchBar.vue index 47fcc59..a1aa6b0 100644 --- a/src/renderer/layout/components/SearchBar.vue +++ b/src/renderer/layout/components/SearchBar.vue @@ -217,7 +217,6 @@ const loadPage = async () => { const token = localStorage.getItem('token'); if (!token) return; const { data } = await getUserDetail(); - console.log('data', data); userStore.user = data.profile || userStore.user || JSON.parse(localStorage.getItem('user') || '{}'); localStorage.setItem('user', JSON.stringify(userStore.user)); diff --git a/src/renderer/store/modules/player.ts b/src/renderer/store/modules/player.ts index 0883e83..efa2c8c 100644 --- a/src/renderer/store/modules/player.ts +++ b/src/renderer/store/modules/player.ts @@ -9,7 +9,7 @@ import { getBilibiliAudioUrl } from '@/api/bilibili'; import { getLikedList, getMusicLrc, getMusicUrl, getParsingMusicUrl, likeSong } from '@/api/music'; import { useMusicHistory } from '@/hooks/MusicHistoryHook'; import { audioService } from '@/services/audioService'; -import type { ILyric, ILyricText, SongResult } from '@/types/music'; +import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music'; import { type Platform } from '@/types/music'; import { getImgUrl } from '@/utils'; import { hasPermission } from '@/utils/auth'; @@ -237,13 +237,7 @@ const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: numbe lyrics.push({ text: line.fullText, trText: '', // 翻译文本稍后处理 - words: hasWords - ? line.words.map((word) => ({ - text: word.text, - startTime: word.startTime, - duration: word.duration - })) - : undefined, + words: hasWords ? (line.words as IWordData[]) : undefined, hasWordByWord: hasWords, startTime: line.startTime, duration: line.duration @@ -274,7 +268,6 @@ export const loadLrc = async (id: string | number): Promise => { const numericId = typeof id === 'string' ? parseInt(id, 10) : id; const { data } = await getMusicLrc(numericId); const { lyrics, times } = parseLyrics(data?.yrc?.lyric || data?.lrc?.lyric); - const tlyric: Record = {}; // 检查是否有逐字歌词 let hasWordByWord = false; @@ -286,15 +279,58 @@ export const loadLrc = async (id: string | number): Promise => { } if (data.tlyric && data.tlyric.lyric) { - const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric); - tLyrics.forEach((lyric, index) => { - tlyric[tTimes[index].toString()] = lyric.text; + const { lyrics: tLyrics } = parseLyrics(data.tlyric.lyric); + + // 按索引顺序一一对应翻译歌词 + // 如果翻译歌词数量与原歌词数量相同,直接按索引匹配 + // 否则尝试通过时间戳匹配 + if (tLyrics.length === lyrics.length) { + // 数量相同,直接按索引对应 + lyrics.forEach((item, index) => { + item.trText = item.text && tLyrics[index] ? tLyrics[index].text : ''; + }); + } else { + // 数量不同,构建时间戳映射并尝试匹配 + const tLyricMap = new Map(); + tLyrics.forEach((lyric) => { + if (lyric.text && lyric.startTime !== undefined) { + // 使用 startTime(毫秒)转换为秒作为键 + const timeInSeconds = lyric.startTime / 1000; + tLyricMap.set(timeInSeconds, lyric.text); + } + }); + + // 为每句歌词查找最接近的翻译 + lyrics.forEach((item, index) => { + if (!item.text) { + item.trText = ''; + return; + } + + const currentTime = times[index]; + let closestTime = -1; + let minDiff = 2.0; // 最大允许差异2秒 + + // 查找最接近的时间戳 + for (const [tTime] of tLyricMap.entries()) { + const diff = Math.abs(tTime - currentTime); + if (diff < minDiff) { + minDiff = diff; + closestTime = tTime; + } + } + + item.trText = closestTime !== -1 ? tLyricMap.get(closestTime) || '' : ''; + }); + } + } else { + // 没有翻译歌词,清空 trText + lyrics.forEach((item) => { + item.trText = ''; }); } - lyrics.forEach((item, index) => { - item.trText = item.text ? tlyric[times[index].toString()] || '' : ''; - }); + console.log('lyrics', lyrics); return { lrcTimeArray: times, @@ -452,14 +488,6 @@ const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex: } }; -const loadLrcAsync = async (playMusic: SongResult) => { - if (playMusic.lyric && playMusic.lyric.lrcTimeArray.length > 0) { - return; - } - const lyrics = await loadLrc(playMusic.id); - playMusic.lyric = lyrics; -}; - // 定时关闭类型 export enum SleepTimerType { NONE = 'none', // 没有定时 @@ -671,18 +699,35 @@ export const usePlayerStore = defineStore('player', () => { currentSound.stop(); currentSound.unload(); } - // 先切换歌曲数据,更新播放状态 - // 加载歌词 - await loadLrcAsync(music); + + // 保存原始歌曲数据 const originalMusic = { ...music }; - // 获取背景色 - const { backgroundColor, primaryColor } = - music.backgroundColor && music.primaryColor - ? music - : await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30')); + + // 并行加载歌词和背景色,提高加载速度 + const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([ + // 加载歌词 + (async () => { + if (music.lyric && music.lyric.lrcTimeArray.length > 0) { + return music.lyric; + } + return await loadLrc(music.id); + })(), + // 获取背景色 + (async () => { + if (music.backgroundColor && music.primaryColor) { + return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor }; + } + return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30')); + })() + ]); + + // 设置歌词和背景色 + music.lyric = lyrics; music.backgroundColor = backgroundColor; music.primaryColor = primaryColor; music.playLoading = true; // 设置加载状态 + + // 更新 playMusic,此时歌词已完全加载 playMusic.value = music; // 更新播放相关状态 @@ -717,6 +762,10 @@ export const usePlayerStore = defineStore('player', () => { // 获取歌曲详情,包括URL const updatedPlayMusic = await getSongDetail(originalMusic); + + // 保留已加载的歌词数据,不要被 getSongDetail 的返回值覆盖 + updatedPlayMusic.lyric = lyrics; + playMusic.value = updatedPlayMusic; playMusicUrl.value = updatedPlayMusic.playMusicUrl as string; music.playMusicUrl = updatedPlayMusic.playMusicUrl as string; @@ -1480,6 +1529,7 @@ export const usePlayerStore = defineStore('player', () => { // 保存当前播放状态 const shouldPlay = play.value; console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停'); + console.log('playMusic.value', playMusic.value.name, playMusic.value.id); // 检查是否有保存的进度 let initialPosition = 0; diff --git a/src/renderer/types/music.ts b/src/renderer/types/music.ts index b279870..5fe8e44 100644 --- a/src/renderer/types/music.ts +++ b/src/renderer/types/music.ts @@ -14,6 +14,7 @@ export interface IWordData { text: string; startTime: number; duration: number; + space?: boolean; } export interface ILyricText { diff --git a/src/renderer/utils/yrcParser.ts b/src/renderer/utils/yrcParser.ts index d4314f7..952ff35 100644 --- a/src/renderer/utils/yrcParser.ts +++ b/src/renderer/utils/yrcParser.ts @@ -8,6 +8,8 @@ export interface WordData { readonly startTime: number; /** 持续时间(毫秒) */ readonly duration: number; + /** 该单词后是否有空格 */ + readonly space?: boolean; } /** @@ -28,8 +30,8 @@ export interface LyricLine { * 元数据接口 */ export interface MetaData { - /** 时间戳 */ - readonly time: number; + /** 时间戳(可选,不带时间的元数据为 undefined) */ + readonly time?: number; /** 内容 */ readonly content: string; } @@ -65,7 +67,7 @@ export type ParseResult = | { success: false; error: LyricParseError }; // 预编译正则表达式以提高性能 -const METADATA_PATTERN = /^\{"t":/; +const METADATA_PATTERN = /^\{("t":|"c":)/; // 匹配 {"t": 或 {"c": const LINE_TIME_PATTERN = /^\[(\d+),(\d+)\](.+)$/; // 逐字歌词格式: [92260,4740]... const LRC_TIME_PATTERN = /^\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)$/; // 标准LRC格式: [00:25.47]... const WORD_PATTERN = /\((\d+),(\d+),\d+\)([^(]*?)(?=\(|$)/g; @@ -99,10 +101,19 @@ const parseMetadata = (line: string): ParseResult => { }; } - if (typeof data.t !== 'number' || !Array.isArray(data.c)) { + // 检查必须有 c 字段(内容数组) + if (!Array.isArray(data.c)) { return { success: false, - error: new LyricParseError('元数据格式无效:缺少必要字段', line) + error: new LyricParseError('元数据格式无效:缺少 c 字段', line) + }; + } + + // t 字段(时间戳)是可选的 + if (data.t !== undefined && typeof data.t !== 'number') { + return { + success: false, + error: new LyricParseError('元数据格式无效:t 字段必须是数字', line) }; } @@ -203,19 +214,21 @@ const parseWordByWordLine = (line: string): ParseResult => { error: new LyricParseError('逐字歌词行格式无效:时间值无效', line) }; } - // 重置正则表达式状态 WORD_PATTERN.lastIndex = 0; const words: WordData[] = []; - const textParts: string[] = []; let match: RegExpExecArray | null; - // 使用exec而不是matchAll以更好地控制性能 + // 第一遍:提取所有单词的原始文本(包含空格),构建完整文本 + const rawTextParts: string[] = []; + const tempWords: Array<{ startTime: number; duration: number; text: string }> = []; + while ((match = WORD_PATTERN.exec(content)) !== null) { const wordStartTime = parseInt(match[1], 10); const wordDuration = parseInt(match[2], 10); - const wordText = match[3].trim(); + const rawWordText = match[3]; // 保留原始文本(可能包含空格) + const wordText = rawWordText.trim(); // 去除首尾空格的文本 // 验证单词数据 if (isNaN(wordStartTime) || isNaN(wordDuration)) { @@ -223,21 +236,51 @@ const parseWordByWordLine = (line: string): ParseResult => { } if (wordText) { - words.push({ + tempWords.push({ text: wordText, startTime: wordStartTime, duration: wordDuration }); - textParts.push(wordText); + rawTextParts.push(rawWordText); // 保留原始格式用于分析空格 } } + // 构建完整的文本(保留原始空格) + const fullText = rawTextParts.join('').trim(); + + // 第二遍:检查每个单词在完整文本中是否后面有空格 + let currentPos = 0; + for (const word of tempWords) { + // 在完整文本中查找当前单词的位置 + const wordIndex = fullText.indexOf(word.text, currentPos); + if (wordIndex === -1) { + // 如果找不到,直接添加不带空格标记的单词 + words.push(word); + continue; + } + + // 计算单词结束位置 + const wordEndPos = wordIndex + word.text.length; + // 检查单词后面是否有空格 + const hasSpace = wordEndPos < fullText.length && fullText[wordEndPos] === ' '; + words.push({ + ...word, + space: hasSpace + }); + + // 更新搜索位置 + currentPos = wordEndPos; + } + + console.log('fullText', fullText); + console.log('words', words); + return { success: true, data: { startTime, duration, - fullText: textParts.join(' '), + fullText, words } }; @@ -306,6 +349,33 @@ const calculateLrcDurations = (lyrics: LyricLine[]): LyricLine[] => { return updatedLyrics; }; +/** + * 解析不带时间戳的纯文本歌词行 + * @param line 纯文本歌词行 + * @returns 解析结果 + */ +const parsePlainTextLine = (line: string): ParseResult => { + // 清理行首尾的 \r 等特殊字符 + const text = line.replace(/\r/g, '').trim(); + + if (!text) { + return { + success: false, + error: new LyricParseError('纯文本歌词行为空', line) + }; + } + + return { + success: true, + data: { + startTime: -1, // -1 表示没有时间信息 + duration: 0, + fullText: text, + words: [] + } + }; +}; + /** * 主解析函数 * @param lyricsStr 歌词字符串 @@ -345,7 +415,13 @@ export const parseLyrics = (lyricsStr: string): ParseResult => { errors.push(result.error); } } else { - errors.push(new LyricParseError(`第${i + 1}行:无法识别的行格式`, trimmedLine)); + // 尝试解析为纯文本歌词行(不带时间戳) + const result = parsePlainTextLine(trimmedLine); + if (result.success) { + lyrics.push(result.data); + } else { + errors.push(result.error); + } } } @@ -359,8 +435,13 @@ export const parseLyrics = (lyricsStr: string): ParseResult => { }; } - // 按时间排序歌词行 - lyrics.sort((a, b) => a.startTime - b.startTime); + // 按时间排序歌词行(将没有时间信息的行放在最前面) + lyrics.sort((a, b) => { + if (a.startTime === -1 && b.startTime === -1) return 0; + if (a.startTime === -1) return -1; + if (b.startTime === -1) return 1; + return a.startTime - b.startTime; + }); // 计算LRC格式的持续时间 const finalLyrics = calculateLrcDurations(lyrics); diff --git a/src/renderer/views/lyric/index.vue b/src/renderer/views/lyric/index.vue index a26cafd..458b832 100644 --- a/src/renderer/views/lyric/index.vue +++ b/src/renderer/views/lyric/index.vue @@ -83,7 +83,19 @@ }" >
- + +
+ +
+ + {{ line.text || '' }}
@@ -131,7 +143,14 @@ const lastUpdateTime = ref(performance.now()); // 静态数据 const staticData = ref<{ - lrcArray: Array<{ text: string; trText: string }>; + lrcArray: Array<{ + text: string; + trText: string; + words?: Array<{ text: string; startTime: number; duration: number; space?: boolean }>; + hasWordByWord?: boolean; + startTime?: number; + duration?: number; + }>; lrcTimeArray: number[]; allTime: number; playMusic: SongResult; @@ -435,6 +454,58 @@ const getLyricStyle = (index: number) => { }; }; +// 逐字歌词样式函数 +const getWordStyle = ( + lineIndex: number, + _wordIndex: number, + word: { text: string; startTime: number; duration: number } +) => { + // 如果不是当前行,返回普通样式 + if (lineIndex !== currentIndex.value) { + return { + color: 'var(--text-color)', + transition: 'color 0.3s ease', + backgroundImage: 'none', + WebkitTextFillColor: 'initial' + }; + } + + // 当前行的逐字效果 + const currentTime = actualTime.value * 1000; // 转换为毫秒 + + // 直接使用绝对时间比较 + const wordStartTime = word.startTime; // 单词开始的绝对时间(毫秒) + const wordEndTime = word.startTime + word.duration; + + if (currentTime >= wordStartTime && currentTime < wordEndTime) { + // 当前正在播放的单词 - 使用渐变进度效果 + const progress = Math.min((currentTime - wordStartTime) / word.duration, 1); + const progressPercent = Math.round(progress * 100); + + return { + backgroundImage: `linear-gradient(to right, var(--highlight-color) 0%, var(--highlight-color) ${progressPercent}%, var(--text-color) ${progressPercent}%, var(--text-color) 100%)`, + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + transition: 'all 0.1s ease' + }; + } else if (currentTime >= wordEndTime) { + // 已经播放过的单词 - 纯色显示 + return { + color: 'var(--highlight-color)', + WebkitTextFillColor: 'initial', + transition: 'none' + }; + } else { + // 还未播放的单词 - 普通状态 + return { + color: 'var(--text-color)', + WebkitTextFillColor: 'initial', + transition: 'none' + }; + } +}; + // 时间偏移量(毫秒) const TIME_OFFSET = 400; @@ -914,7 +985,7 @@ body, } &.dark { - --text-color: #ffffff; + --text-color: #e6e6e6; --text-secondary: #ffffffea; --highlight-color: var(--lyric-highlight-color, #1ed760); --control-bg: rgba(124, 124, 124, 0.3); @@ -1104,6 +1175,24 @@ body, .lyric-text-inner { transition: background 0.3s ease; } + + // 逐字歌词样式 + .word-by-word-lyric { + display: inline-block; + text-align: center; + + .lyric-word { + display: inline-block; + font-weight: inherit; + font-size: inherit; + letter-spacing: inherit; + line-height: inherit; + position: relative; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + } } .lyric-translation {