feat(播放器): 新增播放室页面并集成GSAP动画库
新增播放室页面,支持歌曲播放进度条拖拽功能。在播放队列存储中添加当前播放时间和总时长状态。更新播放器组件以支持跳转到播放室页面,并集成GSAP动画库用于实现进度条拖拽效果。
This commit is contained in:
parent
12aa851d68
commit
c24d51ea9e
7
package-lock.json
generated
7
package-lock.json
generated
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.9.0",
|
||||
"gsap": "^3.13.0",
|
||||
"pinia": "^3.0.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"vue": "^3.5.13",
|
||||
|
@ -1841,6 +1842,12 @@
|
|||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.9.0",
|
||||
"gsap": "^3.13.0",
|
||||
"pinia": "^3.0.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"vue": "^3.5.13",
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||
import { useTemplateRef, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const playQueueStore = usePlayQueueStore()
|
||||
|
||||
const route = useRoute()
|
||||
const player = useTemplateRef('playerRef')
|
||||
|
||||
watch(() => playQueueStore.isPlaying, (newValue) => {
|
||||
|
@ -39,6 +40,9 @@ function setMetadata() {
|
|||
|
||||
navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', playNext)
|
||||
|
||||
playQueueStore.duration = player.value?.duration?? 0
|
||||
playQueueStore.currentTime = player.value?.currentTime?? 0
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,52 +66,71 @@ function playPrevious() {
|
|||
if (player.value) { player.value.currentTime = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentTime() {
|
||||
playQueueStore.currentTime = player.value?.currentTime?? 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<audio
|
||||
:src="playQueueStore.list[playQueueStore.currentIndex] ? playQueueStore.list[playQueueStore.currentIndex].song.sourceUrl : ''"
|
||||
ref="playerRef" :autoplay="playQueueStore.isPlaying" v-if="playQueueStore.list.length !== 0" @ended="playNext"
|
||||
@pause="playQueueStore.isPlaying = false" @play="playQueueStore.isPlaying = true" @playing="() => {
|
||||
ref="playerRef"
|
||||
:autoplay="playQueueStore.isPlaying"
|
||||
v-if="playQueueStore.list.length !== 0"
|
||||
@ended="playNext"
|
||||
@pause="playQueueStore.isPlaying = false"
|
||||
@play="playQueueStore.isPlaying = true"
|
||||
@playing="() => {
|
||||
playQueueStore.isBuffering = false
|
||||
setMetadata()
|
||||
}" @waiting="playQueueStore.isBuffering = true">
|
||||
}"
|
||||
@waiting="playQueueStore.isBuffering = true"
|
||||
@timeupdate="updateCurrentTime">
|
||||
</audio>
|
||||
|
||||
<div
|
||||
class="text-white h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex gap-2 overflow-hidden select-none"
|
||||
v-if="playQueueStore.list.length !== 0">
|
||||
<img :src="playQueueStore.list[playQueueStore.currentIndex].album?.coverUrl ?? ''" class="rounded-full" />
|
||||
<div class="flex items-center w-32">
|
||||
<span class="truncate">{{ playQueueStore.list[playQueueStore.currentIndex].song.name }}</span>
|
||||
|
||||
<div
|
||||
class="text-white h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex gap-2 overflow-hidden select-none"
|
||||
v-if="playQueueStore.list.length !== 0 && route.path !== '/playroom'"
|
||||
>
|
||||
<RouterLink to="/playroom">
|
||||
<img :src="playQueueStore.list[playQueueStore.currentIndex].album?.coverUrl ?? ''" class="rounded-full h-9 w-9" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/playroom">
|
||||
<div class="flex items-center w-32 h-9">
|
||||
<span class="truncate">{{ playQueueStore.list[playQueueStore.currentIndex].song.name }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<button class="h-9 w-9 flex justify-center items-center" @click.stop="() => {
|
||||
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||
}">
|
||||
<div class="w-4 h-4" v-if="playQueueStore.isPlaying">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" v-if="playQueueStore.isBuffering === true">
|
||||
<circle fill="none" stroke-opacity="1" stroke="#FFFFFF" stroke-width=".5" cx="100" cy="100" r="0">
|
||||
<animate attributeName="r" calcMode="spline" dur="2" values="1;80" keyTimes="0;1" keySplines="0 .2 .5 1"
|
||||
repeatCount="indefinite"></animate>
|
||||
<animate attributeName="stroke-width" calcMode="spline" dur="2" values="0;25" keyTimes="0;1"
|
||||
keySplines="0 .2 .5 1" repeatCount="indefinite"></animate>
|
||||
<animate attributeName="stroke-opacity" calcMode="spline" dur="2" values="1;0" keyTimes="0;1"
|
||||
keySplines="0 .2 .5 1" repeatCount="indefinite"></animate>
|
||||
</circle>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" v-else>
|
||||
<path d="M6 5H8V19H6V5ZM16 5H18V19H16V5Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="w-4 h-4" v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M19.376 12.4161L8.77735 19.4818C8.54759 19.635 8.23715 19.5729 8.08397 19.3432C8.02922 19.261 8 19.1645 8 19.0658V4.93433C8 4.65818 8.22386 4.43433 8.5 4.43433C8.59871 4.43433 8.69522 4.46355 8.77735 4.5183L19.376 11.584C19.6057 11.7372 19.6678 12.0477 19.5146 12.2774C19.478 12.3323 19.4309 12.3795 19.376 12.4161Z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button class="h-9 w-9 flex justify-center items-center" @click="() => {
|
||||
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||
}">
|
||||
<div class="w-4 h-4" v-if="playQueueStore.isPlaying">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" v-if="playQueueStore.isBuffering === true">
|
||||
<circle fill="none" stroke-opacity="1" stroke="#FFFFFF" stroke-width=".5" cx="100" cy="100" r="0">
|
||||
<animate attributeName="r" calcMode="spline" dur="2" values="1;80" keyTimes="0;1" keySplines="0 .2 .5 1"
|
||||
repeatCount="indefinite"></animate>
|
||||
<animate attributeName="stroke-width" calcMode="spline" dur="2" values="0;25" keyTimes="0;1"
|
||||
keySplines="0 .2 .5 1" repeatCount="indefinite"></animate>
|
||||
<animate attributeName="stroke-opacity" calcMode="spline" dur="2" values="1;0" keyTimes="0;1"
|
||||
keySplines="0 .2 .5 1" repeatCount="indefinite"></animate>
|
||||
</circle>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" v-else>
|
||||
<path d="M6 5H8V19H6V5ZM16 5H18V19H16V5Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="w-4 h-4" v-else>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M19.376 12.4161L8.77735 19.4818C8.54759 19.635 8.23715 19.5729 8.08397 19.3432C8.02922 19.261 8 19.1645 8 19.0658V4.93433C8 4.65818 8.22386 4.43433 8.5 4.43433C8.59871 4.43433 8.69522 4.46355 8.77735 4.5183L19.376 11.584C19.6057 11.7372 19.6678 12.0477 19.5146 12.2774C19.478 12.3323 19.4309 12.3795 19.376 12.4161Z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -6,10 +6,12 @@ import { createPinia } from 'pinia'
|
|||
import App from './App.vue'
|
||||
import HomePage from './pages/Home.vue'
|
||||
import AlbumDetailView from './pages/AlbumDetail.vue'
|
||||
import Playroom from './pages/Playroom.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: HomePage },
|
||||
{ path: '/albums/:albumId', component: AlbumDetailView },
|
||||
{ path: '/playroom', component: Playroom }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
|
68
src/pages/Playroom.vue
Normal file
68
src/pages/Playroom.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||
import { artistsOrganize } from '../utils'
|
||||
import gsap from 'gsap'
|
||||
import { Draggable } from "gsap/Draggable"
|
||||
import { onMounted } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const playQueueStore = usePlayQueueStore()
|
||||
gsap.registerPlugin(Draggable)
|
||||
|
||||
const progressBarThumb = useTemplateRef('progressBarThumb')
|
||||
const progressBarContainer = useTemplateRef('progressBarContainer')
|
||||
|
||||
const displayTimeLeft = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
Draggable.create(progressBarThumb.value, {
|
||||
type: 'x',
|
||||
bounds: progressBarContainer.value
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function timeFormatter(time: number) {
|
||||
const timeInSeconds = Math.floor(time)
|
||||
if (timeInSeconds < 0) { return '0:00' }
|
||||
const minutes = Math.floor(timeInSeconds / 60)
|
||||
const seconds = Math.floor(timeInSeconds % 60)
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img class="z-0 absolute top-0 left-0 w-screen h-screen blur-2xl object-cover"
|
||||
:src="playQueueStore.list[playQueueStore.currentIndex].album?.coverDeUrl"
|
||||
v-if="playQueueStore.list[playQueueStore.currentIndex].album?.coverDeUrl" />
|
||||
|
||||
<div class="w-full flex justify-center items-center my-auto gap-12 z-10 select-none">
|
||||
<div class="flex flex-col text-center w-96 gap-4">
|
||||
<img :src="playQueueStore.list[playQueueStore.currentIndex].album?.coverUrl"
|
||||
class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
|
||||
<div>
|
||||
<div class="text-white text-lg font-medium">{{ playQueueStore.list[playQueueStore.currentIndex].song.name }}
|
||||
</div>
|
||||
<div class="text-white/75 text-base">
|
||||
{{ artistsOrganize(playQueueStore.list[playQueueStore.currentIndex].song.artists ?? []) }} —
|
||||
{{ playQueueStore.list[playQueueStore.currentIndex].album?.name ?? '未知专辑' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="w-full p-[0.125rem] bg-white/20 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="text-white/75 font-light flex-1 text-left">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</div>
|
||||
<div class="text-white text-xs text-center">MP3</div>
|
||||
<button class="text-white/75 font-light flex-1 text-right" @click="displayTimeLeft = !displayTimeLeft">{{ `${displayTimeLeft ? '-' : ''}${timeFormatter(displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -7,6 +7,8 @@ export const usePlayQueueStore = defineStore('queue', () =>{
|
|||
const isPlaying = ref<boolean>(false)
|
||||
const queueReplaceLock = ref<boolean>(false)
|
||||
const isBuffering = ref<boolean>(false)
|
||||
const currentTime = ref<number>(0)
|
||||
const duration = ref<number>(0)
|
||||
|
||||
return { list, currentIndex, isPlaying, queueReplaceLock, isBuffering }
|
||||
return { list, currentIndex, isPlaying, queueReplaceLock, isBuffering, currentTime, duration }
|
||||
})
|
Loading…
Reference in New Issue
Block a user