Vue3实现网易云播放器

2,156 阅读5分钟

前言

本功能是基于 vue3 + ts 实现的。之所以选择网易云作为练手项目,主要是因为他的API是开源的,相对齐全。 网易云API地址:binaryify.github.io/NeteaseClou…

先上效果图

player.gif

一. 开发步骤

1. 歌单实现逻辑

1.1 首先获取歌单id(这里我拿的是推荐歌单:/personalized)

推荐歌单
说明 : 调用此接口 , 可获取推荐歌单

可选参数 : limit: 取出数量 , 默认为 30 (不支持 offset)

接口地址 : /personalized

调用例子 : /personalized?limit=1

接口返回值:(接口返回值太多了,就此省略。大家可以用postman请求查看)

1.2 获取歌单详情

获取歌单详情
说明 : 歌单能看到歌单名字, 但看不到具体歌单内容 , 调用此接口 , 传入歌单 id, 可 以获取对应歌单内的所有的音乐(未登录状态只能获取不完整的歌单,登录后是完整的),但是返回的trackIds是完整的,tracks 则是不完整的,可拿全部 trackIds 请求一次 song/detail 接口获取所有歌曲的详情 (github.com/Binaryify/N…)

必选参数 : id : 歌单 id

可选参数 : s : 歌单最近的 s 个收藏者,默认为8

接口地址 : /playlist/detail

调用例子 : /playlist/detail?id=24381616

注意!!!
这个接口返回的仅仅是歌单部分内容,并不包含歌曲url - 播放地址(即mp3)。因此还需要额外请求一个获取音乐url的接口。
如果想要完整的歌单,就先拿接口返回的trackIds再去请求song/detail。(这里就不一一说明了)

1.3 获取音乐url

获取音乐 url
说明 : 使用歌单详情接口后 , 能得到的音乐的 id, 但不能得到的音乐 url, 调用此接口, 传入的音乐 id( 可多个 , 用逗号隔开 ), 可以获取对应的音乐的 url,未登录状态或者非会员返回试听片段(返回字段包含被截取的正常歌曲的开始时间和结束时间)

必选参数 : id : 音乐 id

可选参数 : br: 码率,默认设置了 999000 即最大码率,如果要 320k 则可设置为 320000,其他类推

接口地址 : /song/url

调用例子 : /song/url?id=33894312 /song/url?id=405998841,33894312

拿到音乐url后就可以调用HTML audio元素实现音频播放啦。(具体往下看)
这里总结下: 先获取 歌单id -> 然后获取 歌曲id -> 最后获取 音频
反过来就是,要拿音频地址就要先获取歌曲id,所以你能拿到歌曲id的话,歌单获取这步就可以省略了。

2. 播放音频逻辑实现

好啦,歌单逻辑说完了。接下来就是讲如何实现播放了。

2.1 使用audio实现

一般来说,直接

<audio src="音频地址" controls />

这样就已经能实现一个音频播放了。但如果要自定义样式的话,就不需要加controls(设置或返回音频是否应该显示控件(比如播放/暂停等))。那么就要自己做上一首播放,下一首播放,暂停播放,开始播放等功能了。

实现一个简单的音频播放器,首先要了解下audio的一些属性和方法。这里我就先列举几个等下要用到的。 具体可以看 www.w3school.com.cn/jsref/dom_o…

Audio 对象属性

属性概述
paused设置或返回音频是否暂停。
src设置或返回音频的 src 属性的值。
duration返回音频的长度(以秒计)。
controls设置或返回音频是否应该显示控件(比如播放/暂停等)。
ended返回音频的播放是否已结束。

Audio 对象方法

方法概述
play()开始播放音频。
pause()暂停当前播放的音频。
canplay()当浏览器能够开始播放指定的音频/视频时,会发生 canplay 事件。
timeupdate()timeupdate 事件在音频(audio)的播放位置发生改变时触发。

2.2 实现Audio Controls功能

