制作精美的播放器
前言
H5的新增的音频标签(audio),让网页更加的有趣。但是默然的样式实在是不太美观,这篇文章就来教做一个简单精美的播放器Demo,实现音频的点播、进度条的拖拽、歌词的滚动和歌词的点播
。
先来看看成品,在最后面给附上完整代码
环境搭建
我们还是使用熟悉的vue3+nodejs+koa来完成,搭后台是因为想做的一个音乐播放器而不是一个Demo,用来返回我们的音频链接和歌词文件。
一个细节一定要配置静态资源支持断点续传,不然无法实现音频的点播,也就是koa-range插件
。
Lrc格式的歌词可以进这个网址下载 歌词找歌名_LRC歌词下载_歌词搜索大全-LRC歌词网 (lrcgeci.com)
- 后端目录
- 安装依赖
npm install koa --save
npm install koa-router --save
npm i @koa/cors --save
npm install koa-static --save
npm i koa-range --save
npm install koa-body --save
- 后端代码
var Koa = require("koa");
var app = new Koa();
var Router = require("koa-router")();
const fs = require("fs");
// 跨域
const cors = require("@koa/cors");
app.use(cors());
// 静态web资源,支持断点续存
const path = require("path");
var koaStatic = require("koa-static");
koaRange = require("koa-range");
app.use(koaRange);
app.use(koaStatic(path.join(__dirname, "public", "mp3")));
app.use(koaStatic(path.join(__dirname, "public", "lrc")));
app.use(koaStatic(path.join(__dirname, "public")));
// 静态资源的上传
const koaBody = require("koa-body");
app.use(
koaBody({
// 支持文件格式
multipart: true,
// onFileBegin: (name, file) => {},
formidable: {
// 上传目录
uploadDir: path.join(__dirname, "/public"),
// 保留文件扩展名
keepExtensions: true,
},
})
);
// 获取Lrc格式的歌词
Router.get("/getLrc", async (ctx) => {
const getLrc = () => {
return new Promise((res, rej) => {
fs.readFile("./public/lrc/2.lrc", (err, data) => {
if (err) {
return;
}
res(data);
});
});
};
let data = await getLrc();
ctx.body = data;
});
Router.get("/", async (ctx) => {
ctx.body = "ok";
});
app
.use(Router.routes()) //启动路由
.use(Router.allowedMethods());
app.listen(3000);
前端的实现
现在这里了分为几个部分,进度条点播和拖拽的实现、LRC格式歌词的处理、歌词的滚动,歌词的点播
。
audio标签主要用到的属性和方法
属性
- currentTime 音频当前播放时间(通过修改这个属性,就可以修改进度)
- duration 音频总长度 方法
- pause 暂停
- play 播放
- timeupdate 当目前的播放位置改变时(监听进度,只要在播放,就会触发)
进度条的点播和拖拽
我们把进度条写成组件的形式
进度条的点播原理,草图还是可以看看的,要记住每次页面大小发生改变时,都要重新获取这个值
关键代码
进度条的拖拽则是mousedown、mouseup和mousedown事件来控制。
关键代码
不管是点播还是拖拽,最后都会通过change事件
来修改音频的currentTime
属性来修改播放进度。
LRC格式歌词的处理
LRC格式的歌词样式
这里可以通过对字符串的切割来获取我们需要的数据,比如歌词、歌词开始的时间
歌词的滚动
我们只需监听音频的timeupdate
事件,可以知道当前的播放时间,再跟之前我们处理出来的每句歌词时间进行比较,就可以知道当前是哪句歌词。滚动的距离==当前歌词的下标 * 放置歌词容器的高度
关键代码
歌词的点播
最后来实现一下,点击歌词播放。当我们点击的时候可以获取点击歌词的下标,将当前的播放时间改为歌词的播放时间。后面加在0.000001是为了解决处理歌词的时候损失的精度。
源码
- 进度条组件
<template>
<div
@mousedown="setScore($event)"
:class="{ 'cd-slider-frame': true }"
ref="sliderBox"
@selectstart.prevent
@mouseenter="changeShow(true)"
@mouseleave="changeShow(false)"
>
<div :class="{ 'cd-slider-left': true }"></div>
<div v-show="isShow" :class="{ 'cd-slider-block-frame': true }" ref="sliderBlock">
<div
:class="{
'cd-slider-block': true,
'cd-slider-block-grap': isMove,
}"
@mousedown="onMousedown($event)"
@dragstart.prevent=""
></div>
</div>
<div :class="{ 'cd-slider-right': true }"></div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted, watchEffect, defineProps, defineEmits } from "vue";
const props = defineProps({
modelValue: {
type: Number,
dafalt: 0,
},
height: {
type: Number,
default: 12,
},
width: {
type: Number,
default: 240,
},
max: {
type: Number,
default: 100,
},
});
let emit = defineEmits(["update:modelValue", "change", "move"]);
let sliderBox = ref<object>();
let sliderBlock = ref<object>();
let stepData = ref<number>();
// 每一步的宽度
let stepWidth = ref<number>(1);
let maxData = ref<number>(0);
// 是否出现进度圆点
let isShow = ref<boolean>(false);
const changeShow = (data: boolean) => {
isShow.value = data;
};
// 执行事件
function executeChange(): void {
emit("change", score.value);
}
// function setmodelValue(): void {
// emit("update:modelValue", score.value);
// }
// 设置大小
let heightData = ref<number>(0);
let widthData = ref<number>(0);
setSize();
function setSize() {
if (props.width >= 160) {
widthData.value = props.width;
} else {
widthData.value = 160;
}
if (props.height >= 24) {
heightData.value = props.height;
} else {
heightData.value = 24;
}
}
// 距离页面最左边的距离
let pageLeftDistance = ref<number>(0);
// 设置最大显示数值
setMaxData();
function setMaxData() {
if (props.max >= 1) {
maxData.value = props.max;
} else {
maxData.value = 1;
}
}
// 设置步长
setStep();
function setStep(): void {
stepData.value = 1;
stepWidth.value = widthData.value / maxData.value;
}
onMounted(() => {
pageLeftDistance.value = getLeft(sliderBox.value as HTMLElement);
});
// 一定要加,页面大小变化,从新获取距离最左的距离
window.addEventListener("resize", function () {
pageLeftDistance.value = getLeft(sliderBox.value as HTMLElement);
});
let score = ref<number>(0);
let startX = ref<number>(0);
let startY = ref<number>(0);
let isMove = ref<boolean>(false);
// 每次移动的距离
let movingDistance = ref<number>(0);
// 滑块停的位置距离刻度为0的距离
let initialPointDistance = ref<number>(0);
// 求到页面最左边的距离
function getLeft(e: HTMLElement): number {
let offset: number = e.offsetLeft;
if (e.offsetParent != null) offset += getLeft(e.offsetParent as HTMLElement);
return offset;
}
// 鼠标在滑块上,按下
function onMousedown(e: any): void {
(e as Event).stopPropagation();
isMove.value = true;
startY.value = e.pageY;
startX.value = e.pageX;
movingDistance.value = 0;
initialPointDistance.value = e.pageX - pageLeftDistance.value;
if (initialPointDistance.value <= 0) {
initialPointDistance.value = 0;
}
if (initialPointDistance.value >= widthData.value) {
initialPointDistance.value = widthData.value;
}
// console.log(initialPointDistance.value);
}
// 鼠标移动
document.addEventListener("mousemove", (e): void => {
if (isMove.value) {
movingDistance.value = e.pageX - startX.value;
if (
initialPointDistance.value + e.pageX - startX.value >= 0 &&
initialPointDistance.value + e.pageX - startX.value <= widthData.value
) {
movingDistance.value = e.pageX - startX.value;
} else {
if (initialPointDistance.value + e.pageX - startX.value < 0) {
movingDistance.value = -initialPointDistance.value;
} else if (initialPointDistance.value + e.pageX - startX.value > widthData.value) {
movingDistance.value = widthData.value - initialPointDistance.value;
}
}
}
});
// 鼠标松开
document.addEventListener("mouseup", (): void => {
if (isMove.value||isClick.value) {
executeChange();
// setmodelValue();
isMove.value = false;
isClick.value=false
}
});
// 是否点击进度条
let isClick=ref<boolean>(false)
function setScore(e: { pageX: number }): void {
isClick.value = true;
initialPointDistance.value = e.pageX - pageLeftDistance.value;
movingDistance.value = 0;
}
// score控制进度条进度
watchEffect((): void => {
score.value =
Math.round(
(((initialPointDistance.value + movingDistance.value) / widthData.value) *
maxData.value) /
(stepData.value as number)
) * (stepData.value as number);
});
watch(
isMove,
(newval, oldval) => {
emit("move", newval);
},
{ immediate: true }
);
watch(
() => {
return props.modelValue;
},
(newval: number | undefined, oldval) => {
if (Number(newval) > 0 && Number(newval) < maxData.value) {
score.value = newval as number;
} else {
if (Number(newval) <= 0) {
score.value = 0;
}
if (Number(newval) >= maxData.value) {
score.value = maxData.value;
}
}
initialPointDistance.value = (score.value / maxData.value) * widthData.value;
movingDistance.value = 0;
},
{ immediate: true }
);
</script>
<style scoped>
.cd-slider-frame {
display: flex;
align-items: center;
position: relative;
height: v-bind(heightData + "px");
width: v-bind(widthData + "px");
}
.cd-slider-left {
flex: v-bind(score);
height: v-bind(heightData/5 + "px");
width: v-bind(widthData + "px");
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
background-color: #31c27c;
}
.cd-slider-block-frame {
position: absolute;
left: 0;
height: v-bind(heightData + "px");
width: v-bind(heightData * 0.6 + "px");
margin-left: v-bind(
"Math.round((movingDistance + initialPointDistance)/stepWidth) *stepWidth-heightData * 0.3+'px'"
);
}
.cd-slider-block {
position: absolute;
z-index: inherit;
top: 50%;
transform: translateY(-50%);
height: v-bind(heightData * 0.35 + "px");
width: v-bind(heightData * 0.35 + "px");
border-radius: 50%;
background-color: white;
border: 1px solid #31c27c;
}
.cd-slider-block-grap {
cursor: grabbing;
}
.cd-slider-right {
flex: v-bind(maxData-score);
height: v-bind(heightData/5 + "px");
width: v-bind(widthData + "px");
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
background-color: #e3e6ed;
}
</style>
- 播放器
<template>
<section class="player-frame">
<div class="progress-frame">
<cdProgress
v-model="progressVal"
:width="1250"
@change="onChange"
@move="onMove"
></cdProgress>
</div>
<!-- 信息 -->
<section class="message-frame">
<div class="coverImg-frame">
<div class="cover-frame" @click="playScreen(true)">
<el-icon><Upload /></el-icon>
</div>
<img :src="songDetails.coverImg" />
</div>
<div class="message-icon-frame">
<div>{{ songDetails.songName }}-{{ songDetails.singer }}</div>
<div class="icon-frame">
<cd-icon :size="27" name="love"></cd-icon>
<cd-icon :size="27" name="download"></cd-icon>
<cd-icon :size="27" name="ellipsis"></cd-icon>
<cd-icon :size="27" name="messageTwo"></cd-icon>
</div>
</div>
</section>
<!-- 控制 -->
<audio
:src="songDetails.songurl"
ref="player"
@timeupdate="onDurationchange(false)"
></audio>
<section :class="{ 'control-frame': true }">
<el-dropdown trigger="click">
播放方式
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>随机播放</el-dropdown-item>
<el-dropdown-item>顺序播放</el-dropdown-item>
<el-dropdown-item>单曲循环</el-dropdown-item>
<el-dropdown-item>列表循环</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<cd-icon
:size="30"
name="previous"
:color="isPlayScreen ? 'white' : 'black'"
></cd-icon>
<div class="control-img-div">
<cd-icon
name="caretRight"
v-show="!isPlay"
:size="38"
@click="changePlay(true)"
:color="isPlayScreen ? 'white' : 'black'"
></cd-icon>
<cd-icon
name="pause"
v-show="isPlay"
:size="55"
@click="changePlay(false)"
:color="isPlayScreen ? 'white' : 'black'"
></cd-icon>
</div>
<cd-icon :size="30" name="next" :color="isPlayScreen ? 'white' : 'black'"></cd-icon>
<cd-icon
:size="35"
name="volume"
:color="isPlayScreen ? 'white' : 'black'"
></cd-icon>
</section>
<!-- 尾部 -->
<section class="player-footer-frame">
<div>{{ playerCurrentTimeShow }}/{{ playerDurationShow }}</div>
<cd-icon name="menu"></cd-icon>
</section>
</section>
<!-- 全屏播放器 -->
<transition name="popup" mode="default">
<div v-show="isPlayScreen" class="public-fullscreen-width player-screen-frame">
<el-icon class="pull-down-button" @click="playScreen(false)"
><ArrowDownBold
/></el-icon>
<div class="public-frame-width player-screen-contents">
<div class="player-screen-songCoverImg">
<img :src="songDetails.coverImg" alt="" />
</div>
<div class="lyric-frame" @mousewheel="monitorRoll">
<header class="lyric-header-frame">
<span class="lyric-header-song-name">{{ songDetails.songName }}</span>
<span class="player mv lyric-header-mv"> </span>
<div class="lyric-header-singer">歌手:{{ songDetails.singer }}</div>
</header>
<div class="lyric" ref="lyricDom">
<div class="blank-top"></div>
<div
v-for="(data, ind) in lrc"
:key="data.songWord"
:class="{ 'lrc-every': true, currentLrc: ind == lrcInd }"
@click="changeLrc(ind)"
@selectstart.prevent=""
>
{{ data.songWord }}
</div>
<div class="blank-footer"></div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed, getCurrentInstance, watch, reactive } from "vue";
import cdProgress from "@/components/progress/index.vue";
import useTime from "@/hooks/useTime";
import { useStore } from "vuex";
import useMutations from "@/hooks/useMutations";
import useState from "@/hooks/useState";
const currentInstance: any = getCurrentInstance();
const { $axios } = currentInstance.appContext.config.globalProperties;
const store = useStore();
//是否全屏播放器
let isPlayScreen = useState("player", "isPlayScreen", store);
const playScreen = (val: boolean) => {
useMutations("player", ["onIsPlayScreen"], [val], store);
};
watch(
isPlayScreen,
(newval, oldval) => {
if (newval == true) {
document.documentElement.style.overflowY = "hidden";
} else {
document.documentElement.style.overflowY = "scroll";
}
},
{ immediate: true }
);
// 当前播放音乐详情
let isPlay = ref<boolean>(false);
let songDetails = reactive({
singer: "薛之谦",
songName: "天外来物",
songurl: "http://127.0.0.1:3000/2.mp3",
coverImg: "http://127.0.0.1:3000/1.webp",
lrcName: "2",
});
// 播放还是暂停
const changePlay = (val: boolean) => {
isPlay.value = val;
if (val) {
player.value.play();
} else {
player.value.pause();
}
};
let lyricDom = ref();
// 是否滚动歌词
let isRollLrc = ref<boolean>(false);
let timer: any = null;
const monitorRoll = () => {
isRollLrc.value = true;
clearTimeout(timer);
timer = setTimeout(() => {
isRollLrc.value = false;
lyricDom.value.scrollTo({
// 根据具体情况可以变化
top: Number(lrcInd.value) * 45,
behavior: "smooth",
});
}, 2000);
};
// 点击歌词改变播放位置
const changeLrc = (ind: number) => {
player.value.currentTime = lrc.value[ind].startTime + 0.000001;
onDurationchange(true);
};
// 对LRC文件内容进行处理,处理成歌词
interface IOneLrc {
startTime: number;
startTimeString: string;
songWord: string;
}
let lrc = ref<IOneLrc[]>([]);
const disposeLrc = (data: []) => {
lrc.value = [];
let temporalMatch = /^\[[0-9]{2}:[0-9]{2}.[0-9]{2}\]/;
data.forEach((val: string) => {
if (temporalMatch.test(val)) {
let minute: number = 0;
let second: number = 0;
let millisecond: number = 0;
let timeString = val.split("]")[0].split("[")[1];
minute = Number(timeString.split(":")[0]);
second = Number(timeString.split(":")[1].split(".")[0]);
millisecond = Number(timeString.split(":")[1].split(".")[1]);
lrc.value.push({
startTime: minute * 60 + second + millisecond / 60,
startTimeString: timeString.split(".")[0],
songWord: val.split("]")[1].trim(),
});
}
});
// console.log(lrc.value);
};
// 获取歌词的LRC文件
const getLrc = () => {
$axios({
method: "get",
url: "/getLrc",
params: {
lrcName: songDetails.lrcName,
},
}).then((res: { data: any }) => {
// console.log(res.data.split(/\r/));
disposeLrc(res.data.split(/\r/));
});
};
getLrc();
// 当前歌词滚动下标
let lrcInd = ref<number>(0);
watch(lrcInd, (newval, oldval) => {
lyricDom.value.scrollTo({
// 根据具体情况可以变化
top: Number(newval) * 45,
behavior: "smooth",
});
});
let progressVal = ref(0);
let player = ref();
const onChange = (val: number) => {
player.value.pause();
player.value.currentTime = Math.round(playerDuration.value * (val / 100));
player.value.play();
isPlay.value = true;
};
// 是否拖动
let isMove = ref<boolean>(false);
const onMove = (val: boolean) => {
isMove.value = val;
};
// 音乐总时间
let playerDuration = ref<number>(0);
let requestAnimation: any;
const getDuration = () => {
if (Number.isNaN(player.value.duration)) {
requestAnimation = requestAnimationFrame(getDuration);
} else {
playerDuration.value = player.value.duration;
cancelAnimationFrame(requestAnimation);
}
};
let playerDurationShow = computed(() => {
return useTime(playerDuration.value);
});
// 当前播放的时间
let playerCurrentTime = ref<number>(0);
let playerCurrentTimeShow = computed(() => {
return useTime(playerCurrentTime.value);
});
// 当前播放时间timeupdate事件
const onDurationchange = (isContius: boolean = false) => {
playerCurrentTime.value = player.value.currentTime;
if (isMove.value == false) {
progressVal.value = Math.round(
(player.value.currentTime / playerDuration.value) * 100
);
}
if (isContius == false) {
if (isRollLrc.value) {
return;
}
}
// 查找当前歌词的下标
for (let i = 0; i < lrc.value.length; i++) {
if (
lrc.value[i].startTime <= playerCurrentTime.value &&
i + 1 < lrc.value.length &&
lrc.value[i + 1].startTime > playerCurrentTime.value
) {
lrcInd.value = i;
break;
} else if (
lrc.value[i].startTime <= playerCurrentTime.value &&
i + 1 == lrc.value.length
) {
lrcInd.value = lrc.value.length - 1;
}
}
};
const changeSong = (data: string) => {
player.value.src = "http://127.0.0.1:3000/1.mp3";
getDuration();
playerCurrentTime.value = 0;
player.value.play();
};
onMounted(() => {
getDuration();
});
</script>
<style lang="less" scoped>
.player-frame {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
z-index: 6;
bottom: 0;
width: 100vw;
min-width: 1250px;
height: 80px;
background-color: v-bind("isPlayScreen?'rgba(0, 0, 0, 0.3)':'white'");
border-radius: 10px;
box-shadow: 0px 0px 12px 2px rgba(0, 0, 0, 0.1);
// backdrop-filter: blur();
.progress-frame {
position: absolute;
top: 0;
transform: translateY(-50%);
}
.message-frame {
display: flex;
gap: 20px;
.coverImg-frame {
position: relative;
height: 55px;
width: 55px;
border-radius: 5px;
overflow: hidden;
.cover-frame {
position: absolute;
top: 0;
height: 100%;
width: 100%;
opacity: 0;
}
.cover-frame:hover {
opacity: 1;
height: 100%;
width: 100%;
font-size: 35px;
line-height: 55px;
text-align: center;
color: rgba(0, 0, 0, 0.38);
background-color: rgba(0, 0, 0, 0.3);
}
}
.message-icon-frame {
display: flex;
flex-direction: column;
justify-content: space-between;
.icon-frame {
display: flex;
gap: 9px;
}
}
}
.control-frame {
display: flex;
justify-content: center;
align-items: center;
gap: 13px;
width: 900px;
.control-img-div {
text-align: center;
width: 55px;
margin: 0 -10px;
}
}
.player-footer-frame {
display: flex;
align-items: center;
gap: 10px;
}
}
// 全局播放器
.player-screen-frame {
position: fixed;
z-index: 5;
top: 0;
width: 100vw;
height: 100vh;
background: v-bind("'url('+songDetails.coverImg+')'");
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
.pull-down-button {
position: fixed;
top: 15px;
left: 60px;
font-size: 20px;
color: white;
}
.player-screen-contents {
display: flex;
align-items
上一篇: 什么是 lrc 文件?
下一篇: 歌词同步 LRCView