feat(歌词): 添加滚动歌词组件和接口定义

新增 ScrollingLyrics.vue 组件实现歌词滚动显示功能,包括:
1. 解析 LRC 格式歌词文本
2. 根据当前播放时间高亮对应歌词行
3. 支持歌词和间隔时间的处理

同时在 vite-env.d.ts 中新增 LyricsLine 和 GapLine 接口定义,并在 Playroom.vue 中集成歌词组件
This commit is contained in:
Astrian Zheng 2025-05-26 17:29:31 +10:00
parent 3a5377595b
commit 7ecc38dda8
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
3 changed files with 327 additions and 163 deletions

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import axios from 'axios'
import { watch } from 'vue'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
const playQueueStore = usePlayQueueStore()
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
const currentLineIndex = ref(0)
const props = defineProps<{
lrcSrc?: string
}>()
onMounted(async () => {
if (props.lrcSrc) {
const lrcContent = await axios.get(props.lrcSrc)
parsedLyrics.value = parseLyrics(lrcContent.data)
console.log(parsedLyrics.value)
}
})
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
if (!lrcText) return []
const lines = lrcText.split('\n')
const tempParsedLines: (LyricsLine | GapLine)[] = []
// LRC: [mm:ss.xx] [mm:ss]
const timeRegex = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
//
for (const line of lines) {
const matches = [...line.matchAll(timeRegex)]
if (matches.length === 0) continue
//
const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
//
for (const match of matches) {
const minutes = parseInt(match[1])
const seconds = parseInt(match[2])
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0
const totalSeconds = minutes * 60 + seconds + milliseconds / 1000
if (text) {
tempParsedLines.push({
type: 'lyric',
time: totalSeconds,
text: text,
originalTime: match[0]
})
} else {
tempParsedLines.push({
type: 'gap',
time: totalSeconds,
originalTime: match[0]
})
}
}
}
//
tempParsedLines.sort((a, b) => a.time - b.time)
//
const finalLines: (LyricsLine | GapLine)[] = []
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
//
if (lyricLines.length === 0) {
return tempParsedLines
}
//
for (let i = 0; i < gapLines.length; i++) {
const gapLine = gapLines[i]
//
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
if (nextLyricLine) {
const duration = nextLyricLine.time - gapLine.time
gapLine.duration = duration
//
if (duration >= minGapDuration) {
finalLines.push(gapLine)
}
}
}
//
finalLines.push(...lyricLines)
//
return finalLines.sort((a, b) => a.time - b.time)
}
function findCurrentLineIndex(time: number): number {
if (parsedLyrics.value.length === 0) return -1
let index = -1
for (let i = 0; i < parsedLyrics.value.length; i++) {
if (time >= parsedLyrics.value[i].time) {
index = i
} else {
break
}
}
return index
}
watch(() => playQueueStore.currentTime, (value) => {
currentLineIndex.value = findCurrentLineIndex(value)
})
function calculateTopRange() {
return window.innerHeight / 3
}
</script>
<template>
<div class="w-[40rem] overflow-x-visible flex flex-col gap-4 pl-16" :style="{
paddingTop: `${calculateTopRange()}px`,
paddingBottom: `${calculateTopRange() * 2}px`
}">
<div class="" v-for="(line, index) in parsedLyrics" :key="index">
<div v-if="line.type === 'lyric'" class="text-3xl font-bold relative">
<div :class="currentLineIndex === index ? 'text-black/80 blur-3xl' : 'text-black/20 blur-3xl'">{{ line.text }}</div>
<div class="absolute top-0" :class="currentLineIndex === index ? 'text-white' : 'text-white/50'">{{ line.text }}</div>
</div>
<!-- <div v-else class="text-white/50 text-sm">
{{ line.originalTime }}
</div> -->
</div>
</div>
</template>

View File

@ -8,6 +8,8 @@ import { useTemplateRef } from 'vue'
import { ref, watch } from 'vue'
import { usePreferences } from '../stores/usePreferences'
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
import RewindIcon from '../assets/icons/rewind.vue'
import ForwardIcon from '../assets/icons/forward.vue'
import PlayIcon from '../assets/icons/play.vue'
@ -157,10 +159,12 @@ function calculateStickyTop() {
<div class="bg-transparent w-full h-full absolute top-0 left-0" />
</div>
<div class="absolute top-0 left-0 flex justify-center items-center h-screen w-screen overflow-y-auto gap-16 z-10 select-none">
<div class="flex flex-col w-96 gap-4 sticky" :style="{
top: `${calculateStickyTop()}px`
}" ref="controllerRef">
<div class="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none">
<div class="sticky w-96" :style="{
top: `${calculateStickyTop()}px`,
height: `${controllerRef?.clientHeight?? 0}px`
}">
<div class="flex flex-col w-96 gap-4" ref="controllerRef">
<img :src="getCurrentTrack().album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
<div class="flex justify-between items-center gap-2">
<div class="relative flex-auto w-0">
@ -332,7 +336,10 @@ function calculateStickyTop() {
</div>
</div>
</div>
</div>
<div>
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
</div>
</div>
<!-- Queue list -->

14
src/vite-env.d.ts vendored
View File

@ -39,3 +39,17 @@ interface QueueItem {
song: Song
album?: Album
}
interface LyricsLine {
type: 'lyric'
time: number
text: string
originalTime: string
}
interface GapLine {
type: 'gap'
time: number
originalTime: string
duration?: number // 添加间隔持续时间
}