@@ -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 @@
}"
>
-
+
+
+
+
+ {{ word.text }}
+
+
+
{{ 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 {