欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

制作精美的播放器

最编程 2024-04-17 19:29:35
...

前言

H5的新增的音频标签(audio),让网页更加的有趣。但是默然的样式实在是不太美观,这篇文章就来教做一个简单精美的播放器Demo,实现音频的点播、进度条的拖拽、歌词的滚动和歌词的点播

先来看看成品,在最后面给附上完整代码

动画21.gif

环境搭建

我们还是使用熟悉的vue3+nodejs+koa来完成,搭后台是因为想做的一个音乐播放器而不是一个Demo,用来返回我们的音频链接和歌词文件。

一个细节一定要配置静态资源支持断点续传,不然无法实现音频的点播,也就是koa-range插件

Lrc格式的歌词可以进这个网址下载 歌词找歌名_LRC歌词下载_歌词搜索大全-LRC歌词网 (lrcgeci.com)

  • 后端目录

image.png

  • 安装依赖
npm install koa --save
npm install koa-router --save
npm i @koa/cors --save
npm install koa-static --save
npm i koa-range --save
npm install koa-body  --save
  • 后端代码
var Koa = require("koa");
var app = new Koa();
var Router = require("koa-router")();
const fs = require("fs");
// 跨域
const cors = require("@koa/cors");
app.use(cors());
// 静态web资源,支持断点续存
const path = require("path");
var koaStatic = require("koa-static");
koaRange = require("koa-range");
app.use(koaRange);
app.use(koaStatic(path.join(__dirname, "public", "mp3")));
app.use(koaStatic(path.join(__dirname, "public", "lrc")));
app.use(koaStatic(path.join(__dirname, "public")));
// 静态资源的上传
const koaBody = require("koa-body");
app.use(
  koaBody({
    // 支持文件格式
    multipart: true,
    // onFileBegin: (name, file) => {},
    formidable: {
      // 上传目录
      uploadDir: path.join(__dirname, "/public"),
      // 保留文件扩展名
      keepExtensions: true,
    },
  })
);
// 获取Lrc格式的歌词
Router.get("/getLrc", async (ctx) => {
  const getLrc = () => {
    return new Promise((res, rej) => {
      fs.readFile("./public/lrc/2.lrc", (err, data) => {
        if (err) {
          return;
        }
        res(data);
      });
    });
  };
  let data = await getLrc();
  ctx.body = data;
});

Router.get("/", async (ctx) => {
  ctx.body = "ok";
});
app
  .use(Router.routes()) //启动路由
  .use(Router.allowedMethods());
app.listen(3000);

前端的实现

现在这里了分为几个部分,进度条点播和拖拽的实现、LRC格式歌词的处理、歌词的滚动,歌词的点播

audio标签主要用到的属性和方法

属性

  • currentTime 音频当前播放时间(通过修改这个属性,就可以修改进度)
  • duration 音频总长度 方法
  • pause 暂停
  • play 播放
  • timeupdate 当目前的播放位置改变时(监听进度,只要在播放,就会触发)

进度条的点播和拖拽

我们把进度条写成组件的形式

进度条的点播原理,草图还是可以看看的,要记住每次页面大小发生改变时,都要重新获取这个值 image.png

关键代码

image.png

image.png

image.png

进度条的拖拽则是mousedown、mouseup和mousedown事件来控制。

关键代码

image.png

image.png

image.png

不管是点播还是拖拽,最后都会通过change事件来修改音频的currentTime属性来修改播放进度。

image.png

LRC格式歌词的处理

LRC格式的歌词样式

image.png

这里可以通过对字符串的切割来获取我们需要的数据,比如歌词、歌词开始的时间 image.png

歌词的滚动

我们只需监听音频的timeupdate事件,可以知道当前的播放时间,再跟之前我们处理出来的每句歌词时间进行比较,就可以知道当前是哪句歌词。滚动的距离==当前歌词的下标 * 放置歌词容器的高度

关键代码

image.png

image.png

歌词的点播

最后来实现一下,点击歌词播放。当我们点击的时候可以获取点击歌词的下标,将当前的播放时间改为歌词的播放时间。后面加在0.000001是为了解决处理歌词的时候损失的精度。

image.png

源码

  • 进度条组件
<template>
  <div
    @mousedown="setScore($event)"
    :class="{ 'cd-slider-frame': true }"
    ref="sliderBox"
    @selectstart.prevent
    @mouseenter="changeShow(true)"
    @mouseleave="changeShow(false)"
  >
    <div :class="{ 'cd-slider-left': true }"></div>
    <div v-show="isShow" :class="{ 'cd-slider-block-frame': true }" ref="sliderBlock">
      <div
        :class="{
          'cd-slider-block': true,
          'cd-slider-block-grap': isMove,
        }"
        @mousedown="onMousedown($event)"
        @dragstart.prevent=""
      ></div>
    </div>
    <div :class="{ 'cd-slider-right': true }"></div>
  </div>
