blog-admin-web/src/views/api/aplayer/vue-aplayer.vue
灌糖包子 bcc09d7064
打包工具更换为vite
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:34:30 +08:00

854 lines
21 KiB
Vue

<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="isPlaying"
: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>
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 Hls from 'hls.js'
import { warn } from './utils'
// mutex playing instance
let activeMutex = null
const REPEAT = {
NONE: 'none',
MUSIC: 'music',
LIST: 'list',
NO_REPEAT: 'no_repeat',
REPEAT_ONE: 'repeat_one',
REPEAT_ALL: 'repeat_all',
};
const VueAPlayer = {
name: 'APlayer',
disableVersionBadge: false,
components: {
Thumbnail,
Controls,
MusicList,
Lyrics,
},
props: {
music: {
type: Object,
required: true,
validator (song) {
return song.src
},
},
list: {
type: Array,
default () {
return []
},
},
mini: {
type: Boolean,
default: false,
},
showLrc: {
type: Boolean,
default: false,
},
mutex: {
type: Boolean,
default: true,
},
theme: {
type: String,
default: '#41b883',
},
listMaxHeight: String,
/**
* @since 1.4.1
* Fold playlist initially
*/
listFolded: {
type: Boolean,
default: false,
},
/**
* @since 1.2.0 Float mode
*/
float: {
type: Boolean,
default: false,
},
// Audio attributes as props
// since 1.4.0
// autoplay muted preload volume
// autoplay is not observable
/**
* @since 1.4.0
* not observable
*/
autoplay: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable, sync
*/
muted: {
type: Boolean,
default: false,
},
/**
* @since 1.4.0
* observable
*/
preload: {
type: String,
default: 'none'
},
/**
* @since 1.4.0
* observable, sync
*/
volume: {
type: Number,
default: 0.5,
validator (value) {
return value >= 0 && value <= 1
},
},
// play order control
// since 1.5.0
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
shuffle: {
type: Boolean,
default: false,
},
/**
* @since 1.5.0
* @see https://support.apple.com/en-us/HT207230
* twoWay
*/
repeat: {
type: String,
default: REPEAT.NO_REPEAT,
}
},
data () {
return {
internalMusic: this.music,
isPlaying: false,
isSeeking: false,
wasPlayingBeforeSeeking: false,
isMobile: /mobile/i.test(window.navigator.userAgent),
playStat: {
duration: 0,
loadedTime: 0,
playedTime: 0,
},
showList: !this.listFolded,
// handle Promise returned from audio.play()
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
audioPlayPromise: Promise.resolve(),
rejectPlayPromise: Promise.reject(),
// @since 1.2.0 float mode
floatOriginX: 0,
floatOriginY: 0,
floatOffsetLeft: 0,
floatOffsetTop: 0,
// @since 1.3.0 self adapting theme
selfAdaptingTheme: null,
// @since 1.4.0
// sync muted, volume
internalMuted: this.muted,
internalVolume: this.volume,
// @since 1.4.1
// Loading indicator
isLoading: false,
// @since 1.5.1
// sync shuffle, repeat
internalShuffle: this.shuffle,
internalRepeat: this.repeat,
// for shuffling
shuffledList: [],
hls: null
}
},
computed: {
// alias for $refs.audio
audio () {
return this.$refs.audio
},
// sync music
currentMusic: {
get () {
return this.internalMusic
},
set (val) {
this.$emit('update:music', val)
this.internalMusic = val
},
},
// props wrappers
currentTheme () {
return this.selfAdaptingTheme || this.currentMusic.theme || this.theme
},
isFloatMode () {
return this.float && !this.isMobile
},
shouldAutoplay () {
if (this.isMobile) return false
return this.autoplay
},
// useful
floatStyleObj () {
// transform: translate(floatOffsetLeft, floatOffsetY)
return {
transform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
webkitTransform: `translate(${this.floatOffsetLeft}px, ${this.floatOffsetTop}px)`,
}
},
currentPicStyleObj () {
if (this.currentMusic && this.currentMusic.pic) {
return {
backgroundImage: `url(${this.currentMusic.pic})`,
}
}
return {}
},
loadProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.loadedTime / this.playStat.duration
},
playProgress () {
if (this.playStat.duration === 0) return 0
return this.playStat.playedTime / this.playStat.duration
},
playIndex: {
get () {
return this.shuffledList.indexOf(this.currentMusic)
},
set (val) {
this.currentMusic = this.shuffledList[val % this.shuffledList.length]
},
},
// since 1.4.0
// sync muted, volume
isAudioMuted: {
get () {
return this.internalMuted
},
set (val) {
this.$emit('update:muted', val)
this.internalMuted = val
},
},
audioVolume: {
get () {
return this.internalVolume
},
set (val) {
this.$emit('update:volume', val)
this.internalVolume = val
},
},
// since 1.5.0
// sync shuffle, repeat
shouldShuffle: {
get () {
return this.internalShuffle
},
set (val) {
this.$emit('update:shuffle', val)
this.internalShuffle = val
},
},
repeatMode: {
get () {
switch (this.internalRepeat) {
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 (val) {
this.$emit('update:repeat', val)
this.internalRepeat = val
},
},
},
methods: {
// Float mode
onDragBegin () {
this.floatOriginX = this.floatOffsetLeft
this.floatOriginY = this.floatOffsetTop
},
onDragAround ({ offsetLeft, offsetTop }) {
this.floatOffsetLeft = this.floatOriginX + offsetLeft
this.floatOffsetTop = this.floatOriginY + offsetTop
},
// functions
setNextMode () {
if (this.repeatMode === REPEAT.REPEAT_ALL) {
this.repeatMode = REPEAT.REPEAT_ONE
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.repeatMode = REPEAT.NO_REPEAT
} else {
this.repeatMode = REPEAT.REPEAT_ALL
}
},
thenPlay () {
this.$nextTick(() => {
this.play()
})
},
toggle () {
if (!this.audio.paused) {
this.pause()
} else {
this.play()
}
},
play () {
if (this.mutex) {
if (activeMutex && activeMutex !== this) {
activeMutex.pause()
}
activeMutex = this
}
// handle .play() Promise
const audioPlayPromise = this.audio.play()
if (audioPlayPromise) {
return this.audioPlayPromise = new Promise((resolve, reject) => {
// rejectPlayPromise is to force reject audioPlayPromise if it's still pending when pause() is called
this.rejectPlayPromise = reject
audioPlayPromise.then((res) => {
this.rejectPlayPromise = null
resolve(res)
}).catch(warn)
})
}
},
pause () {
this.audioPlayPromise
.then(() => {
this.audio.pause()
})
// Avoid force rejection throws Uncaught
.catch(() => {
this.audio.pause()
})
// audioPlayPromise is still pending
if (this.rejectPlayPromise) {
// force reject playPromise
this.rejectPlayPromise()
this.rejectPlayPromise = null
}
},
switch(index) {
this.playIndex = index
this.thenPlay()
},
// progress bar
onProgressDragBegin (val) {
this.wasPlayingBeforeSeeking = this.isPlaying
this.pause()
this.isSeeking = true
// handle load failures
if (!isNaN(this.audio.duration)) {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragging (val) {
if (isNaN(this.audio.duration)) {
this.playStat.playedTime = 0
} else {
this.audio.currentTime = this.audio.duration * val
}
},
onProgressDragEnd (val) {
this.isSeeking = false
if (this.wasPlayingBeforeSeeking) {
this.thenPlay()
}
},
// volume
toggleMute () {
this.setAudioMuted(!this.audio.muted)
},
setAudioMuted (val) {
this.audio.muted = val
},
setAudioVolume (val) {
this.audio.volume = val
if (val > 0) {
this.setAudioMuted(false)
}
},
// playlist
getShuffledList () {
if (!this.list.length) {
return [this.internalMusic]
}
let unshuffled = [...this.list]
if (!this.internalShuffle || unshuffled.length <= 1) {
return unshuffled
}
let indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (unshuffled.length === 2 && indexOfCurrentMusic !== -1) {
if (indexOfCurrentMusic === 0) {
return unshuffled
} else {
return [this.internalMusic, unshuffled[0]]
}
}
// shuffle list
// @see https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
for (let i = unshuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const tmp = unshuffled[i]
unshuffled[i] = unshuffled[j]
unshuffled[j] = tmp
}
// take currentMusic to first
if (indexOfCurrentMusic !== -1) {
indexOfCurrentMusic = unshuffled.indexOf(this.internalMusic)
if (indexOfCurrentMusic !== 0) {
[unshuffled[0], unshuffled[indexOfCurrentMusic]] = [unshuffled[indexOfCurrentMusic], unshuffled[0]]
}
}
return unshuffled
},
onSelectSong (song) {
if (this.currentMusic === song) {
this.toggle()
} else {
this.currentMusic = song
this.thenPlay()
}
},
// event handlers
// for keeping up with audio states
onAudioPlay () {
this.isPlaying = true
},
onAudioPause () {
this.isPlaying = false
},
onAudioWaiting () {
this.isLoading = true
},
onAudioCanplay () {
this.isLoading = false
},
onAudioDurationChange () {
if (this.audio.duration !== 1) {
this.playStat.duration = this.audio.duration
}
},
onAudioProgress () {
if (this.audio.buffered.length) {
this.playStat.loadedTime = this.audio.buffered.end(this.audio.buffered.length - 1)
} else {
this.playStat.loadedTime = 0
}
},
onAudioTimeUpdate () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeking () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioSeeked () {
this.playStat.playedTime = this.audio.currentTime
},
onAudioVolumeChange () {
this.audioVolume = this.audio.volume
this.isAudioMuted = this.audio.muted
},
onAudioEnded () {
// determine next song according to shuffle and repeat
if (this.repeatMode === REPEAT.REPEAT_ALL) {
if (this.shouldShuffle && this.playIndex === this.shuffledList.length - 1) {
this.shuffledList = this.getShuffledList()
}
this.playIndex++
this.thenPlay()
} else if (this.repeatMode === REPEAT.REPEAT_ONE) {
this.thenPlay()
} else {
this.playIndex++
if (this.playIndex !== 0) {
this.thenPlay()
} else if (this.shuffledList.length === 1) {
this.audio.currentTime = 0
}
}
},
initAudio () {
// since 1.4.0 Audio attributes as props
this.audio.muted = this.muted
this.audio.preload = this.preload
this.audio.volume = this.volume
// since 1.4.0 Emit as many native audio events
// @see https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events
const mediaEvents = [
'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',
]
mediaEvents.forEach(event => {
this.audio.addEventListener(event, e => this.$emit(event, e))
})
// event handlers
// they don't emit native media events
this.audio.addEventListener('play', this.onAudioPlay)
this.audio.addEventListener('pause', this.onAudioPause)
this.audio.addEventListener('abort', this.onAudioPause)
this.audio.addEventListener('waiting', this.onAudioWaiting)
this.audio.addEventListener('canplay', this.onAudioCanplay)
this.audio.addEventListener('progress', this.onAudioProgress)
this.audio.addEventListener('durationchange', this.onAudioDurationChange)
this.audio.addEventListener('seeking', this.onAudioSeeking)
this.audio.addEventListener('seeked', this.onAudioSeeked)
this.audio.addEventListener('timeupdate', this.onAudioTimeUpdate)
this.audio.addEventListener('volumechange', this.onAudioVolumeChange)
this.audio.addEventListener('ended', this.onAudioEnded)
if (this.currentMusic) {
this.audio.src = this.currentMusic.src || this.currentMusic.url
}
},
setSelfAdaptingTheme (color) {
this.selfAdaptingTheme = color
}
},
watch: {
music (music) {
this.internalMusic = music
},
currentMusic: {
handler (music) {
const src = music.src || music.url
// HLS support
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
if (this.audio.canPlayType('application/x-mpegURL') || this.audio.canPlayType('application/vnd.apple.mpegURL')) {
this.audio.src = src
} else {
if (Hls.isSupported()) {
if (!this.hls) {
this.hls = new Hls()
}
this.hls.loadSource(src)
this.hls.attachMedia(this.audio)
} else {
warn('HLS is not supported on your browser')
this.audio.src = src
}
}
} else {
this.audio.src = src
}
// self-adapting theme color
},
},
// since 1.4.0
// observe muted, preload, volume
isAudioMuted (val) {
this.audio.muted = val
},
preload (val) {
this.audio.preload = val
},
audioVolume (val) {
this.audio.volume = val
},
// sync muted, volume
muted (val) {
this.internalMuted = val
},
volume (val) {
this.internalVolume = val
},
// sync shuffle, repeat
shuffle (val) {
this.internalShuffle = val
},
repeat (val) {
this.internalRepeat = val
},
},
created () {
this.shuffledList = this.getShuffledList()
},
mounted () {
this.initAudio()
if (this.autoplay) this.play()
},
beforeDestroy () {
if (activeMutex === this) {
activeMutex = null
}
if (this.hls) {
this.hls.destroy()
}
},
}
export default VueAPlayer
</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>