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>

https://baidu.com