</template>

<script lang="ts" setup>
import { ref, watch, onMounted, watchEffect, defineProps, defineEmits } from "vue";
const props = defineProps({
  modelValue: {
    type: Number,
    dafalt: 0,
  },
  height: {
    type: Number,
    default: 12,
  },
  width: {
    type: Number,
    default: 240,
  },
  max: {
    type: Number,
    default: 100,
  },
});
let emit = defineEmits(["update:modelValue", "change", "move"]);
let sliderBox = ref<object>();
let sliderBlock = ref<object>();
let stepData = ref<number>();
// 每一步的宽度
let stepWidth = ref<number>(1);
let maxData = ref<number>(0);

// 是否出现进度圆点
let isShow = ref<boolean>(false);
const changeShow = (data: boolean) => {
  isShow.value = data;
};
// 执行事件
function executeChange(): void {
  emit("change", score.value);
}
// function setmodelValue(): void {
//   emit("update:modelValue", score.value);
// }
//   设置大小
let heightData = ref<number>(0);
let widthData = ref<number>(0);
setSize();
function setSize() {
  if (props.width >= 160) {
    widthData.value = props.width;
  } else {
    widthData.value = 160;
  }
  if (props.height >= 24) {
    heightData.value = props.height;
  } else {
    heightData.value = 24;
  }
}

// 距离页面最左边的距离
let pageLeftDistance = ref<number>(0);
// 设置最大显示数值
setMaxData();
function setMaxData() {
  if (props.max >= 1) {
    maxData.value = props.max;
  } else {
    maxData.value = 1;
  }
}

// 设置步长
setStep();
function setStep(): void {
  stepData.value = 1;
  stepWidth.value = widthData.value / maxData.value;
}
onMounted(() => {
  pageLeftDistance.value = getLeft(sliderBox.value as HTMLElement);
});
// 一定要加,页面大小变化,从新获取距离最左的距离
window.addEventListener("resize", function () {
  pageLeftDistance.value = getLeft(sliderBox.value as HTMLElement);
});

let score = ref<number>(0);
let startX = ref<number>(0);
let startY = ref<number>(0);
let isMove = ref<boolean>(false);
// 每次移动的距离
let movingDistance = ref<number>(0);
// 滑块停的位置距离刻度为0的距离
let initialPointDistance = ref<number>(0);
// 求到页面最左边的距离
function getLeft(e: HTMLElement): number {
  let offset: number = e.offsetLeft;
  if (e.offsetParent != null) offset += getLeft(e.offsetParent as HTMLElement);
  return offset;
}
// 鼠标在滑块上,按下
function onMousedown(e: any): void {
  (e as Event).stopPropagation();
  isMove.value = true;
  startY.value = e.pageY;
  startX.value = e.pageX;
  movingDistance.value = 0;
  initialPointDistance.value = e.pageX - pageLeftDistance.value;
  if (initialPointDistance.value <= 0) {
    initialPointDistance.value = 0;
  }
  if (initialPointDistance.value >= widthData.value) {
    initialPointDistance.value = widthData.value;
  }
  // console.log(initialPointDistance.value);
}
// 鼠标移动
document.addEventListener("mousemove", (e): void => {
  if (isMove.value) {
    movingDistance.value = e.pageX - startX.value;
    if (
      initialPointDistance.value + e.pageX - startX.value >= 0 &&
      initialPointDistance.value + e.pageX - startX.value <= widthData.value
    ) {
      movingDistance.value = e.pageX - startX.value;
    } else {
      if (initialPointDistance.value + e.pageX - startX.value < 0) {
        movingDistance.value = -initialPointDistance.value;
      } else if (initialPointDistance.value + e.pageX - startX.value > widthData.value) {
        movingDistance.value = widthData.value - initialPointDistance.value;
      }
    }
  }
});
// 鼠标松开
document.addEventListener("mouseup", (): void => {
  if (isMove.value||isClick.value) {
    executeChange();
    // setmodelValue();
    isMove.value = false;
    isClick.value=false
  }
});
// 是否点击进度条
let isClick=ref<boolean>(false)
function setScore(e: { pageX: number }): void {
  isClick.value = true;
  initialPointDistance.value = e.pageX - pageLeftDistance.value;
  movingDistance.value = 0;
}
// score控制进度条进度
watchEffect((): void => {
  score.value =
    Math.round(
      (((initialPointDistance.value + movingDistance.value) / widthData.value) *
        maxData.value) /
        (stepData.value as number)
    ) * (stepData.value as number);
});