一个简易的音乐播放器,一共分为四大功能:上一首播放、下一首播放、暂停播放、开始播放。
需要你们自己加上控制按钮,然后绑定相关事件。如图:

image.png

HTML

<audio
    ref="audio"
    :src="currentPlayUrl"
    @canplay="getDuration"
    @pause="pause"
    @timeupdate="timeupdate"
    @play="play"
/>

开始播放

setup() {
    const audio = ref(); // 这里是拿到audio的dom元素
    
    // 开始播放音频
    function audioPlay() {
      audio.value.play();
    };
    
    // dom元素加载完后开始播放音乐
    onMounted(() => {
      audioPlay();
    });
    
    return {
      audio
    };
}

暂停播放

    /**
     * 控制播放按钮
     * 通过paused属性,判断当前音频播放状态
     */
    function controlPlay() {
      if(!audio.value.paused) {
        audio.value.pause(); // 停止播放
      } else {
        audio.value.play(); // 开始播放
      }
    };

改变播放按钮状态

function play() {
  // 展示播放按钮
};
function pause() {
  // 展示暂停按钮
};

上一首播放、下一首播放

首先要先定义一个歌单列表,然后定义一个当前播放的索引值以及当前播放的url

const state = reactive({
   playList: [
        { url: 'http://m7.music.126.net/20210326150405/a91efaab5690d7966eff4f8104ae575e/ymusic/9ba6/4a9a/e903/eaca05cc36e0d64a2cd104722f6f9cc4.mp3' },
        { url: 'http://m7.music.126.net/20210326154148/251d2d32b08419744ca682170d98bf35/ymusic/0409/520c/5158/3c17fbba85efa6b428d2f2dafd5de326.mp3' },
        { url: 'http://m8.music.126.net/20210326162043/b8af89f1ac2579ea17035b51a7656f7f/ymusic/7634/c63c/a0c1/ad330e06c7f3c791d065af5c255e002c.mp3' }
      ],
    currentIndex: 0
});
const currentPlayUrl = computed(() => {
    return state.playList[state.currentIndex];
});

/**
 * 上一首播放
 * 若当前播放索引值 = 0(第一首),则播放歌单最后一首,否则播放上一首
 */
function prevPlay() {
    state.currentIndex = state.currentIndex === 0 ? state.playList.length - 1 : state.currentIndex - 1; 
    // 这里要延迟播放,因为要先让它加载一下
    nextTick(() => {
        audioPlay();
      });
}

/**
 * 下一首播放
 * 若当前播放索引值 = 歌单列表长度(最后一首),则播放歌单第一首,否则播放下一首
 */
function prevPlay() {
    state.currentIndex = state.currentIndex === state.playList.length - 1 ? 0 : state.currentIndex + 1; 
    nextTick(() => {
        audioPlay();
      });
}

到这里,功能就讲完啦。剩下的就是歌曲信息的展示啦。关于歌曲信息这块,我就重点说下如何获取音频时长吧。

3. 获取音频时长

注意:获取音频时长主要是靠Audio的duration这个属性获取的。但是如果在音频还未可以播放的时候,是不可以获取到它的时长的。

当音频/视频处于加载过程中时,会依次发生以下事件:

  1. loadstart
  2. durationchange
  3. loadedmetadata
  4. loadeddata
  5. progress
  6. canplay
  7. canplaythrough

因此要到canplay这一步才能获取到音频时长。

function getDuration() {
    // 此时可以拿到音频时长(audio.value.duration);
}

二. 完整代码

注:我这里的数据结构格式都是按照网易云接口的数据格式来的。数据结构格式你们可以自己自定义,重点关注逻辑就好了。

