mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
feat: 优化逐字歌词效果,桌面歌词添加逐字歌词效果
This commit is contained in:
@@ -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"> </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;
|
||||
|
||||
@@ -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"> </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"> </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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface IWordData {
|
||||
text: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
space?: boolean;
|
||||
}
|
||||
|
||||
export interface ILyricText {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"> </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 {
|
||||
|
||||
Reference in New Issue
Block a user