react 音乐播放器思路
本文档只是提供一个思路
需求
- 播放暂停
- 上一首下一首
- 音量控制
- 进度条控制
- 歌词高亮显示
- 封面显示
- 歌曲列表显示
所需状态
-
音乐信息:
音乐链接
封面链接
歌词
歌曲总时长
-
当前实时信息:
当前歌曲的实时时间
当前歌曲的实时音量
当前歌曲的播放状态
-
歌曲列表
整体思路
将播放器分成四个组件,第一个是整体组件作为整个功能的布局组件,第二个是歌曲列表组件,第三个是歌曲歌词组件,第四个是控制组件。因为当歌曲在进行交互的时候,所有组件都在进行状态的更新,所以需要统一的状态管理,这里选用的useContext与useReducer模拟一个小型的redux。如果项目较大或者是已经使用全局状态管理工具如redux或mobX的可以使用项目配套统一状态管理工具。有条件的化这里推荐使用mobX,可以多仓库。
一:布局组件
布局组件存放整体的状态,我在这里还对实时的时间与百分比进行一个转化,用来控制进度条能准确的跟着实时进度。
import React, { createContext, useMemo, useReducer } from "react";
import musicReducer from "./musicReducer";
//创建全局上下文
export const Mycontext = createContext();
export default function Music() {
const prevState = {
musicList: [], //音乐列表
currentmusic: {}, //当前音乐信息对象
mp3: "", //当前音乐url(这个是根据对象id请求来的,所以单独写成一个)
playStatus: false, //播放状态,开始或暂停
currentTime: 0, //当前时间,毫秒级
realTime: "", //当前时间,歌词分割出的时间(如果是mobX可以使用计算属性,就没这么麻烦了)
currentDuration: "", //总时长,文字版
currentDurationS: 0, //总时长,毫秒级
currentVolume: 0.3, //当前音量
};
const [state, dispach] = useReducer(musicReducer, prevState);
//进度百分比转化
const sche = useMemo(
() => (state.currentTime * 100) / state.currentDurationS,
[state.currentTime]
);
return (
<Mycontext.Provider value={{ state, dispach }}>
<div className={style.imgBox}>
<img className={style.bg} src={state.currentmusic.cover} alt="." />
</div>
<div className={style.music}>
<div className={style.left}>
<div className={style.top} onClick={() => handleExchange()}>
<Cover />
</div>
<div className={style.bottom}>
<div className={style.name}>
<p>{state.currentmusic.name}</p>
<div className={style.ctrl}>
<div className={style.slider}>
<ConfigProvider
theme={{
token: {
colorPrimary: "#d6d4d4",
},
}}
>
<Slider value={sche} onChange={onChange} />
</ConfigProvider>
</div>
</div>
<p>{state.currentDuration}</p>
</div>
<Player />
</div>
</div>
<div className={style.right}>
<div className={style.top}>
<Lrc />
</div>
<div className={style.bottom}>
<PlayList />
</div>
</div>
</div>
</Mycontext.Provider>
);
}
二:歌曲列表组件
首先要获取列表,我目前的方案是在网易云上创建一个歌单,使用网易云api上将歌单请求过来。在个人管理台进行数据二次加工,主要包括显示和排序,加工后写一个接口,调用自己的接口就行,(大家不用借鉴我的方案,这段描述只是更好的描述后面的代码),获取完列表后发送一个dispatch将第一个设置为当前歌曲。(这里如果想做一个记录上一次播放到哪个歌曲,可以存入loaclstorage,然后通过id找到,我没做的原因是懒)
import React, { useContext, useEffect } from "react";
import { Mycontext } from "../page";
import $fetch from "@/utils/fetch";
export default function PlayList() {
const { state, dispach } = useContext(Mycontext);
useEffect(() => {
getlist().then((r) => setCurrent(r));
}, []);
const setCurrent = async (list) => {
dispach({ type: "SET_CURRENT", payload: { data: list[0] } });
};
const getlist = async () => {
let res = await $fetch("/api/music/getshowmusicall");
dispach({ type: "SAVE_MUSICLIST", payload: { data: res.list } });
return res.list;
};
return (
<ul className={style.list}>
{state.musicList.map((item) => (
<li
className={
state.currentmusic != void 0 && state.currentmusic.id === item.id
? `${style.active} ${style.item}`
: style.item
}
key={item.id}
>
<span
onClick={() =>
dispach({ type: "SET_CURRENT", payload: { data: item } })
}
>
{item.name}
</span>
<span>{item.singer}</span>
</li>
))}
</ul>
);
}
这里还有一个选中歌曲高亮,也非常简单,有手就行。
三:控制模块
这个功能是整个音乐播放器最重要的一个功能,大部分的功能都需要useEffect来实现,所以依赖数组选对依赖项非常重要。首先需要一个<audio>标签,并使用ref获取到,这样就可以控制audio标签。下方是一个控制器组件,用来控制播放暂停,上一首,下一首切换,进度条控制,音量控制。
import React, { useContext, useEffect, useRef } from "react";
import style from "@/style/Player.module.scss";
import { Mycontext } from "../page";
import $fetchM from "@/utils/fetchM";
import MySlider from "./MySlider";
export default function Player() {
const { state, dispach } = useContext(Mycontext);
const prevCountRef = useRef();
const audioRef = useRef();
//进入请求歌词
useEffect(() => {
dispach({ type: "SET_STOP" });
if (state.currentmusic.id != void 0) {
$fetchM(process.env.NEXT_PUBLIC_API_MUSIC + "/song/url", {
id: state.currentmusic.id,
}).then((r) => {
if (r != void 0) {
dispach({
type: "SAVE_MP3",
payload: { data: r.data[0].url },
});
setDuration(r.data[0].time);
}
});
}
}, [state.currentmusic.id]);
//播放暂停
useEffect(() => {
if (state.playStatus === true) {
audioRef.current.play();
} else {
audioRef.current.pause();
}
}, [state.playStatus]);
//设置音量
useEffect(() => {
audioRef.current.volume = state.currentVolume;
}, [state.currentVolume]);
//监听 自动下一首
useEffect(() => {
audioRef.current.addEventListener("ended", () => {
toMusic("next");
dispach({ type: "SET_STOP" });
});
audioRef.current.addEventListener("loadedmetadata", () => {
dispach({ type: "SET_ACT" });
});
audioRef.current.addEventListener("timeupdate", () => {
let timeDisplay = Math.floor(audioRef.current.currentTime);
dispach({ type: "SET_CURREATTIME", payload: { data: timeDisplay } });
});
}, [audioRef.current, state.currentmusic]);
//记录上一个时间,控制播放进度跳转(如果不对其判断,则每过一秒就改变一次当前值,当前的方式能正确判断是否是跳转)
useEffect(() => {
let { currentTime, duration } = audioRef.current;
if ((currentTime, duration)) {
if (
state.currentTime - prevCountRef.current > 2 ||
state.currentTime - prevCountRef.current < -2
) {
audioRef.current.currentTime = state.currentTime;
}
prevCountRef.current = state.currentTime;
}
}, [state.currentTime]);
const setDuration = (mss) => {
let minutes = parseInt((mss % (1000 * 60 * 60)) / (1000 * 60));
let seconds = parseInt((mss % (1000 * 60)) / 1000);
dispach({
type: "SET_DURATION",
payload: { data: `${minutes}:${seconds}` },
});
dispach({
type: "SET_DURATIONS",
payload: { data: mss },
});
};
//控制播放暂停
const handleAct = () => {
if (!state.playStatus) {
dispach({ type: "SET_ACT" });
} else {
dispach({ type: "SET_STOP" });
}
};
const nextMusic = async (mindex) => {
let nmusic;
if (mindex < state.musicList.length) {
nmusic = state.musicList.find((item) => item.mindex === mindex + 1);
} else {
nmusic = state.musicList.find((item) => item.mindex === 1);
}
await dispach({ type: "SET_CURRENT", payload: { data: nmusic } });
await dispach({ type: "SET_CURREATTIME", payload: { data: null } });
};
const prevMusic = async (mindex) => {
let pmusic;
if (mindex > 1) {
pmusic = state.musicList.find((item) => item.mindex === mindex - 1);
} else {
pmusic = state.musicList.find(
(item) => item.mindex === state.musicList.length
);
}
await dispach({ type: "SET_CURRENT", payload: { data: pmusic } });
await dispach({ type: "SET_CURREATTIME", payload: { data: null } });
};
const toMusic = (type) => {
const { mindex } = state.currentmusic;
if (type === "next") {
return nextMusic(mindex);
}
return prevMusic(mindex);
};
//设置音量
const setVolume = (num) => {
dispach({ type: "SET_VOLUME", payload: { data: num } });
};
return (
<div className={style.player}>
<audio ref={audioRef} src={state.mp3} loop={false}></audio>
<div className={style.control}>
<div className={style.prev} onClick={() => toMusic("prev")}>
<BiFirstPage className={style.bi} />
</div>
<div className={style.act} onClick={() => handleAct()}>
{state.playStatus ? (
<BiPause className={style.bia} />
) : (
<BiPlay className={style.bib} />
)}
</div>
<div className={style.next} onClick={() => toMusic("next")}>
<BiLastPage className={style.bi} />
</div>
<div className={style.sound}>
{" "}
<MySlider setVolume={setVolume} />
</div>
</div>
</div>
);
}
里面用到了许多方法是audio的方法,会写一篇文章,记录这些api,方便查找。
四:歌词显示并根据高亮显示当前歌词
这个功能原理也非常简单,如果你有一个lrc文件就会发现它是这样的格式
[00:00:01] 歌词内容
要实现的功能
-
歌词只显示歌词内容
通过lrc文件,不要前面的时间,这个时候就可以通过字符串截取把它截取出来,存到组件状态中。
-
歌词高亮显示
再通过当前时间与前面时间的对比,可以高亮显示,这时需要将前面时间也保存到组件状态中。
-
歌词随时间滚动
通过获取每一个标签的高度,再将当前歌词的下标数乘高度,就是需要位移的部分,通过ref添加style属性
import React, { useContext, useEffect, useRef, useState } from "react";
import { Mycontext } from "../page";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
export default function Lrc() {
const { state, dispach } = useContext(Mycontext);
const [lrclist, setLrclist] = useState([]);
const [active, setcAtive] = useState(null);
const boxRef = useRef();
const ulRef = useRef();
const pRef = useRef();
useEffect(() => {
if (state.currentmusic.lrc != void 0) {
let lrc = state.currentmusic.lrc;
let arr = [];
lrc.split(/[\n]/).forEach((item) => {
let temp = item.split(/\[(.+?)\]/);
arr.push({ time: temp[1], lrc: temp[2] });
});
arr = arr.filter((v) => v["lrc"]); // 去除无歌词内容
setLrclist(arr);
}
}, [state.currentmusic.lrc]);
useEffect(() => {
let a = state.realTime.slice(0, 5);
lrclist.forEach((item, index) => {
let b = item.time.slice(0, 5);
if (a == b) {
setcAtive(index);
}
});
}, [state.realTime]);
useEffect(() => {
let s = +state.currentTime;
let realTime = formattedTime(s);
dispach({ type: "SET_REALTIME", payload: { data: realTime } });
}, [state.currentTime]);
useEffect(() => {
const ulHight = ulRef.current.clientHeight;
let pHight;
let maxHight;
if (pRef.current) {
pHight = pRef.current.clientHeight;
maxHight = ulHight - pHight;
}
let moveHeight = pHight * active;
ulRef.current.style.transform = `translateY(-${moveHeight}px)`;
}, [state.currentTime, ulRef, pRef]);
const formattedTime = (s) => {
const duration = dayjs.duration(s, "seconds");
return duration.format("mm:ss:ms");
};
return (
<div ref={boxRef} className={style.lrc}>
<ul ref={ulRef}>
{lrclist.map((item, index) => (
<p
key={index}
ref={pRef}
className={active === index ? ` ${style.active}` : ""}
>
{item.lrc}
</p>
))}
</ul>
</div>
);
}
这样一个播放器就做好了,还是非常简单的。