<template>
  <div class="audio__wrap">
    <audio
      ref="audio"
      :src="currentPlayUrl"
      @timeupdate="timeupdate"
      @canplay="getDuration"
      @play="play"
      @pause="pause">
    </audio>
    <div
      class="audio__progress"
      :class="[showCircle ? 'show' : 'notShow']"
      @mouseover="showCircle = true"
      @mouseleave="showCircle = false">
      <el-slider
        v-model="currentTime"
        :min="0"
        :max="endTime"
        :show-tooltip="false"
        :show-input-controls="false"
        @change="changeCurrentTime" />
    </div>
    <div class="audio__block">
      <img
        :src="infoList[currentIndex].al.picUrl"
        class="img" />
      <div class="info__block">
        <div>
          <span class="name">{{ infoList[currentIndex].name }} </span>
          <span class="line">-</span>
          <span
            v-for="item in infoList[currentIndex].ar"
            :key="item.id"
            class="singerName">{{ item.name }}
          </span>
          <div class="time">{{ calTime(currentTime) }} / {{ calTime(endTime) }}</div>
        </div>
      </div>
      <div class="icon-controls">
        <i
          class="iconfont icon-diyiyeshouyeshangyishou prev"
          @click="prevPlay()" />
        <play-icon
          :width="45"
          :height="45"
          :iconSize="26"
          :isPlay="isPlay"
          @click="controlPlay" />
        <i
          class="iconfont icon-zuihouyiyemoyexiayishou next"
          @click="nextPlay()" />
      </div>
      <div class="list-controls">
        <i class="iconfont icon-liebiaoxunhuan list-circul" />
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { computed, defineComponent, onMounted, reactive, ref, toRefs } from "@vue/runtime-core";
import { nextTick } from 'vue'
import PlayIcon from '@/components/icon/PlayIcon.vue';
import { calTime } from '@/utils/utils';

export default defineComponent({
  components: { PlayIcon },
  setup() {
    const audio = ref();

    const state = reactive({
      isPlay: false,
      currentTime: 0,
      endTime: 0,
      showCircle: false,
      // 当前播放的歌单url
      playList: [
        { url: 'http://m7.music.126.net/20210326150405/a91efaab5690d7966eff4f8104ae575e/ymusic/9ba6/4a9a/e903/eaca05cc36e0d64a2cd104722f6f9cc4.mp3' },
        { url: 'http://m7.music.126.net/20210326154148/251d2d32b08419744ca682170d98bf35/ymusic/0409/520c/5158/3c17fbba85efa6b428d2f2dafd5de326.mp3' },
        { url: 'http://m8.music.126.net/20210326162043/b8af89f1ac2579ea17035b51a7656f7f/ymusic/7634/c63c/a0c1/ad330e06c7f3c791d065af5c255e002c.mp3' }
      ],
      // 歌曲信息
      infoList: [
        {
          id: 1393394219,
          name: 'Toothbrush',
          al: {
            id: 81949037,
            picUrl: 'https://p1.music.126.net/dGrgYPQEde-xk1oSAZjyyA==/109951164820048861.jpg',
          },
          ar: [
            { id: 28867131, name: 'KikoBlob' }
          ]
        },
        {
          id: 25962464,
          name: 'Tom And Jerry',
          al: {
            id: 2348299,
            picUrl: 'https://p1.music.126.net/7-v1gIVbimGDkQ9ALE6zvg==/6042915906752898.jpg',
          },
          ar: [
            { id: 0, name: 'Bradley' }
          ]
        },
        {
          id: 5041188,
          name: 'Elements',
          al: {
            id: 500661,
            picUrl: 'https://p1.music.126.net/Eu7h6JAEftfSweGLNeDzBA==/6649846325500946.jpg',
          },
          ar: [
            { id: 80585, name: 'Tom Lehrer' }
          ]
        },
      ],
      currentIndex: 0
    });

    // 当前播放的url
    const currentPlayUrl = computed(() => {
        return state.playList[state.currentIndex].url;
    });


    /**
     * 播放音乐
     */
    function audioPlay() {
      audio.value.play();
      state.isPlay = true;
    };
    /**
     * 更新当前时间
     * 如果当前音频进度 = 总时长,则自动播放下一首
     */
    function timeupdate(e: any) {
      state.currentTime = e.target.currentTime;
      if(e.target.currentTime === state.endTime) {
        nextPlay();
      }
    };
    /**
     * 获取音乐时长
     */
    function getDuration() {
      state.endTime = audio.value.duration;
    };
    /**
     * 通过进度条改变当前音频进度
     * @param value 当前的值
     */
    function changeCurrentTime(value: number) {
      audio.value.currentTime = value;
      state.currentTime = value;
    };
    /**
     * 上一首播放
     */
    function prevPlay() {
      state.currentIndex = state.currentIndex === 0 ? state.playList.length - 1 : state.currentIndex - 1; 
      nextTick(() => {
        audioPlay();
      });
    };
    /**
     * 下一首播放
     */
    function nextPlay() {
     state.currentIndex = state.currentIndex === state.playList.length - 1 ? 0 : state.currentIndex + 1; 
      nextTick(() => {
        audioPlay();
      });
    };

    /**
     * 控制播放按钮
     */
    function controlPlay() {
      state.isPlay = !state.isPlay;
      if(!audio.value.paused) {
        audio.value.pause();
      } else {
        audio.value.play();
      }
    };
    function play() {
      state.isPlay = true;
    };
    function pause() {
      state.isPlay = false;
    };

    onMounted(() => {
      audioPlay();
    });

    return {
      ...toRefs(state),
      audio,
      timeupdate,
      getDuration,
      currentPlayUrl,
      prevPlay,
      nextPlay,
      controlPlay,
      changeCurrentTime,
      calTime,
      play,
      pause,
    };
  }
});
</script>