watch(
  isMove,
  (newval, oldval) => {
    emit("move", newval);
  },
  { immediate: true }
);
watch(
  () => {
    return props.modelValue;
  },
  (newval: number | undefined, oldval) => {
    if (Number(newval) > 0 && Number(newval) < maxData.value) {
      score.value = newval as number;
    } else {
      if (Number(newval) <= 0) {
        score.value = 0;
      }
      if (Number(newval) >= maxData.value) {
        score.value = maxData.value;
      }
    }
    initialPointDistance.value = (score.value / maxData.value) * widthData.value;
    movingDistance.value = 0;
  },
  { immediate: true }
);
</script>

<style scoped>
.cd-slider-frame {
  display: flex;
  align-items: center;
  position: relative;
  height: v-bind(heightData + "px");
  width: v-bind(widthData + "px");
}
.cd-slider-left {
  flex: v-bind(score);
  height: v-bind(heightData/5 + "px");
  width: v-bind(widthData + "px");
  border-top-left-radius: 5px;
  border-bottom-left-radius: 5px;
  background-color: #31c27c;
}
.cd-slider-block-frame {
  position: absolute;
  left: 0;
  height: v-bind(heightData + "px");
  width: v-bind(heightData * 0.6 + "px");
  margin-left: v-bind(
    "Math.round((movingDistance + initialPointDistance)/stepWidth) *stepWidth-heightData * 0.3+'px'"
  );
}

.cd-slider-block {
  position: absolute;
  z-index: inherit;
  top: 50%;
  transform: translateY(-50%);
  height: v-bind(heightData * 0.35 + "px");
  width: v-bind(heightData * 0.35 + "px");
  border-radius: 50%;
  background-color: white;
  border: 1px solid #31c27c;
}
.cd-slider-block-grap {
  cursor: grabbing;
}
.cd-slider-right {
  flex: v-bind(maxData-score);
  height: v-bind(heightData/5 + "px");
  width: v-bind(widthData + "px");
  border-top-right-radius: 5px;
  border-bottom-right-radius: 5px;
  background-color: #e3e6ed;
}
</style>

  • 播放器
<template>
  <section class="player-frame">
    <div class="progress-frame">
      <cdProgress
        v-model="progressVal"
        :width="1250"
        @change="onChange"
        @move="onMove"
      ></cdProgress>
    </div>
    <!-- 信息 -->
    <section class="message-frame">
      <div class="coverImg-frame">
        <div class="cover-frame" @click="playScreen(true)">
          <el-icon><Upload /></el-icon>
        </div>
        <img :src="songDetails.coverImg" />
      </div>
      <div class="message-icon-frame">
        <div>{{ songDetails.songName }}-{{ songDetails.singer }}</div>
        <div class="icon-frame">
          <cd-icon :size="27" name="love"></cd-icon>
          <cd-icon :size="27" name="download"></cd-icon>
          <cd-icon :size="27" name="ellipsis"></cd-icon>
          <cd-icon :size="27" name="messageTwo"></cd-icon>
        </div>
      </div>
    </section>
    <!-- 控制 -->
    <audio
      :src="songDetails.songurl"
      ref="player"
      @timeupdate="onDurationchange(false)"
    ></audio>
    <section :class="{ 'control-frame': true }">
      <el-dropdown trigger="click">
        播放方式
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item>随机播放</el-dropdown-item>
            <el-dropdown-item>顺序播放</el-dropdown-item>
            <el-dropdown-item>单曲循环</el-dropdown-item>
            <el-dropdown-item>列表循环</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
      <cd-icon
        :size="30"
        name="previous"
        :color="isPlayScreen ? 'white' : 'black'"
      ></cd-icon>
      <div class="control-img-div">
        <cd-icon
          name="caretRight"
          v-show="!isPlay"
          :size="38"
          @click="changePlay(true)"
          :color="isPlayScreen ? 'white' : 'black'"
        ></cd-icon>
        <cd-icon
          name="pause"
          v-show="isPlay"
          :size="55"
          @click="changePlay(false)"
          :color="isPlayScreen ? 'white' : 'black'"
        ></cd-icon>
      </div>
      <cd-icon :size="30" name="next" :color="isPlayScreen ? 'white' : 'black'"></cd-icon>
      <cd-icon
        :size="35"
        name="volume"
        :color="isPlayScreen ? 'white' : 'black'"
      ></cd-icon>
    </section>
    <!-- 尾部 -->
    <section class="player-footer-frame">
      <div>{{ playerCurrentTimeShow }}/{{ playerDurationShow }}</div>
      <cd-icon name="menu"></cd-icon>
    </section>
  </section>
  <!-- 全屏播放器 -->
  <transition name="popup" mode="default">
    <div v-show="isPlayScreen" class="public-fullscreen-width player-screen-frame">
      <el-icon class="pull-down-button" @click="playScreen(false)"
        ><ArrowDownBold
      /></el-icon>

      <div class="public-frame-width player-screen-contents">
        <div class="player-screen-songCoverImg">
          <img :src="songDetails.coverImg" alt="" />
        </div>
        <div class="lyric-frame" @mousewheel="monitorRoll">
          <header class="lyric-header-frame">
            <span class="lyric-header-song-name">{{ songDetails.songName }}</span>
            <span class="player mv lyric-header-mv"> </span>
            <div class="lyric-header-singer">歌手:{{ songDetails.singer }}</div>
          </header>
          <div class="lyric" ref="lyricDom">
            <div class="blank-top"></div>
            <div
              v-for="(data, ind) in lrc"
              :key="data.songWord"
              :class="{ 'lrc-every': true, currentLrc: ind == lrcInd }"
              @click="changeLrc(ind)"
              @selectstart.prevent=""
            >
              {{ data.songWord }}
            </div>
            <div class="blank-footer"></div>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script lang="ts" setup>
