1 实现功能

  • 随着时间滚动播放歌词

  • 高亮放大当前播放的歌词文字

  • 可以拖动歌词

  • 双击歌词切换到歌词对应的播放时间

2 实现思路

定义了一些数据,包括歌词列表每行歌词的高度歌词滚动的距离当前歌词索引当前播放时间总时长

主要方法:getLyricupdateTimesetCurrentTimegetDurationmouseDownmovemouseUp

2.1 getLyric

获取歌词

2.2 updateTime

更新当前播放时间

并判断是否需要滚动

2.3 setCurrentTime

设置当前audio元素播放时间

双击歌词切换到歌词对应的播放时间

2.4 getDuration

获取audio元素总时长

2.5 mouseDown

鼠标按下事件 主要为了添加鼠标移动事件

2.6 move

鼠标移动事件 设置滚动距离

2.7 mouseUp

鼠标抬起事件 主要是为了移除鼠标移动事件

3 代码

3.1 HTML

<template>
  <div>
    <audio @timeupdate="updateTime" ref="audioRef" :src="audio" controls
           @canplay="getDuration"></audio>

    <div class="lyric" :style="{height:`${lineHeight*10}px`}" @mousedown="mouseDown" @mouseup="mouseUp"
         @mouseleave="mouseUp">
      <div class="lyric-item" v-for="item in lyric" :key="item.index"
           :style="{transform:`translateY(${moveY}px)`,height:`${lineHeight}px`,lineHeight:`${lineHeight}px`}"
           :title="item.formatTime" :class="{active:currentIndex===item.index,noAnimate}"
           @dblclick="setCurrentTime(item.time)">
        {{ item.lyric }}
      </div>
    </div>
  </div>
</template>

3.2 JavaScript

<script setup lang="ts">
import audio from "@/assets/audios/漠河舞厅.mp3";
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import axios from "axios";

const audioRef = ref() // audio元素
const lyric = ref<Lyric[]>([]) // 歌词列表
const lineHeight = ref(40) // 每行歌词的高度
const moveY = ref(lineHeight.value * 4) // 歌词滚动的距离 默认为居中
const currentIndex = ref(0) // 当前歌词索引
let timer: any; // 定时器
const currentTime = ref(0) // 当前播放时间
const duration = ref(0) // 总时长
const noAnimate = ref(false) // 是否不需要动画

/**
 * 歌词类型
 */
interface Lyric {
  time: number;
  lyric: string;
  index: number;
  formatTime: string;
}


/**
 * 格式化时间 000.000
 * @param time 时间
 */
const formatTime = (time: string) => {
  let min = time.split(":")[0] ? time.split(":")[0] : 0;
  let sec = time.split(":")[1] ? time.split(":")[1] : 0;
  return Number(min) * 60 + Number(sec);
};

/**
 * 格式化时间 00:00
 * @param time 时间
 */
const formatTime2 = (time: number) => {
  time = Number(time.toFixed(0))
  if (time < 10) {
    return "0:" + "0" + time;
  }
  if (time < 60 && time >= 10) {
    return "0:" + time;
  }
  if (time >= 60) {
    let n = time / 60;
    n = Math.floor(n);
    let y = time % 60;
    if (y < 10) {
      return n + ":" + "0" + y;
    } else {
      return n + ":" + y;
    }
  }
  return "0:00";
}

/**
 * 获取歌词
 */
const getLyric = async () => {
  const res = await axios({
    url: "/json/data.json",
  })
  lyric.value = []
  const r = res.data.lyric;
  const re = /\[([^\]]+)\]([^\[]+)/g;
  let i = 0;
  r.replace(re, (_: any, $1: any, $2: any) => {
    if (!$2.replace(/\n/g, '').trim()) return "";
    lyric.value.push({
      time: formatTime($1),
      lyric: $2.replace(/\n/g, '').trim(),
      index: i++,
      formatTime: formatTime2(formatTime($1))
    });
    return "";
  });
};

/**
 * 更新当前播放时间 并判断是否需要滚动
 * @param e 事件
 */
const updateTime = (e: any) => {
  currentTime.value = e.currentTarget!!.currentTime
  // 判断是否播放完毕
  if (currentTime.value >= duration.value) {
    audioRef.value!.pause()
  }
  // 判断是否需要滚动
  lyric.value.forEach(item => {
    if (item.time <= currentTime.value + 0.2) {
      currentIndex.value = item.index
      if (!noAnimate.value) {
        moveY.value = lineHeight.value * 4 - lineHeight.value * item.index
      }
    }
  })
}

/**
 * 设置当前audio元素播放时间
 * @param time 时间
 */
const setCurrentTime = (time: number) => {
  audioRef.value.currentTime = time;
}

/**
 * 获取audio元素总时长
 */
const getDuration = () => {
  duration.value = audioRef.value!.duration
}

/**
 * 鼠标按下事件
 * @param e 事件
 */
const mouseDown = (e: MouseEvent) => {
  e.currentTarget!.addEventListener('mousemove', <EventListenerOrEventListenerObject>move) // 添加鼠标移动事件
}

/**
 * 鼠标移动事件
 * @param e 事件
 */
const move = (e: MouseEvent) => {
  clearTimeout(timer);
  // 设置不需要动画 如果有动画会导致滚动距离不准确
  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) => {
  e.currentTarget!.removeEventListener('mousemove', <EventListenerOrEventListenerObject>move) // 移除鼠标移动事件
  timer = setTimeout(async () => { // 2秒后恢复动画
    noAnimate.value = false;
    await nextTick()
  }, 2000)
}

onMounted(() => {
  // 获取歌词
  getLyric()
});

onUnmounted(() => {
  clearTimeout(timer)
})
</script>

3.3 CSS

<style lang="less" scoped>
.lyric {
  height: 300px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  user-select: none;
  cursor: grab;

  .lyric-item {
    height: 30px;
    line-height: 30px;
    text-align: center;
    color: #333;
    font-size: 16px;
    transition: all .3s;
    font-weight: 600;

    &.noAnimate {
      transition: font .3s;
    }

    &.active {
      color: var(--el-color-primary);
      font-size: 22px;
    }
  }
}
</style>