<style lang="scss">
.audio__wrap {
  position: fixed;
  bottom: 0;
  min-width: 1200px;
  width: 100%;
  @include background_color('background-global');
}
.audio__progress {
  .el-slider__runway {
    width: 100%;
    height: 5px;
    margin-bottom: 15px;
    border-radius: 3px;
    position: relative;
    cursor: pointer;
    vertical-align: middle;
    @include background_color('background-dot');
  }
  .el-slider__bar {
    height: 6px;
    border-top-left-radius: 3px;
    border-bottom-left-radius: 3px;
    position: absolute;
    background: #d33a31;
  }
  .el-slider__button {
    display: inline-block;
    width: 20px;
    height: 20px;
    vertical-align: middle;
    border: 2px solid #d33a31;
    background-color: #d33a31;
    border-radius: 50%;
    -webkit-box-sizing: border-box;
    box-sizing: border-box;
    -webkit-transition: .2s;
    transition: .2s;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }
}
.audio__block {
  display: flex;
  align-items: center;
  width: 100%;
  margin: 0 20px 15px 20px;
  @include background_color('background-global');
  .img {
    width: 70px;
    height: 70px;
    border-radius: 8px;
  }
  .info__block {
    margin-left: 20px;
    height: 70px;
    line-height: 35px;
    .name {
      @include font_color('text-primary');
      &:hover {
        cursor: pointer
      }
    }
    .line {
      margin-left: 10px;
      @include font_color('text-second');
    }
    .singerName {
      @include font_color('text-second');
      font-size: 14px;
      margin-left: 10px;
      &:hover {
        cursor: pointer;
        @include font_color('text-second-actived');
      }
    }
    .time {
      @include font_color('text-gray');
      font-size: 14px;
    }
  }
  .icon-controls {
    display: flex;
    align-items: center;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    .prev, .next {
      color: #d33a31;
      font-size: 40px !important;
      margin: 0 20px;
      cursor: pointer;
    }
  }
  .list-controls {
    position: absolute;
    right: 150px;
    .list-circul {
      @include font_color('text-primary');
      font-size: 24px;
    }
  }
}
.show .el-slider__button-wrapper {
  display: block;
}
.notShow .el-slider__button-wrapper {
  display: none;
}
</style>


这是最近在做的一个项目,当时做这个也遇到了不少坑。
现在记下坑,希望对你们有帮助。
或许你们有更好的方法,欢迎大家分享嘻嘻~