feat: 优化逐字歌词效果,桌面歌词添加逐字歌词效果

This commit is contained in:
alger
2025-10-12 17:11:48 +08:00
parent 77f3069e67
commit a5d3ff359c
8 changed files with 351 additions and 82 deletions

View File

@@ -111,27 +111,31 @@
</span>
</div>
</div>
<!-- 无时间戳歌词提示 -->
<div v-if="!supportAutoScroll" class="music-lrc-text no-scroll-tip">
<span>本歌词不支持自动滚动</span>
</div>
<div
v-for="(item, index) in lrcArray"
:id="`music-lrc-text-${index}`"
:key="index"
class="music-lrc-text"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="setAudioTime(index)"
:class="{
'now-text': index === nowIndex,
'hover-text': item.text && item.startTime !== -1
}"
@click="item.startTime !== -1 ? setAudioTime(index) : null"
>
<!-- 逐字歌词显示 -->
<div
v-if="item.hasWordByWord && item.words && item.words.length > 0"
class="word-by-word-lyric"
>
<span
v-for="(word, wordIndex) in item.words"
:key="wordIndex"
class="lyric-word"
:style="getWordStyle(index, wordIndex, word)"
<template v-for="(word, wordIndex) in item.words" :key="wordIndex">
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
{{ word.text }} </span
><span class="lyric-word" v-if="word.space">&nbsp;</span></template
>
{{ word.text }}
</span>
</div>
<!-- 普通歌词显示 -->
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
@@ -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;

View File

@@ -49,27 +49,31 @@
@scroll="handleScroll"
>
<div class="lyrics-padding-top"></div>
<!-- 无时间戳歌词提示 -->
<div v-if="!supportAutoScroll" class="lyric-line no-scroll-tip">
<span>本歌词不支持自动滚动</span>
</div>
<div
v-for="(item, index) in lrcArray"
:key="index"
:id="`lyric-line-${index}`"
class="lyric-line"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="jumpToLyricTime(index)"
:class="{
'now-text': index === nowIndex,
'hover-text': item.text && item.startTime !== -1
}"
@click="item.startTime !== -1 ? jumpToLyricTime(index) : null"
>
<!-- 逐字歌词显示 -->
<div
v-if="item.hasWordByWord && item.words && item.words.length > 0"
class="word-by-word-lyric"
>
<span
v-for="(word, wordIndex) in item.words"
:key="wordIndex"
class="lyric-word"
:style="getWordStyle(index, wordIndex, word)"
<template v-for="(word, wordIndex) in item.words" :key="wordIndex">
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
{{ word.text }} </span
><span class="lyric-word" v-if="word.space">&nbsp;</span></template
>
{{ word.text }}
</span>
</div>
<!-- 普通歌词显示 -->
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
@@ -247,27 +251,31 @@
@scroll="handleScroll"
>
<div class="lyrics-padding-top"></div>
<!-- 无时间戳歌词提示 -->
<div v-if="!supportAutoScroll" class="lyric-line no-scroll-tip">
<span>本歌词不支持自动滚动</span>
</div>
<div
v-for="(item, index) in lrcArray"
:key="index"
:id="`landscape-lyric-line-${index}`"
class="lyric-line"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="jumpToLyricTime(index)"
:class="{
'now-text': index === nowIndex,
'hover-text': item.text && item.startTime !== -1
}"
@click="item.startTime !== -1 ? jumpToLyricTime(index) : null"
>
<!-- 逐字歌词显示 -->
<div
v-if="item.hasWordByWord && item.words && item.words.length > 0"
class="word-by-word-lyric"
>
<span
v-for="(word, wordIndex) in item.words"
:key="wordIndex"
class="lyric-word"
:style="getWordStyle(index, wordIndex, word)"
<template v-for="(word, wordIndex) in item.words" :key="wordIndex">
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
{{ word.text }} </span
><span class="lyric-word" v-if="word.space">&nbsp;</span></template
>
{{ word.text }}
</span>
</div>
<!-- 普通歌词显示 -->
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
@@ -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);

View File

@@ -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();
}

View File

@@ -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));

View File

@@ -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<ILyric> => {
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<string, string> = {};
// 检查是否有逐字歌词
let hasWordByWord = false;
@@ -286,15 +279,58 @@ export const loadLrc = async (id: string | number): Promise<ILyric> => {
}
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<number, string>();
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;

View File

@@ -14,6 +14,7 @@ export interface IWordData {
text: string;
startTime: number;
duration: number;
space?: boolean;
}
export interface ILyricText {

View File

@@ -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<T> =
| { 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<MetaData> => {
};
}
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<LyricLine> => {
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<LyricLine> => {
}
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<LyricLine> => {
// 清理行首尾的 \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<ParsedLyrics> => {
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<ParsedLyrics> => {
};
}
// 按时间排序歌词行
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);

View File

@@ -83,7 +83,19 @@
}"
>
<div class="lyric-text" :style="{ fontSize: `${fontSize}px` }">
<span class="lyric-text-inner" :style="getLyricStyle(index)">
<!-- 逐字歌词显示 -->
<div
v-if="line.hasWordByWord && line.words && line.words.length > 0"
class="word-by-word-lyric"
>
<template v-for="(word, wordIndex) in line.words" :key="wordIndex">
<span class="lyric-word" :style="getWordStyle(index, wordIndex, word)">
{{ word.text }} </span
><span class="lyric-word" v-if="word.space">&nbsp;</span></template
>
</div>
<!-- 普通歌词显示 -->
<span v-else class="lyric-text-inner" :style="getLyricStyle(index)">
{{ line.text || '' }}
</span>
</div>
@@ -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 {