import { ref, onMounted, computed, getCurrentInstance, watch, reactive } from "vue";
import cdProgress from "@/components/progress/index.vue";
import useTime from "@/hooks/useTime";
import { useStore } from "vuex";
import useMutations from "@/hooks/useMutations";
import useState from "@/hooks/useState";
const currentInstance: any = getCurrentInstance();
const { $axios } = currentInstance.appContext.config.globalProperties;
const store = useStore();

//是否全屏播放器
let isPlayScreen = useState("player", "isPlayScreen", store);
const playScreen = (val: boolean) => {
  useMutations("player", ["onIsPlayScreen"], [val], store);
};
watch(
  isPlayScreen,
  (newval, oldval) => {
    if (newval == true) {
      document.documentElement.style.overflowY = "hidden";
    } else {
      document.documentElement.style.overflowY = "scroll";
    }
  },
  { immediate: true }
);
// 当前播放音乐详情
let isPlay = ref<boolean>(false);
let songDetails = reactive({
  singer: "薛之谦",
  songName: "天外来物",
  songurl: "http://127.0.0.1:3000/2.mp3",
  coverImg: "http://127.0.0.1:3000/1.webp",
  lrcName: "2",
});
// 播放还是暂停
const changePlay = (val: boolean) => {
  isPlay.value = val;
  if (val) {
    player.value.play();
  } else {
    player.value.pause();
  }
};

let lyricDom = ref();
// 是否滚动歌词
let isRollLrc = ref<boolean>(false);
let timer: any = null;
const monitorRoll = () => {
  isRollLrc.value = true;
  clearTimeout(timer);
  timer = setTimeout(() => {
    isRollLrc.value = false;
    lyricDom.value.scrollTo({
      // 根据具体情况可以变化
      top: Number(lrcInd.value) * 45,
      behavior: "smooth",
    });
  }, 2000);
};
// 点击歌词改变播放位置
const changeLrc = (ind: number) => {
  player.value.currentTime = lrc.value[ind].startTime + 0.000001;
  onDurationchange(true);
};
// 对LRC文件内容进行处理,处理成歌词
interface IOneLrc {
  startTime: number;
  startTimeString: string;
  songWord: string;
}
let lrc = ref<IOneLrc[]>([]);
const disposeLrc = (data: []) => {
  lrc.value = [];
  let temporalMatch = /^\[[0-9]{2}:[0-9]{2}.[0-9]{2}\]/;
  data.forEach((val: string) => {
    if (temporalMatch.test(val)) {
      let minute: number = 0;
      let second: number = 0;
      let millisecond: number = 0;
      let timeString = val.split("]")[0].split("[")[1];
      minute = Number(timeString.split(":")[0]);
      second = Number(timeString.split(":")[1].split(".")[0]);
      millisecond = Number(timeString.split(":")[1].split(".")[1]);
      lrc.value.push({
        startTime: minute * 60 + second + millisecond / 60,
        startTimeString: timeString.split(".")[0],
        songWord: val.split("]")[1].trim(),
      });
    }
  });
  // console.log(lrc.value);
};

