fix: update language attribute in HTML and adjust script source path for consistency

refactor(ScrollingLyrics): simplify markup and improve readability of lyrics rendering logic
This commit is contained in:
Astrian Zheng 2025-05-26 20:30:55 +10:00
parent 5a200edd11
commit d8d1284c56
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
2 changed files with 134 additions and 141 deletions

View File

@ -14,39 +14,46 @@ const route = useRoute()
<div class="flex flex-col w-full h-full overflow-y-auto pb-24"> <div class="flex flex-col w-full h-full overflow-y-auto pb-24">
<div class="py-8 px-4 md:px-8 w-screen bg-gradient-to-b from-[#00000080] to-transparent z-20 absolute top-0"> <div class="py-8 px-4 md:px-8 w-screen bg-gradient-to-b from-[#00000080] to-transparent z-20 absolute top-0">
<div class="flex justify-between align-center h-[2.625rem] items-center"> <div class="flex justify-between align-center h-[2.625rem] items-center">
<ul class="flex gap-4" v-if="(() => { <ul class="flex gap-4" v-if="(() => {
if (route.path === '/lucky' || route.path === '/library' || route.path === '/') { return true } if (route.path === '/lucky' || route.path === '/library' || route.path === '/') { return true }
else { return false } else { return false }
})()"> })()">
<li> <li>
<RouterLink to="/"> <RouterLink to="/">
<span class="text-4xl" :class="route.path === '/' ? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'">浏览</span> <span class="text-4xl"
:class="route.path === '/' ? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'">浏览</span>
</RouterLink> </RouterLink>
</li> </li>
<li> <li>
<RouterLink to="/lucky" :class="route.path === '/lucky'? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'"> <RouterLink to="/lucky"
:class="route.path === '/lucky' ? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'">
<span class="text-4xl">手气不错</span> <span class="text-4xl">手气不错</span>
</RouterLink> </RouterLink>
</li> </li>
<li> <li>
<RouterLink to="/library" :class="route.path === '/library'? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'"> <RouterLink to="/library"
:class="route.path === '/library' ? 'font-semibold text-white' : 'text-white/50 hover:text-white/80'">
<span class="text-4xl">收藏库</span> <span class="text-4xl">收藏库</span>
</RouterLink> </RouterLink>
</li> </li>
</ul> </ul>
<div v-else> <div v-else>
<button class="text-white w-9 h-9 bg-white/5 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center" @click="$router.back()"> <button
class="text-white w-9 h-9 bg-white/5 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
@click="$router.back()">
<LeftArrowIcon :size="4" /> <LeftArrowIcon :size="4" />
</button> </button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"> <button
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center">
<SearchIcon :size="4" /> <SearchIcon :size="4" />
</button> </button>
<button class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"> <button
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center">
<CorgIcon :size="4" /> <CorgIcon :size="4" />
</button> </button>

View File

