feat(ScrollingLyrics): enhance lyrics display with scroll indicators, loading states, and user interactions
This commit is contained in:
		
							parent
							
								
									7ecc38dda8
								
							
						
					
					
						commit
						6bf9c493aa
					
				| 
						 | 
				
			
			@ -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
 | 
			
		||||
  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)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function calculateTopRange() {
 | 
			
		||||
	return window.innerHeight / 3
 | 
			
		||||
// 使用 GSAP 滚动到指定行
 | 
			
		||||
function scrollToLine(lineIndex: number, smooth = true) {
 | 
			
		||||
  if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
 | 
			
		||||
  
 | 
			
		||||
  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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
 | 
			
		||||
	<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`
 | 
			
		||||
		}">
 | 
			
		||||
	<!-- 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="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>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div>
 | 
			
		||||
			<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
 | 
			
		||||
 | 
			
		||||
			<!-- 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>
 | 
			
		||||
	</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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue
	
	Block a user