学黄轶老师vue3音乐APP(第5章)
这一章是开发音乐APP的播放器部分,包括全屏的播放器和缩小的迷你播放器,以及进度条,歌词滚动,唱片动画,切歌,播放列表等等。
# audio的使用
我们使用原生audio元素,然后再用js去控制这个元素。那么这个元素有哪些是值得我们注意的呢:
# audio对象属性
属性 | 描述 |
---|---|
currentTime | 设置或返回音频中的当前播放位置(以秒计)。 |
duration | 返回音频的长度(以秒计)。 |
paused | 设置或返回音频是否暂停。 |
src | 设置或返回音频的 src 属性的值。 |
volume | 设置或返回音频的音量。 |
# audio对象方法
方法 | 描述 |
---|---|
play() | 开始播放音频。 |
pause() | 暂停当前播放的音频。 |
# audio对象事件
事件名称 | 触发时机 |
---|---|
canplay | 浏览器已经可以播放媒体,但是预测已加载的数据不足以在不暂停的情况下顺利将其播放到结尾(即预测会在播放时暂停以获取更多的缓冲区内容) |
ended | 播放到媒体的结束位置,播放停止。 |
pause | 播放暂停。 |
timeupdate | 由 currentTime 指定的时间更新。 |
整个播放的逻辑应该是这样的:
- 通过接口获取到歌曲的src,将audio的src属性设置好
- audio对象的canplay事件触发后,就可以播放歌曲了
- audio对象的timeupdate事件中更新自己写的进度条的进度
- 使用play() 和 pause() 来播放暂停歌曲
# 播放器组件基础功能
编写player组件,这个组件中包含全屏的大播放器,和迷你的小播放器,以及audio标签。将这个player组件应用到App.vue中,因为播放器是全局所有页面都可以使用的。
<template>
<m-header></m-header>
<tab></tab>
<router-view :style="viewStyle"></router-view>
<player></player>
</template>
2
3
4
5
6
# store设计
state
中定义了如下数据:
// state.js
// 将一些常量写在一个文件中方便维护
import { PLAY_MODE, FAVORITE_KEY } from '@/assets/js/constant'
// load方法是一个从storage中读取数组的方法
import { load } from '@/assets/js/array-store'
const state = {
sequenceList: [], // 原始歌曲顺序列表
playList: [], // 真实的播放列表
playing: false, //歌曲是否在播放
playMode: PLAY_MODE.sequence, //歌曲播放模式
currentIndex: 0, //当前播放的歌曲在播放列表中的索引
fullScreen: false, // 播放器是否全屏
favoriteList: load(FAVORITE_KEY) //收藏的歌曲列表,会存到storage中
}
export default state
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// getters.js
export const currentSong = (state) => {
//这个地方做一下保护,如果没有playList的话就返回一个空对象,以免模板报错
return state.playList[state.currentIndex] || {}
}
2
3
4
5
这里学到的操作有:
- 一些常量要学会写在同一个文件中,这样会比较好维护,比如:
export const SINGER_KEY = '__singer__'
export const FAVORITE_KEY = '__favorite__'
export const PLAY_MODE = {
sequence: 0,
loop: 1,
random: 2
}
2
3
4
5
6
7
8
# 播放歌曲
点击了歌曲之后,将歌曲的索引更新到 currentIndex
,然后currentSong
就会发生改变,然后将audio
的src
设置为currentSong
的src
,然后调用audio
对象的play()
方法,歌曲就可以播放了。
如何在setup中使用vuex呢:
import { useStore } from 'vuex'
import { computed } from 'vue'
const store = useStore()
const currentSong = computed(() => store.getters.currentSong)
2
3
4
使用computed
来获取currentSong
的数据,以此来保证currentSong
的数据是响应式的。
# 播放暂停歌曲
先给audio绑定canplay
事件,在这个事件回调中将songReady
这个标志位置为true
,用来保证歌曲播放不会因为资源没有加载报错。
player组件的播放/暂停按钮绑定togglePlay()
方法
function togglePlay() {
if (!songReady.value) {
return
}
store.commit('setPlayingState', !playing.value)
}
2
3
4
5
6
然后watch
一下playing
的状态,当playing
为true
时播放歌曲,当playing
为false
时暂停歌曲:
watch(playing, (newPlaying) => {
if (!songReady.value) {
return
}
const audioEl = audioRef.value
if (newPlaying) {
audioEl.play()
} else {
audioEl.pause()
}
})
2
3
4
5
6
7
8
9
10
11
# 前进后退功能
前进后退其实就是currentIndex
减一和加一,不过要注意一下第一首歌和最后一首歌时的情况,以及只有一首歌的时候要循环播放。
# 切换歌曲播放模式
切换歌曲播放模式主要有两点要完成:
- 点击播放模式的时候要切换图标
实现:通过store
中的playMode
来确定是哪种播放模式,然后用计算属性切换不同的图标
const modeIcon = computed(() => {
const playModeValue = playMode.value
return playModeValue === PLAY_MODE.sequence ? 'icon-sequence' : playModeValue === PLAY_MODE.random ? 'icon-random' : 'icon-loop'
})
2
3
4
- 播放模式要做相应的更改
实现:改变store
中的playMode
,然后根据原始列表和playMode
,改变播放列表,再找到当前播放的歌曲在播放列表的索引,改变currentIndex
,以确保当前播放的歌曲不会改变。
建议
这种功能建议用hook
函数来实现,这样setup
函数就不会太臃肿
提示
watch
和computed
的区别:
计算属性是比较声明式的,它就是根据一个响应式数据然后通过某种方式计算出另外一个响应式数据。
而watch
更像是执行命令式的代码,更像是我去观测某些数据的变化,然后执行一些逻辑,它更侧重于去写一些逻辑
# 经典洗牌算法
export function shuffle(source) {
// arr为一个新数组
const arr = source.slice()
for (let i = 0; i < arr.length; i++) {
//让arr[i] 和 arr[j]交换位置
const j = getRandomInt(i)
swap(arr, i, j)
}
return arr
}
function getRandomInt(max) {
return Math.floor(Math.random() * (max + 1))
}
function swap(arr, i, j) {
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 歌曲收藏功能
收藏歌曲功能有两点要完成:
- 收藏的歌曲是一个列表
- 刷新页面的时候收藏的歌曲也要存在
实现: 使用本地存储来实现,逻辑其实蛮简单的,每切一首歌的时候都判断一下这首歌有没有被收藏来改变图标,点击收藏歌曲的时候判断歌曲在不在列表中,在的话就从列表中移除,不在的话就添加进去。
# 歌曲进度条实现
- 获取歌曲当前播放的时间
实现:audio
的currentTime
- 获取歌曲的总时长
实现:currentSong.duration
或者audio
的duration
也可以 - 进度条真实反应进度
实现: 在audio
的updateTime
事件中,用歌曲的当前时间/总时长就可以得到进度 - 拖动进度条上的控制柄可以改变歌曲播放进度
实现:使用touchstart
,touchmove
,touchend
事件来实现
touchstart
:
记录一开始的x坐标位置,和进度条初始宽度touchmove
:
拿到x坐标偏移的位置,并改变进度条的宽度,并得到进度progress
(注意要将progress
限制在0和1之间)touchend
:
改变歌曲的播放进度
- 点击进度条也可以直接改变进度
实现:拿到点击事件的x坐标,计算出progress
,然后改变播放进度 - 当歌曲播放完毕时,切换到下一首歌
实现:audio
的ended
事件中找到下一首歌并播放
# 将秒转换成时间分钟
export function formatTime(interval) {
interval = interval | 0
const minute = ((interval / 60 | 0) + '').padStart(2, '0')
const second = (interval % 60 + '').padStart(2, '0')
return `${minute}:${second}`
}
2
3
4
5
6
提示
padStart()
用于头部补全padEnd()
用于尾部补全。
用另一个字符串填充当前字符串(如果需要的话,会重复多次),以便产生的字符串达到给定的长度
# cd唱片旋转功能
当播放歌曲的时候,cd会循环旋转。可以借助css3
的animation
实现。
.playing {
animation: rotate 20s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
2
3
4
5
6
7
8
9
10
11
12
需要注意的是,在暂停歌曲的时候,需要保持住当前旋转的角度,这样在下一次播放的时候角度就是连续的:
function syncTransform(wrapper, inner) {
const wrapperTransform = getComputedStyle(wrapper).transform
const innerTransform = getComputedStyle(inner).transform
wrapper.style.transform = wrapperTransform === 'none' ? innerTransform : innerTransform.concat(' ', wrapperTransform)
}
2
3
4
5
# 歌词逻辑
currentSong
发生变化的时候先异步获取到对应的歌词并存储到vuex
- 歌词做一个前端缓存,如果没有请求过就请求,如果请求过就从缓存中取
const lyricMap = {}
export function getLyric(song) {
// 如果song中有歌词,就不需要请求了
if (song.lyric) {
return Promise.resolve(song.lyric)
}
const mid = song.mid
const lyric = lyricMap[mid]
if (lyric) {
return Promise.resolve(lyric)
}
return get('/api/getLyric', {
mid
}).then((result) => {
const lyric = result ? result.lyric : '[00:00:00]该歌曲暂时无法获取歌词'
lyricMap[mid] = lyric
return lyric
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
使用第三方库
lyric-parser
解析歌词
这个库可以传入歌词,和播放歌词的回调函数,在这个回调函数中得到当前播放到哪一行,根据行号就可以高亮和滚动歌词了,需要多注意边界情况以及异步情况。
以及歌曲暂停的时候歌词也要暂停,正在拖动歌曲进度条的时候也要暂停歌词,然后拖动完毕再次播放歌词。
切换歌曲的时候,需要把之前的歌词对象清空掉。没有歌词是纯音乐的情况要处理
# mini播放器
当store中的fullScreen为false并且当前有歌曲播放的时候,显示miniPlayer,因此编写miniPlayer组件在player组件中使用。
# transition组件实现动画
Vue提供了transition的封装组件,在下列情形中,可以给任何元素和任何组件添加进入/离开过渡
- 条件渲染(使用v-if)
- 条件展示(使用v-show)
- 动态组件
- 组件根节点
当插入或删除包含在 transition 组件中的元素时,Vue将会做一下处理:
- 自动嗅探目标元素是否应用了CSS过渡动画,如果是,在恰当的实际添加/删除CSS类名
- 如果过渡组件提供了 JavaScript钩子函数,这些钩子函数将在恰当的时机被调用
- 如果没有找到JavaScript钩子并且也没有检测到CSS过渡/动画,DOM操作(插入/删除)在下一帧中立即执行(主要:此处指浏览器逐帧动画机制,和Vue的nextTick概念不同)
# 过渡class
在进入/离开的过渡中,会有 6 个 class 切换。
- v-enter-from:定义进入过渡的开始状态。在元素被插入之前生效,在元素被插入之后的下一帧移除。
- v-enter-active:定义进入过渡生效时的状态。在整个进入过渡的阶段中应用,在元素被插入之前生效,在过渡/动画完成之后移除。这个类可以被用来定义进入过渡的过程时间,延迟和曲线函数。
- v-enter-to:定义进入过渡的结束状态。在元素被插入之后下一帧生效 (与此同时 v-enter-from 被移除),在过渡/动画完成之后移除。
- v-leave-from:定义离开过渡的开始状态。在离开过渡被触发时立刻生效,下一帧被移除。
- v-leave-active:定义离开过渡生效时的状态。在整个离开过渡的阶段中应用,在离开过渡被触发时立刻生效,在过渡/动画完成之后移除。这个类可以被用来定义离开过渡的过程时间,延迟和曲线函数。
- v-leave-to:离开过渡的结束状态。在离开过渡被触发之后下一帧生效 (与此同时 v-leave-from 被移除),在过渡/动画完成之后移除。
这里的每个 class 都将以过渡的名字添加前缀。如果你使用了一个没有名字的 <transition/>
,则 v- 是这些 class 名的默认前缀。举例来说,如果你使用了 <transition name="my-transition">
,那么 v-enter-from 会替换为 my-transition-enter-from。
因此miniPlayer的过渡效果:
<template>
<transition name="mini">
xxxx
</transition>
</template>
<style>
.mini-player {
position: fixed;
left: 0;
bottom: 0;
//....
//....
}
&.mini-enter-active, &.mini-leave-active {
transition: all 0.6s cubic-bezier(0.45, 0, 0.55, 1);
}
&.mini-enter-from, &.mini-leave-to {
opacity: 0;
transform: translate3d(0, 100%, 0);
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# miniPlayer圆圈进度条
原理是使用svg,画内层和外层的圆圈,然后用外层圆圈的stroke-dasharray
和stroke-dashoffset
来进行偏移实现进度。
<template>
<div class="progress-circle">
<svg
:width="radius"
:height="radius"
viewBox="0 0 100 100"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="progress-background"
r="50"
cx="50"
cy="50"
fill="transparent"
>
</circle>
<circle
class="progress-bar"
r="50"
cx="50"
cy="50"
fill="transparent"
:stroke-dasharray="dashArray"
:stroke-dashoffset="dashOffset"
>
</circle>
</svg>
<slot></slot>
</div>
</template>
<script>
export default {
name: 'progress-circle',
props: {
radius: {
type: Number,
default: 100
},
progress: {
type: Number,
default: 0
}
},
data() {
return {
//这里是定义虚线的长度 就是圆的周长 2 * PI * r
dashArray: Math.PI * 100
}
},
computed: {
dashOffset() {
return (1 - this.progress) * this.dashArray
}
}
}
</script>
<style lang="scss" scoped>
.progress-circle {
position: relative;
circle {
stroke-width: 8px;
transform-origin: center;
&.progress-background {
transform: scale(0.9);
stroke: $color-theme-d;
}
&.progress-bar {
//这里是改变一下起点
transform: scale(0.9) rotate(-90deg);
stroke: $color-theme;
}
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
提示
在vue中如果要访问一些DOM的话,如果依赖了一些数据变化,那么你一定要await
一个nextTick()
,然后再去访问这些DOM,才能正确获取到想要的DOM。
# 全屏切换过渡效果
这个动画切换效果有以下组成:
- 整个player组件有个从0到1的透明度的切换
- 头部的歌曲名称有个从上往下的动画
- 底部的进度条按钮有个从下往上的动画
- 中间的cd有一个从miniPlayer的小cd位置变大飞入到中间的动画
首先实现1,2,3,配合transition和css就可以实现:
&.normal-enter-active, &.normal-leave-active {
transition: all .6s;
.top, .bottom {
transition: all .6s cubic-bezier(0.45, 0, 0.55, 1);
}
}
&.normal-enter-from, &.normal-leave-to {
opacity: 0;
.top {
transform: translate3d(0, -100px, 0);
}
.bottom {
transform: translate3d(0, 100px, 0);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cd飞入的动画需要借助transition的js钩子来实现,然后在js钩子中动态计算创建出animation:
<template>
<transition
name="normal"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
xxxx
</transition>
</template>
2
3
4
5
6
7
8
9
10
11
注意
注意在钩子中要调用done函数,钩子才知道动画执行完毕了。例如:
function leave(el, done) {
if (entering) {
afterEnter()
}
leaving = true
const { x, y, scale } = getPosAndScale()
const cdWrapperEl = cdWrapperRef.value
cdWrapperEl.style.transition = 'all .6s cubic-bezier(0.45, 0, 0.55, 1)'
cdWrapperEl.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`
cdWrapperEl.addEventListener('transitionend', next)
function next() {
cdWrapperEl.removeEventListener('transitionend', next)
done()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 播放列表
播放列表组件的功能挺常见,但是学到了两个新的知识点:
# teleport组件
Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建 UI。我们可以将它们嵌套在另一个内部,以构建一个组成应用程序 UI 的树。
然而,有时组件模板的一部分逻辑上属于该组件,而从技术角度来看,最好将模板的这一部分移动到 DOM 中 Vue app 之外的其他位置。
一个常见的场景是创建一个包含全屏模式的组件。在大多数情况下,你希望模态框的逻辑存在于组件中,但是模态框的快速定位就很难通过 CSS 来解决,或者需要更改组件组合。
所以播放列表的组件用<teleport to="body"></teleport>
包裹一下,让播放列表渲染到body元素下。
to传的参数必须是 有效的查询选择器或 HTMLElement (如果在浏览器环境中使用)。指定将在其中移动 <teleport>
内容的目标元素
<!-- 正确 -->
<teleport to="#some-id" />
<teleport to=".some-class" />
<teleport to="[data-teleport]" />
<!-- 错误 -->
<teleport to="h1" />
<teleport to="some-string" />
2
3
4
5
6
7
8
# transition-group组件
播放列表里删除一首歌的时候会有个让这首歌的高度慢慢变为0的动画,这个动画可以用transition-group组件实现
只需要用transition-group
包裹歌曲列表就可以了:
//....
<transition-group
ref="listRef"
name="list"
tag="ul"
>
//.....
</transition-group>
//....
<style>
.list-enter-active, .list-leave-active {
transition: all .3s;
}
.list-enter-from, .list-leave-to {
height:0 !important;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 高阶Scroll组件的实现
Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。
# 渲染函数
让我们深入一个简单的例子,这个例子里 render 函数很实用。假设我们要生成一些带锚点的标题:
<h1>
<a name="hello-world" href="#hello-world">
Hello world!
</a>
</h1>
2
3
4
5
锚点标题的使用非常频繁,我们应该创建一个组件:
<anchored-heading :level="1">Hello world!</anchored-heading>
我们来尝试使用 render 函数写上面的例子:
const { createApp, h } = Vue
const app = createApp({})
app.component('anchored-heading', {
render() {
return h(
'h' + this.level, // 标签名
{}, // prop 或 attribute
this.$slots.default() // 包含其子节点的数组
)
},
props: {
level: {
type: Number,
required: true
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
提示
render
函数的优先级高于根据 template
选项或挂载元素的 DOM 内 HTML 模板编译的渲染函数。
# h()参数
h() 函数是一个用于创建 VNode 的实用程序。也许可以更准确地将其命名为 createVNode(),但由于频繁使用和简洁,它被称为 h() 。它接受三个参数:
// @returns {VNode}
h(
// {String | Object | Function} tag
// 一个 HTML 标签名、一个组件、一个异步组件、或
// 一个函数式组件。
//
// 必需的。
'div',
// {Object} props
// 与 attribute、prop 和事件相对应的对象。
// 这会在模板中用到。
//
// 可选的。
{},
// {String | Array | Object} children
// 子 VNodes, 使用 `h()` 构建,
// 或使用字符串获取 "文本 VNode" 或者
// 有插槽的对象。
//
// 可选的。
[
'Some text comes first.',
h('h1', 'A headline'),
h(MyComponent, {
someProp: 'foobar'
})
]
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 高阶Scroll代码
import { h, mergeProps, renderSlot, withCtx, ref, computed, watch, nextTick } from 'vue'
import Scroll from '@/components/base/scroll/scroll'
import { useStore } from 'vuex'
export default {
name: 'wrap-scroll',
props: Scroll.props, //高阶scroll组件的props和scroll相同
emits: Scroll.emits, // emits也和scroll相同
render(ctx) {
return h(
Scroll, //第一个参数代表渲染的scroll组件
//将包含 VNode prop 的多个对象合并为一个单独的对象。其返回的是一个新创建的对象,而作为参数传递的对象则不会被修改。
//可以传递不限数量的对象,后面参数的 property 优先。事件监听器被特殊处理,class 和 style 也是如此,这些 property 的值是被合并的而不是覆盖的。
mergeProps(
{
ref: 'scrollRef'
},
ctx.$props,
{
onScroll: (e) => {
ctx.$emit('scroll', e)
}
}
),
{
default: withCtx(() => {
return [renderSlot(ctx.$slots, 'default')]
})
}
)
},
setup() {
const scrollRef = ref(null)
const scroll = computed(() => {
return scrollRef.value.scroll
})
const store = useStore()
const playList = computed(() => store.state.playList)
watch(playList, async () => {
await nextTick()
scroll.value.refresh()
})
return {
scrollRef,
scroll
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52