@ -1,102 +1,73 @@
<template> <template>
<div <div class="relative overflow-hidden h-full w-[40rem]" ref="lyricsContainer" @wheel="handleWheel">
class="relative overflow-hidden h-full w-[40rem]"
ref="lyricsContainer"
@wheel="handleWheel"
>
<!-- 歌词滚动区域 --> <!-- 歌词滚动区域 -->
<div <div class="relative" ref="lyricsWrapper">
class="relative"
ref="lyricsWrapper"
>
<!-- 顶部填充 --> <!-- 顶部填充 -->
<div class="h-1/2 pointer-events-none"></div> <div class="h-1/2 pointer-events-none"></div>
<div <div v-for="(line, index) in parsedLyrics" :key="index" :ref="el => setLineRef(el as HTMLElement, index)"
v-for="(line, index) in parsedLyrics"
:key="index"
:ref="el => setLineRef(el as HTMLElement, index)"
class="py-8 px-16 cursor-pointer transition-all duration-300 hover:scale-105" class="py-8 px-16 cursor-pointer transition-all duration-300 hover:scale-105"
@click="handleLineClick(line, index)" @click="handleLineClick(line, index)">
>
<div v-if="line.type === 'lyric'" class="relative"> <div v-if="line.type === 'lyric'" class="relative">
<!-- 背景模糊文字 --> <!-- 背景模糊文字 -->
<div <div class="text-3xl font-bold transition-all duration-500" :class="[
class="text-3xl font-bold transition-all duration-500" currentLineIndex === index ? 'text-black/80 blur-xl' : 'text-black/20 blur-2xl'
:class="[ ]">
currentLineIndex === index ? 'text-black/80 blur-xl' : 'text-black/20 blur-2xl'
]"
>
{{ line.text }} {{ line.text }}
</div> </div>
<!-- 前景清晰文字 --> <!-- 前景清晰文字 -->
<div <div class="absolute top-0 left-0 w-full text-3xl font-bold transition-all duration-500" :class="[
class="absolute top-0 left-0 w-full text-3xl font-bold transition-all duration-500" currentLineIndex === index
:class="[ ? 'text-white scale-110'
currentLineIndex === index : index < currentLineIndex
? 'text-white scale-110' ? userScrolling ? 'text-white/60' : 'text-white/60 blur-sm'
: index < currentLineIndex : userScrolling ? 'text-white/40' : 'text-white/40 blur-sm'
? userScrolling ? 'text-white/60' : 'text-white/60 blur-sm' ]">
: userScrolling ? 'text-white/40' : 'text-white/40 blur-sm'
]"
>
{{ line.text }} {{ line.text }}
</div> </div>
</div> </div>
<div v-else-if="line.type === 'gap'" class="flex justify-center items-center py-4"> <div v-else-if="line.type === 'gap'" class="flex items-center gap-2">
<div class="w-16 h-px rounded-full"></div> <div v-for="dot in 3" :key="dot" class="bg-white rounded-full"
:class="currentLineIndex === index ? 'w-4 h-4' : ''"
:style="{ opacity: getGapDotOpacities(line)[dot - 1] }" />
</div> </div>
</div> </div>
<!-- 底部填充 --> <!-- 底部填充 -->
<div class="h-96 pointer-events-none"></div> <div class="h-96 pointer-events-none"></div>
</div> </div>
<!-- 歌词控制面板 --> <!-- 歌词控制面板 -->
<div <div class="absolute top-4 right-4 flex gap-2 opacity-0 transition-opacity duration-300 hover:opacity-100"
class="absolute top-4 right-4 flex gap-2 opacity-0 transition-opacity duration-300 hover:opacity-100" ref="controlPanel">
ref="controlPanel" <button @click="toggleAutoScroll"
>
<button
@click="toggleAutoScroll"
class="px-3 py-1 rounded-full text-xs backdrop-blur-md text-white/80 hover:text-white transition-all duration-200" class="px-3 py-1 rounded-full text-xs backdrop-blur-md text-white/80 hover:text-white transition-all duration-200"
:class="[ :class="[
autoScroll autoScroll
? 'bg-white/20 shadow-lg' ? 'bg-white/20 shadow-lg'
: 'bg-black/20 hover:bg-black/30' : 'bg-black/20 hover:bg-black/30'
]" ]">
>
{{ autoScroll ? '自动' : '手动' }} {{ autoScroll ? '自动' : '手动' }}
</button> </button>
<button <button @click="resetScroll"
@click="resetScroll" class="px-3 py-1 rounded-full text-xs bg-black/20 backdrop-blur-md text-white/80 hover:bg-black/30 hover:text-white transition-all duration-200">
class="px-3 py-1 rounded-full text-xs bg-black/20 backdrop-blur-md text-white/80 hover:bg-black/30 hover:text-white transition-all duration-200"
>
重置 重置
</button> </button>
</div> </div>
<!-- 滚动指示器 --> <!-- 滚动指示器 -->
<div <div class="absolute right-2 top-1/4 bottom-1/4 w-1 bg-white/10 rounded-full overflow-hidden"
class="absolute right-2 top-1/4 bottom-1/4 w-1 bg-white/10 rounded-full overflow-hidden" v-if="parsedLyrics.length > 5">
v-if="parsedLyrics.length > 5" <div class="w-full bg-white/40 rounded-full transition-all duration-300" :style="{
> height: scrollIndicatorHeight + '%',
<div transform: `translateY(${scrollIndicatorPosition}px)`
class="w-full bg-white/40 rounded-full transition-all duration-300" }"></div>
:style="{
height: scrollIndicatorHeight + '%',
transform: `translateY(${scrollIndicatorPosition}px)`
}"
></div>
</div> </div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div <div v-if="loading" class="absolute inset-0 flex items-center justify-center backdrop-blur-sm"
v-if="loading" ref="loadingIndicator">
class="absolute inset-0 flex items-center justify-center backdrop-blur-sm"
ref="loadingIndicator"
>
<div class="flex items-center gap-3 px-6 py-3 rounded-full bg-black/20 backdrop-blur-md"> <div class="flex items-center gap-3 px-6 py-3 rounded-full bg-black/20 backdrop-blur-md">
<div class="w-4 h-4 border-2 border-white/60 border-t-white rounded-full animate-spin"></div> <div class="w-4 h-4 border-2 border-white/60 border-t-white rounded-full animate-spin"></div>
<div class="text-white/80 text-sm font-medium">加载歌词中...</div> <div class="text-white/80 text-sm font-medium">加载歌词中...</div>
@ -104,11 +75,8 @@
</div> </div>
<!-- 无歌词状态 --> <!-- 无歌词状态 -->
<div <div v-if="!loading && parsedLyrics.length === 0" class="absolute inset-0 flex items-center justify-center"
v-if="!loading && parsedLyrics.length === 0" ref="noLyricsIndicator">
class="absolute inset-0 flex items-center justify-center"
ref="noLyricsIndicator"
>
<div class="text-center"> <div class="text-center">
<div class="text-white/40 text-lg font-medium mb-2">暂无歌词</div> <div class="text-white/40 text-lg font-medium mb-2">暂无歌词</div>
<div class="text-white/30 text-sm">享受纯音乐的美妙</div> <div class="text-white/30 text-sm">享受纯音乐的美妙</div>
@ -196,25 +164,25 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
originalTime: '[00:00]' originalTime: '[00:00]'
} }
] ]
const lines = lrcText.split('\n') const lines = lrcText.split('\n')
const tempParsedLines: (LyricsLine | GapLine)[] = [] const tempParsedLines: (LyricsLine | GapLine)[] = []
const timeRegex = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g const timeRegex = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
for (const line of lines) { for (const line of lines) {
const matches = [...line.matchAll(timeRegex)] const matches = [...line.matchAll(timeRegex)]
if (matches.length === 0) continue if (matches.length === 0) continue
const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim() const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
for (const match of matches) { for (const match of matches) {
const minutes = parseInt(match[1]) const minutes = parseInt(match[1])
const seconds = parseInt(match[2]) const seconds = parseInt(match[2])
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0 const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0
const totalSeconds = minutes * 60 + seconds + milliseconds / 1000 const totalSeconds = minutes * 60 + seconds + milliseconds / 1000
if (text) { if (text) {
tempParsedLines.push({ tempParsedLines.push({
type: 'lyric', type: 'lyric',
@ -231,29 +199,29 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
} }
} }
} }
tempParsedLines.sort((a, b) => a.time - b.time) tempParsedLines.sort((a, b) => a.time - b.time)
const finalLines: (LyricsLine | GapLine)[] = [] const finalLines: (LyricsLine | GapLine)[] = []
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[] const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[] const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
if (lyricLines.length === 0) return tempParsedLines if (lyricLines.length === 0) return tempParsedLines
for (let i = 0; i < gapLines.length; i++) { for (let i = 0; i < gapLines.length; i++) {
const gapLine = gapLines[i] const gapLine = gapLines[i]
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time) const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
if (nextLyricLine) { if (nextLyricLine) {
const duration = nextLyricLine.time - gapLine.time const duration = nextLyricLine.time - gapLine.time
gapLine.duration = duration gapLine.duration = duration
if (duration >= minGapDuration) { if (duration >= minGapDuration) {
finalLines.push(gapLine) finalLines.push(gapLine)
} }
} }
} }
finalLines.push(...lyricLines) finalLines.push(...lyricLines)
const sortedLines = finalLines.sort((a, b) => a.time - b.time) const sortedLines = finalLines.sort((a, b) => a.time - b.time)
// //
@ -285,24 +253,24 @@ function findCurrentLineIndex(time: number): number {
// 使 GSAP // 使 GSAP
function scrollToLine(lineIndex: number, smooth = true) { function scrollToLine(lineIndex: number, smooth = true) {
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
const container = lyricsContainer.value const container = lyricsContainer.value
const wrapper = lyricsWrapper.value const wrapper = lyricsWrapper.value
const lineElement = lineRefs.value[lineIndex] const lineElement = lineRefs.value[lineIndex]
const containerHeight = container.clientHeight const containerHeight = container.clientHeight
const containerCenter = containerHeight / 2 const containerCenter = containerHeight / 2
// //
const lineOffsetTop = lineElement.offsetTop const lineOffsetTop = lineElement.offsetTop
const lineHeight = lineElement.clientHeight const lineHeight = lineElement.clientHeight
const targetY = containerCenter - lineOffsetTop - lineHeight / 2 const targetY = containerCenter - lineOffsetTop - lineHeight / 2
// //
if (scrollTween) { if (scrollTween) {
scrollTween.kill() scrollTween.kill()
} }
if (smooth) { if (smooth) {
// 使 GSAP // 使 GSAP
scrollTween = gsap.to(wrapper, { scrollTween = gsap.to(wrapper, {
@ -321,14 +289,14 @@ function scrollToLine(lineIndex: number, smooth = true) {
// //
function highlightCurrentLine(lineIndex: number) { function highlightCurrentLine(lineIndex: number) {
if (!lineRefs.value[lineIndex]) return if (!lineRefs.value[lineIndex]) return
const lineElement = lineRefs.value[lineIndex] const lineElement = lineRefs.value[lineIndex]
// //
if (highlightTween) { if (highlightTween) {
highlightTween.kill() highlightTween.kill()
} }
// //
lineRefs.value.forEach((el, index) => { lineRefs.value.forEach((el, index) => {
if (el && index !== lineIndex) { if (el && index !== lineIndex) {
@ -340,7 +308,7 @@ function highlightCurrentLine(lineIndex: number) {
}) })
} }
}) })
// //
highlightTween = gsap.to(lineElement, { highlightTween = gsap.to(lineElement, {
scale: 1.05, scale: 1.05,
@ -356,7 +324,7 @@ function highlightCurrentLine(lineIndex: number) {
// //
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
event.preventDefault() event.preventDefault()
if (!lyricsWrapper.value || !lyricsContainer.value) return if (!lyricsWrapper.value || !lyricsContainer.value) return
userScrolling.value = true userScrolling.value = true
@ -403,18 +371,18 @@ function handleLineClick(line: LyricsLine | GapLine, index: number) {
// //
// emit('seek', line.time) // emit('seek', line.time)
} }
// //
scrollToLine(index, true) scrollToLine(index, true)
// //
if (lineRefs.value[index]) { if (lineRefs.value[index]) {
gsap.fromTo(lineRefs.value[index], gsap.fromTo(lineRefs.value[index],
{ scale: 1 }, { scale: 1 },
{ {
scale: 1.1, scale: 1.1,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: "power2.inOut"
} }
@ -426,21 +394,21 @@ function handleLineClick(line: LyricsLine | GapLine, index: number) {
function toggleAutoScroll() { function toggleAutoScroll() {
autoScroll.value = !autoScroll.value autoScroll.value = !autoScroll.value
userScrolling.value = false userScrolling.value = false
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[0], gsap.fromTo(controlPanel.value.children[0],
{ scale: 1 }, { scale: 1 },
{ {
scale: 0.95, scale: 0.95,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: "power2.inOut"
} }
) )
} }
if (autoScroll.value && currentLineIndex.value >= 0) { if (autoScroll.value && currentLineIndex.value >= 0) {
nextTick(() => { nextTick(() => {
scrollToLine(currentLineIndex.value, true) scrollToLine(currentLineIndex.value, true)
@ -451,35 +419,35 @@ function toggleAutoScroll() {
// //
function resetScroll() { function resetScroll() {
if (!lyricsWrapper.value) return if (!lyricsWrapper.value) return
// //
if (scrollTween) scrollTween.kill() if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill() if (highlightTween) highlightTween.kill()
// //
gsap.to(lyricsWrapper.value, { gsap.to(lyricsWrapper.value, {
y: 0, y: 0,
duration: 0.3, duration: 0.3,
ease: "power2.out" ease: "power2.out"
}) })
autoScroll.value = true autoScroll.value = true
userScrolling.value = false userScrolling.value = false
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[1], gsap.fromTo(controlPanel.value.children[1],
{ scale: 1 }, { scale: 1 },
{ {
scale: 0.95, scale: 0.95,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: "power2.inOut"
} }
) )
} }
if (currentLineIndex.value >= 0) { if (currentLineIndex.value >= 0) {
nextTick(() => { nextTick(() => {
scrollToLine(currentLineIndex.value, true) scrollToLine(currentLineIndex.value, true)
@ -487,16 +455,34 @@ function resetScroll() {
} }
} }
// gap
function getGapDotOpacities(line: GapLine) {
// gap
const duration = line.duration ?? 0
if (duration <= 0) return [0.3, 0.3, 0.3]
//
const now = playQueueStore.currentTime
// gap
const start = line.time
//
let progress = (now - start) / duration
progress = Math.max(0, Math.min(1, progress))
//
const thresholds = [1 / 4, 2 / 4, 3 / 4]
// 0.3 1
return thresholds.map(t => progress >= t ? 1 : progress >= t - 1 / 3 ? 0.6 : 0.3)
}
// //
watch(() => playQueueStore.currentTime, (time) => { watch(() => playQueueStore.currentTime, (time) => {
const newIndex = findCurrentLineIndex(time) const newIndex = findCurrentLineIndex(time)
if (newIndex !== currentLineIndex.value && newIndex >= 0) { if (newIndex !== currentLineIndex.value && newIndex >= 0) {
currentLineIndex.value = newIndex currentLineIndex.value = newIndex
// //
highlightCurrentLine(newIndex) highlightCurrentLine(newIndex)
// //
if (autoScroll.value && !userScrolling.value) { if (autoScroll.value && !userScrolling.value) {
nextTick(() => { nextTick(() => {
@ -512,14 +498,14 @@ watch(() => props.lrcSrc, async (newSrc) => {
// //
currentLineIndex.value = -1 currentLineIndex.value = -1
lineRefs.value = [] lineRefs.value = []
// //
if (scrollTween) scrollTween.kill() if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill() if (highlightTween) highlightTween.kill()
if (newSrc) { if (newSrc) {
loading.value = true loading.value = true
// //
if (loadingIndicator.value) { if (loadingIndicator.value) {
gsap.fromTo(loadingIndicator.value, gsap.fromTo(loadingIndicator.value,
@ -527,20 +513,20 @@ watch(() => props.lrcSrc, async (newSrc) => {
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" } { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" }
) )
} }
try { try {
const response = await axios.get(newSrc) const response = await axios.get(newSrc)
parsedLyrics.value = parseLyrics(response.data) parsedLyrics.value = parseLyrics(response.data)
console.log('Parsed lyrics:', parsedLyrics.value) console.log('Parsed lyrics:', parsedLyrics.value)
autoScroll.value = true autoScroll.value = true
userScrolling.value = false userScrolling.value = false
// //
if (lyricsWrapper.value) { if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 }) gsap.set(lyricsWrapper.value, { y: 0 })
} }
} catch (error) { } catch (error) {
console.error('Failed to load lyrics:', error) console.error('Failed to load lyrics:', error)
parsedLyrics.value = [] parsedLyrics.value = []
@ -549,7 +535,7 @@ watch(() => props.lrcSrc, async (newSrc) => {
} }
} else { } else {
parsedLyrics.value = [] parsedLyrics.value = []
// //
if (lyricsWrapper.value) { if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 }) gsap.set(lyricsWrapper.value, { y: 0 })
@ -566,17 +552,17 @@ onMounted(() => {
{ opacity: 0, x: 0, duration: 0.2, ease: "power2.out", delay: 0.2 } { opacity: 0, x: 0, duration: 0.2, ease: "power2.out", delay: 0.2 }
) )
} }
// //
nextTick(() => { nextTick(() => {
lineRefs.value.forEach((el, index) => { lineRefs.value.forEach((el, index) => {
if (el) { if (el) {
gsap.fromTo(el, gsap.fromTo(el,
{ opacity: 0, y: 30 }, { opacity: 0, y: 30 },
{ {
opacity: 1, opacity: 1,
y: 0, y: 0,
duration: 0.2, duration: 0.2,
ease: "power2.out", ease: "power2.out",
delay: index * 0.1 delay: index * 0.1
} }