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
|
||||
}>()
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.lrcSrc) {
|
||||
const lrcContent = await axios.get(props.lrcSrc)
|
||||
parsedLyrics.value = parseLyrics(lrcContent.data)
|
||||
console.log(parsedLyrics.value)
|
||||
}
|
||||
// 滚动指示器相关计算
|
||||
const scrollIndicatorHeight = computed(() => {
|
||||
if (parsedLyrics.value.length === 0) return 0
|
||||
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 显示大约5行的比例
|
||||
})
|
||||
|
||||
const scrollIndicatorPosition = computed(() => {
|
||||
if (parsedLyrics.value.length === 0 || currentLineIndex.value < 0) return 0
|
||||
const progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
|
||||
const containerHeight = lyricsContainer.value?.clientHeight || 400
|
||||
const indicatorTrackHeight = containerHeight / 2 // 指示器轨道高度
|
||||
return progress * (indicatorTrackHeight - (scrollIndicatorHeight.value / 100 * indicatorTrackHeight))
|
||||
})
|
||||
|
||||
// 设置行引用
|
||||
function setLineRef(el: HTMLElement | null, index: number) {
|
||||
if (el) {
|
||||
lineRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
// 歌词解析函数
|
||||
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
|
||||
if (!lrcText) return []
|
||||
|
||||
const lines = lrcText.split('\n')
|
||||
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
||||
|
||||
// LRC时间格式正则: [mm:ss.xx] 或 [mm:ss]
|
||||
const timeRegex = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
|
||||
|
||||
// 第一步:解析所有时间标签和歌词
|
||||
for (const line of lines) {
|
||||
const matches = [...line.matchAll(timeRegex)]
|
||||
if (matches.length === 0) continue
|
||||
|
||||
// 提取歌词文本(去掉所有时间标签)
|
||||
const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
|
||||
|
||||
// 解析每个时间标签
|
||||
for (const match of matches) {
|
||||
const minutes = parseInt(match[1])
|
||||
const seconds = parseInt(match[2])
|
||||
|
@ -63,44 +228,33 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
|
|||
}
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
tempParsedLines.sort((a, b) => a.time - b.time)
|
||||
|
||||
// 第二步:处理间隔,只保留超过指定时长的间隔
|
||||
const finalLines: (LyricsLine | GapLine)[] = []
|
||||
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
|
||||
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
|
||||
|
||||
// 如果没有歌词行,直接返回
|
||||
if (lyricLines.length === 0) {
|
||||
return tempParsedLines
|
||||
}
|
||||
if (lyricLines.length === 0) return tempParsedLines
|
||||
|
||||
// 为每个间隔计算持续时间
|
||||
for (let i = 0; i < gapLines.length; i++) {
|
||||
const gapLine = gapLines[i]
|
||||
|
||||
// 找到这个间隔之后的第一个歌词行
|
||||
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
|
||||
|
||||
if (nextLyricLine) {
|
||||
const duration = nextLyricLine.time - gapLine.time
|
||||
gapLine.duration = duration
|
||||
|
||||
// 只有当间隔超过最小持续时间时才保留
|
||||
if (duration >= minGapDuration) {
|
||||
finalLines.push(gapLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加所有歌词行
|
||||
finalLines.push(...lyricLines)
|
||||
|
||||
// 最终排序
|
||||
return finalLines.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
// 查找当前行索引
|
||||
function findCurrentLineIndex(time: number): number {
|
||||
if (parsedLyrics.value.length === 0) return -1
|
||||
|
||||
|
@ -115,29 +269,336 @@ function findCurrentLineIndex(time: number): number {
|
|||
return index
|
||||
}
|
||||
|
||||
watch(() => playQueueStore.currentTime, (value) => {
|
||||
currentLineIndex.value = findCurrentLineIndex(value)
|
||||
})
|
||||
// 使用 GSAP 滚动到指定行
|
||||
function scrollToLine(lineIndex: number, smooth = true) {
|
||||
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
|
||||
|
||||
function calculateTopRange() {
|
||||
return window.innerHeight / 3
|
||||
const container = lyricsContainer.value
|
||||
const wrapper = lyricsWrapper.value
|
||||
const lineElement = lineRefs.value[lineIndex]
|
||||
|
||||
const containerHeight = container.clientHeight
|
||||
const containerCenter = containerHeight / 2
|
||||
|
||||
// 计算目标位置
|
||||
const lineOffsetTop = lineElement.offsetTop
|
||||
const lineHeight = lineElement.clientHeight
|
||||
const targetY = containerCenter - lineOffsetTop - lineHeight / 2
|
||||
|
||||
// 停止之前的滚动动画
|
||||
if (scrollTween) {
|
||||
scrollTween.kill()
|
||||
}
|
||||
|
||||
if (smooth) {
|
||||
// 使用 GSAP 平滑滚动
|
||||
scrollTween = gsap.to(wrapper, {
|
||||
y: targetY,
|
||||
duration: 0.8,
|
||||
ease: "power2.out",
|
||||
onComplete: () => {
|
||||
scrollTween = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
gsap.set(wrapper, { y: targetY })
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮当前行动画
|
||||
function highlightCurrentLine(lineIndex: number) {
|
||||
if (!lineRefs.value[lineIndex]) return
|
||||
|
||||
const lineElement = lineRefs.value[lineIndex]
|
||||
|
||||
// 停止之前的高亮动画
|
||||
if (highlightTween) {
|
||||
highlightTween.kill()
|
||||
}
|
||||
|
||||
// 重置所有行的样式
|
||||
lineRefs.value.forEach((el, index) => {
|
||||
if (el && index !== lineIndex) {
|
||||
gsap.to(el, {
|
||||
scale: 1,
|
||||
opacity: index < lineIndex ? 0.6 : 0.4,
|
||||
duration: 0.3,
|
||||
ease: "power2.out"
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 高亮当前行
|
||||
highlightTween = gsap.to(lineElement, {
|
||||
scale: 1.05,
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
ease: "back.out(1.7)",
|
||||
onComplete: () => {
|
||||
highlightTween = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理鼠标滚轮
|
||||
function handleWheel(event: WheelEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!lyricsWrapper.value) return
|
||||
|
||||
// 用户手动滚动
|
||||
userScrolling.value = true
|
||||
autoScroll.value = false
|
||||
|
||||
// 停止当前滚动动画
|
||||
if (scrollTween) {
|
||||
scrollTween.kill()
|
||||
}
|
||||
|
||||
// 获取当前位置并应用滚动
|
||||
const currentY = gsap.getProperty(lyricsWrapper.value, "y") as number
|
||||
const newY = currentY - event.deltaY * 0.5
|
||||
|
||||
// 限制滚动范围
|
||||
const maxScroll = parsedLyrics.value.length * 80
|
||||
const limitedY = Math.max(-maxScroll, Math.min(maxScroll, newY))
|
||||
|
||||
gsap.to(lyricsWrapper.value, {
|
||||
y: limitedY,
|
||||
duration: 0.1,
|
||||
ease: "power2.out"
|
||||
})
|
||||
|
||||
// 清除之前的超时
|
||||
if (userScrollTimeout) {
|
||||
clearTimeout(userScrollTimeout)
|
||||
}
|
||||
|
||||
// 3秒后重新启用自动滚动
|
||||
userScrollTimeout = setTimeout(() => {
|
||||
userScrolling.value = false
|
||||
autoScroll.value = true
|
||||
|
||||
// 重新滚动到当前行
|
||||
if (currentLineIndex.value >= 0) {
|
||||
scrollToLine(currentLineIndex.value, true)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 处理歌词行点击
|
||||
function handleLineClick(line: LyricsLine | GapLine, index: number) {
|
||||
if (line.type === 'lyric') {
|
||||
console.log('Jump to time:', line.time)
|
||||
// 这里可以发出事件让父组件处理音频跳转
|
||||
// emit('seek', line.time)
|
||||
}
|
||||
|
||||
// 滚动到点击的行
|
||||
scrollToLine(index, true)
|
||||
|
||||
// 添加点击反馈动画
|
||||
if (lineRefs.value[index]) {
|
||||
gsap.fromTo(lineRefs.value[index],
|
||||
{ scale: 1 },
|
||||
{
|
||||
scale: 1.1,
|
||||
duration: 0.1,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.inOut"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换自动滚动
|
||||
function toggleAutoScroll() {
|
||||
autoScroll.value = !autoScroll.value
|
||||
userScrolling.value = false
|
||||
|
||||
// 按钮点击动画
|
||||
if (controlPanel.value) {
|
||||
gsap.fromTo(controlPanel.value.children[0],
|
||||
{ scale: 1 },
|
||||
{
|
||||
scale: 0.95,
|
||||
duration: 0.1,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.inOut"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (autoScroll.value && currentLineIndex.value >= 0) {
|
||||
nextTick(() => {
|
||||
scrollToLine(currentLineIndex.value, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重置滚动
|
||||
function resetScroll() {
|
||||
if (!lyricsWrapper.value) return
|
||||
|
||||
// 停止所有动画
|
||||
if (scrollTween) scrollTween.kill()
|
||||
if (highlightTween) highlightTween.kill()
|
||||
|
||||
// 重置位置
|
||||
gsap.to(lyricsWrapper.value, {
|
||||
y: 0,
|
||||
duration: 0.6,
|
||||
ease: "power2.out"
|
||||
})
|
||||
|
||||
autoScroll.value = true
|
||||
userScrolling.value = false
|
||||
|
||||
// 按钮点击动画
|
||||
if (controlPanel.value) {
|
||||
gsap.fromTo(controlPanel.value.children[1],
|
||||
{ scale: 1 },
|
||||
{
|
||||
scale: 0.95,
|
||||
duration: 0.1,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.inOut"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (currentLineIndex.value >= 0) {
|
||||
nextTick(() => {
|
||||
scrollToLine(currentLineIndex.value, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听播放时间变化
|
||||
watch(() => playQueueStore.currentTime, (time) => {
|
||||
const newIndex = findCurrentLineIndex(time)
|
||||
|
||||
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
|
||||
currentLineIndex.value = newIndex
|
||||
|
||||
// 高亮动画
|
||||
highlightCurrentLine(newIndex)
|
||||
|
||||
// 自动滚动
|
||||
if (autoScroll.value && !userScrolling.value) {
|
||||
nextTick(() => {
|
||||
scrollToLine(newIndex, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听歌词源变化
|
||||
watch(() => props.lrcSrc, async (newSrc) => {
|
||||
// 重置状态
|
||||
currentLineIndex.value = -1
|
||||
lineRefs.value = []
|
||||
|
||||
// 停止所有动画
|
||||
if (scrollTween) scrollTween.kill()
|
||||
if (highlightTween) highlightTween.kill()
|
||||
|
||||
if (newSrc) {
|
||||
loading.value = true
|
||||
|
||||
// 加载动画
|
||||
if (loadingIndicator.value) {
|
||||
gsap.fromTo(loadingIndicator.value,
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(newSrc)
|
||||
parsedLyrics.value = parseLyrics(response.data)
|
||||
console.log('Parsed lyrics:', parsedLyrics.value)
|
||||
|
||||
autoScroll.value = true
|
||||
userScrolling.value = false
|
||||
|
||||
// 重置滚动位置
|
||||
if (lyricsWrapper.value) {
|
||||
gsap.set(lyricsWrapper.value, { y: 0 })
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load lyrics:', error)
|
||||
parsedLyrics.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
parsedLyrics.value = []
|
||||
|
||||
// 重置滚动位置
|
||||
if (lyricsWrapper.value) {
|
||||
gsap.set(lyricsWrapper.value, { y: 0 })
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时的入场动画
|
||||
onMounted(() => {
|
||||
// 控制面板入场动画
|
||||
if (controlPanel.value) {
|
||||
gsap.fromTo(controlPanel.value,
|
||||
{ opacity: 0, x: 20 },
|
||||
{ opacity: 0, x: 0, duration: 0.5, ease: "power2.out", delay: 0.5 }
|
||||
)
|
||||
}
|
||||
|
||||
// 歌词行入场动画
|
||||
nextTick(() => {
|
||||
lineRefs.value.forEach((el, index) => {
|
||||
if (el) {
|
||||
gsap.fromTo(el,
|
||||
{ opacity: 0, y: 30 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.5,
|
||||
ease: "power2.out",
|
||||
delay: index * 0.1
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (scrollTween) scrollTween.kill()
|
||||
if (highlightTween) highlightTween.kill()
|
||||
if (userScrollTimeout) clearTimeout(userScrollTimeout)
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
scrollToLine,
|
||||
toggleAutoScroll,
|
||||
resetScroll,
|
||||
getCurrentLine: () => currentLineIndex.value >= 0 ? parsedLyrics.value[currentLineIndex.value] : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[40rem] overflow-x-visible flex flex-col gap-4 pl-16" :style="{
|
||||
paddingTop: `${calculateTopRange()}px`,
|
||||
paddingBottom: `${calculateTopRange() * 2}px`
|
||||
}">
|
||||
<div class="" v-for="(line, index) in parsedLyrics" :key="index">
|
||||
<div v-if="line.type === 'lyric'" class="text-3xl font-bold relative">
|
||||
<div :class="currentLineIndex === index ? 'text-black/80 blur-3xl' : 'text-black/20 blur-3xl'">{{ line.text }}</div>
|
||||
<div class="absolute top-0" :class="currentLineIndex === index ? 'text-white' : 'text-white/50'">{{ line.text }}</div>
|
||||
</div>
|
||||
<!-- <div v-else class="text-white/50 text-sm">
|
||||
{{ line.originalTime }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.lyrics-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lyrics-container {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
|
@ -3,7 +3,7 @@ import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
|||
import { artistsOrganize } from '../utils'
|
||||
import gsap from 'gsap'
|
||||
import { Draggable } from "gsap/Draggable"
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { usePreferences } from '../stores/usePreferences'
|
||||
|
@ -34,8 +34,27 @@ const progressBarContainer = useTemplateRef('progressBarContainer')
|
|||
const playQueueDialogContainer = useTemplateRef('playQueueDialogContainer')
|
||||
const playQueueDialog = useTemplateRef('playQueueDialog')
|
||||
const controllerRef = useTemplateRef('controllerRef')
|
||||
const mainContainer = useTemplateRef('mainContainer')
|
||||
const lyricsSection = useTemplateRef('lyricsSection')
|
||||
const scrollingLyrics = useTemplateRef('scrollingLyrics')
|
||||
const albumCover = useTemplateRef('albumCover')
|
||||
const songInfo = useTemplateRef('songInfo')
|
||||
const progressSection = useTemplateRef('progressSection')
|
||||
const controlButtons = useTemplateRef('controlButtons')
|
||||
|
||||
const favoriteButton = useTemplateRef('favoriteButton')
|
||||
const volumeButton = useTemplateRef('volumeButton')
|
||||
const queueButton = useTemplateRef('queueButton')
|
||||
const prevButton = useTemplateRef('prevButton')
|
||||
const playButton = useTemplateRef('playButton')
|
||||
const nextButton = useTemplateRef('nextButton')
|
||||
const lyricsButton = useTemplateRef('lyricsButton')
|
||||
const moreButton = useTemplateRef('moreButton')
|
||||
|
||||
const presentQueueListDialog = ref(false)
|
||||
const controllerHeight = ref(500)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let heightSyncTween: gsap.core.Tween | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
Draggable.create(progressBarThumb.value, {
|
||||
|
@ -49,6 +68,9 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
thumbUpdate()
|
||||
|
||||
setupHeightSync()
|
||||
setupEntranceAnimations()
|
||||
})
|
||||
|
||||
function timeFormatter(time: number) {
|
||||
|
@ -103,41 +125,110 @@ function playPrevious() {
|
|||
}
|
||||
}
|
||||
|
||||
function setupHeightSync() {
|
||||
if (controllerRef.value && lyricsSection.value) {
|
||||
updateLyricsHeight()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateLyricsHeight()
|
||||
})
|
||||
resizeObserver.observe(controllerRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLyricsHeight() {
|
||||
if (!controllerRef.value || !lyricsSection.value) return
|
||||
|
||||
const newHeight = controllerRef.value.offsetHeight
|
||||
|
||||
if (Math.abs(newHeight - controllerHeight.value) > 5) {
|
||||
controllerHeight.value = newHeight
|
||||
|
||||
if (heightSyncTween) {
|
||||
heightSyncTween.kill()
|
||||
}
|
||||
|
||||
heightSyncTween = gsap.to(lyricsSection.value, {
|
||||
height: newHeight,
|
||||
duration: 0.3,
|
||||
ease: "power2.out"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setupEntranceAnimations() {
|
||||
if (controllerRef.value) {
|
||||
gsap.fromTo(controllerRef.value.children,
|
||||
{ opacity: 0, y: 30, scale: 0.95 },
|
||||
{
|
||||
opacity: 1, y: 0, scale: 1,
|
||||
duration: 0.6, ease: "power2.out", stagger: 0.1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (lyricsSection.value) {
|
||||
gsap.fromTo(lyricsSection.value,
|
||||
{ opacity: 0, x: 50 },
|
||||
{ opacity: 1, x: 0, duration: 0.8, ease: "power2.out", delay: 0.3 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayPause() {
|
||||
if (playButton.value) {
|
||||
gsap.to(playButton.value, {
|
||||
scale: 0.9, duration: 0.1, yoyo: true, repeat: 1,
|
||||
ease: "power2.inOut",
|
||||
onComplete: () => {
|
||||
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
|
||||
playQueueStore.shuffleCurrent = false
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
switch (playQueueStore.playMode.repeat) {
|
||||
case 'off': playQueueStore.playMode.repeat = 'all'; break
|
||||
case 'all': playQueueStore.playMode.repeat = 'single'; break
|
||||
case 'single': playQueueStore.playMode.repeat = 'off'; break
|
||||
}
|
||||
}
|
||||
|
||||
function makePlayQueueListPresent() {
|
||||
presentQueueListDialog.value = true
|
||||
|
||||
const tl = gsap.timeline()
|
||||
tl.from(playQueueDialog.value, {
|
||||
marginLeft: '-24rem',
|
||||
duration: 0.2,
|
||||
ease: 'power2.out'
|
||||
}).from(playQueueDialogContainer.value, {
|
||||
backgroundColor: 'transparent',
|
||||
duration: 0.2,
|
||||
ease: 'power2.out'
|
||||
}, '<')
|
||||
tl.to(playQueueDialogContainer.value, {
|
||||
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out'
|
||||
}).to(playQueueDialog.value, {
|
||||
x: 0, duration: 0.4, ease: 'power3.out'
|
||||
}, '<0.1').fromTo(playQueueDialog.value.children,
|
||||
{ opacity: 0, x: -20 },
|
||||
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 }, '<0.2')
|
||||
}
|
||||
|
||||
function makePlayQueueListDismiss() {
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
presentQueueListDialog.value = false
|
||||
gsap.set(playQueueDialog.value, { x: -384 })
|
||||
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' })
|
||||
}
|
||||
})
|
||||
tl.to(playQueueDialog.value, {
|
||||
marginLeft: '-24rem',
|
||||
duration: 0.2,
|
||||
ease: 'power2.out'
|
||||
}).to(playQueueDialogContainer.value, {
|
||||
backgroundColor: 'transparent',
|
||||
duration: 0.2,
|
||||
ease: 'power2.out'
|
||||
}).set(playQueueDialogContainer.value, {
|
||||
backgroundColor: '#17171780',
|
||||
ease: 'power2.out'
|
||||
}).set(playQueueDialog.value, {
|
||||
marginLeft: '0rem',
|
||||
ease: 'power2.out'
|
||||
})
|
||||
|
||||
tl.to(playQueueDialog.value.children, {
|
||||
opacity: 0, x: -20, duration: 0.2, ease: 'power2.in', stagger: 0.03
|
||||
}).to(playQueueDialog.value, {
|
||||
x: -384, duration: 0.3, ease: 'power2.in'
|
||||
}, '<0.1').to(playQueueDialogContainer.value, {
|
||||
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in'
|
||||
}, '<')
|
||||
}
|
||||
|
||||
function getCurrentTrack() {
|
||||
|
@ -151,23 +242,53 @@ function getCurrentTrack() {
|
|||
function calculateStickyTop() {
|
||||
return (window.innerHeight - (controllerRef.value?.clientHeight ?? 0)) / 2
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
if (heightSyncTween) heightSyncTween.kill()
|
||||
})
|
||||
|
||||
// New: Watch for track changes and animate
|
||||
watch(() => playQueueStore.currentIndex, () => {
|
||||
if (albumCover.value) {
|
||||
gsap.to(albumCover.value, {
|
||||
scale: 0.95, opacity: 0.7, duration: 0.2,
|
||||
ease: "power2.inOut", yoyo: true, repeat: 1
|
||||
})
|
||||
}
|
||||
|
||||
if (songInfo.value) {
|
||||
gsap.fromTo(songInfo.value,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: "power2.out", delay: 0.3 }
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Background remains unchanged -->
|
||||
<div class="z-0 absolute top-0 left-0 w-screen h-screen" v-if="getCurrentTrack().album?.coverDeUrl">
|
||||
<img class="w-full h-full blur-2xl object-cover" :src="getCurrentTrack().album?.coverDeUrl" />
|
||||
<div class="bg-transparent w-full h-full absolute top-0 left-0" />
|
||||
</div>
|
||||
|
||||
<!-- Main content area - new flex layout -->
|
||||
<div class="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none">
|
||||
<div class="sticky w-96" :style="{
|
||||
top: `${calculateStickyTop()}px`,
|
||||
height: `${controllerRef?.clientHeight?? 0}px`
|
||||
}">
|
||||
<div class="flex items-center justify-center gap-16 h-fit my-auto" ref="mainContainer">
|
||||
|
||||
<!-- Controller area -->
|
||||
<div class="flex flex-col w-96 gap-4" ref="controllerRef">
|
||||
<img :src="getCurrentTrack().album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<!-- Album cover with hover effect -->
|
||||
<div ref="albumCover" class="relative">
|
||||
<img :src="getCurrentTrack().album?.coverUrl"
|
||||
class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96 transition-transform duration-300 hover:scale-105" />
|
||||
</div>
|
||||
|
||||
<!-- Song info with enhanced styling -->
|
||||
<div class="flex justify-between items-center gap-2" ref="songInfo">
|
||||
<div class="relative flex-auto w-0">
|
||||
<!-- ...existing song info code... -->
|
||||
<div class="">
|
||||
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
|
||||
{{ getCurrentTrack().song.name }}
|
||||
|
@ -187,17 +308,19 @@ function calculateStickyTop() {
|
|||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl">
|
||||
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl transition-all duration-200 hover:bg-black/20 hover:scale-110"
|
||||
ref="favoriteButton">
|
||||
<span class="text-white">
|
||||
<StarEmptyIcon :size="6" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Progress section with enhanced interactions -->
|
||||
<div class="flex flex-col gap-1" ref="progressSection">
|
||||
<!-- ...existing progress bar code... -->
|
||||
<div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl">
|
||||
<div class="w-full" ref="progressBarContainer">
|
||||
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
|
||||
|
@ -205,9 +328,9 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
|
||||
<div class="w-full flex justify-between">
|
||||
<!-- ...existing time display code... -->
|
||||
<div class="font-medium flex-1 text-left relative">
|
||||
<span
|
||||
class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
<span class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
<span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-center relative">
|
||||
|
@ -216,20 +339,20 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
<div class="flex flex-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||
<span
|
||||
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
<button class="text-white/90 font-medium text-right relative transition-colors duration-200 hover:text-white"
|
||||
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||
<span class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<!-- Control buttons with enhanced animations -->
|
||||
<div class="w-full flex justify-between items-center" ref="controlButtons">
|
||||
<div class="flex-1 text-left flex gap-1">
|
||||
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
|
||||
ref="volumeButton">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<SpeakerIcon :size="6" />
|
||||
|
@ -239,8 +362,8 @@ function calculateStickyTop() {
|
|||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
|
||||
@click="makePlayQueueListPresent">
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
|
||||
@click="makePlayQueueListPresent" ref="queueButton">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<MusicListIcon :size="6" />
|
||||
|
@ -253,9 +376,8 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
|
||||
<div class="flex-2 text-center align-center justify-center gap-2 flex">
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playPrevious">
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200 hover:scale-105"
|
||||
@click="playPrevious" ref="prevButton">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<RewindIcon :size="8" />
|
||||
|
@ -266,8 +388,9 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200"
|
||||
@click="handlePlayPause" ref="playButton">
|
||||
<!-- ...existing play/pause icon code... -->
|
||||
<div v-if="playQueueStore.isPlaying">
|
||||
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
|
@ -298,8 +421,8 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playNext">
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200 hover:scale-105"
|
||||
@click="playNext" ref="nextButton">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<ForwardIcon :size="8" />
|
||||
|
@ -313,7 +436,8 @@ function calculateStickyTop() {
|
|||
|
||||
<div class="flex-1 text-right flex gap-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
|
||||
ref="lyricsButton">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<ChatBubbleQuoteIcon :size="6" />
|
||||
|
@ -323,7 +447,8 @@ function calculateStickyTop() {
|
|||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25 transition-all duration-200 hover:scale-110"
|
||||
ref="moreButton">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-sm absolute top-0 left-0">
|
||||
<EllipsisHorizontalIcon :size="6" />
|
||||
|
@ -336,53 +461,37 @@ function calculateStickyTop() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lyrics section - height synced with controller -->
|
||||
<div class="w-96" ref="lyricsSection">
|
||||
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined"
|
||||
class="h-full" ref="scrollingLyrics" />
|
||||
</div>
|
||||
<div>
|
||||
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue list -->
|
||||
<!-- Queue list dialog with enhanced animations -->
|
||||
<dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss"
|
||||
ref="playQueueDialogContainer" style="background-color: #17171780;">
|
||||
<div
|
||||
class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col"
|
||||
ref="playQueueDialogContainer" style="background-color: transparent;">
|
||||
<div class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col transform -translate-x-96"
|
||||
@click.stop ref="playQueueDialog">
|
||||
<div class="flex justify-between mx-8 mb-4">
|
||||
<div class="text-white font-medium text-2xl">播放队列</div>
|
||||
<button
|
||||
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
||||
<button class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:bg-neutral-700/80 hover:scale-110"
|
||||
@click="makePlayQueueListDismiss">
|
||||
<XIcon :size="4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mx-8 mb-4">
|
||||
<button
|
||||
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
||||
<button class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
||||
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
|
||||
@click="() => {
|
||||
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
|
||||
playQueueStore.shuffleCurrent = false
|
||||
}">
|
||||
@click="toggleShuffle">
|
||||
<ShuffleIcon :size="4" />
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
||||
<button class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
||||
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
|
||||
@click="() => {
|
||||
switch (playQueueStore.playMode.repeat) {
|
||||
case 'off':
|
||||
playQueueStore.playMode.repeat = 'all'
|
||||
break
|
||||
case 'all':
|
||||
playQueueStore.playMode.repeat = 'single'
|
||||
break
|
||||
case 'single':
|
||||
playQueueStore.playMode.repeat = 'off'
|
||||
break
|
||||
}
|
||||
}">
|
||||
@click="toggleRepeat">
|
||||
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
|
||||
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
|
||||
</button>
|
||||
|
|
Loading…
Reference in New Issue
Block a user