1 实现功能
在音乐播放歌词滚动效果下实现逐字歌词
根据浏览器渲染时间进行监听音乐播放
修改处理歌词方法
根据播放进度修改高亮某个字的进度
2 代码
2.1 HTML
<template>
<div>
<audio ref="audioRef" @timeupdate="updateTime" :src="audio" controls @canplay="getDuration" id="audio"></audio>
<div :style="{ '--height': `${lineHeight * 9}px`, '--line-height': `${lineHeight}px` }" class="lyric-container">
<div class="lyric" @mousedown="mouseDown" @mouseup="mouseUp" @mouseleave="mouseUp">
<div class="lyric-item" v-for="item in lyric" :key="item.lineIndex"
:style="{ transform: `translateY(${moveY}px)` }" :class="{ active: currentIndex === item.lineIndex, noAnimate }"
@dblclick="setCurrentTime(item.startTime / 1000)">
<span v-for="word in item.words" class="word"
:class="{ active: currentIndex === item.lineIndex && activeWordIndex >= word.wordIndex, default: word.duration <= 0 }"
:style="{ '--percent': currentIndex === item.lineIndex ? `${<number><unknown>((currentTime - word.startTime / 1000) / (word.duration / 1000)).toFixed(2) * 100}%` : '' }">
{{ word.word }}
</span>
</div>
</div>
</div>
</div>
</template>
2.2 JavaScript
<script setup lang="ts">
import audio from "@/assets/audios/1.mp3";
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import axios from "axios";
const audioRef = ref() // audio元素
const lyric = ref<LineLyric[]>([]) // 歌词列表
const lineHeight = ref(40) // 每行歌词的高度
const moveY = ref(lineHeight.value * 4) // 歌词滚动的距离 默认为居中
const currentIndex = ref(0) // 当前歌词索引
let moveTimer: any; // 定时器
const currentTime = ref(0) // 当前播放时间
const duration = ref(0) // 总时长
const noAnimate = ref(false) // 是否不需要动画
const activeWordIndex = ref(0) // 当前高亮的歌词索引
let lyricTimer: any = null; // 定时器
/**
* 歌词类型
*/
interface LineLyric {
startTime: number;
duration: number;
words: LyricWord[];
lineIndex: number;
}
interface LyricWord {
startTime: number;
duration: number;
word: string;
wordIndex: number;
}
/**
* 获取歌词
*/
const getLyric = async (): Promise<void> => {
const res = await axios({
url: "/json/data.json",
})
let lineIndex = 0;
let wordIndex = 0;
const detail = res.data['yrc']['detail']
lyric.value = []
const lyricStr = res.data['yrc']['lyric'];
const re = /\[([^\]]+)\]([^\[]+)/g; // 匹配歌词 时间+歌词 [00:00.000]歌词
let lineArr = lyricStr.match(re)
// 处理歌词详情信息
detail.forEach((item: any) => {
const lineObj = {
startTime: item.t,
duration: -1,
words: [] as LyricWord[],
lineIndex: lineIndex++
}
const txs = item.c.map((c: any) => {
return c['tx']
})
const wordObj = {
startTime: item.t | 0,
duration: -1,
word: <string>txs.join(''),
wordIndex: wordIndex++
}
lineObj.words.push(<any>wordObj)
lyric.value.push(lineObj)
})
// 处理歌词
lineArr.forEach((line: string) => {
const timeRe1 = /(\d+)\,(\d+)/ // 匹配 歌词时间
const time = line.match(timeRe1)!;
const lineObj = {
startTime: Number(time[1]!),
duration: Number(time[2]!),
words: [] as LyricWord[],
lineIndex: lineIndex++
}
const wordsRe = /\((\d+),(\d+),(\d+)\)([^\(]+)/g // 匹配歌词
let words = line.match(wordsRe)!;
words.forEach((word) => {
const wordRe = /\((\d+),(\d+),(\d+)\)([^\(]+)/ // 匹配每个字
const w = word.match(wordRe)!;
const wordObj = {
startTime: Number(w[1]!),
duration: Number(w[2]!),
word: w[4]!,
wordIndex: wordIndex++
} as LyricWord
lineObj.words.push(wordObj)
})
lyric.value.push(lineObj)
})
};
/**
* 初始化
*/
const init = () => {
duration.value = 0;
currentIndex.value = 0;
currentTime.value = 0;
moveY.value = lineHeight.value * 4;
noAnimate.value = false;
noAnimate.value = false;
activeWordIndex.value = 0;
clearTimeout(moveTimer)
clearInterval(lyricTimer)
lyricTimer = null;
const audioDom: any = document.getElementById('audio');
audioDom.addEventListener("playing", () => { //监听音频播放事件
lyricTimer = setInterval(() => {
currentTime.value = audioDom.currentTime
// 判断是否播放完毕
if (currentTime.value >= duration.value) {
audioDom!.pause()
}
// 判断是否需要滚动
lyric.value.forEach(item => {
if (item.startTime / 1000 <= currentTime.value + 0.2) {
currentIndex.value = item.lineIndex
if (!noAnimate.value) {
moveY.value = lineHeight.value * 4 - lineHeight.value * item.lineIndex
}
// 判断是否需要高亮 当前歌词每个字的时间小于当前播放时间秒时高亮
item.words.forEach(word => {
if (word.startTime / 1000 <= currentTime.value) {
activeWordIndex.value = word.wordIndex
}
})
}
})
}, 16.67)
});
audioDom.addEventListener("pause", () => { //监听音频暂停事件
clearInterval(lyricTimer)
lyricTimer = null;
});
}
/**
* 更新时间 歌词滚动
*/
const updateTime = () => {
const audioDom: any = document.getElementById('audio');
if (!lyricTimer) { // 如果没有定时器则创建定时器
currentTime.value = audioDom.currentTime
// 判断是否播放完毕
if (currentTime.value >= duration.value) {
audioDom!.pause()
}
// 判断是否需要滚动
lyric.value.forEach(item => {
if (item.startTime / 1000 <= currentTime.value + 0.2) {
currentIndex.value = item.lineIndex
if (!noAnimate.value) {
moveY.value = lineHeight.value * 4 - lineHeight.value * item.lineIndex
}
// 判断是否需要高亮 当前歌词每个字的时间小于当前播放时间秒时高亮
item.words.forEach(word => {
if (word.startTime / 1000 <= currentTime.value) {
activeWordIndex.value = word.wordIndex
}
})
}
})
}
}
/**
* 设置当前audio元素播放时间
* @param time 时间
*/
const setCurrentTime = (time: number): void => {
noAnimate.value = false;
audioRef.value.currentTime = time;
}
/**
* 获取audio元素总时长
*/
const getDuration = (): void => {
duration.value = audioRef.value!.duration
}
/**
* 鼠标按下事件
* @param e 事件
*/
const mouseDown = (e: MouseEvent): void => {
e.currentTarget!.addEventListener('mousemove', <EventListenerOrEventListenerObject>move) // 添加鼠标移动事件
}
/**
* 鼠标移动事件
* @param e 事件
*/
const move = (e: MouseEvent): void => {
clearTimeout(moveTimer);
// 设置不需要动画 如果有动画会导致滚动距离不准确
noAnimate.value = true
// 获取鼠标移动的距离
const disY = e.movementY
// 判断是否超出范围
if (moveY.value + disY > lineHeight.value * 4) {
moveY.value = 4 * lineHeight.value
return
} else if (moveY.value + disY < -lineHeight.value * (lyric.value.length - 5)) {
moveY.value = -lineHeight.value * (lyric.value.length - 5)
return
}
// 设置滚动距离
moveY.value += disY
}
/**
* 鼠标抬起事件
* @param e 事件
*/
const mouseUp = (e: MouseEvent): void => {
e.currentTarget!.removeEventListener('mousemove', <EventListenerOrEventListenerObject>move) // 移除鼠标移动事件
moveTimer = setTimeout(async () => { // 2秒后恢复动画
noAnimate.value = false;
await nextTick()
}, 2000)
}
onMounted(() => {
// 获取歌词
getLyric()
init();
});
onUnmounted(() => {
clearTimeout(moveTimer)
clearInterval(lyricTimer)
lyricTimer = null;
})
</script>
2.3 CSS
<style lang="less" scoped>
audio {
width: 600px;
margin: 30px auto 20px auto;
display: block;
}
.lyric-container {
height: var(--height);
margin: 0 auto;
position: relative;
}
.lyric {
height: var(--height);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
user-select: none;
cursor: grab;
min-width: 600px;
.lyric-item {
height: var(--line-height);
line-height: var(--line-height);
text-align: center;
color: #333;
font-size: 20px;
transition: all .3s;
font-weight: 600;
&.noAnimate {
transition: font .3s;
}
&.active {
font-size: 30px;
.word.active {
background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(255, 255, 255, 0) 100%),
-webkit-linear-gradient(left, var(--el-color-primary) var(--percent), #333 0%);
color: var(--el-color-primary);
}
.word.default {
background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(255, 255, 255, 0) 100%),
-webkit-linear-gradient(left, #333 100%, var(--el-color-primary) 0%);
}
}
.word {
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(255, 255, 255, 0) 100%),
-webkit-linear-gradient(left, #333 100%, var(--el-color-primary) 0%);
}
}
}
</style>