// 获取歌词的LRC文件
const getLrc = () => {
  $axios({
    method: "get",
    url: "/getLrc",
    params: {
      lrcName: songDetails.lrcName,
    },
  }).then((res: { data: any }) => {
    // console.log(res.data.split(/\r/));
    disposeLrc(res.data.split(/\r/));
  });
};
getLrc();
// 当前歌词滚动下标
let lrcInd = ref<number>(0);
watch(lrcInd, (newval, oldval) => {
  lyricDom.value.scrollTo({
    // 根据具体情况可以变化
    top: Number(newval) * 45,
    behavior: "smooth",
  });
});
let progressVal = ref(0);
let player = ref();
const onChange = (val: number) => {
  player.value.pause();
  player.value.currentTime = Math.round(playerDuration.value * (val / 100));
  player.value.play();
  isPlay.value = true;
};

// 是否拖动
let isMove = ref<boolean>(false);
const onMove = (val: boolean) => {
  isMove.value = val;
};
// 音乐总时间
let playerDuration = ref<number>(0);
let requestAnimation: any;
const getDuration = () => {
  if (Number.isNaN(player.value.duration)) {
    requestAnimation = requestAnimationFrame(getDuration);
  } else {
    playerDuration.value = player.value.duration;
    cancelAnimationFrame(requestAnimation);
  }
};
let playerDurationShow = computed(() => {
  return useTime(playerDuration.value);
});
// 当前播放的时间
let playerCurrentTime = ref<number>(0);
let playerCurrentTimeShow = computed(() => {
  return useTime(playerCurrentTime.value);
});
// 当前播放时间timeupdate事件
const onDurationchange = (isContius: boolean = false) => {
  playerCurrentTime.value = player.value.currentTime;
  if (isMove.value == false) {
    progressVal.value = Math.round(
      (player.value.currentTime / playerDuration.value) * 100
    );
  }
  if (isContius == false) {
    if (isRollLrc.value) {
      return;
    }
  }
  // 查找当前歌词的下标
  for (let i = 0; i < lrc.value.length; i++) {
    if (
      lrc.value[i].startTime <= playerCurrentTime.value &&
      i + 1 < lrc.value.length &&
      lrc.value[i + 1].startTime > playerCurrentTime.value
    ) {
      lrcInd.value = i;
      break;
    } else if (
      lrc.value[i].startTime <= playerCurrentTime.value &&
      i + 1 == lrc.value.length
    ) {
      lrcInd.value = lrc.value.length - 1;
    }
  }
};
const changeSong = (data: string) => {
  player.value.src = "http://127.0.0.1:3000/1.mp3";
  getDuration();
  playerCurrentTime.value = 0;
  player.value.play();
};
onMounted(() => {
  getDuration();
});
</script>

<style lang="less" scoped>
.player-frame {
  display: flex;
  justify-content: center;
  align-items: center;
  position: fixed;
  z-index: 6;
  bottom: 0;
  width: 100vw;
  min-width: 1250px;
  height: 80px;
  background-color: v-bind("isPlayScreen?'rgba(0, 0, 0, 0.3)':'white'");
  border-radius: 10px;
  box-shadow: 0px 0px 12px 2px rgba(0, 0, 0, 0.1);
  // backdrop-filter: blur();
  .progress-frame {
    position: absolute;
    top: 0;
    transform: translateY(-50%);
  }
  .message-frame {
    display: flex;
    gap: 20px;
    .coverImg-frame {
      position: relative;
      height: 55px;
      width: 55px;
      border-radius: 5px;
      overflow: hidden;
      .cover-frame {
        position: absolute;
        top: 0;
        height: 100%;
        width: 100%;
        opacity: 0;
      }
      .cover-frame:hover {
        opacity: 1;
        height: 100%;
        width: 100%;
        font-size: 35px;
        line-height: 55px;
        text-align: center;
        color: rgba(0, 0, 0, 0.38);
        background-color: rgba(0, 0, 0, 0.3);
      }
    }
    .message-icon-frame {
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      .icon-frame {
        display: flex;
        gap: 9px;
      }
    }
  }
  .control-frame {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 13px;
    width: 900px;
    .control-img-div {
      text-align: center;
      width: 55px;
      margin: 0 -10px;
    }
  }
  .player-footer-frame {
    display: flex;
    align-items: center;
    gap: 10px;
  }
}
// 全局播放器
.player-screen-frame {
  position: fixed;
  z-index: 5;
  top: 0;
  width: 100vw;
  height: 100vh;
  background: v-bind("'url('+songDetails.coverImg+')'");
  background-position: 50% 50%;
  background-repeat: no-repeat;
  background-size: cover;
  .pull-down-button {
    position: fixed;
    top: 15px;
    left: 60px;
    font-size: 20px;
    color: white;
  }

  .player-screen-contents {
    display: flex;
    align-items