feat: 逐字歌词

This commit is contained in:
alger
2025-10-11 20:23:54 +08:00
parent 4575e4f26d
commit cb2baeadf5
10 changed files with 877 additions and 367 deletions
+133 -21
View File
@@ -119,7 +119,22 @@
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@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">
{{ item.trText }}
</div>
@@ -144,7 +159,7 @@
<script setup lang="ts">
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 Cover3D from '@/components/cover/Cover3D.vue';
@@ -157,6 +172,7 @@ import {
correctionTime,
lrcArray,
nowIndex,
nowTime,
playMusic,
setAudioTime,
textColors,
@@ -179,6 +195,7 @@ const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const showStickyHeader = ref(false);
const lyricSettingsRef = ref<InstanceType<typeof LyricSettings>>();
const isSongChanging = ref(false);
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
@@ -274,6 +291,8 @@ const mouseLeaveLayout = () => {
};
watch(nowIndex, () => {
// 歌曲切换时不自动滚动
if (isSongChanging.value) return;
debouncedLrcScroll();
});
@@ -337,25 +356,30 @@ watch(
{ immediate: true }
);
// 修改 useLyricProgress 的使用方式
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
// 修改 getLrcStyle 函数
const getLrcStyle = (index: number) => {
const colors = textColors.value || getTextColors;
const colors = textColors.value || getTextColors();
const originalStyle = originalLrcStyle(index);
if (index === nowIndex.value) {
// 当前播放的歌词,使用渐变效果
return {
...originalStyle,
backgroundImage: originalStyle.backgroundImage
?.replace(/#ffffff/g, colors.active)
.replace(/#ffffff8a/g, `${colors.primary}`),
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent'
};
// 当前播放的歌词
if (originalStyle.backgroundImage) {
// 有渐变进度时,使用渐变效果
return {
...originalStyle,
backgroundImage: originalStyle.backgroundImage
.replace(/#ffffff/g, colors.active)
.replace(/#ffffff8a/g, `${colors.primary}`),
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(() => {
if (animationFrame.value) {
@@ -502,12 +577,24 @@ onMounted(() => {
}
});
// 添加对 playMusic 的监听
watch(playMusic, () => {
nextTick(() => {
lrcScroll('instant', true);
});
});
// 添加对 playMusic.id 的监听,歌曲切换时滚动到顶部
watch(
() => playMusic.value.id,
(newId, oldId) => {
// 只在歌曲真正切换时滚动到顶部
if (newId !== oldId && newId) {
isSongChanging.value = true;
// 延迟滚动,确保 nowIndex 已重置
setTimeout(() => {
lrcScroll('instant', true);
// 延迟恢复自动滚动,等待歌词数据更新
setTimeout(() => {
isSongChanging.value = false;
}, 300);
}, 100);
}
}
);
defineExpose({
lrcScroll,
@@ -621,6 +708,9 @@ defineExpose({
.music-lrc-container {
padding-top: 30vh;
.music-lrc-text:last-child {
margin-bottom: 200px;
}
}
.music-lrc {
@@ -670,6 +760,28 @@ defineExpose({
opacity: 0.7;
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 {
@@ -57,7 +57,22 @@
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@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">
{{ item.trText }}
</div>
@@ -119,7 +134,22 @@
<div class="lyrics-container" v-if="!config.hideLyrics" @click="showFullLyricScreen">
<div v-if="lrcArray.length > 0" class="lyrics-wrapper">
<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 v-else class="no-lyrics">
@@ -225,7 +255,22 @@
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@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">
{{ item.trText }}
</div>
@@ -770,7 +815,11 @@ const visibleLyrics = computed(() => {
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({
@@ -1020,6 +1069,57 @@ const getLrcStyle = (index: number) => {
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>
<style scoped lang="scss">
@@ -1574,7 +1674,7 @@ const getLrcStyle = (index: number) => {
// 通用歌词样式
.lyric-line {
@apply cursor-pointer transition-all duration-300;
@apply cursor-pointer transition-all duration-300 font-medium;
font-weight: 500;
letter-spacing: var(--lyric-letter-spacing, 0);
line-height: var(--lyric-line-height, 1.6);
@@ -1587,7 +1687,7 @@ const getLrcStyle = (index: number) => {
}
&.now-text {
@apply font-bold py-4;
@apply font-medium py-4;
color: var(--text-color-active);
opacity: 1;
}
@@ -1599,6 +1699,25 @@ const getLrcStyle = (index: number) => {
.translation {
@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 {
@apply text-center text-base opacity-60;
color: var(--text-color-primary);
@@ -202,7 +202,7 @@ watch(
<style lang="scss" scoped>
.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;
animation-duration: 0.3s !important;
transition: all 0.3s ease;