之前用Vue3写了一个手机端的网易云音乐APP,写到播放组件的时候,我发现开源社区并没有让我满意的歌词解析插件,也就是说你想实现解析歌词只能自己手动去写,这里我参考了ustbhuangyi的lyric-parser,不过由于他的算法并不支持解析网易云的歌词,所以我打算自己手动实现一波
1. 前言
你读这篇文章应具有的知识储备
1.TypeScript的基本语法
2.JavaScript的基本语法
安装
npm i lyric-resolver
Github地址
例子
应用场景
当你想做一个歌词滚动,类似于网易云音乐客户端的那种歌词效果时,你就会用到这个插件
歌词数据分析
这是一个基本的歌词数据
[00:00.000] 作词 : 野田洋次郎
[00:01.000] 作曲 : 野田洋次郎
[00:19.600]
[00:20.000]やっと眼を覚ましたかい それなのになぜ眼も合わせやしないんだい?
[00:30.090]「遅いよ」と怒る君 これでもやれるだけ飛ばしてきたんだよ
[00:38.720]
[00:39.670]心が身体を追い越してきたんだよ
[00:44.340]
[00:45.340]君の髪や瞳だけで胸が痛いよ
[00:50.560]同じ時を吸いこんで離したくないよ
[00:55.200]遥か昔から知る その声に
[01:00.260]生まれてはじめて 何を言えばいい?
[01:07.240]
[01:07.640]君の前前前世から僕は 君を探しはじめたよ
[01:12.280]そのぶきっちょな笑い方をめがけて やってきたんだよ
[01:17.260]
[01:17.630]君が全然全部なくなって チリヂリになったって
[01:22.430]もう迷わない また1から探しはじめるさ
[01:27.240]むしろ0から また宇宙をはじめてみようか
[01:32.670]
[01:43.440]どっから話すかな 君が眠っていた間のストーリー
[01:53.480]何億 何光年分の物語を語りにきたんだよ けどいざその姿この眼に映すと
[02:07.720]
[02:08.720]君も知らぬ君とジャレて 戯れたいよ
[02:13.530]君の消えぬ痛みまで愛してみたいよ
[02:18.480]銀河何個分かの 果てに出逢えた
[02:23.530]その手を壊さずに どう握ったならいい?
[02:30.500]
[02:31.000]君の前前前世から僕は 君を探しはじめたよ
[02:35.680]その騒がしい声と涙をめがけ やってきたんだよ
[02:40.550]
[02:40.990]そんな革命前夜の僕らを誰が止めるというんだろう
[02:45.720]もう迷わない 君のハートに旗を立てるよ
[02:50.740]君は僕から諦め方を 奪い取ったの
[02:55.880]
[03:53.030]前前前世から僕は 君を探しはじめたよ
[03:57.290]そのぶきっちょな笑い方をめがけて やってきたんだよ
[04:01.990]
[04:02.620]君が全然全部なくなって チリヂリになったって
[04:07.240]もう迷わない また1から探しはじめるさ
[04:12.370]何光年でも この歌を口ずさみながら
[04:18.090]
可以发现,歌词的时间会给出分钟、秒、毫秒的时间,如[03:58.970]就是 3min:58s:970ms,我们可以根据这个时间来计算歌词的时间,从而正确的向用户输出当前歌词应该是哪一行
基本用法
import Lyric, { HandlerParams } from 'lyric-resolver'
import { getLyric } from '../api/lyric.js'
export async function useLyric(): any {
const lrc = await getLyric()
const currentLyric = new Lyric(lrc, handleLyric)
/*
* @params curLineNum [number] Current line of lyric
* @params txt [string] Current line's txt
*
* @return void
* */
function handleLyric(payload: HandlerParams): void {
const { curLineNum, txt } = payload
// 你也可以向下面这样操作得到curLineNum
const curLine: number = currentLyric.curLine
}
function play(): void {
currentLyric.play()
}
function stop(): void {
currentLyric.stop()
}
function togglePlay(): void {
currentLyric.togglePlay()
}
function seek(time: number): void {
currentLyric.seek(time)
}
}
从代码的字面意思就能猜到实现了什么功能,我主要想说的是
const { curLineNum, txt } = payload
curLineNum就是拿到当前播放的是第几行歌词了,txt就是拿到当前的歌词文本
实现
时间计算
还记得之前说过的歌词时间计算么?想拿到歌词时间就需要我们先拿到[03:58.970]这里面的数据,将它转化为一个number类型的数据
正则匹配
const lyricTimeReg: RegExp = /\[(\d{2}):(\d{2}).(\d{2,3})]/g
我拿[01:07.640]君の前前前世から僕は 君を探しはじめたよ这一段歌词来解析,看看通过正则匹配后会得到什么格式的数据
const time = lyricTimeReg.exec('[01:07.640]君の前前前世から僕は 君を探しはじめたよ')
输出后我们得到了以下的数据
[
"[01:07.640]",
"01", // 分钟
"07", // 秒
"640" // 毫秒
]
我们接下来只需要一个辅助函数,将这个数据转化为以毫秒为单位的number
function transformRegTime(times: RegExpExecArray): number {
const result: number[] = []
times.forEach((time, index) => {
if (index >= 1 && index <= 3) {
result.push(parseInt(time))
}
})
return (result[0] * 60 + result[1]) * 1000 + result[2]
}
最后测试一下输出的结果
transformRegTime(time!) // 67640
得到了67640,也就是67s的位置,关于time!的非空断言则是exec会返回一个RegExpExecArray | null的类型
OK,时间计算问题解决了,现在来写插件主题部分了
主体实现
接口
// 每一行歌词的时间与歌词文本
interface Lines {
lineTime: number
txt: string
}
// 处理函数的参数定义
interface HandlerParams {
curLineNum: number
txt: string
}
// 播放状态
const enum PLAYING_STATE {
stop = 0,
playing = 1,
}
初始化
export default class Lyric {
lines: Lines[]
lrc: string
state: any
curLine: number
startTime: number
stopTime: number
offset: number
timer: any
handler: any
constructor(lrc: string, handler: (params: HandlerParams) => void) {
this.lrc = lrc
this.lines = []
this.state = PLAYING_STATE.stop
this.curLine = 0
this.timer = null
this.startTime = 0
this.stopTime = 0
this.offset = 0
this.handler = handler
this._init()
}
private _init(): void {
this._initLines()
}
private _initLines(): void {
this.lrc.split('\n').forEach((lrc) => {
let time = lyricTimeReg.exec(lrc)
// 后面传time就不用非空断言了
if (!time) {
return
}
let txt: string = lrc.replace(lineTimeReg, '')
// 过滤空白文本
if (txt === '') {
return
}
this.lines.push({
lineTime: transformRegTime(time),
txt,
})
})
// 升序,确保歌词是由它的时间来决定当前的位置
this.lines.sort((a, b) => {
return a.lineTime - b.lineTime
})
}
}
目前我们只需要关注
lines: Lines[]
对这一行的处理,当我们进行如下操作时
const lyric = new Lyric(txt)
console.log(lyric.lines)
最后打印出来的会是这样
OK,到这里我们对歌词的初始化解析就结束了,下面来实现一下歌词的播放,暂停功能
播放/暂停
export default class Lyric {
lines: Lines[]
lrc: string
state: any
curLine: number
startTime: number
stopTime: number
offset: number
timer: any
handler: any
constructor(lrc: string, handler: (params: HandlerParams) => void) {
this.lrc = lrc
this.lines = []
this.state = PLAYING_STATE.stop
this.curLine = 0
this.timer = null
this.startTime = 0
this.stopTime = 0
this.offset = 0
this.handler = handler
this._init()
}
private _playReset(): void {
// 计算距离下一行歌词还有多少时间
let { delay, targetIndex } = this._calculateDelay()
this.curLine = targetIndex
clearTimeout(this.timer)
this.timer = setTimeout(() => {
// hanlder的实现,也就是用户自己定义的对歌词信息获取的函数
this._callHandler(this.curLine++)
if (this.curLine < this.lines.length && this.state === PLAYING_STATE.playing) {
this._playReset()
}
}, delay)
}
play(): void {
this.state = PLAYING_STATE.playing
this.startTime = Date.now()
if (this.curLine < this.lines.length) {
clearTimeout(this.timer)
this._playReset()
}
}
stop(): void {
this.state = PLAYING_STATE.stop
this.stopTime = Date.now()
this.offset = this.offset + this.stopTime - this.startTime
clearTimeout(this.timer)
}
togglePlay(): void {
if (this.state === PLAYING_STATE.playing) {
this.stop()
} else {
this.play()
}
}
private _calculateDelay(): any {
let delay: number = this._findLine(this.curLine).lineTime - this.offset
let targetIndex: number = this.curLine
let isFind: boolean = false
if (delay < 0) {
this.lines.forEach((line, index) => {
delay = this._findLine(index).lineTime - this.offset
if (delay >= 0 && !isFind) {
targetIndex = index
isFind = true
return
}
})
} else {
this.lines.forEach((line, index) => {
if (
this.offset >= this._findLine(index - 1).lineTime &&
this.offset < line.lineTime
) {
targetIndex = index
delay = this._findLine(targetIndex).lineTime - this.offset
}
})
}
return {
delay,
targetIndex,
}
}
/**
function handleLyric(payload: HandlerParams): void {
const { curLineNum, txt } = payload
const curLine: number = currentLyric.curLine
}
*/
private _callHandler(index: number): void {
if (index < 0) {
return
}
let curLine = index
if (this._findCur()?.txt === '') {
}
try {
this.handler({
curLineNum: curLine,
txt: this._findCur()?.txt.trim() || '',
})
} catch (e) {
return
}
}
// 找到当前歌词的信息
private _findCur(): Lines {
return this.lines[this.curLine]
}
// 找到指定行数的歌词信息,类型为Lines
private _findLine(i: number): Lines {
const lines = this.lines
if (i < 0) {
return lines[0]
}
if (i >= lines.length) {
return lines[lines.length - 1]
}
return lines[i]
}
}
对于歌词的播放,其实就是一个递归循环,通过计算播放下一行歌词还需要多少时间得到了delay来进行操作,不过这有一个痛点,也是我做这个插件时遇到的一个问题如何计算出下一次的delay?
下面我想解释一下几个数据
startTime歌词播放的时间stopTime歌曲停止的时间offset当前歌词播放的进度时间
我是这样做的
play() {
this.startTime = Date.now()
}
stop() {
this.stopTime = Date.now()
this.offset = this.offset + (this.stopTime - this.startTime)
}
这样就能正确的计算出进度了, 暂停播放的时间 - 开始播放的时间 = 本次播放的时间,注意我说的是本次播放,而不是歌曲播放,也就是你这一次播放,暂停后总共花了多少时间,然后之前的offset加上本次播放时间,就是当前歌曲播放的进度啦
seek
那有人会有疑问,如果我回退了怎么办?也就是当前是第N行,我要回到第N - M行去怎么办?或者快进到N + M行去怎么办?
这时候就可以用seek来实现
seek(offset: number): void {
this.offset = offset
this.play()
}
最终代码
interface Lines {
lineTime: number
txt: string
}
// 处理函数的参数定义
interface HandlerParams {
curLineNum: number
txt: string
}
// 播放状态
const enum PLAYING_STATE {
stop = 0,
playing = 1,
}
export default class Lyric {
lines: Lines[]
lrc: string
state: any
curLine: number
startTime: number
stopTime: number
offset: number
timer: any
handler: any
constructor(lrc: string, handler: (params: HandlerParams) => void) {
this.lrc = lrc
this.lines = []
this.state = PLAYING_STATE.stop
this.curLine = 0
this.timer = null
this.startTime = 0
this.stopTime = 0
this.offset = 0
this.handler = handler
this._init()
}
private _init(): void {
this._initLines()
}
private _initLines(): void {
this.lrc.split('\n').forEach((lrc) => {
let time = lyricTimeReg.exec(lrc)
// 后面传time就不用非空断言了
if (!time) {
return
}
let txt: string = lrc.replace(lineTimeReg, '')
// 过滤空白文本
if (txt === '') {
return
}
this.lines.push({
lineTime: transformRegTime(time),
txt,
})
})
// 升序,确保歌词是由它的时间来决定当前的位置
this.lines.sort((a, b) => {
return a.lineTime - b.lineTime
})
}
private _playReset(): void {
// 计算距离下一行歌词还有多少时间
let { delay, targetIndex } = this._calculateDelay()
this.curLine = targetIndex
clearTimeout(this.timer)
this.timer = setTimeout(() => {
// hanlder的实现,也就是用户自己定义的对歌词信息获取的函数
this._callHandler(this.curLine++)
if (this.curLine < this.lines.length && this.state === PLAYING_STATE.playing) {
this._playReset()
}
}, delay)
}
play(): void {
this.state = PLAYING_STATE.playing
this.startTime = Date.now()
if (this.curLine < this.lines.length) {
clearTimeout(this.timer)
this._playReset()
}
}
stop(): void {
this.state = PLAYING_STATE.stop
this.stopTime = Date.now()
this.offset = this.offset + this.stopTime - this.startTime
clearTimeout(this.timer)
}
togglePlay(): void {
if (this.state === PLAYING_STATE.playing) {
this.stop()
} else {
this.play()
}
}
private _calculateDelay(): any {
let delay: number = this._findLine(this.curLine).lineTime - this.offset
let targetIndex: number = this.curLine
let isFind: boolean = false
if (delay < 0) {
this.lines.forEach((line, index) => {
delay = this._findLine(index).lineTime - this.offset
if (delay >= 0 && !isFind) {
targetIndex = index
isFind = true
return
}
})
} else {
this.lines.forEach((line, index) => {
if (
this.offset >= this._findLine(index - 1).lineTime &&
this.offset < line.lineTime
) {
targetIndex = index
delay = this._findLine(targetIndex).lineTime - this.offset
}
})
}
return {
delay,
targetIndex,
}
}
/**
function handleLyric(payload: HandlerParams): void {
const { curLineNum, txt } = payload
const curLine: number = currentLyric.curLine
}
*/
private _callHandler(index: number): void {
if (index < 0) {
return
}
let curLine = index
if (this._findCur()?.txt === '') {
return
}
try {
this.handler({
curLineNum: curLine,
txt: this._findCur()?.txt.trim() || '',
})
} catch (e) {
return
}
}
// 找到当前歌词的信息
private _findCur(): Lines {
return this.lines[this.curLine]
}
// 找到指定行数的歌词信息,类型为Lines
private _findLine(i: number): Lines {
const lines = this.lines
if (i < 0) {
return lines[0]
}
if (i >= lines.length) {
return lines[lines.length - 1]
}
return lines[i]
}
}
完结撒花
如果你对本篇文章的内容有什么不懂的欢迎在评论区留言,我尽量解答~