音乐播放器重构

This commit is contained in:
灌糖包子 2026-05-16 22:51:43 +08:00
parent 25e80d73ae
commit 6d2e522818
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D
25 changed files with 1053 additions and 1726 deletions

View File

@ -168,8 +168,23 @@
</el-table-column>
</el-table>
</el-drawer>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="40%" title="播放音乐">
<a-player v-if="musicPlaying && currentMusic" ref="player" autoplay showLrc :list="musicList" v-model:music="currentMusic" @play="musicPlay"/>
<el-drawer v-model="musicPlaying" :close-on-click-modal="false" size="1200px" title="播放音乐" class="music-drawer">
<music-player-panel
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>
</div>
</template>
@ -181,7 +196,7 @@ import { useBaseList } from '@/model/baselist'
import { MsgResult, Page } from '@/model/common.dto'
import { UploadInstance, ElMessage, ElMessageBox } from 'element-plus'
import { MusicModel, MusicLibModel, MusicLyricModel, MusicPlayerItem } from '@/model/api/music'
import APlayer from './aplayer/vue-aplayer.vue'
import MusicPlayerPanel from './aplayer/music-player-panel.vue'
import prettyBytes from 'pretty-bytes'
// mm:ss
function formatDuration(seconds?: number): string {
@ -225,7 +240,7 @@ const musicList = ref<MusicPlayerItem[]>([])
const currentMusic = ref<MusicPlayerItem>()
const lyricForm = ref<VForm>()
const musicUpload = ref<UploadInstance>()
const player = ref<InstanceType<typeof APlayer>>()
const player = ref<InstanceType<typeof MusicPlayerPanel>>()
const lyricLoading = ref(false)
const libDrawerVisible = ref(false)
const libTableData = ref<(MusicLibModel & { _isNew?: boolean })[]>([])
@ -384,20 +399,39 @@ function musicPlay() {
player.value?.pause()
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (currentId === 0) {
player.value?.switch(musicList.value.length - 1)
} else {
player.value?.switch(currentId - 1)
}
switchPrev()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (currentId === musicList.value.length - 1) {
player.value?.switch(0)
} else {
player.value?.switch(currentId + 1)
}
switchNext()
})
}
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() {
return http.get<never, any>('/music/lib/list').then(data => {

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 265 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 590 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 473 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 357 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 315 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 278 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 240 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 294 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 272 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 585 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 604 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 301 B

View File

@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,153 +0,0 @@
<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>

View File

@ -1,117 +0,0 @@
<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>

View File

@ -1,130 +0,0 @@
<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>

View File

@ -1,25 +0,0 @@
<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>

View File

@ -1,33 +0,0 @@
<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>

View File

@ -1,136 +0,0 @@
<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>

View File

@ -1,108 +0,0 @@
<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>

View File

@ -1,134 +0,0 @@
<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>

View File

@ -1,3 +0,0 @@
@aplayer-height: 66px;
@lrc-height: 30px;
@aplayer-height-lrc: @aplayer-height + @lrc-height - 6;

File diff suppressed because it is too large Load Diff

View File

@ -1,834 +0,0 @@
<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>