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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { watch } from 'vue'
|
import gsap from 'gsap'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
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 playQueueStore = usePlayQueueStore()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
|
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<{
|
const props = defineProps<{
|
||||||
lrcSrc?: string
|
lrcSrc?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
onMounted(async () => {
|
// 滚动指示器相关计算
|
||||||
if (props.lrcSrc) {
|
const scrollIndicatorHeight = computed(() => {
|
||||||
const lrcContent = await axios.get(props.lrcSrc)
|
if (parsedLyrics.value.length === 0) return 0
|
||||||
parsedLyrics.value = parseLyrics(lrcContent.data)
|
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 显示大约5行的比例
|
||||||
console.log(parsedLyrics.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)[] {
|
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
|
||||||
if (!lrcText) return []
|
if (!lrcText) return []
|
||||||
|
|
||||||
const lines = lrcText.split('\n')
|
const lines = lrcText.split('\n')
|
||||||
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
||||||
|
|
||||||
// LRC时间格式正则: [mm:ss.xx] 或 [mm:ss]
|
|
||||||
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])
|
||||||
|
@ -63,44 +228,33 @@ 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)
|
||||||
|
|
||||||
// 最终排序
|
|
||||||
return finalLines.sort((a, b) => a.time - b.time)
|
return finalLines.sort((a, b) => a.time - b.time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查找当前行索引
|
||||||
function findCurrentLineIndex(time: number): number {
|
function findCurrentLineIndex(time: number): number {
|
||||||
if (parsedLyrics.value.length === 0) return -1
|
if (parsedLyrics.value.length === 0) return -1
|
||||||
|
|
||||||
|
@ -115,29 +269,336 @@ function findCurrentLineIndex(time: number): number {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => playQueueStore.currentTime, (value) => {
|
// 使用 GSAP 滚动到指定行
|
||||||
currentLineIndex.value = findCurrentLineIndex(value)
|
function scrollToLine(lineIndex: number, smooth = true) {
|
||||||
})
|
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
|
||||||
|
|
||||||
function calculateTopRange() {
|
const container = lyricsContainer.value
|
||||||
return window.innerHeight / 3
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<style scoped>
|
||||||
<div class="w-[40rem] overflow-x-visible flex flex-col gap-4 pl-16" :style="{
|
/* 自定义滚动条样式 */
|
||||||
paddingTop: `${calculateTopRange()}px`,
|
.lyrics-container::-webkit-scrollbar {
|
||||||
paddingBottom: `${calculateTopRange() * 2}px`
|
display: none;
|
||||||
}">
|
}
|
||||||
<div class="" v-for="(line, index) in parsedLyrics" :key="index">
|
|
||||||
<div v-if="line.type === 'lyric'" class="text-3xl font-bold relative">
|
.lyrics-container {
|
||||||
<div :class="currentLineIndex === index ? 'text-black/80 blur-3xl' : 'text-black/20 blur-3xl'">{{ line.text }}</div>
|
-ms-overflow-style: none;
|
||||||
<div class="absolute top-0" :class="currentLineIndex === index ? 'text-white' : 'text-white/50'">{{ line.text }}</div>
|
scrollbar-width: none;
|
||||||
</div>
|
}
|
||||||
<!-- <div v-else class="text-white/50 text-sm">
|
</style>
|
||||||
{{ line.originalTime }}
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -3,7 +3,7 @@ import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { artistsOrganize } from '../utils'
|
import { artistsOrganize } from '../utils'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { Draggable } from "gsap/Draggable"
|
import { Draggable } from "gsap/Draggable"
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useTemplateRef } from 'vue'
|
import { useTemplateRef } from 'vue'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { usePreferences } from '../stores/usePreferences'
|
import { usePreferences } from '../stores/usePreferences'
|
||||||
|
@ -34,8 +34,27 @@ const progressBarContainer = useTemplateRef('progressBarContainer')
|
||||||
const playQueueDialogContainer = useTemplateRef('playQueueDialogContainer')
|
const playQueueDialogContainer = useTemplateRef('playQueueDialogContainer')
|
||||||
const playQueueDialog = useTemplateRef('playQueueDialog')
|
const playQueueDialog = useTemplateRef('playQueueDialog')
|
||||||
const controllerRef = useTemplateRef('controllerRef')
|
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 presentQueueListDialog = ref(false)
|
||||||
|
const controllerHeight = ref(500)
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let heightSyncTween: gsap.core.Tween | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
Draggable.create(progressBarThumb.value, {
|
Draggable.create(progressBarThumb.value, {
|
||||||
|
@ -49,6 +68,9 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
thumbUpdate()
|
thumbUpdate()
|
||||||
|
|
||||||
|
setupHeightSync()
|
||||||
|
setupEntranceAnimations()
|
||||||
})
|
})
|
||||||
|
|
||||||
function timeFormatter(time: number) {
|
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() {
|
function makePlayQueueListPresent() {
|
||||||
presentQueueListDialog.value = true
|
presentQueueListDialog.value = true
|
||||||
|
|
||||||
const tl = gsap.timeline()
|
const tl = gsap.timeline()
|
||||||
tl.from(playQueueDialog.value, {
|
tl.to(playQueueDialogContainer.value, {
|
||||||
marginLeft: '-24rem',
|
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out'
|
||||||
duration: 0.2,
|
}).to(playQueueDialog.value, {
|
||||||
ease: 'power2.out'
|
x: 0, duration: 0.4, ease: 'power3.out'
|
||||||
}).from(playQueueDialogContainer.value, {
|
}, '<0.1').fromTo(playQueueDialog.value.children,
|
||||||
backgroundColor: 'transparent',
|
{ opacity: 0, x: -20 },
|
||||||
duration: 0.2,
|
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 }, '<0.2')
|
||||||
ease: 'power2.out'
|
|
||||||
}, '<')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePlayQueueListDismiss() {
|
function makePlayQueueListDismiss() {
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
presentQueueListDialog.value = false
|
presentQueueListDialog.value = false
|
||||||
|
gsap.set(playQueueDialog.value, { x: -384 })
|
||||||
|
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tl.to(playQueueDialog.value, {
|
|
||||||
marginLeft: '-24rem',
|
tl.to(playQueueDialog.value.children, {
|
||||||
duration: 0.2,
|
opacity: 0, x: -20, duration: 0.2, ease: 'power2.in', stagger: 0.03
|
||||||
ease: 'power2.out'
|
}).to(playQueueDialog.value, {
|
||||||
}).to(playQueueDialogContainer.value, {
|
x: -384, duration: 0.3, ease: 'power2.in'
|
||||||
backgroundColor: 'transparent',
|
}, '<0.1').to(playQueueDialogContainer.value, {
|
||||||
duration: 0.2,
|
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in'
|
||||||
ease: 'power2.out'
|
}, '<')
|
||||||
}).set(playQueueDialogContainer.value, {
|
|
||||||
backgroundColor: '#17171780',
|
|
||||||
ease: 'power2.out'
|
|
||||||
}).set(playQueueDialog.value, {
|
|
||||||
marginLeft: '0rem',
|
|
||||||
ease: 'power2.out'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentTrack() {
|
function getCurrentTrack() {
|
||||||
|
@ -151,23 +242,53 @@ function getCurrentTrack() {
|
||||||
function calculateStickyTop() {
|
function calculateStickyTop() {
|
||||||
return (window.innerHeight - (controllerRef.value?.clientHeight ?? 0)) / 2
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- Background remains unchanged -->
|
||||||
<div class="z-0 absolute top-0 left-0 w-screen h-screen" v-if="getCurrentTrack().album?.coverDeUrl">
|
<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" />
|
<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 class="bg-transparent w-full h-full absolute top-0 left-0" />
|
||||||
</div>
|
</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="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="{
|
<div class="flex items-center justify-center gap-16 h-fit my-auto" ref="mainContainer">
|
||||||
top: `${calculateStickyTop()}px`,
|
|
||||||
height: `${controllerRef?.clientHeight?? 0}px`
|
<!-- Controller area -->
|
||||||
}">
|
|
||||||
<div class="flex flex-col w-96 gap-4" ref="controllerRef">
|
<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" />
|
<!-- Album cover with hover effect -->
|
||||||
<div class="flex justify-between items-center gap-2">
|
<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">
|
<div class="relative flex-auto w-0">
|
||||||
|
<!-- ...existing song info code... -->
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
|
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
|
||||||
{{ getCurrentTrack().song.name }}
|
{{ getCurrentTrack().song.name }}
|
||||||
|
@ -187,17 +308,19 @@ function calculateStickyTop() {
|
||||||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<span class="text-white">
|
||||||
<StarEmptyIcon :size="6" />
|
<StarEmptyIcon :size="6" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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-full" ref="progressBarContainer">
|
||||||
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
|
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
|
||||||
|
@ -205,9 +328,9 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex justify-between">
|
<div class="w-full flex justify-between">
|
||||||
|
<!-- ...existing time display code... -->
|
||||||
<div class="font-medium flex-1 text-left relative">
|
<div class="font-medium flex-1 text-left relative">
|
||||||
<span
|
<span class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</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>
|
<span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-center relative">
|
<div class="text-xs text-center relative">
|
||||||
|
@ -216,20 +339,20 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
<button class="text-white/90 font-medium text-right relative transition-colors duration-200 hover:text-white"
|
||||||
<span
|
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||||
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 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>
|
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
<div class="w-6 h-6 relative">
|
||||||
<span class="text-black blur-md absolute top-0 left-0">
|
<span class="text-black blur-md absolute top-0 left-0">
|
||||||
<SpeakerIcon :size="6" />
|
<SpeakerIcon :size="6" />
|
||||||
|
@ -239,8 +362,8 @@ function calculateStickyTop() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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"
|
||||||
@click="makePlayQueueListPresent">
|
@click="makePlayQueueListPresent" ref="queueButton">
|
||||||
<div class="w-6 h-6 relative">
|
<div class="w-6 h-6 relative">
|
||||||
<span class="text-black blur-md absolute top-0 left-0">
|
<span class="text-black blur-md absolute top-0 left-0">
|
||||||
<MusicListIcon :size="6" />
|
<MusicListIcon :size="6" />
|
||||||
|
@ -253,9 +376,8 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-2 text-center align-center justify-center gap-2 flex">
|
<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 transition-all duration-200 hover:scale-105"
|
||||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
@click="playPrevious" ref="prevButton">
|
||||||
@click="playPrevious">
|
|
||||||
<div class="w-8 h-8 relative">
|
<div class="w-8 h-8 relative">
|
||||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||||
<RewindIcon :size="8" />
|
<RewindIcon :size="8" />
|
||||||
|
@ -266,8 +388,9 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200"
|
||||||
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
|
@click="handlePlayPause" ref="playButton">
|
||||||
|
<!-- ...existing play/pause icon code... -->
|
||||||
<div v-if="playQueueStore.isPlaying">
|
<div v-if="playQueueStore.isPlaying">
|
||||||
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
||||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||||
|
@ -298,8 +421,8 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
<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">
|
@click="playNext" ref="nextButton">
|
||||||
<div class="w-8 h-8 relative">
|
<div class="w-8 h-8 relative">
|
||||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||||
<ForwardIcon :size="8" />
|
<ForwardIcon :size="8" />
|
||||||
|
@ -313,7 +436,8 @@ function calculateStickyTop() {
|
||||||
|
|
||||||
<div class="flex-1 text-right flex gap-1">
|
<div class="flex-1 text-right flex gap-1">
|
||||||
<div class="flex-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">
|
<div class="w-6 h-6 relative">
|
||||||
<span class="text-black blur-md absolute top-0 left-0">
|
<span class="text-black blur-md absolute top-0 left-0">
|
||||||
<ChatBubbleQuoteIcon :size="6" />
|
<ChatBubbleQuoteIcon :size="6" />
|
||||||
|
@ -323,7 +447,8 @@ function calculateStickyTop() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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">
|
<div class="w-6 h-6 relative">
|
||||||
<span class="text-black blur-sm absolute top-0 left-0">
|
<span class="text-black blur-sm absolute top-0 left-0">
|
||||||
<EllipsisHorizontalIcon :size="6" />
|
<EllipsisHorizontalIcon :size="6" />
|
||||||
|
@ -336,53 +461,37 @@ function calculateStickyTop() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
|
||||||
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Queue list -->
|
<!-- Queue list dialog with enhanced animations -->
|
||||||
<dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss"
|
<dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss"
|
||||||
ref="playQueueDialogContainer" style="background-color: #17171780;">
|
ref="playQueueDialogContainer" style="background-color: transparent;">
|
||||||
<div
|
<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"
|
||||||
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"
|
|
||||||
@click.stop ref="playQueueDialog">
|
@click.stop ref="playQueueDialog">
|
||||||
<div class="flex justify-between mx-8 mb-4">
|
<div class="flex justify-between mx-8 mb-4">
|
||||||
<div class="text-white font-medium text-2xl">播放队列</div>
|
<div class="text-white font-medium text-2xl">播放队列</div>
|
||||||
<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 transition-all duration-200 hover:bg-neutral-700/80 hover:scale-110"
|
||||||
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"
|
|
||||||
@click="makePlayQueueListDismiss">
|
@click="makePlayQueueListDismiss">
|
||||||
<XIcon :size="4" />
|
<XIcon :size="4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 mx-8 mb-4">
|
<div class="flex gap-2 mx-8 mb-4">
|
||||||
<button
|
<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="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
|
||||||
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
|
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
|
||||||
@click="() => {
|
@click="toggleShuffle">
|
||||||
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
|
|
||||||
playQueueStore.shuffleCurrent = false
|
|
||||||
}">
|
|
||||||
<ShuffleIcon :size="4" />
|
<ShuffleIcon :size="4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<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="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
|
||||||
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
|
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
|
||||||
@click="() => {
|
@click="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
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
|
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
|
||||||
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
|
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
|
||||||
</button>
|
</button>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user