feat(ScrollingLyrics): enhance lyrics display with scroll indicators, loading states, and user interactions

This commit is contained in:
Astrian Zheng 2025-05-26 17:49:26 +10:00
parent 7ecc38dda8
commit 6bf9c493aa
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
2 changed files with 704 additions and 134 deletions

View File

@ -1,44 +1,209 @@
<template>
<div
class="relative overflow-hidden h-full w-full bg-gradient-to-b from-black/5 via-transparent to-black/5"
ref="lyricsContainer"
@wheel="handleWheel"
>
<!-- 歌词滚动区域 -->
<div
class="relative"
ref="lyricsWrapper"
>
<!-- 顶部填充 -->
<div class="h-1/2 pointer-events-none"></div>
<div
v-for="(line, index) in parsedLyrics"
:key="index"
:ref="el => setLineRef(el, index)"
class="py-8 px-16 cursor-pointer transition-all duration-300 hover:scale-105"
@click="handleLineClick(line, index)"
>
<div v-if="line.type === 'lyric'" class="relative text-center">
<!-- 背景模糊文字 -->
<div
class="text-3xl font-bold transition-all duration-500"
:class="[
currentLineIndex === index ? 'text-black/80 blur-3xl' : 'text-black/20 blur-3xl'
]"
>
{{ line.text }}
</div>
<!-- 前景清晰文字 -->
<div
class="absolute top-0 left-0 w-full text-3xl font-bold transition-all duration-500"
:class="[
currentLineIndex === index
? 'text-white scale-110'
: index < currentLineIndex
? 'text-white/60'
: 'text-white/40'
]"
>
{{ line.text }}
</div>
</div>
<div v-else-if="line.type === 'gap'" class="flex justify-center items-center py-4">
<div class="w-16 h-px bg-gradient-to-r from-transparent via-white/30 to-transparent rounded-full"></div>
</div>
</div>
<!-- 底部填充 -->
<div class="h-1/2 pointer-events-none"></div>
</div>
<!-- 中央指示线 -->
<div class="absolute top-1/2 left-16 right-16 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent pointer-events-none transform -translate-y-1/2"></div>
<!-- 歌词控制面板 -->
<div
class="absolute top-4 right-4 flex gap-2 opacity-0 transition-opacity duration-300 hover:opacity-100"
ref="controlPanel"
>
<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="[
autoScroll
? 'bg-white/20 shadow-lg'
: 'bg-black/20 hover:bg-black/30'
]"
>
{{ autoScroll ? '自动' : '手动' }}
</button>
<button
@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"
>
重置
</button>
</div>
<!-- 滚动指示器 -->
<div
class="absolute right-2 top-1/4 bottom-1/4 w-1 bg-white/10 rounded-full overflow-hidden"
v-if="parsedLyrics.length > 5"
>
<div
class="w-full bg-white/40 rounded-full transition-all duration-300"
:style="{
height: scrollIndicatorHeight + '%',
transform: `translateY(${scrollIndicatorPosition}px)`
}"
></div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
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="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>
</div>
<!-- 无歌词状态 -->
<div
v-if="!loading && parsedLyrics.length === 0"
class="absolute inset-0 flex items-center justify-center"
ref="noLyricsIndicator"
>
<div class="text-center">
<div class="text-white/40 text-lg font-medium mb-2">暂无歌词</div>
<div class="text-white/30 text-sm">享受纯音乐的美妙</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch, nextTick, computed, onUnmounted } from 'vue'
import axios from 'axios'
import { watch } from 'vue'
import gsap from 'gsap'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
//
interface LyricsLine {
type: 'lyric'
time: number
text: string
originalTime: string
}
interface GapLine {
type: 'gap'
time: number
originalTime: string
duration?: number
}
const playQueueStore = usePlayQueueStore()
//
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
const currentLineIndex = ref(0)
const currentLineIndex = ref(-1)
const autoScroll = ref(true)
const loading = ref(false)
const userScrolling = ref(false)
// DOM
const lyricsContainer = ref<HTMLElement>()
const lyricsWrapper = ref<HTMLElement>()
const lineRefs = ref<(HTMLElement | null)[]>([])
const controlPanel = ref<HTMLElement>()
const loadingIndicator = ref<HTMLElement>()
const noLyricsIndicator = ref<HTMLElement>()
// GSAP
let scrollTween: gsap.core.Tween | null = null
let highlightTween: gsap.core.Tween | null = null
let userScrollTimeout: number | null = null
// Props
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)
}
//
const scrollIndicatorHeight = computed(() => {
if (parsedLyrics.value.length === 0) return 0
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 5
})
const scrollIndicatorPosition = computed(() => {
if (parsedLyrics.value.length === 0 || currentLineIndex.value < 0) return 0
const progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
const containerHeight = lyricsContainer.value?.clientHeight || 400
const indicatorTrackHeight = containerHeight / 2 //
return progress * (indicatorTrackHeight - (scrollIndicatorHeight.value / 100 * indicatorTrackHeight))
})
//
function setLineRef(el: HTMLElement | null, index: number) {
if (el) {
lineRefs.value[index] = el
}
}
//
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])
@ -63,44 +228,33 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
}
}
//
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
}
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
@ -115,29 +269,336 @@ function findCurrentLineIndex(time: number): number {
return index
}
watch(() => playQueueStore.currentTime, (value) => {
currentLineIndex.value = findCurrentLineIndex(value)
})
// 使 GSAP
function scrollToLine(lineIndex: number, smooth = true) {
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
function calculateTopRange() {
return window.innerHeight / 3
const container = lyricsContainer.value
const wrapper = lyricsWrapper.value
const lineElement = lineRefs.value[lineIndex]
const containerHeight = container.clientHeight
const containerCenter = containerHeight / 2
//
const lineOffsetTop = lineElement.offsetTop
const lineHeight = lineElement.clientHeight
const targetY = containerCenter - lineOffsetTop - lineHeight / 2
//
if (scrollTween) {
scrollTween.kill()
}
if (smooth) {
// 使 GSAP
scrollTween = gsap.to(wrapper, {
y: targetY,
duration: 0.8,
ease: "power2.out",
onComplete: () => {
scrollTween = null
}
})
} else {
gsap.set(wrapper, { y: targetY })
}
}
//
function highlightCurrentLine(lineIndex: number) {
if (!lineRefs.value[lineIndex]) return
const lineElement = lineRefs.value[lineIndex]
//
if (highlightTween) {
highlightTween.kill()
}
//
lineRefs.value.forEach((el, index) => {
if (el && index !== lineIndex) {
gsap.to(el, {
scale: 1,
opacity: index < lineIndex ? 0.6 : 0.4,
duration: 0.3,
ease: "power2.out"
})
}
})
//
highlightTween = gsap.to(lineElement, {
scale: 1.05,
opacity: 1,
duration: 0.5,
ease: "back.out(1.7)",
onComplete: () => {
highlightTween = null
}
})
}
//
function handleWheel(event: WheelEvent) {
event.preventDefault()
if (!lyricsWrapper.value) return
//
userScrolling.value = true
autoScroll.value = false
//
if (scrollTween) {
scrollTween.kill()
}
//
const currentY = gsap.getProperty(lyricsWrapper.value, "y") as number
const newY = currentY - event.deltaY * 0.5
//
const maxScroll = parsedLyrics.value.length * 80
const limitedY = Math.max(-maxScroll, Math.min(maxScroll, newY))
gsap.to(lyricsWrapper.value, {
y: limitedY,
duration: 0.1,
ease: "power2.out"
})
//
if (userScrollTimeout) {
clearTimeout(userScrollTimeout)
}
// 3
userScrollTimeout = setTimeout(() => {
userScrolling.value = false
autoScroll.value = true
//
if (currentLineIndex.value >= 0) {
scrollToLine(currentLineIndex.value, true)
}
}, 3000)
}
//
function handleLineClick(line: LyricsLine | GapLine, index: number) {
if (line.type === 'lyric') {
console.log('Jump to time:', line.time)
//
// emit('seek', line.time)
}
//
scrollToLine(index, true)
//
if (lineRefs.value[index]) {
gsap.fromTo(lineRefs.value[index],
{ scale: 1 },
{
scale: 1.1,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
)
}
}
//
function toggleAutoScroll() {
autoScroll.value = !autoScroll.value
userScrolling.value = false
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[0],
{ scale: 1 },
{
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
)
}
if (autoScroll.value && currentLineIndex.value >= 0) {
nextTick(() => {
scrollToLine(currentLineIndex.value, true)
})
}
}
//
function resetScroll() {
if (!lyricsWrapper.value) return
//
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
//
gsap.to(lyricsWrapper.value, {
y: 0,
duration: 0.6,
ease: "power2.out"
})
autoScroll.value = true
userScrolling.value = false
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[1],
{ scale: 1 },
{
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
)
}
if (currentLineIndex.value >= 0) {
nextTick(() => {
scrollToLine(currentLineIndex.value, true)
})
}
}
//
watch(() => playQueueStore.currentTime, (time) => {
const newIndex = findCurrentLineIndex(time)
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
currentLineIndex.value = newIndex
//
highlightCurrentLine(newIndex)
//
if (autoScroll.value && !userScrolling.value) {
nextTick(() => {
scrollToLine(newIndex, true)
})
}
}
})
//
watch(() => props.lrcSrc, async (newSrc) => {
//
currentLineIndex.value = -1
lineRefs.value = []
//
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (newSrc) {
loading.value = true
//
if (loadingIndicator.value) {
gsap.fromTo(loadingIndicator.value,
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" }
)
}
try {
const response = await axios.get(newSrc)
parsedLyrics.value = parseLyrics(response.data)
console.log('Parsed lyrics:', parsedLyrics.value)
autoScroll.value = true
userScrolling.value = false
//
if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 })
}
} catch (error) {
console.error('Failed to load lyrics:', error)
parsedLyrics.value = []
} finally {
loading.value = false
}
} else {
parsedLyrics.value = []
//
if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 })
}
}
}, { immediate: true })
//
onMounted(() => {
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value,
{ opacity: 0, x: 20 },
{ opacity: 0, x: 0, duration: 0.5, ease: "power2.out", delay: 0.5 }
)
}
//
nextTick(() => {
lineRefs.value.forEach((el, index) => {
if (el) {
gsap.fromTo(el,
{ opacity: 0, y: 30 },
{
opacity: 1,
y: 0,
duration: 0.5,
ease: "power2.out",
delay: index * 0.1
}
)
}
})
})
})
//
onUnmounted(() => {
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (userScrollTimeout) clearTimeout(userScrollTimeout)
})
//
defineExpose({
scrollToLine,
toggleAutoScroll,
resetScroll,
getCurrentLine: () => currentLineIndex.value >= 0 ? parsedLyrics.value[currentLineIndex.value] : null
})
</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>
<style scoped>
/* 自定义滚动条样式 */
.lyrics-container::-webkit-scrollbar {
display: none;
}
.lyrics-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@ -3,7 +3,7 @@ import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize } from '../utils'
import gsap from 'gsap'
import { Draggable } from "gsap/Draggable"
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useTemplateRef } from 'vue'
import { ref, watch } from 'vue'
import { usePreferences } from '../stores/usePreferences'
@ -34,8 +34,27 @@ const progressBarContainer = useTemplateRef('progressBarContainer')
const playQueueDialogContainer = useTemplateRef('playQueueDialogContainer')
const playQueueDialog = useTemplateRef('playQueueDialog')
const controllerRef = useTemplateRef('controllerRef')
const mainContainer = useTemplateRef('mainContainer')
const lyricsSection = useTemplateRef('lyricsSection')
const scrollingLyrics = useTemplateRef('scrollingLyrics')
const albumCover = useTemplateRef('albumCover')
const songInfo = useTemplateRef('songInfo')
const progressSection = useTemplateRef('progressSection')
const controlButtons = useTemplateRef('controlButtons')
const favoriteButton = useTemplateRef('favoriteButton')
const volumeButton = useTemplateRef('volumeButton')
const queueButton = useTemplateRef('queueButton')
const prevButton = useTemplateRef('prevButton')
const playButton = useTemplateRef('playButton')
const nextButton = useTemplateRef('nextButton')
const lyricsButton = useTemplateRef('lyricsButton')
const moreButton = useTemplateRef('moreButton')
const presentQueueListDialog = ref(false)
const controllerHeight = ref(500)
let resizeObserver: ResizeObserver | null = null
let heightSyncTween: gsap.core.Tween | null = null
onMounted(async () => {
Draggable.create(progressBarThumb.value, {
@ -49,6 +68,9 @@ onMounted(async () => {
}
})
thumbUpdate()
setupHeightSync()
setupEntranceAnimations()
})
function timeFormatter(time: number) {
@ -103,41 +125,110 @@ function playPrevious() {
}
}
function setupHeightSync() {
if (controllerRef.value && lyricsSection.value) {
updateLyricsHeight()
resizeObserver = new ResizeObserver(() => {
updateLyricsHeight()
})
resizeObserver.observe(controllerRef.value)
}
}
function updateLyricsHeight() {
if (!controllerRef.value || !lyricsSection.value) return
const newHeight = controllerRef.value.offsetHeight
if (Math.abs(newHeight - controllerHeight.value) > 5) {
controllerHeight.value = newHeight
if (heightSyncTween) {
heightSyncTween.kill()
}
heightSyncTween = gsap.to(lyricsSection.value, {
height: newHeight,
duration: 0.3,
ease: "power2.out"
})
}
}
function setupEntranceAnimations() {
if (controllerRef.value) {
gsap.fromTo(controllerRef.value.children,
{ opacity: 0, y: 30, scale: 0.95 },
{
opacity: 1, y: 0, scale: 1,
duration: 0.6, ease: "power2.out", stagger: 0.1
}
)
}
if (lyricsSection.value) {
gsap.fromTo(lyricsSection.value,
{ opacity: 0, x: 50 },
{ opacity: 1, x: 0, duration: 0.8, ease: "power2.out", delay: 0.3 }
)
}
}
function handlePlayPause() {
if (playButton.value) {
gsap.to(playButton.value, {
scale: 0.9, duration: 0.1, yoyo: true, repeat: 1,
ease: "power2.inOut",
onComplete: () => {
playQueueStore.isPlaying = !playQueueStore.isPlaying
}
})
}
}
function toggleShuffle() {
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
playQueueStore.shuffleCurrent = false
}
function toggleRepeat() {
switch (playQueueStore.playMode.repeat) {
case 'off': playQueueStore.playMode.repeat = 'all'; break
case 'all': playQueueStore.playMode.repeat = 'single'; break
case 'single': playQueueStore.playMode.repeat = 'off'; break
}
}
function makePlayQueueListPresent() {
presentQueueListDialog.value = true
const tl = gsap.timeline()
tl.from(playQueueDialog.value, {
marginLeft: '-24rem',
duration: 0.2,
ease: 'power2.out'
}).from(playQueueDialogContainer.value, {
backgroundColor: 'transparent',
duration: 0.2,
ease: 'power2.out'
}, '<')
tl.to(playQueueDialogContainer.value, {
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out'
}).to(playQueueDialog.value, {
x: 0, duration: 0.4, ease: 'power3.out'
}, '<0.1').fromTo(playQueueDialog.value.children,
{ opacity: 0, x: -20 },
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 }, '<0.2')
}
function makePlayQueueListDismiss() {
const tl = gsap.timeline({
onComplete: () => {
presentQueueListDialog.value = false
gsap.set(playQueueDialog.value, { x: -384 })
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' })
}
})
tl.to(playQueueDialog.value, {
marginLeft: '-24rem',
duration: 0.2,
ease: 'power2.out'
}).to(playQueueDialogContainer.value, {
backgroundColor: 'transparent',
duration: 0.2,
ease: 'power2.out'
}).set(playQueueDialogContainer.value, {
backgroundColor: '#17171780',
ease: 'power2.out'
}).set(playQueueDialog.value, {
marginLeft: '0rem',
ease: 'power2.out'
})
tl.to(playQueueDialog.value.children, {
opacity: 0, x: -20, duration: 0.2, ease: 'power2.in', stagger: 0.03
}).to(playQueueDialog.value, {
x: -384, duration: 0.3, ease: 'power2.in'
}, '<0.1').to(playQueueDialogContainer.value, {
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in'
}, '<')
}
function getCurrentTrack() {
@ -151,23 +242,53 @@ function getCurrentTrack() {
function calculateStickyTop() {
return (window.innerHeight - (controllerRef.value?.clientHeight ?? 0)) / 2
}
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect()
if (heightSyncTween) heightSyncTween.kill()
})
// New: Watch for track changes and animate
watch(() => playQueueStore.currentIndex, () => {
if (albumCover.value) {
gsap.to(albumCover.value, {
scale: 0.95, opacity: 0.7, duration: 0.2,
ease: "power2.inOut", yoyo: true, repeat: 1
})
}
if (songInfo.value) {
gsap.fromTo(songInfo.value,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: "power2.out", delay: 0.3 }
)
}
})
</script>
<template>
<!-- Background remains unchanged -->
<div class="z-0 absolute top-0 left-0 w-screen h-screen" v-if="getCurrentTrack().album?.coverDeUrl">
<img class="w-full h-full blur-2xl object-cover" :src="getCurrentTrack().album?.coverDeUrl" />
<div class="bg-transparent w-full h-full absolute top-0 left-0" />
</div>
<!-- Main content area - new flex layout -->
<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 items-center justify-center gap-16 h-fit my-auto" ref="mainContainer">
<!-- Controller area -->
<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">
<!-- Album cover with hover effect -->
<div ref="albumCover" class="relative">
<img :src="getCurrentTrack().album?.coverUrl"
class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96 transition-transform duration-300 hover:scale-105" />
</div>
<!-- Song info with enhanced styling -->
<div class="flex justify-between items-center gap-2" ref="songInfo">
<div class="relative flex-auto w-0">
<!-- ...existing song info code... -->
<div class="">
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
{{ getCurrentTrack().song.name }}
@ -187,17 +308,19 @@ function calculateStickyTop() {
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
</div>
</div>
</div>
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl">
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl transition-all duration-200 hover:bg-black/20 hover:scale-110"
ref="favoriteButton">
<span class="text-white">
<StarEmptyIcon :size="6" />
</span>
</button>
</div>
<div class="flex flex-col gap-1">
<!-- Progress section with enhanced interactions -->
<div class="flex flex-col gap-1" ref="progressSection">
<!-- ...existing progress bar code... -->
<div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl">
<div class="w-full" ref="progressBarContainer">
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
@ -205,9 +328,9 @@ function calculateStickyTop() {
</div>
<div class="w-full flex justify-between">
<!-- ...existing time display code... -->
<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-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">
@ -216,20 +339,20 @@ function calculateStickyTop() {
</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>
<button class="text-white/90 font-medium text-right relative transition-colors duration-200 hover:text-white"
@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">
<!-- Control buttons with enhanced animations -->
<div class="w-full flex justify-between items-center" ref="controlButtons">
<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">
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
ref="volumeButton">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<SpeakerIcon :size="6" />
@ -239,8 +362,8 @@ function calculateStickyTop() {
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
@click="makePlayQueueListPresent">
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
@click="makePlayQueueListPresent" ref="queueButton">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<MusicListIcon :size="6" />
@ -253,9 +376,8 @@ function calculateStickyTop() {
</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">
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200 hover:scale-105"
@click="playPrevious" ref="prevButton">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<RewindIcon :size="8" />
@ -266,8 +388,9 @@ function calculateStickyTop() {
</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">
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200"
@click="handlePlayPause" ref="playButton">
<!-- ...existing play/pause icon code... -->
<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">
@ -298,8 +421,8 @@ function calculateStickyTop() {
</div>
</button>
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
@click="playNext">
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200 hover:scale-105"
@click="playNext" ref="nextButton">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<ForwardIcon :size="8" />
@ -313,7 +436,8 @@ function calculateStickyTop() {
<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">
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
ref="lyricsButton">
<div class="w-6 h-6 relative">
<span class="text-black blur-md absolute top-0 left-0">
<ChatBubbleQuoteIcon :size="6" />
@ -323,7 +447,8 @@ function calculateStickyTop() {
</span>
</div>
</button>
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
ref="moreButton">
<div class="w-6 h-6 relative">
<span class="text-black blur-sm absolute top-0 left-0">
<EllipsisHorizontalIcon :size="6" />
@ -336,53 +461,37 @@ function calculateStickyTop() {
</div>
</div>
</div>
<!-- Lyrics section - height synced with controller -->
<div class="w-96" ref="lyricsSection">
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined"
class="h-full" ref="scrollingLyrics" />
</div>
<div>
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
</div>
</div>
<!-- Queue list -->
<!-- Queue list dialog with enhanced animations -->
<dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss"
ref="playQueueDialogContainer" style="background-color: #17171780;">
<div
class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col"
ref="playQueueDialogContainer" style="background-color: transparent;">
<div class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col transform -translate-x-96"
@click.stop ref="playQueueDialog">
<div class="flex justify-between mx-8 mb-4">
<div class="text-white font-medium text-2xl">播放队列</div>
<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 transition-all duration-200 hover:bg-neutral-700/80 hover:scale-110"
@click="makePlayQueueListDismiss">
<XIcon :size="4" />
</button>
</div>
<div class="flex gap-2 mx-8 mb-4">
<button
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
<button class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
@click="() => {
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
playQueueStore.shuffleCurrent = false
}">
@click="toggleShuffle">
<ShuffleIcon :size="4" />
</button>
<button
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
<button class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
@click="() => {
switch (playQueueStore.playMode.repeat) {
case 'off':
playQueueStore.playMode.repeat = 'all'
break
case 'all':
playQueueStore.playMode.repeat = 'single'
break
case 'single':
playQueueStore.playMode.repeat = 'off'
break
}
}">
@click="toggleRepeat">
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
</button>