Compare commits
No commits in common. "10b19a3311266d54944eb5a979f400e9021c85fb" and "25e80d73ae585aa5b566cef3d8df42cf7747ab57" have entirely different histories.
10b19a3311
...
25e80d73ae
@ -168,23 +168,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" title="播放音乐" class="music-drawer">
|
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="40%" title="播放音乐">
|
||||||
<music-player-panel
|
<a-player v-if="musicPlaying && currentMusic" ref="player" autoplay showLrc :list="musicList" v-model:music="currentMusic" @play="musicPlay"/>
|
||||||
v-if="musicPlaying && currentMusic"
|
|
||||||
ref="player"
|
|
||||||
autoplay
|
|
||||||
:music="currentMusic"
|
|
||||||
:list="musicList"
|
|
||||||
@update:music="currentMusic = $event"
|
|
||||||
@play="musicPlay"
|
|
||||||
@toggle="player?.toggle()"
|
|
||||||
@prev="switchPrev"
|
|
||||||
@next="switchNext"
|
|
||||||
@toggle-shuffle="player && (player.shuffle = !player.shuffle)"
|
|
||||||
@next-mode="cycleRepeatMode"
|
|
||||||
@toggle-mute="togglePlayerMute"
|
|
||||||
@select-song="onSelectSong"
|
|
||||||
/>
|
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -196,7 +181,7 @@ import { useBaseList } from '@/model/baselist'
|
|||||||
import { MsgResult, Page } from '@/model/common.dto'
|
import { MsgResult, Page } from '@/model/common.dto'
|
||||||
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
|
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
|
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
|
||||||
import MusicPlayerPanel from './aplayer/music-player-panel.vue'
|
import APlayer from './aplayer/vue-aplayer.vue'
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
// 格式化秒为 mm:ss
|
// 格式化秒为 mm:ss
|
||||||
function formatDuration(seconds?: number): string {
|
function formatDuration(seconds?: number): string {
|
||||||
@ -240,7 +225,7 @@ const musicList = ref<MusicPlayerItem[]>([])
|
|||||||
const currentMusic = ref<MusicPlayerItem>()
|
const currentMusic = ref<MusicPlayerItem>()
|
||||||
const lyricForm = ref<VForm>()
|
const lyricForm = ref<VForm>()
|
||||||
const musicUpload = ref<UploadInstance>()
|
const musicUpload = ref<UploadInstance>()
|
||||||
const player = ref<InstanceType<typeof MusicPlayerPanel>>()
|
const player = ref<InstanceType<typeof APlayer>>()
|
||||||
const lyricLoading = ref(false)
|
const lyricLoading = ref(false)
|
||||||
const libDrawerVisible = ref(false)
|
const libDrawerVisible = ref(false)
|
||||||
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
|
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
|
||||||
@ -399,39 +384,20 @@ function musicPlay() {
|
|||||||
player.value?.pause()
|
player.value?.pause()
|
||||||
})
|
})
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||||
switchPrev()
|
if (currentId === 0) {
|
||||||
|
player.value?.switch(musicList.value.length - 1)
|
||||||
|
} else {
|
||||||
|
player.value?.switch(currentId - 1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||||
switchNext()
|
if (currentId === musicList.value.length - 1) {
|
||||||
|
player.value?.switch(0)
|
||||||
|
} else {
|
||||||
|
player.value?.switch(currentId + 1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function switchPrev() {
|
|
||||||
if (!currentMusic.value) return
|
|
||||||
const idx = musicList.value.indexOf(currentMusic.value)
|
|
||||||
const prevIdx = idx <= 0 ? musicList.value.length - 1 : idx - 1
|
|
||||||
player.value?.switch(prevIdx)
|
|
||||||
}
|
|
||||||
function switchNext() {
|
|
||||||
if (!currentMusic.value) return
|
|
||||||
const idx = musicList.value.indexOf(currentMusic.value)
|
|
||||||
const nextIdx = idx >= musicList.value.length - 1 ? 0 : idx + 1
|
|
||||||
player.value?.switch(nextIdx)
|
|
||||||
}
|
|
||||||
function cycleRepeatMode() {
|
|
||||||
if (!player.value) return
|
|
||||||
if (player.value.repeatMode === 'no_repeat') player.value.repeatMode = 'repeat_all'
|
|
||||||
else if (player.value.repeatMode === 'repeat_all') player.value.repeatMode = 'repeat_one'
|
|
||||||
else player.value.repeatMode = 'no_repeat'
|
|
||||||
}
|
|
||||||
function togglePlayerMute() {
|
|
||||||
if (!player.value?.audio) return
|
|
||||||
const audio = player.value.audio as unknown as HTMLAudioElement
|
|
||||||
audio.muted = !audio.muted
|
|
||||||
}
|
|
||||||
function onSelectSong(song: MusicPlayerItem) {
|
|
||||||
const idx = musicList.value.indexOf(song)
|
|
||||||
if (idx >= 0) player.value?.switch(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLibs() {
|
function loadLibs() {
|
||||||
return http.get<never, any>('/music/lib/list').then(data => {
|
return http.get<never, any>('/music/lib/list').then(data => {
|
||||||
|
|||||||
3
src/views/api/aplayer/assets/loading.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path fill="#FFF" d="M4 16c0-6.6 5.4-12 12-12s12 5.4 12 12c0 1.2-0.8 2-2 2s-2-0.8-2-2c0-4.4-3.6-8-8-8s-8 3.6-8 8 3.6 8 8 8c1.2 0 2 0.8 2 2s-0.8 2-2 2c-6.6 0-12-5.4-12-12z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 265 B |
3
src/views/api/aplayer/assets/lrc.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M26.667 5.333h-21.333c-0 0-0.001 0-0.001 0-1.472 0-2.666 1.194-2.666 2.666 0 0 0 0.001 0 0.001v-0 16c0 0 0 0.001 0 0.001 0 1.472 1.194 2.666 2.666 2.666 0 0 0.001 0 0.001 0h21.333c0 0 0.001 0 0.001 0 1.472 0 2.666-1.194 2.666-2.666 0-0 0-0.001 0-0.001v0-16c0-0 0-0.001 0-0.001 0-1.472-1.194-2.666-2.666-2.666-0 0-0.001 0-0.001 0h0zM5.333 16h5.333v2.667h-5.333v-2.667zM18.667 24h-13.333v-2.667h13.333v2.667zM26.667 24h-5.333v-2.667h5.333v2.667zM26.667 18.667h-13.333v-2.667h13.333v2.667z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 590 B |
3
src/views/api/aplayer/assets/menu.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-5 0 32 32">
|
||||||
|
<path d="M20.8 14.4q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2zM1.6 11.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2zM20.8 20.8q0.704 0 1.152 0.48t0.448 1.12-0.48 1.12-1.12 0.48h-19.2q-0.64 0-1.12-0.48t-0.48-1.12 0.448-1.12 1.152-0.48h19.2z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
3
src/views/api/aplayer/assets/no_repeat.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M2.667 7.027l1.707-1.693 22.293 22.293-1.693 1.707-4-4h-11.64v4l-5.333-5.333 5.333-5.333v4h8.973l-8.973-8.973v0.973h-2.667v-3.64l-4-4zM22.667 17.333h2.667v5.573l-2.667-2.667v-2.907zM22.667 6.667v-4l5.333 5.333-5.333 5.333v-4h-10.907l-2.667-2.667h13.573z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 357 B |
3
src/views/api/aplayer/assets/pause.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
|
||||||
|
<path stroke="#000" fill="#FFF" d="M14.080 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048zM2.88 4.8q2.88 0 2.88 2.048v18.24q0 2.112-2.88 2.112t-2.88-2.112v-18.24q0-2.048 2.88-2.048z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
3
src/views/api/aplayer/assets/play.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-8 0 32 32">
|
||||||
|
<path stroke="#000" fill="#FFF" d="M15.552 15.168q0.448 0.32 0.448 0.832 0 0.448-0.448 0.768l-13.696 8.512q-0.768 0.512-1.312 0.192t-0.544-1.28v-16.448q0-0.96 0.544-1.28t1.312 0.192z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 278 B |
3
src/views/api/aplayer/assets/repeat_all.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
3
src/views/api/aplayer/assets/repeat_one.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M9.333 9.333h13.333v4l5.333-5.333-5.333-5.333v4h-16v8h2.667v-5.333zM22.667 22.667h-13.333v-4l-5.333 5.333 5.333 5.333v-4h16v-8h-2.667v5.333zM17.333 20v-8h-1.333l-2.667 1.333v1.333h2v5.333h2z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 294 B |
3
src/views/api/aplayer/assets/shuffle.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M22.667 4l7 6-7 6 7 6-7 6v-4h-3.653l-3.76-3.76 2.827-2.827 2.587 2.587h2v-8h-2l-12 12h-6v-4h4.347l12-12h3.653v-4zM2.667 8h6l3.76 3.76-2.827 2.827-2.587-2.587h-4.347v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 272 B |
3
src/views/api/aplayer/assets/skip.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M25.468 6.947c-0.326-0.172-0.724-0.151-1.030 0.057l-6.438 4.38v-3.553c0-0.371-0.205-0.71-0.532-0.884-0.326-0.172-0.724-0.151-1.030 0.057l-12 8.164c-0.274 0.186-0.438 0.496-0.438 0.827s0.164 0.641 0.438 0.827l12 8.168c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-3.556l6.438 4.382c0.169 0.115 0.365 0.174 0.562 0.174 0.16 0 0.321-0.038 0.468-0.116 0.327-0.173 0.532-0.514 0.532-0.884v-16.333c0-0.371-0.205-0.71-0.532-0.884z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
3
src/views/api/aplayer/assets/volume_down.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
3
src/views/api/aplayer/assets/volume_off.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 301 B |
3
src/views/api/aplayer/assets/volume_up.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
|
||||||
|
<path d="M13.728 6.272v19.456q0 0.448-0.352 0.8t-0.8 0.32-0.8-0.32l-5.952-5.952h-4.672q-0.48 0-0.8-0.352t-0.352-0.8v-6.848q0-0.48 0.352-0.8t0.8-0.352h4.672l5.952-5.952q0.32-0.32 0.8-0.32t0.8 0.32 0.352 0.8zM20.576 16q0 1.344-0.768 2.528t-2.016 1.664q-0.16 0.096-0.448 0.096-0.448 0-0.8-0.32t-0.32-0.832q0-0.384 0.192-0.64t0.544-0.448 0.608-0.384 0.512-0.64 0.192-1.024-0.192-1.024-0.512-0.64-0.608-0.384-0.544-0.448-0.192-0.64q0-0.48 0.32-0.832t0.8-0.32q0.288 0 0.448 0.096 1.248 0.48 2.016 1.664t0.768 2.528zM25.152 16q0 2.72-1.536 5.056t-4 3.36q-0.256 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.704 0.672-1.056 1.024-0.512 1.376-0.8 1.312-0.96 2.048-2.4t0.736-3.104-0.736-3.104-2.048-2.4q-0.352-0.288-1.376-0.8-0.672-0.352-0.672-1.056 0-0.448 0.32-0.8t0.8-0.352q0.224 0 0.48 0.096 2.496 1.056 4 3.36t1.536 5.056zM29.728 16q0 4.096-2.272 7.552t-6.048 5.056q-0.224 0.096-0.448 0.096-0.48 0-0.832-0.352t-0.32-0.8q0-0.64 0.704-1.056 0.128-0.064 0.384-0.192t0.416-0.192q0.8-0.448 1.44-0.896 2.208-1.632 3.456-4.064t1.216-5.152-1.216-5.152-3.456-4.064q-0.64-0.448-1.44-0.896-0.128-0.096-0.416-0.192t-0.384-0.192q-0.704-0.416-0.704-1.056 0-0.448 0.32-0.8t0.832-0.352q0.224 0 0.448 0.096 3.776 1.632 6.048 5.056t2.272 7.552z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
153
src/views/api/aplayer/components/aplayer-controller-progress.vue
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="aplayer-bar-wrap"
|
||||||
|
@mousedown="onThumbMouseDown"
|
||||||
|
ref="barWrap"
|
||||||
|
>
|
||||||
|
<div class="aplayer-bar">
|
||||||
|
<div
|
||||||
|
class="aplayer-loaded"
|
||||||
|
:style="{width: `${loadProgress * 100}%`}">
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="aplayer-played"
|
||||||
|
:style="{width: `${playProgress * 100}%`, background: theme}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref="thumb"
|
||||||
|
@mouseover="thumbHovered = true"
|
||||||
|
@mouseout="thumbHovered = false"
|
||||||
|
class="aplayer-thumb"
|
||||||
|
:style="{borderColor: theme, backgroundColor: thumbHovered ? theme : '#fff'}"
|
||||||
|
>
|
||||||
|
<span class="aplayer-loading-icon" :style="{backgroundColor: theme }">
|
||||||
|
<player-icon type="loading"/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getElementViewLeft } from '../utils'
|
||||||
|
import PlayerIcon from './aplayer-icon.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loadProgress: number
|
||||||
|
playProgress: number
|
||||||
|
theme?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['dragbegin', 'dragging', 'dragend'])
|
||||||
|
|
||||||
|
const thumbHovered = ref(false)
|
||||||
|
const barWrap = ref<unknown>(null)
|
||||||
|
|
||||||
|
const onThumbMouseDown = (e: MouseEvent) => {
|
||||||
|
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
||||||
|
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
||||||
|
percentage = percentage > 0 ? percentage : 0
|
||||||
|
percentage = percentage < 1 ? percentage : 1
|
||||||
|
emit('dragbegin', percentage)
|
||||||
|
document.addEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
document.addEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDocumentMouseMove = (e: MouseEvent) => {
|
||||||
|
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
||||||
|
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
||||||
|
percentage = percentage > 0 ? percentage : 0
|
||||||
|
percentage = percentage < 1 ? percentage : 1
|
||||||
|
emit('dragging', percentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDocumentMouseUp = (e: MouseEvent) => {
|
||||||
|
document.removeEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
document.removeEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
const barWidth = (<HTMLElement>barWrap.value).clientWidth
|
||||||
|
let percentage = (e.clientX - getElementViewLeft(<HTMLElement>barWrap.value)) / barWidth
|
||||||
|
percentage = percentage > 0 ? percentage : 0
|
||||||
|
percentage = percentage < 1 ? percentage : 1
|
||||||
|
emit('dragend', percentage)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.aplayer-bar-wrap {
|
||||||
|
margin: 0 0 0 5px;
|
||||||
|
padding: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
.aplayer-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
background: #cdcdcd;
|
||||||
|
.aplayer-loaded {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #aaa;
|
||||||
|
height: 2px;
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
will-change: width;
|
||||||
|
}
|
||||||
|
.aplayer-played {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
transition: background-color .3s;
|
||||||
|
will-change: width;
|
||||||
|
.aplayer-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 5px;
|
||||||
|
margin-top: -5px;
|
||||||
|
margin-right: -10px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid;
|
||||||
|
transform: scale(.8);
|
||||||
|
will-change: transform;
|
||||||
|
transition: transform 300ms, background-color .3s, border-color .3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
overflow: hidden;
|
||||||
|
.aplayer-loading-icon {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
.icon-svg {
|
||||||
|
position: absolute;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-loading {
|
||||||
|
.aplayer-bar-wrap .aplayer-bar .aplayer-thumb .aplayer-loading-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.aplayer-info .aplayer-controller .aplayer-bar-wrap .aplayer-bar .aplayer-played .aplayer-thumb {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0)
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
src/views/api/aplayer/components/aplayer-controller-volume.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="aplayer-volume-wrap">
|
||||||
|
<icon-button
|
||||||
|
:class="`aplayer-icon-${volumeIcon}`"
|
||||||
|
:icon="volumeIcon"
|
||||||
|
@click="emit('togglemute')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="aplayer-volume-bar-wrap"
|
||||||
|
@mousedown="onBarMouseDown"
|
||||||
|
>
|
||||||
|
<div class="aplayer-volume-bar" ref="bar">
|
||||||
|
<div
|
||||||
|
class="aplayer-volume"
|
||||||
|
:style="{
|
||||||
|
height: muted ? 0 : `${Math.trunc(volume * 100)}%`,
|
||||||
|
background: theme
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import IconButton from './aplayer-iconbutton.vue'
|
||||||
|
import {getElementViewTop} from '../utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
volume: number
|
||||||
|
muted: boolean
|
||||||
|
theme: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits(['setvolume', 'togglemute'])
|
||||||
|
const barHeight = 40
|
||||||
|
|
||||||
|
const volumeIcon = computed(() => {
|
||||||
|
if (props.muted || props.volume <= 0) return 'volume_off'
|
||||||
|
if (props.volume >= 1) return 'volume_up'
|
||||||
|
return 'volume_down'
|
||||||
|
})
|
||||||
|
|
||||||
|
const bar = ref<unknown>(null)
|
||||||
|
|
||||||
|
const onBarMouseDown = () => {
|
||||||
|
document.addEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
document.addEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
}
|
||||||
|
const onDocumentMouseMove = (e: MouseEvent) => {
|
||||||
|
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
|
||||||
|
percentage = percentage > 0 ? percentage : 0
|
||||||
|
percentage = percentage < 1 ? percentage : 1
|
||||||
|
emit('setvolume', percentage)
|
||||||
|
}
|
||||||
|
const onDocumentMouseUp = (e: MouseEvent) => {
|
||||||
|
document.removeEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
document.removeEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
|
||||||
|
let percentage = (barHeight - e.clientY + getElementViewTop(<HTMLElement>bar.value)) / barHeight
|
||||||
|
percentage = percentage > 0 ? percentage : 0
|
||||||
|
percentage = percentage < 1 ? percentage : 1
|
||||||
|
emit('setvolume', percentage)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.aplayer-volume-wrap {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 0;
|
||||||
|
&:hover .aplayer-volume-bar-wrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.aplayer-volume-bar-wrap {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 15px;
|
||||||
|
left: -4px;
|
||||||
|
right: -4px;
|
||||||
|
height: 40px;
|
||||||
|
z-index: -1;
|
||||||
|
transition: all .2s ease;
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -16px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 62px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.07), 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.aplayer-volume-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 11px;
|
||||||
|
width: 5px;
|
||||||
|
height: 40px;
|
||||||
|
background: #aaa;
|
||||||
|
border-radius: 2.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.aplayer-volume {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
transition: height 0.1s ease, background-color .3s;
|
||||||
|
will-change: height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
src/views/api/aplayer/components/aplayer-controller.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="aplayer-controller">
|
||||||
|
<v-progress
|
||||||
|
:loadProgress="loadProgress"
|
||||||
|
:playProgress="playProgress"
|
||||||
|
:theme="theme"
|
||||||
|
@dragbegin="val => emit('dragbegin', val)"
|
||||||
|
@dragend="val => emit('dragend', val)"
|
||||||
|
@dragging="val => emit('dragging', val)"
|
||||||
|
/>
|
||||||
|
<div class="aplayer-time">
|
||||||
|
<div class="aplayer-time-inner">
|
||||||
|
- <span class="aplayer-ptime">{{secondToTime(stat.playedTime)}}</span> / <span
|
||||||
|
class="aplayer-dtime">{{secondToTime(stat.duration)}}</span>
|
||||||
|
</div>
|
||||||
|
<volume
|
||||||
|
v-if="!isMobile"
|
||||||
|
:volume="volume"
|
||||||
|
:theme="theme"
|
||||||
|
:muted="muted"
|
||||||
|
@togglemute="emit('togglemute')"
|
||||||
|
@setvolume="(v: number) => emit('setvolume', v)"
|
||||||
|
/>
|
||||||
|
<icon-button
|
||||||
|
class="aplayer-icon-mode"
|
||||||
|
icon="shuffle"
|
||||||
|
:class="{ 'inactive': !shuffle }"
|
||||||
|
@click="emit('toggleshuffle')"
|
||||||
|
/>
|
||||||
|
<icon-button
|
||||||
|
class="aplayer-icon-mode"
|
||||||
|
:icon="repeat === 'repeat_one' ? 'repeat_one' : 'repeat_all'"
|
||||||
|
:class="{ 'inactive': repeat === 'no_repeat'}"
|
||||||
|
@click="emit('nextmode')"
|
||||||
|
/>
|
||||||
|
<icon-button
|
||||||
|
class="aplayer-icon-menu"
|
||||||
|
icon="menu"
|
||||||
|
:class="{ 'inactive': !showList }"
|
||||||
|
@click="emit('togglelist')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import IconButton from './aplayer-iconbutton.vue'
|
||||||
|
import VProgress from './aplayer-controller-progress.vue'
|
||||||
|
import Volume from './aplayer-controller-volume.vue'
|
||||||
|
import { StatType } from '@/model/api/music'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
shuffle: boolean
|
||||||
|
repeat: string
|
||||||
|
stat: StatType
|
||||||
|
theme: string
|
||||||
|
volume: number
|
||||||
|
muted: boolean
|
||||||
|
showList: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits(['dragbegin', 'dragend', 'dragging', 'togglemute', 'setvolume', 'toggleshuffle', 'nextmode', 'togglelist'])
|
||||||
|
const loadProgress = computed(() => {
|
||||||
|
if (props.stat.duration === 0) return 0
|
||||||
|
return props.stat.loadedTime / props.stat.duration
|
||||||
|
})
|
||||||
|
const playProgress = computed(() => {
|
||||||
|
if (props.stat.duration === 0) return 0
|
||||||
|
return props.stat.playedTime / props.stat.duration
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondToTime = (second: number) => {
|
||||||
|
if (isNaN(second)) {
|
||||||
|
return '00:00'
|
||||||
|
}
|
||||||
|
const pad0 = (num: number) => {
|
||||||
|
return num < 10 ? '0' + num : '' + num
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.trunc(second / 60)
|
||||||
|
const sec = Math.trunc(second - min * 60)
|
||||||
|
const hours = Math.trunc(min / 60)
|
||||||
|
const minAdjust = Math.trunc((second / 60) - (60 * Math.trunc((second / 60) / 60)))
|
||||||
|
return second >= 3600 ? pad0(hours) + ':' + pad0(minAdjust) + ':' + pad0(sec) : pad0(min) + ':' + pad0(sec)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.aplayer-controller {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
.aplayer-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
height: 17px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 11px;
|
||||||
|
padding-left: 7px;
|
||||||
|
.aplayer-volume-wrap {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.aplayer-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-left: 4px;
|
||||||
|
&.inactive {
|
||||||
|
opacity: .3;
|
||||||
|
}
|
||||||
|
&.aplayer-icon-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-volume-wrap + .aplayer-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&.aplayer-time-narrow {
|
||||||
|
.aplayer-icon-mode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.aplayer-icon-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/views/api/aplayer/components/aplayer-icon.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<img class="icon-svg" :src="svg" :style="style" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{type: string}>()
|
||||||
|
const svgModules = import.meta.glob('../assets/*.svg', { eager: true, import: 'default' }) as Record<string, string>
|
||||||
|
|
||||||
|
const SVGs = Object.entries(svgModules).reduce((svgs: {[propName:string]: string}, [path, svgFile]) => {
|
||||||
|
const fileNameMatch = path.match(/^.*\/(.+?)\.svg$/)
|
||||||
|
if (fileNameMatch) {
|
||||||
|
svgs[fileNameMatch[1]] = svgFile
|
||||||
|
}
|
||||||
|
return svgs
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const svg = computed(() => SVGs[props.type])
|
||||||
|
const style = computed(() => {
|
||||||
|
if (props.type === 'next') {
|
||||||
|
return { transform: 'rotate(180deg)' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
33
src/views/api/aplayer/components/aplayer-iconbutton.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="aplayer-icon"
|
||||||
|
>
|
||||||
|
<player-icon :type="icon"/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import PlayerIcon from './aplayer-icon.vue'
|
||||||
|
defineProps<{icon: string}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.aplayer-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .8;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
136
src/views/api/aplayer/components/aplayer-list.vue
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="slide-v">
|
||||||
|
<div
|
||||||
|
class="aplayer-list"
|
||||||
|
:style="listHeightStyle"
|
||||||
|
ref="list"
|
||||||
|
v-show="show"
|
||||||
|
>
|
||||||
|
<ol ref="ol" :style="listHeightStyle">
|
||||||
|
<li
|
||||||
|
v-for="(aMusic, index) of musicList"
|
||||||
|
:key="index"
|
||||||
|
:class="{'aplayer-list-light': aMusic === currentMusic}"
|
||||||
|
@click="emit('selectsong', aMusic)"
|
||||||
|
>
|
||||||
|
<span class="aplayer-list-cur" :style="{background: theme}"></span>
|
||||||
|
<span class="aplayer-list-index">{{ index + 1}}</span>
|
||||||
|
<span class="aplayer-list-title">{{ aMusic.title || 'Untitled' }}</span>
|
||||||
|
<span class="aplayer-list-author">{{ aMusic.artist || 'Unknown' }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { MusicPlayerItem } from '@/model/api/music'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
currentMusic: MusicPlayerItem
|
||||||
|
musicList: MusicPlayerItem[]
|
||||||
|
theme?: string
|
||||||
|
listMaxHeight?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits(['selectsong'])
|
||||||
|
const listHeightStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
height: `${33 * props.musicList.length - 1}px`,
|
||||||
|
maxHeight: props.listMaxHeight || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.aplayer-list {
|
||||||
|
overflow: hidden;
|
||||||
|
&.slide-v-enter-active,
|
||||||
|
&.slide-v-leave-active {
|
||||||
|
transition: height 500ms ease;
|
||||||
|
will-change: height;
|
||||||
|
}
|
||||||
|
&.slide-v-enter,
|
||||||
|
&.slide-v-leave-to {
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
li.aplayer-list-light:not(:hover) {
|
||||||
|
background-color: inherit;
|
||||||
|
transition: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:not(:hover) {
|
||||||
|
li.aplayer-list-light {
|
||||||
|
transition: background-color .6s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
padding: 0 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid #e9e9e9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
text-align: start;
|
||||||
|
display: flex;
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: #efefef;
|
||||||
|
}
|
||||||
|
&.aplayer-list-light {
|
||||||
|
background: #efefef;
|
||||||
|
.aplayer-list-cur {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-list-cur {
|
||||||
|
display: none;
|
||||||
|
width: 3px;
|
||||||
|
height: 22px;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 5px;
|
||||||
|
transition: background-color .3s;
|
||||||
|
}
|
||||||
|
.aplayer-list-index {
|
||||||
|
color: #666;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.aplayer-list-title {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.aplayer-list-author {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
src/views/api/aplayer/components/aplayer-lrc.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="aplayer-lrc">
|
||||||
|
<div
|
||||||
|
class="aplayer-lrc-contents"
|
||||||
|
:style="transformStyle"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-for="(line, index) of lrcLines"
|
||||||
|
:key="index"
|
||||||
|
:class="{ 'aplayer-lrc-current': index === currentLineIndex }"
|
||||||
|
>
|
||||||
|
{{ line[1] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { parseLrc } from '../utils'
|
||||||
|
import { StatType } from '@/model/api/music'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lrcSource: string
|
||||||
|
playStat: StatType
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentLineIndex = ref(0)
|
||||||
|
|
||||||
|
const lrcLines = computed(() => parseLrc(props.lrcSource))
|
||||||
|
|
||||||
|
const transformStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
transform: `translateY(${-currentLineIndex.value * 16}px)`,
|
||||||
|
webkitTransform: `translateY(${-currentLineIndex.value * 16}px)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.playStat.playedTime, (playedTime: number) => {
|
||||||
|
for (let i = 0; i < lrcLines.value.length; i++) {
|
||||||
|
const line = lrcLines.value[i]
|
||||||
|
const nextLine = lrcLines.value[i + 1]
|
||||||
|
if (playedTime >= line[0] && (!nextLine || playedTime < nextLine[0])) {
|
||||||
|
currentLineIndex.value = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
@import "../less/variables";
|
||||||
|
.aplayer-lrc {
|
||||||
|
position: relative;
|
||||||
|
height: @lrc-height;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 10%;
|
||||||
|
content: ' ';
|
||||||
|
background: -moz-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||||
|
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00ffffff', GradientType=0);
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 33%;
|
||||||
|
content: ' ';
|
||||||
|
background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||||
|
background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffffff', endColorstr='#ccffffff', GradientType=0);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: all 0.5s ease-out;
|
||||||
|
opacity: 0.4;
|
||||||
|
overflow: hidden;
|
||||||
|
&.aplayer-lrc-current {
|
||||||
|
opacity: 1;
|
||||||
|
overflow: visible;
|
||||||
|
height: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-lrc-contents {
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.5s ease-out;
|
||||||
|
user-select: text;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
src/views/api/aplayer/components/aplayer-thumbnail.vue
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="aplayer-pic"
|
||||||
|
:style="{backgroundColor: theme}"
|
||||||
|
@mousedown="onDragBegin"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<img v-if="pic" ref="thumbnailPic" :src="pic" @load="imgLoad" />
|
||||||
|
<div class="aplayer-button" :class="playing ? 'aplayer-pause' : 'aplayer-play'">
|
||||||
|
<icon-button
|
||||||
|
:icon="playing ? 'pause' : 'play'"
|
||||||
|
:class="playing ? 'aplayer-icon-pause' : 'aplayer-icon-play'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import IconButton from './aplayer-iconbutton.vue'
|
||||||
|
import { adaptingThemeColor } from '../utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
pic?: string
|
||||||
|
theme: string
|
||||||
|
playing: boolean
|
||||||
|
enableDrag: boolean
|
||||||
|
}>(), {
|
||||||
|
playing: false,
|
||||||
|
enableDrag: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay', 'adaptingTheme'])
|
||||||
|
|
||||||
|
const hasMovedSinceMouseDown = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const thumbnailPic = ref<unknown>(null)
|
||||||
|
|
||||||
|
const onDragBegin = (e: MouseEvent) => {
|
||||||
|
if (props.enableDrag) {
|
||||||
|
hasMovedSinceMouseDown.value = false
|
||||||
|
emit('dragbegin')
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
dragStartY.value = e.clientY
|
||||||
|
document.addEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
document.addEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onDocumentMouseMove = (e: MouseEvent) => {
|
||||||
|
hasMovedSinceMouseDown.value = true
|
||||||
|
emit('dragging', {offsetLeft: e.clientX - dragStartX.value, offsetTop: e.clientY - dragStartY.value})
|
||||||
|
}
|
||||||
|
const onDocumentMouseUp = () => {
|
||||||
|
document.removeEventListener('mouseup', onDocumentMouseUp)
|
||||||
|
document.removeEventListener('mousemove', onDocumentMouseMove)
|
||||||
|
emit('dragend')
|
||||||
|
}
|
||||||
|
const onClick = () => {
|
||||||
|
if (!hasMovedSinceMouseDown.value) {
|
||||||
|
emit('toggleplay')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const imgLoad = () => {
|
||||||
|
const color = adaptingThemeColor(<HTMLImageElement>thumbnailPic.value)
|
||||||
|
emit('adaptingTheme', `rgb(${color.r},${color.g},${color.b})`)
|
||||||
|
}
|
||||||
|
watch(() => props.pic, (pic?: string) => {
|
||||||
|
if (!pic) emit('adaptingTheme', null)
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
@import "../less/variables";
|
||||||
|
.aplayer-float {
|
||||||
|
.aplayer-pic:active {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-pic {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
height: @aplayer-height;
|
||||||
|
width: @aplayer-height;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
.aplayer-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-button {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.8;
|
||||||
|
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
.aplayer-play {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
bottom: 50%;
|
||||||
|
right: 50%;
|
||||||
|
margin: 0 -15px -15px 0;
|
||||||
|
.aplayer-icon-play {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 4px;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aplayer-pause {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
.aplayer-icon-pause {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/views/api/aplayer/less/variables.less
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@aplayer-height: 66px;
|
||||||
|
@lrc-height: 30px;
|
||||||
|
@aplayer-height-lrc: @aplayer-height + @lrc-height - 6;
|
||||||
@ -1,999 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="music-player-panel" :style="panelBgStyle">
|
|
||||||
<!-- 上半部分:唱片 + 歌曲信息 -->
|
|
||||||
<div class="player-main">
|
|
||||||
<!-- 左侧:唱片盘面 + 圆形频谱 -->
|
|
||||||
<div class="disc-section">
|
|
||||||
<div class="disc-container">
|
|
||||||
<!-- 唱片 -->
|
|
||||||
<div class="disc-outer" :class="{ playing: isPlaying }">
|
|
||||||
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
|
|
||||||
<div class="vinyl-grooves"></div>
|
|
||||||
<div class="disc-cover-wrapper">
|
|
||||||
<img v-if="currentMusic?.pic" ref="coverImg" :src="currentMusic.pic" class="disc-cover" crossorigin="anonymous" @load="onCoverLoad" />
|
|
||||||
<div v-else class="disc-cover disc-cover-empty"></div>
|
|
||||||
</div>
|
|
||||||
<div class="disc-center-dot"></div>
|
|
||||||
</div>
|
|
||||||
<!-- 唱针 SVG -->
|
|
||||||
<svg class="needle" :class="{ playing: isPlaying }" viewBox="0 0 120 200" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="60" cy="16" r="14" fill="#555" stroke="#777" stroke-width="2"/>
|
|
||||||
<circle cx="60" cy="16" r="6" fill="#888" stroke="#aaa" stroke-width="1"/>
|
|
||||||
<rect x="57" y="16" width="6" height="120" rx="3" fill="url(#armGradient)"/>
|
|
||||||
<rect x="55" y="130" width="10" height="30" rx="2" fill="#444" stroke="#666" stroke-width="1"/>
|
|
||||||
<rect x="58" y="158" width="4" height="14" rx="1" fill="#333"/>
|
|
||||||
<circle cx="60" cy="174" r="3" fill="#222" stroke="#555" stroke-width="1"/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="armGradient" x1="0" y1="0" x2="1" y2="0">
|
|
||||||
<stop offset="0%" stop-color="#777"/>
|
|
||||||
<stop offset="50%" stop-color="#bbb"/>
|
|
||||||
<stop offset="100%" stop-color="#777"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 右侧:歌曲信息 + 歌词 -->
|
|
||||||
<div class="info-section">
|
|
||||||
<div class="song-info">
|
|
||||||
<h2 class="song-title">{{ currentMusic?.title || 'Untitled' }}</h2>
|
|
||||||
<div class="song-meta">
|
|
||||||
<span v-if="currentMusic?.album">专辑:{{ currentMusic.album }}</span>
|
|
||||||
<span v-if="currentMusic?.artist">歌手:{{ currentMusic.artist }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lyrics-area" ref="lyricsContainer">
|
|
||||||
<div class="lyrics-content" :style="lyricsTransformStyle">
|
|
||||||
<p
|
|
||||||
v-for="(line, index) in lrcLines"
|
|
||||||
:key="index"
|
|
||||||
:class="{ 'current-line': index === currentLineIndex }"
|
|
||||||
>{{ line[1] || '' }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="!lrcLines.length" class="no-lyrics">暂无歌词</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 进度条 -->
|
|
||||||
<div class="progress-section">
|
|
||||||
<span class="time-label">{{ formatTime(playedTime) }}</span>
|
|
||||||
<div class="progress-bar" ref="progressBar" @mousedown="onProgressMouseDown">
|
|
||||||
<div class="progress-loaded" :style="{ width: loadProgress + '%' }"></div>
|
|
||||||
<div class="progress-played" :style="{ width: playProgress + '%' }">
|
|
||||||
<span class="progress-thumb"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="time-label">{{ formatTime(duration) }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- 播放控制 -->
|
|
||||||
<div class="controls-section">
|
|
||||||
<button class="ctrl-btn" :class="{ active: shuffle }" @click="emit('toggleShuffle')" title="随机播放">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ctrl-btn" @click="emit('prev')" title="上一首">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ctrl-btn ctrl-btn-play" @click="emit('toggle')" :title="isPlaying ? '暂停' : '播放'">
|
|
||||||
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ctrl-btn" @click="emit('next')" title="下一首">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ctrl-btn" :class="{ active: repeatMode !== 'no_repeat' }" @click="emit('nextMode')" :title="repeatTitle">
|
|
||||||
<svg v-if="repeatMode === 'repeat_one'" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="volume-control">
|
|
||||||
<button class="ctrl-btn" @click="emit('toggleMute')" :title="muted ? '取消静音' : '静音'">
|
|
||||||
<svg v-if="muted" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
|
||||||
<svg v-else-if="volume > 0.5" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
|
|
||||||
</button>
|
|
||||||
<input type="range" class="volume-slider" min="0" max="1" step="0.01" :value="volume" @input="onVolumeInput" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 播放列表 -->
|
|
||||||
<div class="playlist-section" v-if="list.length > 1">
|
|
||||||
<div class="playlist-header" @click="playlistVisible = !playlistVisible">
|
|
||||||
<span>播放列表 ({{ list.length }})</span>
|
|
||||||
<span class="playlist-toggle">{{ playlistVisible ? '收起' : '展开' }}</span>
|
|
||||||
</div>
|
|
||||||
<transition name="slide">
|
|
||||||
<div class="playlist-body" v-show="playlistVisible">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in list"
|
|
||||||
:key="index"
|
|
||||||
class="playlist-item"
|
|
||||||
:class="{ active: item === currentMusic }"
|
|
||||||
@click="emit('selectSong', item)"
|
|
||||||
>
|
|
||||||
<span class="playlist-index">{{ index + 1 }}</span>
|
|
||||||
<span class="playlist-title">{{ item.title || 'Untitled' }}</span>
|
|
||||||
<span class="playlist-artist">{{ item.artist || '' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
<!-- 隐藏的audio -->
|
|
||||||
<audio ref="audioEl"></audio>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue'
|
|
||||||
import { MusicPlayerItem, StatType } from '@/model/api/music'
|
|
||||||
import { parseLrc, adaptingThemeColor } from './utils'
|
|
||||||
import { Particle, spawnParticles, updateAndDrawParticles, drawCircularSpectrum, formatTime } from './visualizer'
|
|
||||||
import Hls from 'hls.js'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
music: MusicPlayerItem
|
|
||||||
list: MusicPlayerItem[]
|
|
||||||
autoplay?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:music', 'toggle', 'prev', 'next', 'toggleShuffle', 'nextMode', 'toggleMute', 'selectSong', 'play'])
|
|
||||||
|
|
||||||
const audioEl = ref<HTMLAudioElement | null>(null)
|
|
||||||
const progressBar = ref<HTMLDivElement | null>(null)
|
|
||||||
const visualizerCanvas = ref<HTMLCanvasElement | null>(null)
|
|
||||||
const coverImg = ref<HTMLImageElement | null>(null)
|
|
||||||
const playlistVisible = ref(false)
|
|
||||||
const themeColor = ref<{ r: number; g: number; b: number } | null>(null)
|
|
||||||
|
|
||||||
const panelBgStyle = computed(() => {
|
|
||||||
if (!themeColor.value) return null
|
|
||||||
const { r, g, b } = themeColor.value
|
|
||||||
const dr = Math.floor(r * 0.25), dg = Math.floor(g * 0.25), db = Math.floor(b * 0.25)
|
|
||||||
return { backgroundColor: `rgb(${dr}, ${dg}, ${db})` }
|
|
||||||
})
|
|
||||||
|
|
||||||
function onCoverLoad() {
|
|
||||||
if (!coverImg.value) return
|
|
||||||
try {
|
|
||||||
const color = adaptingThemeColor(coverImg.value)
|
|
||||||
themeColor.value = color
|
|
||||||
} catch {
|
|
||||||
themeColor.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPlaying = ref(false)
|
|
||||||
const shuffle = ref(false)
|
|
||||||
const repeatMode = ref<'no_repeat' | 'repeat_one' | 'repeat_all'>('no_repeat')
|
|
||||||
const muted = ref(false)
|
|
||||||
const volume = ref(0.5)
|
|
||||||
const currentLineIndex = ref(0)
|
|
||||||
const hls = ref<Hls | null>(null)
|
|
||||||
|
|
||||||
const playStat = reactive<StatType>({
|
|
||||||
duration: 0,
|
|
||||||
loadedTime: 0,
|
|
||||||
playedTime: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const internalMusic = ref<MusicPlayerItem>(props.music)
|
|
||||||
const currentMusic = computed({
|
|
||||||
get: () => internalMusic.value,
|
|
||||||
set: (val) => {
|
|
||||||
internalMusic.value = val
|
|
||||||
emit('update:music', val)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const playedTime = computed(() => playStat.playedTime)
|
|
||||||
const duration = computed(() => playStat.duration)
|
|
||||||
const loadProgress = computed(() => playStat.duration ? (playStat.loadedTime / playStat.duration) * 100 : 0)
|
|
||||||
const playProgress = computed(() => playStat.duration ? (playStat.playedTime / playStat.duration) * 100 : 0)
|
|
||||||
|
|
||||||
const lrcLines = computed(() => {
|
|
||||||
if (!currentMusic.value?.lrc) return []
|
|
||||||
return parseLrc(currentMusic.value.lrc)
|
|
||||||
})
|
|
||||||
|
|
||||||
const lyricsContainer = ref<HTMLDivElement | null>(null)
|
|
||||||
const lyricsTransformStyle = computed(() => {
|
|
||||||
const lineHeight = 34
|
|
||||||
const containerHeight = lyricsContainer.value?.clientHeight || 300
|
|
||||||
const centerOffset = Math.floor(containerHeight / 2 / lineHeight)
|
|
||||||
const offset = Math.max(0, currentLineIndex.value - centerOffset)
|
|
||||||
return { transform: `translateY(-${offset * lineHeight}px)` }
|
|
||||||
})
|
|
||||||
|
|
||||||
const repeatTitle = computed(() => {
|
|
||||||
if (repeatMode.value === 'no_repeat') return '顺序播放'
|
|
||||||
if (repeatMode.value === 'repeat_one') return '单曲循环'
|
|
||||||
return '列表循环'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audio Visualizer
|
|
||||||
let audioCtx: AudioContext | null = null
|
|
||||||
let analyser: AnalyserNode | null = null
|
|
||||||
let source: MediaElementAudioSourceNode | null = null
|
|
||||||
let animationId: number | null = null
|
|
||||||
let dataArray: Uint8Array<ArrayBuffer> | null = null
|
|
||||||
let visualizerConnected = false
|
|
||||||
|
|
||||||
// 粒子系统
|
|
||||||
const particles: Particle[] = []
|
|
||||||
|
|
||||||
function initVisualizer(audio: HTMLAudioElement) {
|
|
||||||
if (visualizerConnected) return
|
|
||||||
audioCtx = new AudioContext()
|
|
||||||
analyser = audioCtx.createAnalyser()
|
|
||||||
analyser.fftSize = 256
|
|
||||||
source = audioCtx.createMediaElementSource(audio)
|
|
||||||
source.connect(analyser)
|
|
||||||
analyser.connect(audioCtx.destination)
|
|
||||||
dataArray = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
visualizerConnected = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawVisualizer() {
|
|
||||||
if (!visualizerCanvas.value || !analyser || !dataArray) return
|
|
||||||
const canvas = visualizerCanvas.value
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
const width = canvas.width
|
|
||||||
const height = canvas.height
|
|
||||||
if (width === 0 || height === 0) return
|
|
||||||
|
|
||||||
analyser.getByteFrequencyData(dataArray)
|
|
||||||
ctx.clearRect(0, 0, width, height)
|
|
||||||
|
|
||||||
const { lowEnergy, highEnergy } = drawCircularSpectrum({ ctx, width, height, dataArray })
|
|
||||||
|
|
||||||
const centerX = width / 2
|
|
||||||
const centerY = height / 2
|
|
||||||
const discRadius = Math.min(width, height) * 0.30
|
|
||||||
spawnParticles(particles, centerX, centerY, discRadius, lowEnergy, highEnergy)
|
|
||||||
updateAndDrawParticles(ctx, particles)
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(drawVisualizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startVisualizer() {
|
|
||||||
if (animationId) return
|
|
||||||
if (audioCtx?.state === 'suspended') audioCtx.resume()
|
|
||||||
drawVisualizer()
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopVisualizer() {
|
|
||||||
if (animationId) {
|
|
||||||
cancelAnimationFrame(animationId)
|
|
||||||
animationId = null
|
|
||||||
}
|
|
||||||
particles.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyVisualizer() {
|
|
||||||
stopVisualizer()
|
|
||||||
source?.disconnect()
|
|
||||||
analyser?.disconnect()
|
|
||||||
source = null
|
|
||||||
analyser = null
|
|
||||||
dataArray = null
|
|
||||||
visualizerConnected = false
|
|
||||||
|
|
||||||
if (audioCtx && audioCtx.state !== 'closed') {
|
|
||||||
void audioCtx.close()
|
|
||||||
}
|
|
||||||
audioCtx = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyHls() {
|
|
||||||
hls.value?.detachMedia()
|
|
||||||
hls.value?.destroy()
|
|
||||||
hls.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio controls
|
|
||||||
function play() {
|
|
||||||
const media = audioEl.value
|
|
||||||
if (!media) return
|
|
||||||
media.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause() {
|
|
||||||
audioEl.value?.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
if (isPlaying.value) pause()
|
|
||||||
else play()
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTrack(index: number) {
|
|
||||||
const normalizedIndex = ((index % props.list.length) + props.list.length) % props.list.length
|
|
||||||
currentMusic.value = props.list[normalizedIndex]
|
|
||||||
nextTick(() => play())
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProgressRatio(clientX: number) {
|
|
||||||
if (!progressBar.value) return 0
|
|
||||||
const rect = progressBar.value.getBoundingClientRect()
|
|
||||||
if (rect.width === 0) return 0
|
|
||||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onProgressMouseDown(e: MouseEvent) {
|
|
||||||
if (!progressBar.value || !audioEl.value) return
|
|
||||||
const pct = resolveProgressRatio(e.clientX)
|
|
||||||
if (!isNaN(audioEl.value.duration)) {
|
|
||||||
audioEl.value.currentTime = audioEl.value.duration * pct
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMove = (ev: MouseEvent) => {
|
|
||||||
const p = resolveProgressRatio(ev.clientX)
|
|
||||||
if (!isNaN(audioEl.value!.duration)) {
|
|
||||||
audioEl.value!.currentTime = audioEl.value!.duration * p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const onUp = () => {
|
|
||||||
document.removeEventListener('mousemove', onMove)
|
|
||||||
document.removeEventListener('mouseup', onUp)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', onMove)
|
|
||||||
document.addEventListener('mouseup', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onVolumeInput(e: Event) {
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
|
||||||
volume.value = val
|
|
||||||
if (audioEl.value) {
|
|
||||||
audioEl.value.volume = val
|
|
||||||
if (val > 0) {
|
|
||||||
audioEl.value.muted = false
|
|
||||||
muted.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function initAudio() {
|
|
||||||
const media = audioEl.value
|
|
||||||
if (!media) return
|
|
||||||
|
|
||||||
media.volume = volume.value
|
|
||||||
|
|
||||||
media.addEventListener('play', () => {
|
|
||||||
isPlaying.value = true
|
|
||||||
emit('play')
|
|
||||||
})
|
|
||||||
media.addEventListener('pause', () => { isPlaying.value = false })
|
|
||||||
media.addEventListener('timeupdate', () => {
|
|
||||||
playStat.playedTime = media.currentTime
|
|
||||||
})
|
|
||||||
media.addEventListener('durationchange', () => {
|
|
||||||
if (media.duration !== 1) playStat.duration = media.duration
|
|
||||||
})
|
|
||||||
media.addEventListener('progress', () => {
|
|
||||||
if (media.buffered.length) {
|
|
||||||
playStat.loadedTime = media.buffered.end(media.buffered.length - 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
media.addEventListener('ended', () => {
|
|
||||||
const currentIndex = props.list.indexOf(currentMusic.value)
|
|
||||||
if (repeatMode.value === 'repeat_one') {
|
|
||||||
media.currentTime = 0
|
|
||||||
play()
|
|
||||||
} else if (repeatMode.value === 'repeat_all') {
|
|
||||||
switchTrack(currentIndex + 1)
|
|
||||||
} else {
|
|
||||||
if (currentIndex < props.list.length - 1) {
|
|
||||||
switchTrack(currentIndex + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
media.addEventListener('volumechange', () => {
|
|
||||||
volume.value = media.volume
|
|
||||||
muted.value = media.muted
|
|
||||||
})
|
|
||||||
|
|
||||||
loadSource(currentMusic.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSource(music: MusicPlayerItem) {
|
|
||||||
const media = audioEl.value
|
|
||||||
const src = music.src
|
|
||||||
if (!media || !src) return
|
|
||||||
|
|
||||||
if (/\.m3u8(?=[#?]|$)/.test(src)) {
|
|
||||||
if (media.canPlayType('application/x-mpegURL') || media.canPlayType('application/vnd.apple.mpegURL')) {
|
|
||||||
destroyHls()
|
|
||||||
media.src = src
|
|
||||||
} else if (Hls.isSupported()) {
|
|
||||||
destroyHls()
|
|
||||||
const hlsInstance = new Hls()
|
|
||||||
hlsInstance.loadSource(src)
|
|
||||||
hlsInstance.attachMedia(media)
|
|
||||||
hls.value = hlsInstance
|
|
||||||
} else {
|
|
||||||
destroyHls()
|
|
||||||
media.src = src
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
destroyHls()
|
|
||||||
media.src = src
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(currentMusic, (music) => {
|
|
||||||
if (!music) return
|
|
||||||
loadSource(music)
|
|
||||||
currentLineIndex.value = 0
|
|
||||||
playStat.playedTime = 0
|
|
||||||
playStat.duration = 0
|
|
||||||
playStat.loadedTime = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.music, (music) => {
|
|
||||||
internalMusic.value = music
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => playStat.playedTime, (time) => {
|
|
||||||
const lines = lrcLines.value
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const nextLine = lines[i + 1]
|
|
||||||
if (time >= lines[i][0] && (!nextLine || time < nextLine[0])) {
|
|
||||||
currentLineIndex.value = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(isPlaying, (playing) => {
|
|
||||||
if (playing) startVisualizer()
|
|
||||||
else stopVisualizer()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.list, () => {
|
|
||||||
playlistVisible.value = props.list.length > 1
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
|
||||||
|
|
||||||
function resizeCanvas() {
|
|
||||||
const canvas = visualizerCanvas.value
|
|
||||||
if (!canvas) return
|
|
||||||
const parent = canvas.parentElement
|
|
||||||
if (!parent) return
|
|
||||||
const rect = parent.getBoundingClientRect()
|
|
||||||
if (rect.width === 0 || rect.height === 0) return
|
|
||||||
canvas.width = rect.width
|
|
||||||
canvas.height = rect.height
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initAudio()
|
|
||||||
if (props.autoplay) {
|
|
||||||
nextTick(() => play())
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
if (audioEl.value) {
|
|
||||||
initVisualizer(audioEl.value)
|
|
||||||
}
|
|
||||||
resizeCanvas()
|
|
||||||
})
|
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
resizeCanvas()
|
|
||||||
if (isPlaying.value && visualizerConnected && !animationId) {
|
|
||||||
startVisualizer()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (visualizerCanvas.value?.parentElement) {
|
|
||||||
resizeObserver.observe(visualizerCanvas.value.parentElement)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyVisualizer()
|
|
||||||
resizeObserver?.disconnect()
|
|
||||||
destroyHls()
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
audio: audioEl,
|
|
||||||
isPlaying,
|
|
||||||
currentMusic,
|
|
||||||
playStat,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
toggle,
|
|
||||||
switch: switchTrack,
|
|
||||||
shuffle,
|
|
||||||
repeatMode,
|
|
||||||
muted,
|
|
||||||
volume,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
// === 颜色变量 ===
|
|
||||||
@color-primary: #64c8ff;
|
|
||||||
@color-primary-purple: #a78bfa;
|
|
||||||
@color-white: #fff;
|
|
||||||
@color-text: #e0e0e0;
|
|
||||||
@color-text-secondary: #aaa;
|
|
||||||
@color-text-muted: #888;
|
|
||||||
@color-text-dim: #555;
|
|
||||||
@color-vinyl-dark: #111;
|
|
||||||
@color-vinyl-groove: #222;
|
|
||||||
@color-vinyl-groove-alt: #1a1a1a;
|
|
||||||
@color-vinyl-border: #333;
|
|
||||||
|
|
||||||
// === 尺寸变量 ===
|
|
||||||
@disc-section-width: 420px;
|
|
||||||
@disc-size: 360px;
|
|
||||||
@disc-cover-ratio: 52%;
|
|
||||||
@disc-cover-border: 6px;
|
|
||||||
@needle-width: 80px;
|
|
||||||
@needle-height: 134px;
|
|
||||||
@lyrics-line-height: 34px;
|
|
||||||
@ctrl-btn-size: 36px;
|
|
||||||
@ctrl-btn-play-size: 48px;
|
|
||||||
@playlist-max-height: 140px;
|
|
||||||
@info-max-width: 380px;
|
|
||||||
|
|
||||||
// === 混合 ===
|
|
||||||
.text-ellipsis() {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-full() {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-center() {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-center() {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-player-panel {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
color: @color-text;
|
|
||||||
padding: 24px 30px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 上半部分 ===
|
|
||||||
.player-main {
|
|
||||||
display: flex;
|
|
||||||
gap: 40px;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 唱片区域 ===
|
|
||||||
.disc-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: @disc-section-width;
|
|
||||||
.flex-center();
|
|
||||||
}
|
|
||||||
|
|
||||||
.disc-container {
|
|
||||||
position: relative;
|
|
||||||
width: @disc-size;
|
|
||||||
height: @disc-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 唱针 ===
|
|
||||||
.needle {
|
|
||||||
position: absolute;
|
|
||||||
top: -16px;
|
|
||||||
right: -10px;
|
|
||||||
width: @needle-width;
|
|
||||||
height: @needle-height;
|
|
||||||
z-index: 10;
|
|
||||||
transform-origin: 50% 12%;
|
|
||||||
transform: rotate(-30deg);
|
|
||||||
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5));
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
transform: rotate(5deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 唱片 ===
|
|
||||||
.disc-outer {
|
|
||||||
position: relative;
|
|
||||||
width: @disc-size;
|
|
||||||
height: @disc-size;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle at 50% 50%,
|
|
||||||
@color-vinyl-dark 0%, @color-vinyl-dark 28%,
|
|
||||||
@color-vinyl-groove-alt 28.5%, @color-vinyl-groove 29%, @color-vinyl-groove-alt 29.5%,
|
|
||||||
@color-vinyl-groove 32%, @color-vinyl-groove-alt 32.5%, @color-vinyl-groove 33%,
|
|
||||||
@color-vinyl-groove-alt 35%, @color-vinyl-groove 35.5%, @color-vinyl-groove-alt 36%,
|
|
||||||
@color-vinyl-groove 38%, @color-vinyl-groove-alt 38.5%, @color-vinyl-groove 39%,
|
|
||||||
@color-vinyl-groove-alt 41%, @color-vinyl-groove 41.5%,
|
|
||||||
@color-vinyl-groove-alt 60%, @color-vinyl-groove 60.5%, @color-vinyl-groove-alt 61%,
|
|
||||||
@color-vinyl-groove 63%, @color-vinyl-groove-alt 63.5%, @color-vinyl-groove 64%,
|
|
||||||
@color-vinyl-groove-alt 66%, @color-vinyl-groove 66.5%, @color-vinyl-groove-alt 67%,
|
|
||||||
@color-vinyl-groove 69%, @color-vinyl-groove-alt 69.5%, @color-vinyl-groove 70%,
|
|
||||||
@color-vinyl-groove-alt 72%, @color-vinyl-groove 72.5%, @color-vinyl-groove-alt 73%,
|
|
||||||
@color-vinyl-groove 75%, @color-vinyl-groove-alt 75.5%, @color-vinyl-groove 76%,
|
|
||||||
@color-vinyl-groove-alt 78%, @color-vinyl-groove 78.5%, @color-vinyl-groove-alt 79%,
|
|
||||||
@color-vinyl-groove 81%, @color-vinyl-groove-alt 81.5%, @color-vinyl-groove 82%,
|
|
||||||
@color-vinyl-groove-alt 84%, @color-vinyl-groove 84.5%, @color-vinyl-groove-alt 85%,
|
|
||||||
@color-vinyl-groove 87%, @color-vinyl-groove-alt 87.5%, @color-vinyl-groove 88%,
|
|
||||||
@color-vinyl-groove-alt 90%, @color-vinyl-groove 90.5%, @color-vinyl-groove-alt 91%,
|
|
||||||
@color-vinyl-groove 93%, @color-vinyl-groove-alt 93.5%, @color-vinyl-groove 94%,
|
|
||||||
@color-vinyl-groove-alt 96%, @color-vinyl-groove 96.5%, @color-vinyl-groove-alt 97%,
|
|
||||||
@color-vinyl-border 98%, @color-vinyl-groove 100%
|
|
||||||
);
|
|
||||||
box-shadow: 0 0 0 4px @color-vinyl-border, 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 30px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: disc-spin 20s linear infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
animation-play-state: running;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vinyl-grooves {
|
|
||||||
.abs-full();
|
|
||||||
border-radius: 50%;
|
|
||||||
background: repeating-radial-gradient(
|
|
||||||
circle at center,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(255, 255, 255, 0.02) 2.5px,
|
|
||||||
transparent 3px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visualizer-canvas {
|
|
||||||
.abs-full();
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disc-cover-wrapper {
|
|
||||||
.abs-center();
|
|
||||||
width: @disc-cover-ratio;
|
|
||||||
height: @disc-cover-ratio;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 2;
|
|
||||||
border: @disc-cover-border solid @color-vinyl-border;
|
|
||||||
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.disc-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
&.disc-cover-empty {
|
|
||||||
background: radial-gradient(circle, #2a2a2a 30%, @color-vinyl-dark 70%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disc-center-dot {
|
|
||||||
.abs-center();
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, #666 0%, @color-vinyl-border 60%, @color-vinyl-groove 100%);
|
|
||||||
border: 2px solid @color-dim-border;
|
|
||||||
z-index: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@color-dim-border: #555;
|
|
||||||
|
|
||||||
@keyframes disc-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 歌曲信息 ===
|
|
||||||
.info-section {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: @info-max-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-info {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
color: @color-white;
|
|
||||||
.text-ellipsis();
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: @color-text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 歌词 ===
|
|
||||||
.lyrics-area {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
|
|
||||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 82%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyrics-content {
|
|
||||||
transition: transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: @lyrics-line-height;
|
|
||||||
height: @lyrics-line-height;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(255, 255, 255, 0.35);
|
|
||||||
transition: all 0.4s ease;
|
|
||||||
.text-ellipsis();
|
|
||||||
|
|
||||||
&.current-line {
|
|
||||||
color: @color-white;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-lyrics {
|
|
||||||
.flex-center();
|
|
||||||
height: 100%;
|
|
||||||
color: @color-text-dim;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 进度条 ===
|
|
||||||
.progress-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 0 6px 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: @color-text-muted;
|
|
||||||
min-width: 42px;
|
|
||||||
text-align: center;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover .progress-thumb {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-loaded {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-played {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, @color-primary, @color-primary-purple);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-thumb {
|
|
||||||
position: absolute;
|
|
||||||
right: -5px;
|
|
||||||
top: -3px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: @color-white;
|
|
||||||
box-shadow: 0 0 6px rgba(100, 200, 255, 0.8);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 控制按钮 ===
|
|
||||||
.controls-section {
|
|
||||||
.flex-center();
|
|
||||||
gap: 16px;
|
|
||||||
padding: 6px 0 10px 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctrl-btn {
|
|
||||||
width: @ctrl-btn-size;
|
|
||||||
height: @ctrl-btn-size;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
.flex-center();
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s;
|
|
||||||
padding: 8px;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: @color-white;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: @color-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ctrl-btn-play {
|
|
||||||
width: @ctrl-btn-play-size;
|
|
||||||
height: @ctrl-btn-play-size;
|
|
||||||
background: rgba(100, 200, 255, 0.15);
|
|
||||||
color: @color-white;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid rgba(100, 200, 255, 0.3);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(100, 200, 255, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider {
|
|
||||||
width: 70px;
|
|
||||||
height: 3px;
|
|
||||||
accent-color: @color-primary;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 播放列表 ===
|
|
||||||
.playlist-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 6px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
color: @color-text-muted;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-toggle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: @color-text-dim;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-body {
|
|
||||||
max-height: @playlist-max-height;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: rgba(100, 200, 255, 0.12);
|
|
||||||
color: @color-primary;
|
|
||||||
|
|
||||||
.playlist-index {
|
|
||||||
color: @color-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-index {
|
|
||||||
width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: @color-text-dim;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-title {
|
|
||||||
flex: 1;
|
|
||||||
.text-ellipsis();
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-artist {
|
|
||||||
color: @color-text-dim;
|
|
||||||
margin-left: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 过渡动画 ===
|
|
||||||
.slide-enter-active, .slide-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
max-height: @playlist-max-height;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.slide-enter-from, .slide-leave-to {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* 音频可视化 - 粒子系统与圆形频谱绘制
|
|
||||||
*
|
|
||||||
* 负责唱片周围的圆形音频频谱条绘制, 以及随节奏从唱片边缘飘散的粒子效果。
|
|
||||||
* 频谱条根据音频频率数据径向排列在唱片外圈, 粒子根据低频/高频能量动态生成。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 单个粒子的状态描述 */
|
|
||||||
export interface Particle {
|
|
||||||
x: number // 当前 x 坐标
|
|
||||||
y: number // 当前 y 坐标
|
|
||||||
vx: number // x 方向速度
|
|
||||||
vy: number // y 方向速度
|
|
||||||
life: number // 已存活帧数
|
|
||||||
maxLife: number // 最大存活帧数
|
|
||||||
size: number // 粒子半径
|
|
||||||
r: number // 颜色 R 分量
|
|
||||||
g: number // 颜色 G 分量
|
|
||||||
b: number // 颜色 B 分量
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 粒子数量上限, 防止性能问题 */
|
|
||||||
const MAX_PARTICLES = 200
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在唱片边缘生成新粒子
|
|
||||||
*
|
|
||||||
* 粒子从唱片圆周的随机角度向外发射, 数量由低频能量决定, 速度由高频能量加成。
|
|
||||||
* 颜色在蓝紫色调范围内随机偏移。
|
|
||||||
*
|
|
||||||
* @param particles - 粒子数组(会被直接修改)
|
|
||||||
* @param centerX - 唱片圆心 x 坐标
|
|
||||||
* @param centerY - 唱片圆心 y 坐标
|
|
||||||
* @param discRadius - 唱片半径(像素)
|
|
||||||
* @param energy - 低频能量(0~1), 决定生成数量和粒子大小
|
|
||||||
* @param highEnergy - 高频能量(0~1), 决定发射速度和颜色偏移
|
|
||||||
*/
|
|
||||||
export function spawnParticles(particles: Particle[], centerX: number, centerY: number, discRadius: number, energy: number, highEnergy: number) {
|
|
||||||
const count = Math.floor(energy * 4)
|
|
||||||
for (let i = 0; i < count && particles.length < MAX_PARTICLES; i++) {
|
|
||||||
const angle = Math.random() * Math.PI * 2
|
|
||||||
const speed = 0.5 + energy * 2 + highEnergy * 1.5
|
|
||||||
const hue = Math.random()
|
|
||||||
particles.push({
|
|
||||||
x: centerX + Math.cos(angle) * (discRadius + 4),
|
|
||||||
y: centerY + Math.sin(angle) * (discRadius + 4),
|
|
||||||
vx: Math.cos(angle) * speed * (0.6 + Math.random() * 0.8),
|
|
||||||
vy: Math.sin(angle) * speed * (0.6 + Math.random() * 0.8),
|
|
||||||
life: 1,
|
|
||||||
maxLife: 40 + Math.random() * 40,
|
|
||||||
size: 1.5 + energy * 2 + Math.random() * 1.5,
|
|
||||||
r: Math.floor(100 + hue * 155),
|
|
||||||
g: Math.floor(180 - hue * 100 + highEnergy * 60),
|
|
||||||
b: Math.floor(220 + hue * 35),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新粒子物理状态并绘制到 Canvas
|
|
||||||
*
|
|
||||||
* 每帧对所有粒子: 移动位置、衰减速度、递增生命周期。
|
|
||||||
* 生命结束的粒子被移除, 存活的粒子按透明度渐变绘制, 较大粒子额外绘制发光光圈。
|
|
||||||
*
|
|
||||||
* @param ctx - Canvas 2D 渲染上下文
|
|
||||||
* @param particles - 粒子数组(会被直接修改, 移除死亡粒子)
|
|
||||||
*/
|
|
||||||
export function updateAndDrawParticles(ctx: CanvasRenderingContext2D, particles: Particle[]) {
|
|
||||||
for (let i = particles.length - 1; i >= 0; i--) {
|
|
||||||
const p = particles[i]
|
|
||||||
p.x += p.vx
|
|
||||||
p.y += p.vy
|
|
||||||
p.vx *= 0.98
|
|
||||||
p.vy *= 0.98
|
|
||||||
p.life += 1
|
|
||||||
|
|
||||||
const progress = p.life / p.maxLife
|
|
||||||
if (progress >= 1) {
|
|
||||||
particles.splice(i, 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const alpha = 1 - progress
|
|
||||||
const size = p.size * (1 - progress * 0.5)
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(p.x, p.y, size, 0, Math.PI * 2)
|
|
||||||
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.7})`
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// 发光效果: 在粒子外围画一个更大的半透明圆
|
|
||||||
if (size > 1.5) {
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(p.x, p.y, size * 2, 0, Math.PI * 2)
|
|
||||||
ctx.fillStyle = `rgba(${p.r}, ${p.g}, ${p.b}, ${alpha * 0.15})`
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** drawCircularSpectrum 的入参 */
|
|
||||||
export interface SpectrumParams {
|
|
||||||
ctx: CanvasRenderingContext2D
|
|
||||||
width: number // Canvas 宽度
|
|
||||||
height: number // Canvas 高度
|
|
||||||
dataArray: Uint8Array<ArrayBuffer> // Web Audio API 频率数据
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 频率能量分析结果 */
|
|
||||||
export interface EnergyResult {
|
|
||||||
lowEnergy: number // 低频段能量(0~1), 对应鼓点/贝斯
|
|
||||||
highEnergy: number // 高频段能量(0~1), 对应人声/镲片
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从频率数据中计算低频和高频的平均能量
|
|
||||||
*
|
|
||||||
* 将频率数据分为低频段(前25%)和高频段(后40%), 分别求归一化均值。
|
|
||||||
* 用于驱动粒子系统的生成参数。
|
|
||||||
*
|
|
||||||
* @param dataArray - FFT 频率数据(0~255)
|
|
||||||
* @returns 低频和高频的归一化能量值
|
|
||||||
*/
|
|
||||||
function computeFrequencyEnergy(dataArray: Uint8Array<ArrayBuffer>): EnergyResult {
|
|
||||||
const barCount = dataArray.length
|
|
||||||
let lowSum = 0, highSum = 0
|
|
||||||
const lowEnd = Math.floor(barCount * 0.25)
|
|
||||||
const highStart = Math.floor(barCount * 0.6)
|
|
||||||
for (let i = 0; i < lowEnd; i++) lowSum += dataArray[i]
|
|
||||||
for (let i = highStart; i < barCount; i++) highSum += dataArray[i]
|
|
||||||
return {
|
|
||||||
lowEnergy: lowSum / (lowEnd * 255),
|
|
||||||
highEnergy: highSum / ((barCount - highStart) * 255),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绘制圆形音频频谱
|
|
||||||
*
|
|
||||||
* 以 Canvas 中心为圆心, 将频率数据的每个频段绘制为一条从唱片边缘向外辐射的线段。
|
|
||||||
* 线段长度与频率振幅成正比, 颜色沿频率轴从蓝色渐变到紫粉色。
|
|
||||||
* 绘制完成后自动计算并返回频率能量, 供粒子系统使用。
|
|
||||||
*
|
|
||||||
* @param params - 包含 Canvas 上下文、尺寸和频率数据
|
|
||||||
* @returns 低频/高频能量分析结果
|
|
||||||
*/
|
|
||||||
export function drawCircularSpectrum(params: SpectrumParams): EnergyResult {
|
|
||||||
const { ctx, width, height, dataArray } = params
|
|
||||||
const centerX = width / 2
|
|
||||||
const centerY = height / 2
|
|
||||||
const discRadius = Math.min(width, height) * 0.30
|
|
||||||
const barCount = dataArray.length
|
|
||||||
const maxBarLength = Math.min(width, height) * 0.18
|
|
||||||
|
|
||||||
for (let i = 0; i < barCount; i++) {
|
|
||||||
const value = dataArray[i]
|
|
||||||
const barLength = (value / 255) * maxBarLength
|
|
||||||
if (barLength < 1) continue
|
|
||||||
const angle = (i / barCount) * Math.PI * 2 - Math.PI / 2
|
|
||||||
|
|
||||||
const startX = centerX + Math.cos(angle) * (discRadius + 2)
|
|
||||||
const startY = centerY + Math.sin(angle) * (discRadius + 2)
|
|
||||||
const endX = centerX + Math.cos(angle) * (discRadius + 2 + barLength)
|
|
||||||
const endY = centerY + Math.sin(angle) * (discRadius + 2 + barLength)
|
|
||||||
|
|
||||||
const ratio = i / barCount
|
|
||||||
const r = Math.floor(100 + ratio * 155)
|
|
||||||
const g = Math.floor(180 - ratio * 100)
|
|
||||||
const b = Math.floor(230 + ratio * 25)
|
|
||||||
const alpha = 0.4 + (value / 255) * 0.6
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(startX, startY)
|
|
||||||
ctx.lineTo(endX, endY)
|
|
||||||
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
||||||
ctx.lineWidth = Math.max(1.5, (Math.PI * 2 * discRadius) / barCount * 0.7)
|
|
||||||
ctx.lineCap = 'round'
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
return computeFrequencyEnergy(dataArray)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将秒数格式化为 MM:SS 格式
|
|
||||||
*
|
|
||||||
* @param sec - 秒数
|
|
||||||
* @returns 格式化后的时间字符串, 如 "03:25"
|
|
||||||
*/
|
|
||||||
export function formatTime(sec: number) {
|
|
||||||
if (isNaN(sec)) return '00:00'
|
|
||||||
const m = Math.floor(sec / 60)
|
|
||||||
const s = Math.floor(sec % 60)
|
|
||||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
834
src/views/api/aplayer/vue-aplayer.vue
Normal file
@ -0,0 +1,834 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="aplayer"
|
||||||
|
:class="{
|
||||||
|
'aplayer-narrow': mini,
|
||||||
|
'aplayer-withlist' : !mini && list.length > 0,
|
||||||
|
'aplayer-withlrc': !mini && (!!$slots.display || showLrc),
|
||||||
|
'aplayer-float': isFloatMode,
|
||||||
|
'aplayer-loading': isPlaying && isLoading
|
||||||
|
}"
|
||||||
|
:style="floatStyleObj"
|
||||||
|
>
|
||||||
|
<div class="aplayer-body">
|
||||||
|
<thumbnail
|
||||||
|
:pic="currentMusic.pic"
|
||||||
|
:playing="thumbnailPlaying"
|
||||||
|
:enable-drag="isFloatMode"
|
||||||
|
:theme="currentMusic.theme || theme"
|
||||||
|
@toggleplay="toggle"
|
||||||
|
@dragbegin="onDragBegin"
|
||||||
|
@dragging="onDragAround"
|
||||||
|
@adaptingTheme="setSelfAdaptingTheme"
|
||||||
|
/>
|
||||||
|
<div class="aplayer-info" v-show="!mini">
|
||||||
|
<div class="aplayer-music">
|
||||||
|
<span class="aplayer-title">{{ currentMusic.title || 'Untitled' }}</span>
|
||||||
|
<span class="aplayer-author">{{ currentMusic.artist || currentMusic.author || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<slot name="display" :current-music="currentMusic" :play-stat="playStat">
|
||||||
|
<lyrics :lrc-source="currentMusic.lrc" :play-stat="playStat" v-if="showLrc && currentMusic.lrc" />
|
||||||
|
</slot>
|
||||||
|
<controls
|
||||||
|
:shuffle="shouldShuffle"
|
||||||
|
:repeat="repeatMode"
|
||||||
|
:stat="playStat"
|
||||||
|
:volume="audioVolume"
|
||||||
|
:muted="isAudioMuted"
|
||||||
|
:theme="currentTheme"
|
||||||
|
:showList="showList"
|
||||||
|
:isMobile="isMobile"
|
||||||
|
@toggleshuffle="shouldShuffle = !shouldShuffle"
|
||||||
|
@togglelist="showList = !showList"
|
||||||
|
@togglemute="toggleMute"
|
||||||
|
@setvolume="setAudioVolume"
|
||||||
|
@dragbegin="onProgressDragBegin"
|
||||||
|
@dragend="onProgressDragEnd"
|
||||||
|
@dragging="onProgressDragging"
|
||||||
|
@nextmode="setNextMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio ref="audio"></audio>
|
||||||
|
<music-list
|
||||||
|
:show="showList && !mini"
|
||||||
|
:current-music="currentMusic"
|
||||||
|
:music-list="list"
|
||||||
|
:play-index="playIndex"
|
||||||
|
:listMaxHeight="listMaxHeight"
|
||||||
|
:theme="currentTheme"
|
||||||
|
@selectsong="onSelectSong"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
import type { MusicPlayerItem, StatType } from '@/model/api/music'
|
||||||
|
import Thumbnail from './components/aplayer-thumbnail.vue'
|
||||||
|
import MusicList from './components/aplayer-list.vue'
|
||||||
|
import Controls from './components/aplayer-controller.vue'
|
||||||
|
import Lyrics from './components/aplayer-lrc.vue'
|
||||||
|
import { warn } from './utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'APlayer',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface PlayerMusicItem extends MusicPlayerItem {
|
||||||
|
author?: string
|
||||||
|
theme?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragOffset {
|
||||||
|
offsetLeft: number
|
||||||
|
offsetTop: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerExpose {
|
||||||
|
play: () => Promise<void> | undefined
|
||||||
|
pause: () => void
|
||||||
|
toggle: () => void
|
||||||
|
switch: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPEAT = {
|
||||||
|
NONE: 'none',
|
||||||
|
MUSIC: 'music',
|
||||||
|
LIST: 'list',
|
||||||
|
NO_REPEAT: 'no_repeat',
|
||||||
|
REPEAT_ONE: 'repeat_one',
|
||||||
|
REPEAT_ALL: 'repeat_all',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type RepeatValue = typeof REPEAT[keyof typeof REPEAT]
|
||||||
|
type NormalizedRepeatValue = typeof REPEAT.NO_REPEAT | typeof REPEAT.REPEAT_ONE | typeof REPEAT.REPEAT_ALL
|
||||||
|
|
||||||
|
const MEDIA_EVENTS = [
|
||||||
|
'abort',
|
||||||
|
'canplay', 'canplaythrough',
|
||||||
|
'durationchange',
|
||||||
|
'emptied', 'encrypted', 'ended', 'error',
|
||||||
|
'interruptbegin', 'interruptend',
|
||||||
|
'loadeddata', 'loadedmetadata', 'loadstart',
|
||||||
|
'mozaudioavailable',
|
||||||
|
'pause', 'play', 'playing', 'progress',
|
||||||
|
'ratechange',
|
||||||
|
'seeked', 'seeking', 'stalled', 'suspend',
|
||||||
|
'timeupdate',
|
||||||
|
'volumechange',
|
||||||
|
'waiting',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type MediaEventName = typeof MEDIA_EVENTS[number]
|
||||||
|
|
||||||
|
let activeMutex: PlayerExpose | null = null
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
music: {
|
||||||
|
type: Object as PropType<PlayerMusicItem>,
|
||||||
|
required: true,
|
||||||
|
validator: (song: PlayerMusicItem) => Boolean(song?.src),
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
type: Array as PropType<PlayerMusicItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
mini: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showLrc: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
mutex: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: '#41b883',
|
||||||
|
},
|
||||||
|
listMaxHeight: String,
|
||||||
|
listFolded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
float: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autoplay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
type: String as PropType<HTMLMediaElement['preload']>,
|
||||||
|
default: 'none',
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.5,
|
||||||
|
validator: (value: number) => value >= 0 && value <= 1,
|
||||||
|
},
|
||||||
|
shuffle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
repeat: {
|
||||||
|
type: String as PropType<RepeatValue>,
|
||||||
|
default: 'no_repeat',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:music',
|
||||||
|
'update:muted',
|
||||||
|
'update:volume',
|
||||||
|
'update:shuffle',
|
||||||
|
'update:repeat',
|
||||||
|
'abort',
|
||||||
|
'canplay',
|
||||||
|
'canplaythrough',
|
||||||
|
'durationchange',
|
||||||
|
'emptied',
|
||||||
|
'encrypted',
|
||||||
|
'ended',
|
||||||
|
'error',
|
||||||
|
'interruptbegin',
|
||||||
|
'interruptend',
|
||||||
|
'loadeddata',
|
||||||
|
'loadedmetadata',
|
||||||
|
'loadstart',
|
||||||
|
'mozaudioavailable',
|
||||||
|
'pause',
|
||||||
|
'play',
|
||||||
|
'playing',
|
||||||
|
'progress',
|
||||||
|
'ratechange',
|
||||||
|
'seeked',
|
||||||
|
'seeking',
|
||||||
|
'stalled',
|
||||||
|
'suspend',
|
||||||
|
'timeupdate',
|
||||||
|
'volumechange',
|
||||||
|
'waiting',
|
||||||
|
] as const)
|
||||||
|
|
||||||
|
const audio = ref<HTMLAudioElement | null>(null)
|
||||||
|
const internalMusic = ref<PlayerMusicItem>(props.music)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const isSeeking = ref(false)
|
||||||
|
const wasPlayingBeforeSeeking = ref(false)
|
||||||
|
const isMobile = /mobile/i.test(window.navigator.userAgent)
|
||||||
|
const playStat = reactive<StatType>({
|
||||||
|
duration: 0,
|
||||||
|
loadedTime: 0,
|
||||||
|
playedTime: 0,
|
||||||
|
})
|
||||||
|
const showList = ref(!props.listFolded)
|
||||||
|
const audioPlayPromise = ref<Promise<void>>(Promise.resolve())
|
||||||
|
const rejectPlayPromise = ref<((reason?: unknown) => void) | null>(null)
|
||||||
|
const floatOriginX = ref(0)
|
||||||
|
const floatOriginY = ref(0)
|
||||||
|
const floatOffsetLeft = ref(0)
|
||||||
|
const floatOffsetTop = ref(0)
|
||||||
|
const selfAdaptingTheme = ref<string | null>(null)
|
||||||
|
const internalMuted = ref(props.muted)
|
||||||
|
const internalVolume = ref(props.volume)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const internalShuffle = ref(props.shuffle)
|
||||||
|
const internalRepeat = ref<RepeatValue>(props.repeat)
|
||||||
|
const shuffledList = ref<PlayerMusicItem[]>([])
|
||||||
|
const hls = ref<Hls | null>(null)
|
||||||
|
|
||||||
|
const currentMusic = computed({
|
||||||
|
get: () => internalMusic.value,
|
||||||
|
set: (value: PlayerMusicItem) => {
|
||||||
|
emit('update:music', value)
|
||||||
|
internalMusic.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTheme = computed(() => selfAdaptingTheme.value || currentMusic.value.theme || props.theme)
|
||||||
|
const isFloatMode = computed(() => props.float && !isMobile)
|
||||||
|
const thumbnailPlaying = computed(() => isPlaying.value || (isSeeking.value && wasPlayingBeforeSeeking.value))
|
||||||
|
const floatStyleObj = computed(() => {
|
||||||
|
return {
|
||||||
|
transform: `translate(${floatOffsetLeft.value}px, ${floatOffsetTop.value}px)`,
|
||||||
|
webkitTransform: `translate(${floatOffsetLeft.value}px, ${floatOffsetTop.value}px)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const playIndex = computed({
|
||||||
|
get: () => shuffledList.value.indexOf(currentMusic.value),
|
||||||
|
set: (value: number) => {
|
||||||
|
if (!shuffledList.value.length) return
|
||||||
|
const normalizedIndex = value % shuffledList.value.length
|
||||||
|
currentMusic.value = shuffledList.value[normalizedIndex < 0 ? normalizedIndex + shuffledList.value.length : normalizedIndex]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAudioMuted = computed({
|
||||||
|
get: () => internalMuted.value,
|
||||||
|
set: (value: boolean) => {
|
||||||
|
emit('update:muted', value)
|
||||||
|
internalMuted.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioVolume = computed({
|
||||||
|
get: () => internalVolume.value,
|
||||||
|
set: (value: number) => {
|
||||||
|
emit('update:volume', value)
|
||||||
|
internalVolume.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldShuffle = computed({
|
||||||
|
get: () => internalShuffle.value,
|
||||||
|
set: (value: boolean) => {
|
||||||
|
emit('update:shuffle', value)
|
||||||
|
internalShuffle.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const repeatMode = computed<NormalizedRepeatValue>({
|
||||||
|
get: () => {
|
||||||
|
switch (internalRepeat.value) {
|
||||||
|
case REPEAT.NONE:
|
||||||
|
case REPEAT.NO_REPEAT:
|
||||||
|
return REPEAT.NO_REPEAT
|
||||||
|
case REPEAT.MUSIC:
|
||||||
|
case REPEAT.REPEAT_ONE:
|
||||||
|
return REPEAT.REPEAT_ONE
|
||||||
|
default:
|
||||||
|
return REPEAT.REPEAT_ALL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:repeat', value)
|
||||||
|
internalRepeat.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDragBegin() {
|
||||||
|
floatOriginX.value = floatOffsetLeft.value
|
||||||
|
floatOriginY.value = floatOffsetTop.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragAround({ offsetLeft, offsetTop }: DragOffset) {
|
||||||
|
floatOffsetLeft.value = floatOriginX.value + offsetLeft
|
||||||
|
floatOffsetTop.value = floatOriginY.value + offsetTop
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNextMode() {
|
||||||
|
if (repeatMode.value === REPEAT.REPEAT_ALL) {
|
||||||
|
repeatMode.value = REPEAT.REPEAT_ONE
|
||||||
|
} else if (repeatMode.value === REPEAT.REPEAT_ONE) {
|
||||||
|
repeatMode.value = REPEAT.NO_REPEAT
|
||||||
|
} else {
|
||||||
|
repeatMode.value = REPEAT.REPEAT_ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function thenPlay() {
|
||||||
|
nextTick(() => {
|
||||||
|
void play()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
if (!media.paused) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
void play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
if (props.mutex) {
|
||||||
|
if (activeMutex && activeMutex !== playerExpose) {
|
||||||
|
activeMutex.pause()
|
||||||
|
}
|
||||||
|
activeMutex = playerExpose
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPlayPromise = media.play()
|
||||||
|
if (mediaPlayPromise) {
|
||||||
|
audioPlayPromise.value = new Promise<void>((resolve, reject) => {
|
||||||
|
rejectPlayPromise.value = reject
|
||||||
|
mediaPlayPromise.then(() => {
|
||||||
|
rejectPlayPromise.value = null
|
||||||
|
resolve()
|
||||||
|
}).catch((error) => {
|
||||||
|
warn(String(error))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return audioPlayPromise.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
audioPlayPromise.value
|
||||||
|
.then(() => {
|
||||||
|
media.pause()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
media.pause()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rejectPlayPromise.value) {
|
||||||
|
rejectPlayPromise.value()
|
||||||
|
rejectPlayPromise.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTrack(index: number) {
|
||||||
|
playIndex.value = index
|
||||||
|
thenPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProgressDragBegin(value: number) {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
wasPlayingBeforeSeeking.value = isPlaying.value
|
||||||
|
isSeeking.value = true
|
||||||
|
pause()
|
||||||
|
|
||||||
|
if (!isNaN(media.duration)) {
|
||||||
|
media.currentTime = media.duration * value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProgressDragging(value: number) {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
if (isNaN(media.duration)) {
|
||||||
|
playStat.playedTime = 0
|
||||||
|
} else {
|
||||||
|
media.currentTime = media.duration * value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProgressDragEnd() {
|
||||||
|
if (wasPlayingBeforeSeeking.value) {
|
||||||
|
thenPlay()
|
||||||
|
} else {
|
||||||
|
isSeeking.value = false
|
||||||
|
wasPlayingBeforeSeeking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
setAudioMuted(!media.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAudioMuted(value: boolean) {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
media.muted = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAudioVolume(value: number) {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
media.volume = value
|
||||||
|
if (value > 0) {
|
||||||
|
setAudioMuted(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShuffledList() {
|
||||||
|
if (!props.list.length) {
|
||||||
|
return [internalMusic.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
const unshuffled = [...props.list]
|
||||||
|
if (!internalShuffle.value || unshuffled.length <= 1) {
|
||||||
|
return unshuffled
|
||||||
|
}
|
||||||
|
|
||||||
|
let indexOfCurrentMusic = unshuffled.indexOf(internalMusic.value)
|
||||||
|
if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
|
||||||
|
if (indexOfCurrentMusic === 0) {
|
||||||
|
return unshuffled
|
||||||
|
}
|
||||||
|
return [internalMusic.value, unshuffled[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = unshuffled.length - 1; index > 0; index--) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * (index + 1))
|
||||||
|
const tmp = unshuffled[index]
|
||||||
|
unshuffled[index] = unshuffled[randomIndex]
|
||||||
|
unshuffled[randomIndex] = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexOfCurrentMusic !== -1) {
|
||||||
|
indexOfCurrentMusic = unshuffled.indexOf(internalMusic.value)
|
||||||
|
if (indexOfCurrentMusic !== 0) {
|
||||||
|
[unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unshuffled
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectSong(song: PlayerMusicItem) {
|
||||||
|
if (currentMusic.value === song) {
|
||||||
|
toggle()
|
||||||
|
} else {
|
||||||
|
currentMusic.value = song
|
||||||
|
thenPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioPlay() {
|
||||||
|
isPlaying.value = true
|
||||||
|
isSeeking.value = false
|
||||||
|
wasPlayingBeforeSeeking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioPause() {
|
||||||
|
isPlaying.value = false
|
||||||
|
if (!isSeeking.value) {
|
||||||
|
wasPlayingBeforeSeeking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioWaiting() {
|
||||||
|
isLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioCanplay() {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioDurationChange() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
if (media.duration !== 1) {
|
||||||
|
playStat.duration = media.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioProgress() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
if (media.buffered.length) {
|
||||||
|
playStat.loadedTime = media.buffered.end(media.buffered.length - 1)
|
||||||
|
} else {
|
||||||
|
playStat.loadedTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioTimeUpdate() {
|
||||||
|
if (!audio.value) return
|
||||||
|
playStat.playedTime = audio.value.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioSeeking() {
|
||||||
|
if (!audio.value) return
|
||||||
|
playStat.playedTime = audio.value.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioSeeked() {
|
||||||
|
if (!audio.value) return
|
||||||
|
playStat.playedTime = audio.value.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioVolumeChange() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
audioVolume.value = media.volume
|
||||||
|
isAudioMuted.value = media.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioEnded() {
|
||||||
|
const media = audio.value
|
||||||
|
|
||||||
|
if (repeatMode.value === REPEAT.REPEAT_ALL) {
|
||||||
|
if (shouldShuffle.value && playIndex.value === shuffledList.value.length - 1) {
|
||||||
|
shuffledList.value = getShuffledList()
|
||||||
|
}
|
||||||
|
playIndex.value += 1
|
||||||
|
thenPlay()
|
||||||
|
} else if (repeatMode.value === REPEAT.REPEAT_ONE) {
|
||||||
|
thenPlay()
|
||||||
|
} else {
|
||||||
|
playIndex.value += 1
|
||||||
|
if (playIndex.value !== 0) {
|
||||||
|
thenPlay()
|
||||||
|
} else if (shuffledList.value.length === 1 && media) {
|
||||||
|
media.currentTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAudio() {
|
||||||
|
const media = audio.value
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
media.muted = props.muted
|
||||||
|
media.preload = props.preload
|
||||||
|
media.volume = props.volume
|
||||||
|
|
||||||
|
MEDIA_EVENTS.forEach((eventName) => {
|
||||||
|
media.addEventListener(eventName, (event) => emitMediaEvent(eventName, event))
|
||||||
|
})
|
||||||
|
|
||||||
|
media.addEventListener('play', onAudioPlay)
|
||||||
|
media.addEventListener('pause', onAudioPause)
|
||||||
|
media.addEventListener('abort', onAudioPause)
|
||||||
|
media.addEventListener('waiting', onAudioWaiting)
|
||||||
|
media.addEventListener('canplay', onAudioCanplay)
|
||||||
|
media.addEventListener('progress', onAudioProgress)
|
||||||
|
media.addEventListener('durationchange', onAudioDurationChange)
|
||||||
|
media.addEventListener('seeking', onAudioSeeking)
|
||||||
|
media.addEventListener('seeked', onAudioSeeked)
|
||||||
|
media.addEventListener('timeupdate', onAudioTimeUpdate)
|
||||||
|
media.addEventListener('volumechange', onAudioVolumeChange)
|
||||||
|
media.addEventListener('ended', onAudioEnded)
|
||||||
|
|
||||||
|
media.src = currentMusic.value.src || currentMusic.value.url || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelfAdaptingTheme(color: string | null) {
|
||||||
|
selfAdaptingTheme.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitMediaEvent<T extends MediaEventName>(eventName: T, event: Event) {
|
||||||
|
emit(eventName, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.music, (music) => {
|
||||||
|
internalMusic.value = music
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(currentMusic, (music) => {
|
||||||
|
const media = audio.value
|
||||||
|
const src = music.src || music.url
|
||||||
|
if (!media || !src) return
|
||||||
|
|
||||||
|
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
|
||||||
|
if (media.canPlayType('application/x-mpegURL') || media.canPlayType('application/vnd.apple.mpegURL')) {
|
||||||
|
media.src = src
|
||||||
|
} else if (Hls.isSupported()) {
|
||||||
|
if (!hls.value) {
|
||||||
|
hls.value = new Hls()
|
||||||
|
}
|
||||||
|
hls.value.loadSource(src)
|
||||||
|
hls.value.attachMedia(media)
|
||||||
|
} else {
|
||||||
|
warn('HLS is not supported on your browser')
|
||||||
|
media.src = src
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
media.src = src
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isAudioMuted, (value) => {
|
||||||
|
if (!audio.value) return
|
||||||
|
audio.value.muted = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.preload, (value) => {
|
||||||
|
if (!audio.value) return
|
||||||
|
audio.value.preload = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(audioVolume, (value) => {
|
||||||
|
if (!audio.value) return
|
||||||
|
audio.value.volume = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.muted, (value) => {
|
||||||
|
internalMuted.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.volume, (value) => {
|
||||||
|
internalVolume.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.shuffle, (value) => {
|
||||||
|
internalShuffle.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.repeat, (value) => {
|
||||||
|
internalRepeat.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
shuffledList.value = getShuffledList()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initAudio()
|
||||||
|
if (props.autoplay) {
|
||||||
|
void play()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const playerExpose: PlayerExpose = {
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggle,
|
||||||
|
switch: switchTrack,
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
audio,
|
||||||
|
currentMusic,
|
||||||
|
isPlaying,
|
||||||
|
playStat,
|
||||||
|
...playerExpose,
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (activeMutex === playerExpose) {
|
||||||
|
activeMutex = null
|
||||||
|
}
|
||||||
|
hls.value?.destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
@import "./less/variables";
|
||||||
|
|
||||||
|
.aplayer {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
color: #000;
|
||||||
|
background-color: #fff;
|
||||||
|
margin: 5px;
|
||||||
|
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.07), 0 1px 5px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
line-height: initial;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-lrc-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-body {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
.aplayer-info {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
text-align: start;
|
||||||
|
padding: 14px 7px 0 10px;
|
||||||
|
height: @aplayer-height;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.aplayer-music {
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-left: 5px;
|
||||||
|
user-select: text;
|
||||||
|
cursor: default;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
|
||||||
|
.aplayer-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-lrc {
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini mode
|
||||||
|
&.aplayer-narrow {
|
||||||
|
width: @aplayer-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.aplayer-withlrc {
|
||||||
|
.aplayer-body {
|
||||||
|
.aplayer-pic {
|
||||||
|
height: @aplayer-height-lrc;
|
||||||
|
width: @aplayer-height-lrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-info {
|
||||||
|
height: @aplayer-height-lrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-info {
|
||||||
|
padding: 10px 7px 0 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.aplayer-withlist {
|
||||||
|
.aplayer-body {
|
||||||
|
.aplayer-info {
|
||||||
|
border-bottom: 1px solid #e9e9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aplayer-controller .aplayer-time .aplayer-icon.aplayer-icon-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* floating player on top */
|
||||||
|
position: relative;
|
||||||
|
&.aplayer-float {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aplayer-roll {
|
||||||
|
0% {
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: -100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||