mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
feat: 逐字歌词
This commit is contained in:
@@ -65,7 +65,7 @@ export const getMusicLrc = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取新的歌词数据
|
// 获取新的歌词数据
|
||||||
const res = await request.get<ILyric>('/lyric', { params: { id } });
|
const res = await request.get<ILyric>('/lyric/new', { params: { id } });
|
||||||
|
|
||||||
// 只有在成功获取新数据后才删除旧缓存并添加新缓存
|
// 只有在成功获取新数据后才删除旧缓存并添加新缓存
|
||||||
if (res?.data) {
|
if (res?.data) {
|
||||||
|
|||||||
@@ -90,10 +90,11 @@ export class CacheManager {
|
|||||||
musicSources?: string[]
|
musicSources?: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// 深度克隆数据,确保可以被 IndexedDB 存储
|
||||||
await saveData('music_url_cache', {
|
await saveData('music_url_cache', {
|
||||||
id,
|
id,
|
||||||
data: result,
|
data: cloneDeep(result),
|
||||||
musicSources: musicSources || [],
|
musicSources: cloneDeep(musicSources || []),
|
||||||
createTime: Date.now()
|
createTime: Date.now()
|
||||||
});
|
});
|
||||||
console.log(`缓存音乐URL成功: ${id}`);
|
console.log(`缓存音乐URL成功: ${id}`);
|
||||||
|
|||||||
@@ -119,7 +119,22 @@
|
|||||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||||
@click="setAudioTime(index)"
|
@click="setAudioTime(index)"
|
||||||
>
|
>
|
||||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
<!-- 逐字歌词显示 -->
|
||||||
|
<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)"
|
||||||
|
>
|
||||||
|
{{ word.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 普通歌词显示 -->
|
||||||
|
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||||
<div v-show="config.showTranslation" class="music-lrc-text-tr">
|
<div v-show="config.showTranslation" class="music-lrc-text-tr">
|
||||||
{{ item.trText }}
|
{{ item.trText }}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +159,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDebounceFn } from '@vueuse/core';
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Cover3D from '@/components/cover/Cover3D.vue';
|
import Cover3D from '@/components/cover/Cover3D.vue';
|
||||||
@@ -157,6 +172,7 @@ import {
|
|||||||
correctionTime,
|
correctionTime,
|
||||||
lrcArray,
|
lrcArray,
|
||||||
nowIndex,
|
nowIndex,
|
||||||
|
nowTime,
|
||||||
playMusic,
|
playMusic,
|
||||||
setAudioTime,
|
setAudioTime,
|
||||||
textColors,
|
textColors,
|
||||||
@@ -179,6 +195,7 @@ const animationFrame = ref<number | null>(null);
|
|||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
const showStickyHeader = ref(false);
|
const showStickyHeader = ref(false);
|
||||||
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
|
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
|
||||||
|
const isSongChanging = ref(false);
|
||||||
|
|
||||||
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
|
||||||
|
|
||||||
@@ -274,6 +291,8 @@ const mouseLeaveLayout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
watch(nowIndex, () => {
|
watch(nowIndex, () => {
|
||||||
|
// 歌曲切换时不自动滚动
|
||||||
|
if (isSongChanging.value) return;
|
||||||
debouncedLrcScroll();
|
debouncedLrcScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,25 +356,30 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 修改 useLyricProgress 的使用方式
|
|
||||||
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
||||||
|
|
||||||
// 修改 getLrcStyle 函数
|
|
||||||
const getLrcStyle = (index: number) => {
|
const getLrcStyle = (index: number) => {
|
||||||
const colors = textColors.value || getTextColors;
|
const colors = textColors.value || getTextColors();
|
||||||
const originalStyle = originalLrcStyle(index);
|
const originalStyle = originalLrcStyle(index);
|
||||||
|
|
||||||
if (index === nowIndex.value) {
|
if (index === nowIndex.value) {
|
||||||
// 当前播放的歌词,使用渐变效果
|
// 当前播放的歌词
|
||||||
return {
|
if (originalStyle.backgroundImage) {
|
||||||
...originalStyle,
|
// 有渐变进度时,使用渐变效果
|
||||||
backgroundImage: originalStyle.backgroundImage
|
return {
|
||||||
?.replace(/#ffffff/g, colors.active)
|
...originalStyle,
|
||||||
.replace(/#ffffff8a/g, `${colors.primary}`),
|
backgroundImage: originalStyle.backgroundImage
|
||||||
backgroundClip: 'text',
|
.replace(/#ffffff/g, colors.active)
|
||||||
WebkitBackgroundClip: 'text',
|
.replace(/#ffffff8a/g, `${colors.primary}`),
|
||||||
color: 'transparent'
|
backgroundClip: 'text',
|
||||||
};
|
WebkitBackgroundClip: 'text',
|
||||||
|
color: 'transparent'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
color: colors.primary
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非当前播放的歌词,使用普通颜色
|
// 非当前播放的歌词,使用普通颜色
|
||||||
@@ -364,6 +388,57 @@ const getLrcStyle = (index: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 逐字歌词样式函数
|
||||||
|
const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
|
||||||
|
const colors = textColors.value || getTextColors();
|
||||||
|
// 如果不是当前行,返回普通样式
|
||||||
|
if (lineIndex !== nowIndex.value) {
|
||||||
|
return {
|
||||||
|
color: colors.primary,
|
||||||
|
transition: 'color 0.3s ease',
|
||||||
|
// 重置背景相关属性
|
||||||
|
backgroundImage: 'none',
|
||||||
|
WebkitTextFillColor: 'initial'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前行的逐字效果,应用歌词矫正时间
|
||||||
|
const currentTime = (nowTime.value + correctionTime.value) * 1000; // 转换为毫秒,确保与word时间单位一致
|
||||||
|
|
||||||
|
// 直接使用绝对时间比较
|
||||||
|
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, ${colors.active} 0%, ${colors.active} ${progressPercent}%, ${colors.primary} ${progressPercent}%, ${colors.primary} 100%)`,
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
textShadow: `0 0 8px ${colors.active}40`,
|
||||||
|
transition: 'all 0.1s ease'
|
||||||
|
};
|
||||||
|
} else if (currentTime >= wordEndTime) {
|
||||||
|
// 已经播放过的单词 - 纯色显示
|
||||||
|
return {
|
||||||
|
color: colors.active,
|
||||||
|
WebkitTextFillColor: 'initial',
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 还未播放的单词 - 普通状态
|
||||||
|
return {
|
||||||
|
color: colors.primary,
|
||||||
|
WebkitTextFillColor: 'initial',
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 组件卸载时清理动画
|
// 组件卸载时清理动画
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (animationFrame.value) {
|
if (animationFrame.value) {
|
||||||
@@ -502,12 +577,24 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加对 playMusic 的监听
|
// 添加对 playMusic.id 的监听,歌曲切换时滚动到顶部
|
||||||
watch(playMusic, () => {
|
watch(
|
||||||
nextTick(() => {
|
() => playMusic.value.id,
|
||||||
lrcScroll('instant', true);
|
(newId, oldId) => {
|
||||||
});
|
// 只在歌曲真正切换时滚动到顶部
|
||||||
});
|
if (newId !== oldId && newId) {
|
||||||
|
isSongChanging.value = true;
|
||||||
|
// 延迟滚动,确保 nowIndex 已重置
|
||||||
|
setTimeout(() => {
|
||||||
|
lrcScroll('instant', true);
|
||||||
|
// 延迟恢复自动滚动,等待歌词数据更新
|
||||||
|
setTimeout(() => {
|
||||||
|
isSongChanging.value = false;
|
||||||
|
}, 300);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
lrcScroll,
|
lrcScroll,
|
||||||
@@ -621,6 +708,9 @@ defineExpose({
|
|||||||
|
|
||||||
.music-lrc-container {
|
.music-lrc-container {
|
||||||
padding-top: 30vh;
|
padding-top: 30vh;
|
||||||
|
.music-lrc-text:last-child {
|
||||||
|
margin-bottom: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-lrc {
|
.music-lrc {
|
||||||
@@ -670,6 +760,28 @@ defineExpose({
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
color: var(--text-color-primary);
|
color: var(--text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 逐字歌词样式
|
||||||
|
.word-by-word-lyric {
|
||||||
|
@apply flex flex-wrap;
|
||||||
|
|
||||||
|
.lyric-word {
|
||||||
|
@apply inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
cursor: inherit;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-text {
|
.hover-text {
|
||||||
|
|||||||
@@ -57,7 +57,22 @@
|
|||||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||||
@click="jumpToLyricTime(index)"
|
@click="jumpToLyricTime(index)"
|
||||||
>
|
>
|
||||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
<!-- 逐字歌词显示 -->
|
||||||
|
<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)"
|
||||||
|
>
|
||||||
|
{{ word.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 普通歌词显示 -->
|
||||||
|
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||||
<div v-if="config.showTranslation && item.trText" class="translation">
|
<div v-if="config.showTranslation && item.trText" class="translation">
|
||||||
{{ item.trText }}
|
{{ item.trText }}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +134,22 @@
|
|||||||
<div class="lyrics-container" v-if="!config.hideLyrics" @click="showFullLyricScreen">
|
<div class="lyrics-container" v-if="!config.hideLyrics" @click="showFullLyricScreen">
|
||||||
<div v-if="lrcArray.length > 0" class="lyrics-wrapper">
|
<div v-if="lrcArray.length > 0" class="lyrics-wrapper">
|
||||||
<div v-for="(line, idx) in visibleLyrics" :key="idx" class="lyric-line">
|
<div v-for="(line, idx) in visibleLyrics" :key="idx" class="lyric-line">
|
||||||
{{ line.text }}
|
<!-- 逐字歌词显示 -->
|
||||||
|
<div
|
||||||
|
v-if="line.hasWordByWord && line.words && line.words.length > 0"
|
||||||
|
class="word-by-word-lyric"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(word, wordIndex) in line.words"
|
||||||
|
:key="wordIndex"
|
||||||
|
class="lyric-word"
|
||||||
|
:style="getWordStyle(line.originalIndex, wordIndex, word)"
|
||||||
|
>
|
||||||
|
{{ word.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 普通歌词显示 -->
|
||||||
|
<span v-else>{{ line.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="no-lyrics">
|
<div v-else class="no-lyrics">
|
||||||
@@ -225,7 +255,22 @@
|
|||||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||||
@click="jumpToLyricTime(index)"
|
@click="jumpToLyricTime(index)"
|
||||||
>
|
>
|
||||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
<!-- 逐字歌词显示 -->
|
||||||
|
<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)"
|
||||||
|
>
|
||||||
|
{{ word.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 普通歌词显示 -->
|
||||||
|
<span v-else :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||||
<div v-if="config.showTranslation && item.trText" class="translation">
|
<div v-if="config.showTranslation && item.trText" class="translation">
|
||||||
{{ item.trText }}
|
{{ item.trText }}
|
||||||
</div>
|
</div>
|
||||||
@@ -770,7 +815,11 @@ const visibleLyrics = computed(() => {
|
|||||||
startIdx = Math.max(0, endIdx - numLines + 1);
|
startIdx = Math.max(0, endIdx - numLines + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lrcArray.value.slice(startIdx, endIdx + 1);
|
// 返回带有原始索引的歌词数组
|
||||||
|
return lrcArray.value.slice(startIdx, endIdx + 1).map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
originalIndex: startIdx + idx
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -1020,6 +1069,57 @@ const getLrcStyle = (index: number) => {
|
|||||||
color: colors.primary
|
color: colors.primary
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 逐字歌词样式函数
|
||||||
|
const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
|
||||||
|
const colors = textColors.value || getTextColors();
|
||||||
|
// 如果不是当前行,返回普通样式
|
||||||
|
if (lineIndex !== nowIndex.value) {
|
||||||
|
return {
|
||||||
|
color: colors.primary,
|
||||||
|
transition: 'color 0.3s ease',
|
||||||
|
// 重置背景相关属性
|
||||||
|
backgroundImage: 'none',
|
||||||
|
WebkitTextFillColor: 'initial'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前行的逐字效果
|
||||||
|
const currentTime = nowTime.value * 1000; // 转换为毫秒,确保与word时间单位一致
|
||||||
|
|
||||||
|
// 直接使用绝对时间比较
|
||||||
|
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, ${colors.active} 0%, ${colors.active} ${progressPercent}%, ${colors.primary} ${progressPercent}%, ${colors.primary} 100%)`,
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
textShadow: `0 0 8px ${colors.active}40`,
|
||||||
|
transition: 'all 0.1s ease'
|
||||||
|
};
|
||||||
|
} else if (currentTime >= wordEndTime) {
|
||||||
|
// 已经播放过的单词 - 纯色显示
|
||||||
|
return {
|
||||||
|
color: colors.active,
|
||||||
|
WebkitTextFillColor: 'initial',
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 还未播放的单词 - 普通状态
|
||||||
|
return {
|
||||||
|
color: colors.primary,
|
||||||
|
WebkitTextFillColor: 'initial',
|
||||||
|
transition: 'none'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -1574,7 +1674,7 @@ const getLrcStyle = (index: number) => {
|
|||||||
|
|
||||||
// 通用歌词样式
|
// 通用歌词样式
|
||||||
.lyric-line {
|
.lyric-line {
|
||||||
@apply cursor-pointer transition-all duration-300;
|
@apply cursor-pointer transition-all duration-300 font-medium;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: var(--lyric-letter-spacing, 0);
|
letter-spacing: var(--lyric-letter-spacing, 0);
|
||||||
line-height: var(--lyric-line-height, 1.6);
|
line-height: var(--lyric-line-height, 1.6);
|
||||||
@@ -1587,7 +1687,7 @@ const getLrcStyle = (index: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.now-text {
|
&.now-text {
|
||||||
@apply font-bold py-4;
|
@apply font-medium py-4;
|
||||||
color: var(--text-color-active);
|
color: var(--text-color-active);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -1599,6 +1699,25 @@ const getLrcStyle = (index: number) => {
|
|||||||
.translation {
|
.translation {
|
||||||
@apply font-normal opacity-70 mt-1 text-base;
|
@apply font-normal opacity-70 mt-1 text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 逐字歌词样式
|
||||||
|
.word-by-word-lyric {
|
||||||
|
@apply flex flex-wrap justify-center;
|
||||||
|
|
||||||
|
.lyric-word {
|
||||||
|
@apply inline-block;
|
||||||
|
font-weight: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
cursor: inherit;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全屏歌词相关样式
|
// 全屏歌词相关样式
|
||||||
@@ -1765,6 +1884,10 @@ const getLrcStyle = (index: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lyric-word {
|
||||||
|
@apply px-[2px];
|
||||||
|
}
|
||||||
|
|
||||||
.no-lyrics {
|
.no-lyrics {
|
||||||
@apply text-center text-base opacity-60;
|
@apply text-center text-base opacity-60;
|
||||||
color: var(--text-color-primary);
|
color: var(--text-color-primary);
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ watch(
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mobile-play-bar {
|
.mobile-play-bar {
|
||||||
@apply fixed bottom-[56px] left-0 w-full flex flex-col;
|
@apply fixed bottom-[76px] left-0 w-full flex flex-col;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
animation-duration: 0.3s !important;
|
animation-duration: 0.3s !important;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getSongUrl } from '@/store/modules/player';
|
|||||||
import type { Artist, ILyricText, SongResult } from '@/types/music';
|
import type { Artist, ILyricText, SongResult } from '@/types/music';
|
||||||
import { isElectron } from '@/utils';
|
import { isElectron } from '@/utils';
|
||||||
import { getTextColors } from '@/utils/linearColor';
|
import { getTextColors } from '@/utils/linearColor';
|
||||||
|
import { parseLyrics } from '@/utils/yrcParser';
|
||||||
|
|
||||||
const windowData = window as any;
|
const windowData = window as any;
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export const musicDB = await useIndexedDB(
|
|||||||
{ name: 'music_url_cache', keyPath: 'id' },
|
{ name: 'music_url_cache', keyPath: 'id' },
|
||||||
{ name: 'music_failed_cache', keyPath: 'id' }
|
{ name: 'music_failed_cache', keyPath: 'id' }
|
||||||
],
|
],
|
||||||
2
|
3
|
||||||
);
|
);
|
||||||
|
|
||||||
// 键盘事件处理器,在初始化后设置
|
// 键盘事件处理器,在初始化后设置
|
||||||
@@ -268,27 +269,119 @@ const initProgressAnimation = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析歌词字符串并转换为ILyricText格式
|
||||||
|
* @param lyricsStr 歌词字符串
|
||||||
|
* @returns 解析后的歌词数据
|
||||||
|
*/
|
||||||
|
const parseLyricsString = async (
|
||||||
|
lyricsStr: string
|
||||||
|
): Promise<{ lrcArray: ILyricText[]; lrcTimeArray: number[]; hasWordByWord: boolean }> => {
|
||||||
|
if (!lyricsStr || typeof lyricsStr !== 'string') {
|
||||||
|
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parseResult = parseLyrics(lyricsStr);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
console.error('歌词解析失败:', parseResult.error.message);
|
||||||
|
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lyrics } = parseResult.data;
|
||||||
|
const lrcArray: ILyricText[] = [];
|
||||||
|
const lrcTimeArray: number[] = [];
|
||||||
|
let hasWordByWord = false;
|
||||||
|
|
||||||
|
for (const line of lyrics) {
|
||||||
|
// 检查是否有逐字歌词
|
||||||
|
const hasWords = line.words && line.words.length > 0;
|
||||||
|
if (hasWords) {
|
||||||
|
hasWordByWord = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lrcArray.push({
|
||||||
|
text: line.fullText,
|
||||||
|
trText: '', // 翻译文本稍后处理
|
||||||
|
words: hasWords
|
||||||
|
? line.words.map((word) => ({
|
||||||
|
text: word.text,
|
||||||
|
startTime: word.startTime,
|
||||||
|
duration: word.duration
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
hasWordByWord: hasWords,
|
||||||
|
startTime: line.startTime,
|
||||||
|
duration: line.duration
|
||||||
|
});
|
||||||
|
|
||||||
|
lrcTimeArray.push(line.startTime);
|
||||||
|
}
|
||||||
|
console.log('parseLyricsString', lrcArray);
|
||||||
|
return { lrcArray, lrcTimeArray, hasWordByWord };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析歌词时发生错误:', error);
|
||||||
|
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 设置音乐相关的监听器
|
// 设置音乐相关的监听器
|
||||||
const setupMusicWatchers = () => {
|
const setupMusicWatchers = () => {
|
||||||
const store = getPlayerStore();
|
const store = getPlayerStore();
|
||||||
|
|
||||||
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
||||||
watch(
|
watch(
|
||||||
() => store.playMusic,
|
() => store.playMusic.id,
|
||||||
() => {
|
async (newId, oldId) => {
|
||||||
nextTick(async () => {
|
// 如果没有歌曲ID,清空歌词
|
||||||
console.log('歌曲切换,更新歌词数据');
|
if (!newId) {
|
||||||
// 更新歌词数据
|
lrcArray.value = [];
|
||||||
const rawLrc = playMusic.value.lyric?.lrcArray || [];
|
lrcTimeArray.value = [];
|
||||||
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
|
nowIndex.value = 0;
|
||||||
try {
|
return;
|
||||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
}
|
||||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
|
||||||
lrcArray.value = rawLrc as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 避免相同ID的重复执行(但允许初始化时执行)
|
||||||
|
if (newId === oldId && lrcArray.value.length > 0) return;
|
||||||
|
|
||||||
|
// 歌曲切换时重置歌词索引
|
||||||
|
if (newId !== oldId) {
|
||||||
|
nowIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick(async () => {
|
||||||
|
console.log('歌曲切换,更新歌词数据');
|
||||||
|
|
||||||
|
// 检查是否有原始歌词字符串需要解析
|
||||||
|
const lyricData = playMusic.value.lyric;
|
||||||
|
if (lyricData && typeof lyricData === 'string') {
|
||||||
|
// 如果歌词是字符串格式,使用新的解析器
|
||||||
|
const {
|
||||||
|
lrcArray: parsedLrcArray,
|
||||||
|
lrcTimeArray: parsedTimeArray,
|
||||||
|
hasWordByWord
|
||||||
|
} = await parseLyricsString(lyricData);
|
||||||
|
lrcArray.value = parsedLrcArray;
|
||||||
|
lrcTimeArray.value = parsedTimeArray;
|
||||||
|
|
||||||
|
// 更新歌曲的歌词数据结构
|
||||||
|
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||||
|
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用现有的歌词数据结构
|
||||||
|
const rawLrc = lyricData?.lrcArray || [];
|
||||||
|
lrcTimeArray.value = lyricData?.lrcTimeArray || [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||||
|
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||||
|
lrcArray.value = rawLrc as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||||
if (isElectron && isLyricWindowOpen.value) {
|
if (isElectron && isLyricWindowOpen.value) {
|
||||||
console.log('歌词窗口已打开,同步最新歌词数据');
|
console.log('歌词窗口已打开,同步最新歌词数据');
|
||||||
@@ -302,10 +395,7 @@ const setupMusicWatchers = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{ immediate: true }
|
||||||
deep: true,
|
|
||||||
immediate: true
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,20 +653,46 @@ export const adjustCorrectionTime = (delta: number) => {
|
|||||||
// 获取当前播放歌词
|
// 获取当前播放歌词
|
||||||
export const isCurrentLrc = (index: number, time: number): boolean => {
|
export const isCurrentLrc = (index: number, time: number): boolean => {
|
||||||
const currentTime = lrcTimeArray.value[index];
|
const currentTime = lrcTimeArray.value[index];
|
||||||
|
|
||||||
|
// 如果是最后一句歌词,只需要判断时间是否大于等于当前句的开始时间
|
||||||
|
if (index === lrcTimeArray.value.length - 1) {
|
||||||
|
const correctedTime = time + correctionTime.value;
|
||||||
|
return correctedTime >= currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非最后一句歌词,需要判断时间在当前句和下一句之间
|
||||||
const nextTime = lrcTimeArray.value[index + 1];
|
const nextTime = lrcTimeArray.value[index + 1];
|
||||||
const correctedTime = time + correctionTime.value;
|
const correctedTime = time + correctionTime.value;
|
||||||
return correctedTime > currentTime && correctedTime < nextTime;
|
return correctedTime >= currentTime && correctedTime < nextTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前播放歌词INDEX
|
// 获取当前播放歌词INDEX
|
||||||
export const getLrcIndex = (time: number): number => {
|
export const getLrcIndex = (time: number): number => {
|
||||||
const correctedTime = time + correctionTime.value;
|
const correctedTime = time + correctionTime.value;
|
||||||
for (let i = 0; i < lrcTimeArray.value.length; i++) {
|
|
||||||
if (isCurrentLrc(i, correctedTime - correctionTime.value)) {
|
// 如果歌词数组为空,返回当前索引
|
||||||
|
if (lrcTimeArray.value.length === 0) {
|
||||||
|
return nowIndex.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理最后一句歌词的情况
|
||||||
|
const lastIndex = lrcTimeArray.value.length - 1;
|
||||||
|
if (correctedTime >= lrcTimeArray.value[lastIndex]) {
|
||||||
|
nowIndex.value = lastIndex;
|
||||||
|
return lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找当前时间对应的歌词索引
|
||||||
|
for (let i = 0; i < lrcTimeArray.value.length - 1; i++) {
|
||||||
|
const currentTime = lrcTimeArray.value[i];
|
||||||
|
const nextTime = lrcTimeArray.value[i + 1];
|
||||||
|
|
||||||
|
if (correctedTime >= currentTime && correctedTime < nextTime) {
|
||||||
nowIndex.value = i;
|
nowIndex.value = i;
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nowIndex.value;
|
return nowIndex.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -833,6 +949,9 @@ onUnmounted(() => {
|
|||||||
stopLyricSync();
|
stopLyricSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 导出歌词解析函数供外部使用
|
||||||
|
export { parseLyricsString };
|
||||||
|
|
||||||
// 添加播放控制命令监听
|
// 添加播放控制命令监听
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
windowData.electron.ipcRenderer.on('lyric-control-back', (_, command: string) => {
|
windowData.electron.ipcRenderer.on('lyric-control-back', (_, command: string) => {
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
import { Howl } from 'howler';
|
|
||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
|
||||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
|
||||||
import { audioService } from '@/services/audioService';
|
|
||||||
import { useSettingsStore } from '@/store';
|
|
||||||
import type { ILyric, ILyricText, SongResult } from '@/types/music';
|
|
||||||
import { getImgUrl } from '@/utils';
|
|
||||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
|
||||||
|
|
||||||
const musicHistory = useMusicHistory();
|
|
||||||
|
|
||||||
// 获取歌曲url
|
|
||||||
export const getSongUrl = async (id: any, songData: any, isDownloaded: boolean = false) => {
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
const { unlimitedDownload } = settingsStore.setData;
|
|
||||||
|
|
||||||
const { data } = await getMusicUrl(id, !unlimitedDownload);
|
|
||||||
let url = '';
|
|
||||||
let songDetail: any = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
|
||||||
const res = await getParsingMusicUrl(id, cloneDeep(songData));
|
|
||||||
if (res.data.data?.url) {
|
|
||||||
url = res.data.data.url;
|
|
||||||
songDetail = res.data.data;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
songDetail = data.data[0] as any;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('error', error);
|
|
||||||
}
|
|
||||||
if (isDownloaded) {
|
|
||||||
return songDetail;
|
|
||||||
}
|
|
||||||
url = url || data.data[0].url;
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSongDetail = async (playMusic: SongResult) => {
|
|
||||||
playMusic.playLoading = true;
|
|
||||||
const playMusicUrl =
|
|
||||||
playMusic.playMusicUrl || (await getSongUrl(playMusic.id, cloneDeep(playMusic)));
|
|
||||||
const { backgroundColor, primaryColor } =
|
|
||||||
playMusic.backgroundColor && playMusic.primaryColor
|
|
||||||
? playMusic
|
|
||||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
|
||||||
|
|
||||||
playMusic.playLoading = false;
|
|
||||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
|
|
||||||
export const useMusicListHook = () => {
|
|
||||||
const handlePlayMusic = async (state: any, playMusic: SongResult, isPlay: boolean = true) => {
|
|
||||||
const updatedPlayMusic = await getSongDetail(playMusic);
|
|
||||||
state.playMusic = updatedPlayMusic;
|
|
||||||
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
|
|
||||||
|
|
||||||
// 记录当前设置的播放状态
|
|
||||||
state.play = isPlay;
|
|
||||||
|
|
||||||
// 每次设置新歌曲时,立即更新 localStorage
|
|
||||||
localStorage.setItem('currentPlayMusic', JSON.stringify(state.playMusic));
|
|
||||||
localStorage.setItem('currentPlayMusicUrl', state.playMusicUrl);
|
|
||||||
localStorage.setItem('isPlaying', state.play.toString());
|
|
||||||
|
|
||||||
// 设置网页标题
|
|
||||||
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
|
|
||||||
loadLrcAsync(state, updatedPlayMusic.id);
|
|
||||||
musicHistory.addMusic(state.playMusic);
|
|
||||||
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
|
|
||||||
state.playListIndex = playListIndex;
|
|
||||||
// 请求后续五首歌曲的详情
|
|
||||||
fetchSongs(state, playListIndex + 1, playListIndex + 6);
|
|
||||||
};
|
|
||||||
|
|
||||||
const preloadingSounds = ref<Howl[]>([]);
|
|
||||||
|
|
||||||
// 用于预加载下一首歌曲的 MP3 数据
|
|
||||||
const preloadNextSong = (nextSongUrl: string) => {
|
|
||||||
try {
|
|
||||||
// 限制同时预加载的数量
|
|
||||||
if (preloadingSounds.value.length >= 2) {
|
|
||||||
const oldestSound = preloadingSounds.value.shift();
|
|
||||||
if (oldestSound) {
|
|
||||||
oldestSound.unload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sound = new Howl({
|
|
||||||
src: [nextSongUrl],
|
|
||||||
html5: true,
|
|
||||||
preload: true,
|
|
||||||
autoplay: false
|
|
||||||
});
|
|
||||||
|
|
||||||
preloadingSounds.value.push(sound);
|
|
||||||
|
|
||||||
// 添加加载错误处理
|
|
||||||
sound.on('loaderror', () => {
|
|
||||||
console.error('预加载音频失败:', nextSongUrl);
|
|
||||||
const index = preloadingSounds.value.indexOf(sound);
|
|
||||||
if (index > -1) {
|
|
||||||
preloadingSounds.value.splice(index, 1);
|
|
||||||
}
|
|
||||||
sound.unload();
|
|
||||||
});
|
|
||||||
|
|
||||||
return sound;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('预加载音频出错:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
|
|
||||||
try {
|
|
||||||
const songs = state.playList.slice(
|
|
||||||
Math.max(0, startIndex),
|
|
||||||
Math.min(endIndex, state.playList.length)
|
|
||||||
);
|
|
||||||
|
|
||||||
const detailedSongs = await Promise.all(
|
|
||||||
songs.map(async (song: SongResult) => {
|
|
||||||
try {
|
|
||||||
// 如果歌曲详情已经存在,就不重复请求
|
|
||||||
if (!song.playMusicUrl) {
|
|
||||||
return await getSongDetail(song);
|
|
||||||
}
|
|
||||||
return song;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取歌曲详情失败:', error);
|
|
||||||
return song;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 加载下一首的歌词
|
|
||||||
const nextSong = detailedSongs[0];
|
|
||||||
if (nextSong && !(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
|
||||||
try {
|
|
||||||
nextSong.lyric = await loadLrc(nextSong.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载歌词失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新播放列表中的歌曲详情
|
|
||||||
detailedSongs.forEach((song, index) => {
|
|
||||||
if (song && startIndex + index < state.playList.length) {
|
|
||||||
state.playList[startIndex + index] = song;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 只预加载下一首歌曲
|
|
||||||
if (nextSong && nextSong.playMusicUrl) {
|
|
||||||
preloadNextSong(nextSong.playMusicUrl);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取歌曲列表失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextPlay = async (state: any) => {
|
|
||||||
if (state.playList.length === 0) {
|
|
||||||
state.play = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let playListIndex: number;
|
|
||||||
|
|
||||||
if (state.playMode === 2) {
|
|
||||||
// 随机播放模式
|
|
||||||
do {
|
|
||||||
playListIndex = Math.floor(Math.random() * state.playList.length);
|
|
||||||
} while (playListIndex === state.playListIndex && state.playList.length > 1);
|
|
||||||
} else {
|
|
||||||
// 列表循环模式
|
|
||||||
playListIndex = (state.playListIndex + 1) % state.playList.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.playListIndex = playListIndex;
|
|
||||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevPlay = async (state: any) => {
|
|
||||||
if (state.playList.length === 0) {
|
|
||||||
state.play = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
|
||||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
|
||||||
await fetchSongs(state, playListIndex - 5, playListIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseTime = (timeString: string): number => {
|
|
||||||
const [minutes, seconds] = timeString.split(':');
|
|
||||||
return Number(minutes) * 60 + Number(seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
|
|
||||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
|
||||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
|
||||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
|
||||||
const time = parseTime(timeText);
|
|
||||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
|
||||||
return { time, text };
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
|
||||||
const lines = lyricsString.split('\n');
|
|
||||||
const lyrics: ILyricText[] = [];
|
|
||||||
const times: number[] = [];
|
|
||||||
lines.forEach((line) => {
|
|
||||||
const { time, text } = parseLyricLine(line);
|
|
||||||
times.push(time);
|
|
||||||
lyrics.push({ text, trText: '' });
|
|
||||||
});
|
|
||||||
return { lyrics, times };
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
|
|
||||||
try {
|
|
||||||
const { data } = await getMusicLrc(playMusicId);
|
|
||||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
|
||||||
const tlyric: Record<string, string> = {};
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics.forEach((item, index) => {
|
|
||||||
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
lrcTimeArray: times,
|
|
||||||
lrcArray: lyrics
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading lyrics:', err);
|
|
||||||
return {
|
|
||||||
lrcTimeArray: [],
|
|
||||||
lrcArray: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 异步加载歌词的方法
|
|
||||||
const loadLrcAsync = async (state: any, playMusicId: any) => {
|
|
||||||
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lyrics = await loadLrc(playMusicId);
|
|
||||||
state.playMusic.lyric = lyrics;
|
|
||||||
};
|
|
||||||
|
|
||||||
const play = () => {
|
|
||||||
audioService.getCurrentSound()?.play();
|
|
||||||
};
|
|
||||||
|
|
||||||
const pause = () => {
|
|
||||||
audioService.getCurrentSound()?.pause();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 在组件卸载时清理预加载的音频
|
|
||||||
onUnmounted(() => {
|
|
||||||
preloadingSounds.value.forEach((sound) => sound.unload());
|
|
||||||
preloadingSounds.value = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
handlePlayMusic,
|
|
||||||
nextPlay,
|
|
||||||
prevPlay,
|
|
||||||
play,
|
|
||||||
pause
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -14,6 +14,7 @@ import { type Platform } from '@/types/music';
|
|||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
import { hasPermission } from '@/utils/auth';
|
import { hasPermission } from '@/utils/auth';
|
||||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||||
|
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
||||||
|
|
||||||
import { useSettingsStore } from './settings';
|
import { useSettingsStore } from './settings';
|
||||||
import { useUserStore } from './user';
|
import { useUserStore } from './user';
|
||||||
@@ -207,30 +208,56 @@ export const getSongUrl = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTime = (timeString: string): number => {
|
/**
|
||||||
const [minutes, seconds] = timeString.split(':');
|
* 使用新的yrcParser解析歌词
|
||||||
return Number(minutes) * 60 + Number(seconds);
|
* @param lyricsString 歌词字符串
|
||||||
};
|
* @returns 解析后的歌词数据
|
||||||
|
*/
|
||||||
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
|
|
||||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
|
||||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
|
||||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
|
||||||
const time = parseTime(timeText);
|
|
||||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
|
||||||
return { time, text };
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
||||||
const lines = lyricsString.split('\n');
|
if (!lyricsString || typeof lyricsString !== 'string') {
|
||||||
const lyrics: ILyricText[] = [];
|
return { lyrics: [], times: [] };
|
||||||
const times: number[] = [];
|
}
|
||||||
lines.forEach((line) => {
|
|
||||||
const { time, text } = parseLyricLine(line);
|
try {
|
||||||
times.push(time);
|
const parseResult = parseYrcLyrics(lyricsString);
|
||||||
lyrics.push({ text, trText: '' });
|
|
||||||
});
|
if (!parseResult.success) {
|
||||||
return { lyrics, times };
|
console.error('歌词解析失败:', parseResult.error.message);
|
||||||
|
return { lyrics: [], times: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lyrics: parsedLyrics } = parseResult.data;
|
||||||
|
const lyrics: ILyricText[] = [];
|
||||||
|
const times: number[] = [];
|
||||||
|
|
||||||
|
for (const line of parsedLyrics) {
|
||||||
|
// 检查是否有逐字歌词
|
||||||
|
const hasWords = line.words && line.words.length > 0;
|
||||||
|
|
||||||
|
lyrics.push({
|
||||||
|
text: line.fullText,
|
||||||
|
trText: '', // 翻译文本稍后处理
|
||||||
|
words: hasWords
|
||||||
|
? line.words.map((word) => ({
|
||||||
|
text: word.text,
|
||||||
|
startTime: word.startTime,
|
||||||
|
duration: word.duration
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
hasWordByWord: hasWords,
|
||||||
|
startTime: line.startTime,
|
||||||
|
duration: line.duration
|
||||||
|
});
|
||||||
|
|
||||||
|
// 时间数组使用秒为单位(与原有逻辑保持一致)
|
||||||
|
times.push(line.startTime / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lyrics, times };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析歌词时发生错误:', error);
|
||||||
|
return { lyrics: [], times: [] };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
||||||
@@ -238,16 +265,26 @@ export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
|||||||
console.log('B站音频,无需加载歌词');
|
console.log('B站音频,无需加载歌词');
|
||||||
return {
|
return {
|
||||||
lrcTimeArray: [],
|
lrcTimeArray: [],
|
||||||
lrcArray: []
|
lrcArray: [],
|
||||||
|
hasWordByWord: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||||
const { data } = await getMusicLrc(numericId);
|
const { data } = await getMusicLrc(numericId);
|
||||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
const { lyrics, times } = parseLyrics(data?.yrc?.lyric || data?.lrc?.lyric);
|
||||||
const tlyric: Record<string, string> = {};
|
const tlyric: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 检查是否有逐字歌词
|
||||||
|
let hasWordByWord = false;
|
||||||
|
for (const lyric of lyrics) {
|
||||||
|
if (lyric.hasWordByWord) {
|
||||||
|
hasWordByWord = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.tlyric && data.tlyric.lyric) {
|
if (data.tlyric && data.tlyric.lyric) {
|
||||||
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
||||||
tLyrics.forEach((lyric, index) => {
|
tLyrics.forEach((lyric, index) => {
|
||||||
@@ -258,15 +295,18 @@ export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
|||||||
lyrics.forEach((item, index) => {
|
lyrics.forEach((item, index) => {
|
||||||
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lrcTimeArray: times,
|
lrcTimeArray: times,
|
||||||
lrcArray: lyrics
|
lrcArray: lyrics,
|
||||||
|
hasWordByWord
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading lyrics:', err);
|
console.error('Error loading lyrics:', err);
|
||||||
return {
|
return {
|
||||||
lrcTimeArray: [],
|
lrcTimeArray: [],
|
||||||
lrcArray: []
|
lrcArray: [],
|
||||||
|
hasWordByWord: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -275,7 +315,6 @@ const getSongDetail = async (playMusic: SongResult) => {
|
|||||||
// playMusic.playLoading 在 handlePlayMusic 中已设置,这里不再设置
|
// playMusic.playLoading 在 handlePlayMusic 中已设置,这里不再设置
|
||||||
|
|
||||||
if (playMusic.source === 'bilibili') {
|
if (playMusic.source === 'bilibili') {
|
||||||
console.log('处理B站音频详情');
|
|
||||||
try {
|
try {
|
||||||
// 如果需要获取URL
|
// 如果需要获取URL
|
||||||
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
||||||
|
|||||||
@@ -9,13 +9,27 @@ export interface IRecommendMusic {
|
|||||||
category: number;
|
category: number;
|
||||||
result: SongResult[];
|
result: SongResult[];
|
||||||
}
|
}
|
||||||
|
// 逐字歌词单词数据
|
||||||
|
export interface IWordData {
|
||||||
|
text: string;
|
||||||
|
startTime: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ILyricText {
|
export interface ILyricText {
|
||||||
text: string;
|
text: string;
|
||||||
trText: string;
|
trText: string;
|
||||||
|
words?: IWordData[];
|
||||||
|
hasWordByWord?: boolean;
|
||||||
|
startTime?: number;
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILyric {
|
export interface ILyric {
|
||||||
lrcTimeArray: number[];
|
lrcTimeArray: number[];
|
||||||
lrcArray: ILyricText[];
|
lrcArray: ILyricText[];
|
||||||
|
// 新增字段标识是否包含逐字歌词
|
||||||
|
hasWordByWord?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SongResult {
|
export interface SongResult {
|
||||||
|
|||||||
388
src/renderer/utils/yrcParser.ts
Normal file
388
src/renderer/utils/yrcParser.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* 歌词单词数据接口
|
||||||
|
*/
|
||||||
|
export interface WordData {
|
||||||
|
/** 单词文本内容 */
|
||||||
|
readonly text: string;
|
||||||
|
/** 开始时间(毫秒) */
|
||||||
|
readonly startTime: number;
|
||||||
|
/** 持续时间(毫秒) */
|
||||||
|
readonly duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 歌词行数据接口
|
||||||
|
*/
|
||||||
|
export interface LyricLine {
|
||||||
|
/** 行开始时间(毫秒) */
|
||||||
|
readonly startTime: number;
|
||||||
|
/** 行持续时间(毫秒) */
|
||||||
|
readonly duration: number;
|
||||||
|
/** 完整文本内容 */
|
||||||
|
readonly fullText: string;
|
||||||
|
/** 单词数组 */
|
||||||
|
readonly words: readonly WordData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据接口
|
||||||
|
*/
|
||||||
|
export interface MetaData {
|
||||||
|
/** 时间戳 */
|
||||||
|
readonly time: number;
|
||||||
|
/** 内容 */
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结果接口
|
||||||
|
*/
|
||||||
|
export interface ParsedLyrics {
|
||||||
|
/** 元数据数组 */
|
||||||
|
readonly metadata: readonly MetaData[];
|
||||||
|
/** 歌词行数组 */
|
||||||
|
readonly lyrics: readonly LyricLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义解析错误类
|
||||||
|
*/
|
||||||
|
export class LyricParseError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly line?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'LyricParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结果类型
|
||||||
|
*/
|
||||||
|
export type ParseResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; error: LyricParseError };
|
||||||
|
|
||||||
|
// 预编译正则表达式以提高性能
|
||||||
|
const METADATA_PATTERN = /^\{"t":/;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间格式化函数
|
||||||
|
* @param ms 毫秒数
|
||||||
|
* @returns 格式化的时间字符串
|
||||||
|
*/
|
||||||
|
export const formatTime = (ms: number): string => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
const milliseconds = ms % 1000;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析元数据行
|
||||||
|
* @param line 元数据行字符串
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
const parseMetadata = (line: string): ParseResult<MetaData> => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
|
||||||
|
// 类型守卫:检查数据结构
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('元数据格式无效:不是有效的对象', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.t !== 'number' || !Array.isArray(data.c)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('元数据格式无效:缺少必要字段', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = data.c
|
||||||
|
.filter((item: any) => item && typeof item.tx === 'string')
|
||||||
|
.map((item: any) => item.tx)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
time: data.t,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError(
|
||||||
|
`JSON解析失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
line
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析标准LRC格式的歌词行
|
||||||
|
* @param line 歌词行字符串
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
const parseLrcLine = (line: string): ParseResult<LyricLine> => {
|
||||||
|
const lrcMatch = line.match(LRC_TIME_PATTERN);
|
||||||
|
if (!lrcMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('LRC歌词行格式无效:无法匹配时间信息', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = parseInt(lrcMatch[1], 10);
|
||||||
|
const seconds = parseInt(lrcMatch[2], 10);
|
||||||
|
const milliseconds = parseInt(lrcMatch[3].padEnd(3, '0'), 10); // 处理2位或3位毫秒
|
||||||
|
const text = lrcMatch[4].trim();
|
||||||
|
|
||||||
|
// 验证时间值
|
||||||
|
if (
|
||||||
|
isNaN(minutes) ||
|
||||||
|
isNaN(seconds) ||
|
||||||
|
isNaN(milliseconds) ||
|
||||||
|
minutes < 0 ||
|
||||||
|
seconds < 0 ||
|
||||||
|
milliseconds < 0 ||
|
||||||
|
seconds >= 60
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('LRC歌词行格式无效:时间值无效', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = minutes * 60000 + seconds * 1000 + milliseconds;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
duration: 0, // LRC格式没有持续时间信息
|
||||||
|
fullText: text,
|
||||||
|
words: [] // LRC格式没有逐字信息
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析逐字歌词行
|
||||||
|
* @param line 歌词行字符串
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
const parseWordByWordLine = (line: string): ParseResult<LyricLine> => {
|
||||||
|
// 使用预编译的正则表达式
|
||||||
|
const lineTimeMatch = line.match(LINE_TIME_PATTERN);
|
||||||
|
if (!lineTimeMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('逐字歌词行格式无效:无法匹配时间信息', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = parseInt(lineTimeMatch[1], 10);
|
||||||
|
const duration = parseInt(lineTimeMatch[2], 10);
|
||||||
|
const content = lineTimeMatch[3];
|
||||||
|
|
||||||
|
// 验证时间值
|
||||||
|
if (isNaN(startTime) || isNaN(duration) || startTime < 0 || duration < 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('逐字歌词行格式无效:时间值无效', line)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置正则表达式状态
|
||||||
|
WORD_PATTERN.lastIndex = 0;
|
||||||
|
|
||||||
|
const words: WordData[] = [];
|
||||||
|
const textParts: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
// 使用exec而不是matchAll以更好地控制性能
|
||||||
|
while ((match = WORD_PATTERN.exec(content)) !== null) {
|
||||||
|
const wordStartTime = parseInt(match[1], 10);
|
||||||
|
const wordDuration = parseInt(match[2], 10);
|
||||||
|
const wordText = match[3].trim();
|
||||||
|
|
||||||
|
// 验证单词数据
|
||||||
|
if (isNaN(wordStartTime) || isNaN(wordDuration)) {
|
||||||
|
continue; // 跳过无效的单词数据
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordText) {
|
||||||
|
words.push({
|
||||||
|
text: wordText,
|
||||||
|
startTime: wordStartTime,
|
||||||
|
duration: wordDuration
|
||||||
|
});
|
||||||
|
textParts.push(wordText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
duration,
|
||||||
|
fullText: textParts.join(' '),
|
||||||
|
words
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析歌词行(自动检测格式)
|
||||||
|
* @param line 歌词行字符串
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
const parseLyricLine = (line: string): ParseResult<LyricLine> => {
|
||||||
|
// 首先尝试解析逐字歌词格式
|
||||||
|
if (LINE_TIME_PATTERN.test(line)) {
|
||||||
|
return parseWordByWordLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 然后尝试解析标准LRC格式
|
||||||
|
if (LRC_TIME_PATTERN.test(line)) {
|
||||||
|
return parseLrcLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('歌词行格式无效:不匹配任何已知格式', line)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算LRC格式歌词的持续时间
|
||||||
|
* @param lyrics 歌词行数组
|
||||||
|
* @returns 更新持续时间后的歌词行数组
|
||||||
|
*/
|
||||||
|
const calculateLrcDurations = (lyrics: LyricLine[]): LyricLine[] => {
|
||||||
|
if (lyrics.length === 0) return lyrics;
|
||||||
|
|
||||||
|
const updatedLyrics: LyricLine[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lyrics.length; i++) {
|
||||||
|
const currentLine = lyrics[i];
|
||||||
|
|
||||||
|
// 如果已经有持续时间(逐字歌词),直接使用
|
||||||
|
if (currentLine.duration > 0) {
|
||||||
|
updatedLyrics.push(currentLine);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算LRC格式的持续时间
|
||||||
|
let duration = 0;
|
||||||
|
if (i < lyrics.length - 1) {
|
||||||
|
// 使用下一行的开始时间减去当前行的开始时间
|
||||||
|
duration = lyrics[i + 1].startTime - currentLine.startTime;
|
||||||
|
} else {
|
||||||
|
// 最后一行,使用默认持续时间(3秒)
|
||||||
|
duration = 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保持续时间不为负数
|
||||||
|
duration = Math.max(duration, 0);
|
||||||
|
|
||||||
|
updatedLyrics.push({
|
||||||
|
...currentLine,
|
||||||
|
duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedLyrics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主解析函数
|
||||||
|
* @param lyricsStr 歌词字符串
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
export const parseLyrics = (lyricsStr: string): ParseResult<ParsedLyrics> => {
|
||||||
|
if (typeof lyricsStr !== 'string') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError('输入参数必须是字符串')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = lyricsStr.trim().split('\n');
|
||||||
|
const metadata: MetaData[] = [];
|
||||||
|
const lyrics: LyricLine[] = [];
|
||||||
|
const errors: LyricParseError[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const trimmedLine = lines[i].trim();
|
||||||
|
if (!trimmedLine) continue;
|
||||||
|
|
||||||
|
// 使用预编译正则表达式进行快速检测
|
||||||
|
if (METADATA_PATTERN.test(trimmedLine)) {
|
||||||
|
const result = parseMetadata(trimmedLine);
|
||||||
|
if (result.success) {
|
||||||
|
metadata.push(result.data);
|
||||||
|
} else {
|
||||||
|
errors.push(result.error);
|
||||||
|
}
|
||||||
|
} else if (trimmedLine.startsWith('[')) {
|
||||||
|
const result = parseLyricLine(trimmedLine);
|
||||||
|
if (result.success) {
|
||||||
|
lyrics.push(result.data);
|
||||||
|
} else {
|
||||||
|
errors.push(result.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(new LyricParseError(`第${i + 1}行:无法识别的行格式`, trimmedLine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有太多错误,可能整个文件格式有问题
|
||||||
|
if (errors.length > 0 && errors.length > lines.length * 0.5) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError(
|
||||||
|
`解析失败:错误行数过多 (${errors.length}/${lines.length}),可能文件格式不正确 ${JSON.stringify(errors)}`
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按时间排序歌词行
|
||||||
|
lyrics.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
// 计算LRC格式的持续时间
|
||||||
|
const finalLyrics = calculateLrcDurations(lyrics);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
metadata,
|
||||||
|
lyrics: finalLyrics
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new LyricParseError(
|
||||||
|
`解析过程中发生错误: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出默认解析函数(向后兼容)
|
||||||
|
*/
|
||||||
|
export default parseLyrics;
|
||||||
Reference in New Issue
Block a user