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 { ref, watch } from 'vue'
import { usePreferences } from '../stores/usePreferences' import { usePreferences } from '../stores/usePreferences'
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
import RewindIcon from '../assets/icons/rewind.vue' import RewindIcon from '../assets/icons/rewind.vue'
import ForwardIcon from '../assets/icons/forward.vue' import ForwardIcon from '../assets/icons/forward.vue'
import PlayIcon from '../assets/icons/play.vue' import PlayIcon from '../assets/icons/play.vue'
@ -157,182 +159,187 @@ function calculateStickyTop() {
<div class="bg-transparent w-full h-full absolute top-0 left-0" /> <div class="bg-transparent w-full h-full absolute top-0 left-0" />
</div> </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="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none">
<div class="flex flex-col w-96 gap-4 sticky" :style="{ <div class="sticky w-96" :style="{
top: `${calculateStickyTop()}px` top: `${calculateStickyTop()}px`,
}" ref="controllerRef"> height: `${controllerRef?.clientHeight?? 0}px`
<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="flex flex-col w-96 gap-4" ref="controllerRef">
<div class="relative flex-auto w-0"> <img :src="getCurrentTrack().album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
<div class=""> <div class="flex justify-between items-center gap-2">
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80"> <div class="relative flex-auto w-0">
{{ getCurrentTrack().song.name }} <div class="">
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
{{ getCurrentTrack().song.name }}
</div>
<div class="text-black/90 blur-lg text-base truncate w-80">
{{ getCurrentTrack().song.artists ?? [] }}
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
</div>
</div> </div>
<div class="text-black/90 blur-lg text-base truncate w-80">
{{ getCurrentTrack().song.artists ?? [] }} <div class="absolute top-0">
{{ getCurrentTrack().album?.name ?? '未知专辑' }} <div class="text-white text-lg font-medium truncate w-80">
</div> {{ getCurrentTrack().song.name }}
</div> </div>
<div class="text-white/75 text-base truncate w-80">
<div class="absolute top-0"> {{ artistsOrganize(getCurrentTrack().song.artists ?? []) }}
<div class="text-white text-lg font-medium truncate w-80"> {{ getCurrentTrack().album?.name ?? '未知专辑' }}
{{ getCurrentTrack().song.name }} </div>
</div>
<div class="text-white/75 text-base truncate w-80">
{{ artistsOrganize(getCurrentTrack().song.artists ?? []) }}
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
</div> </div>
</div> </div>
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl">
<span class="text-white">
<StarEmptyIcon :size="6" />
</span>
</button>
</div> </div>
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl"> <div class="flex flex-col gap-1">
<span class="text-white"> <div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl">
<StarEmptyIcon :size="6" /> <div class="w-full" ref="progressBarContainer">
</span> <div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
</button> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="w-full flex justify-between">
<div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl"> <div class="font-medium flex-1 text-left relative">
<div class="w-full" ref="progressBarContainer">
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
</div>
</div>
<div class="w-full flex justify-between">
<div class="font-medium flex-1 text-left relative">
<span
class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
<span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
</div>
<div class="text-xs text-center relative">
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
<span class="text-white">{{ formatDetector() }}</span>
</div>
<div class="flex flex-1">
<div class="flex-1" />
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
<span <span
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span> class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span> <span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
</div>
<div class="text-xs text-center relative">
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
<span class="text-white">{{ formatDetector() }}</span>
</div>
<div class="flex flex-1">
<div class="flex-1" />
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
<span
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
</button>
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class="flex-1 text-left flex gap-1">
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<SpeakerIcon :size="6" />
</span>
<span class="text-white">
<SpeakerIcon :size="6" />
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
@click="makePlayQueueListPresent">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<MusicListIcon :size="6" />
</span>
<span class="text-white">
<MusicListIcon :size="6" />
</span>
</div>
</button>
</div>
<div class="flex-2 text-center align-center justify-center gap-2 flex">
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playPrevious">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<RewindIcon :size="8" />
</span>
<span class="text-white">
<RewindIcon :size="8" />
</span>
</div>
</button>
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
<div v-if="playQueueStore.isPlaying">
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<LoadingIndicator :size="6" />
</span>
<span class="text-white">
<LoadingIndicator :size="6" />
</span>
</div>
<div v-else class="w-8 h-8 relative">
<span class="text-black blur-md absolute top-0 left-0">
<PauseIcon :size="8" />
</span>
<span class="text-white">
<PauseIcon :size="8" />
</span>
</div>
</div>
<div v-else>
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<PlayIcon :size="8" />
</span>
<span class="text-white">
<PlayIcon :size="8" />
</span>
</div>
</div>
</button>
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playNext">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<ForwardIcon :size="8" />
</span>
<span class="text-white">
<ForwardIcon :size="8" />
</span>
</div>
</button>
</div>
<div class="flex-1 text-right flex gap-1">
<div class="flex-1" />
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<ChatBubbleQuoteIcon :size="6" />
</span>
<span class="text-white">
<ChatBubbleQuoteIcon :size="6" />
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-sm absolute top-0 left-0">
<EllipsisHorizontalIcon :size="6" />
</span>
<span class="text-white">
<EllipsisHorizontalIcon :size="6" />
</span>
</div>
</button> </button>
</div> </div>
</div> </div>
</div>
<div class="w-full flex justify-between items-center">
<div class="flex-1 text-left flex gap-1">
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<SpeakerIcon :size="6" />
</span>
<span class="text-white">
<SpeakerIcon :size="6" />
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
@click="makePlayQueueListPresent">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<MusicListIcon :size="6" />
</span>
<span class="text-white">
<MusicListIcon :size="6" />
</span>
</div>
</button>
</div>
<div class="flex-2 text-center align-center justify-center gap-2 flex">
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playPrevious">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<RewindIcon :size="8" />
</span>
<span class="text-white">
<RewindIcon :size="8" />
</span>
</div>
</button>
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
<div v-if="playQueueStore.isPlaying">
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<LoadingIndicator :size="6" />
</span>
<span class="text-white">
<LoadingIndicator :size="6" />
</span>
</div>
<div v-else class="w-8 h-8 relative">
<span class="text-black blur-md absolute top-0 left-0">
<PauseIcon :size="8" />
</span>
<span class="text-white">
<PauseIcon :size="8" />
</span>
</div>
</div>
<div v-else>
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<PlayIcon :size="8" />
</span>
<span class="text-white">
<PlayIcon :size="8" />
</span>
</div>
</div>
</button>
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playNext">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<ForwardIcon :size="8" />
</span>
<span class="text-white">
<ForwardIcon :size="8" />
</span>
</div>
</button>
</div>
<div class="flex-1 text-right flex gap-1">
<div class="flex-1" />
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<ChatBubbleQuoteIcon :size="6" />
</span>
<span class="text-white">
<ChatBubbleQuoteIcon :size="6" />
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<div class="w-6 h-6 relative">
<span class="text-black blur-sm absolute top-0 left-0">
<EllipsisHorizontalIcon :size="6" />
</span>
<span class="text-white">
<EllipsisHorizontalIcon :size="6" />
</span>
</div>
</button>
</div>
</div> </div>
</div> </div>
<div>
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
</div>
</div> </div>
<!-- Queue list --> <!-- Queue list -->

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

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