Vue3+TypeScript实现网易云音乐WebApp(播放界面:播放、暂停、音量控制、播放进度控制(点击/拖拽进度条)、上一首、下一首)

1. 成果展示

真实接口地址 本项目使用的是真实线上的网易云API

线上演示地址 目前只做了每日推荐(需登录)以及排行榜功能,点个star吧大佬们!

项目GitHub地址 main分支是Vue3+TypeScriptvue2分支是去年用Vue2写的

歌曲播放控制

页面功能简单分析(具体实现往下滑)

头部 - 路由跳转以及歌曲信息

旋转图片部分 - 用歌曲是否播放控制图片的旋转(添加css类名即可)

歌词部分 - 控制包裹歌词的divtransform: translateY(0px)属性。audio标签的timeupdate钩子函数可以与当前歌词时间进行匹配,进而让当前匹配到的歌词往上滑动。请移步具体实现文章

播放模式部分 - 生成随机的索引,在ids数组里匹配, 拿到id后进行请求资源,然后播放。

歌曲播放控制部分 - audio.play()是播放, audio.pause()是暂停。上一首,下一首的控制:在歌曲列表页面(播放页面的前一个页面),会拿到所有歌曲的id,并存进一个叫ids的数组,通过切换ids的索引来拿到当前歌曲的id , 即可实现歌曲切换,要注意边界值

音量控制以及播放进度控制 - 写一个progressBar组件,下面详细分析。

2. 处理接口数据

创建当前播放歌曲信息接口

// src/typings/index.ts

// 歌曲详情里歌手信息
type artist = {
  id: number,
  name: string,
}

// 歌曲详情里专辑信息
type album = {
  id: number,
  name: string,
  picUrl: string
}

export interface ILyric {
  time: number,
  lyric: string,
  uid: number
}

// 歌曲详情
export interface IMusicDetail {
  name: string,
  id: number,
  artist: artist,
  album: album
}

// 当前播放歌曲信息
export interface IMusicInfo extends IMusicDetail {
  url: string,
  ids?: number[], // 当前播放列表的所有歌曲的id
  isVip: boolean, // 当前音乐是否需要VIP
  lyric: ILyric[]
}

创建vuex来存储当前播放歌曲

如何不知道如何使用vue3+ts创建vuex的,请点击右侧观看。Vue3 + TypeScript创建Vuex

// src/store/modules/player.ts

import { Module, VuexModule, Mutation, Action, getModule } from 'vuex-module-decorators'
import store from '@/store'
import { IMusicInfo } from '@/typings'
import { SET_MUSIC_VOLUME, SET_PLAYING_MUSIC, SET_PLAYING_MUSIC_INDEX } from '../types'

@Module({ dynamic: true, store, namespaced: true, name: 'player' })
class Player extends VuexModule {
  playingMusic = {}
  playingMusicIndex = -1
  musicVolume = 1

  get music() {
    return JSON.parse(localStorage.playingMusic)
  }

  get index() {
    return parseInt(JSON.parse(localStorage.playingMusicIndex))
  }

  get volume() {
    return JSON.parse(localStorage.musicVolume)
  }

  // 设置当前播放歌曲信息
  @Mutation
  SET_PLAYING_MUSIC(music: IMusicInfo) {
    this.playingMusic = music
    localStorage.setItem('playingMusic', JSON.stringify(music))
  }

  // 设置当前播放歌曲的索引
  @Mutation
  SET_PLAYING_MUSIC_INDEX(index: number) {
    this.playingMusicIndex = index
    localStorage.setItem('playingMusicIndex', JSON.stringify(index))
  }

  // 设置全局播放音量
  @Mutation
  SET_MUSIC_VOLUME(volume: number) {
    this.playingMusicIndex = volume
    localStorage.setItem('musicVolume', JSON.stringify(volume))
  }

  @Action({ rawError: true })
  setPlayingMusic(music: IMusicInfo) {
    this.context.commit(SET_PLAYING_MUSIC, music)
  }

  @Action({ rawError: true })
  setPlayingMusicIndex(index: number) {
    this.context.commit(SET_PLAYING_MUSIC_INDEX, index)
  }

  @Action({ rawError: true })
  setMusicVolume(volume: number) {
    this.context.commit(SET_MUSIC_VOLUME, volume)
  }
}

export const PlayerModule = getModule(Player)

调用接口获取数据并处理

// src/utils/index.ts

// 根据id获取歌曲 并存到vuex
export const handleGetMusic = (id: string, ids?: number[]):Promise<object> => {
  return new Promise((resolve, reject) => {
    GetMusicDetail({ ids: id }).then(res => {
      const detail = formatMusicDetail(res.songs)
      GetMusicUrl({ id }).then(res2 => {
        const url = res2.data[0].url
        const isVip = res2.data[0].fee === 1
        console.log('****************************************************************************************************************************************************')
        console.log(url)
        GetMusicLyrics({ id }).then(res => {
          // 目前只处理原歌词(不处理翻译歌词)
          const lyrics = formatMusicLyrics(res.lrc.lyric, res.tlyric.lyric)
          const playingMusic = {
            name: detail.name,
            id: detail.id,
            album: detail.album,
            artist: detail.artist,
            url,
            ids,
            isVip,
            lyric: lyrics.lyric
          }
          PlayerModule.setPlayingMusic(playingMusic) // 设置当前播放歌曲
          HistoryModule.setHistoryMusic(playingMusic) // 设置历史播放歌曲
          resolve({ code: 200 })
        }).catch(e => { reject(e) })
      }).catch(e => { reject(e) })
    }).catch(e => { reject(e) })
  })
}

在页面中使用

建议放在播放页的前一个页面进行使用(也就是歌曲列表页)。获取到当前点击音乐的id当前列表所有音乐的ids后,传入到handleGetMusic()函数里,在then回调方法里再做页面的跳转loading的处理

下面上一个示例:

const handleMusicItemClick = async(value: {songId: string, songIndex: string}) => {
  loading.value = true
  const songId = value.songId
  const songIndex = Number(value.songIndex)
  PlayerModule.setPlayingMusicIndex(songIndex)
  const canplay = await MusicCanPlay({ id: songId })
  if (canplay.success) { // 当前歌曲有版权
    handleGetMusic(songId, detail.value.ids).then(res => {
      loading.value = false
      $router.push({ path: '/play', query: { id: songId }})
    })
  } else {
    instance.ctx.$Toast.fail('抱歉,正在争取版权中...')
  }
}

以上内容已经获取到了播放页面所需要的数据,接下来我们就可以对音乐进行一些控制了

3. 歌曲播放、暂停、专辑图片旋转的实现

3.1 数据、dom结构、CSS样式定义

专辑图片的dom结构

// src/Play/index.vue

// img里绑定的src是根据当前视窗高度来在加载不同尺寸的图片
<div :class="{'songPic': true , 'rotate': isPlaying, 'rotate rotatePause': !isPlaying}" ref="songPic">
    <img :src="`${playingMusic.album.picUrl}?param=${clientHeight < 650 ? '150y150' : '200y200'}`" alt="Album">
</div>

上一首 暂停 下一首 的dom结构

// src/Play/index.vue

<!-- 上一首 暂停 下一首 -->
<div class="control">
    <svg-icon @click="handlePrevMusic" class="prev" iconClass='prev'/>
    <svg-icon @click="handleClickPause" class="playing" iconClass='playing' v-if="isPlaying"/>
    <svg-icon @click="handleClickPlay" class="pause" iconClass='pause' v-if="!isPlaying"/>
    <svg-icon @click="handleNextMusic" class="next" iconClass='next'/>
</div>
// src/Play/index.vue

setup() {
    const isPlaying = ref<boolean>(false) // 当前歌曲播放状态
    let playingMusic = ref<any>({}) // 当前播放歌曲的信息,从vuex里获取
    let ids: number[] = [] // 播放列表所有歌曲的id,用来传入handleGetMusic函数
    let currentIndex:number = -1 // 当前播放歌曲的索引
    
    // 将当前播放歌曲的信息给整出来
    playingMusic = PlayerModule.music
    ids = PlayerModule.music.ids
    currentIndex = PlayerModule.index
    
    return {
        isPlaying,
        playingMusic,
        ids
    }
}
// src/styles/index.scss

// 旋转专辑相关
.rotate img{
  animation: RotateCricle 15s linear infinite;
}
.rotatePause img{
 animation-play-state:paused;
  -webkit-animation-play-state:paused; /* Safari 和 Chrome */
}
@keyframes RotateCricle{
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

3.2 TS控制

// src/Play/index.vue

// 播放歌曲
const handleClickPlay = (): void => {
  console.log('播放')
  isPlaying.value = true
  audio.value.play()
}
// 暂停歌曲
const handleClickPause = (): void => {
  console.log('暂停')
  isPlaying.value = false
  audio.value.pause()
}

4. 处理歌曲播放时间

4.1 dom结构的定义

// src/Play/index.vue

<!-- 进度条区域含时间 -->
<div class="progress">
    <div class="currentTime">{{currentTimeStr}}</div>
        <div class="progress-container">
          <progress-bar
            @progressClick="handleClickProgress"
            @progressMove="handleMoveProgress"
            @progressTouch='handleTouchProgress'
            :width="percentage"
          />
        </div>
    <div class="allTime">{{durationStr}}</div>
</div>

<!-- audio标签 -->
<audio
    :src="playingMusic.url"
    ref="audio"
    @canplay="handleGetDuration" // 在这个钩子函数拿到总的播放时长
    @timeupdate="handleTimeUpdate" // 这个钩子是实时触发的
    @ended="handleMusicEnded" // 播放完成的钩子
>
</audio>

4.2 TS控制

4.2.1 获取歌曲总时长、处理歌曲正在播放的时间

// src/Play/index.vue

setup() {
    const durationStr = ref<string>('') // 歌曲总时长 ss:mm格式
    const currentTimeStr = ref<string>('') // 歌曲正在播放的时间 ss:mm格式
    const percentage = ref<string>('') // 传递给progressBar组件的进度条宽度百分比
    
    // 获取歌曲总时长
    const handleGetDuration = (e: any): void => {
      duration = e.target.duration
      durationStr.value = handleFormatDuration(duration)
    }
    
    // 处理歌曲播放进程
    const handleTimeUpdate = (e:any): void => {
      const { currentTime } = e.target
      currentTimeStr.value = handleFormatDuration(currentTime)
    }
    
    return {
        durationStr,
        currentTimeStr,
        handleGetDuration,
        handleTimeUpdate
    }
}

4.2.2 将时间(number)类型处理成ss:mm(string)的函数

// stc/utils/index.ts

// 格式化歌曲播放时间
export const handleFormatDuration = (duration: number):string => {
  const mins = Math.floor(duration / 60) < 10 ? `0${Math.floor(duration / 60)}` : Math.floor(duration / 60)
  const sec = Math.floor(duration % 60) < 10 ? `0${Math.floor(duration % 60)}` : Math.floor(duration % 60)
  return `${mins}:${sec}`
}

5. 实现歌曲播放进度条跟随歌曲时间自行滑动

5.1 首先要实现以下前面提到的progressBar组件

传递给父组件三个事件

  1. progressClick: 点击进度条时触发,参数是当前进度条长度所占的百分比
  2. progressMove:拖拽进度条时触发,参数是当前进度条长度所占的百分比
  3. progressTouch:拖拽进度条结束时触发,参数是当前进度条长度所占的百分比

接收五个参数

  1. strokeWidth:进度条的高度,默认为4px
  2. trackColor:进度条轨道的颜色,默认为#e5e5e5
  3. color:进度条的颜色,默认为#ffb3a7
  4. dotWidth:进度条圆点的大小,默认为12px
  5. width:进度条的宽度,默认为0

5.1.1 dom结构

// src/views/Play/components/ProgressBar.vue

<template>
  <div
    class='progress-box'
    ref="progressRef"
    @click="handleClickProgress"
    @touchstart='handleTouchStart'
    @touchmove='handleTouchMove'
    @touchend='handleTouchEnd'
    style="width: 100%;"
  >
    <div
    class="track"
    :style="{
      backgroundColor: trackColor,
      height: `${strokeWidth}px`,
      marginTop: `${(20 - Number(strokeWidth)) / 2}px`
    }"
      >
    </div>
    <div
    class="progress-bar"
    :style="{
      backgroundColor: color,
      height: `${strokeWidth}px`,
      width: `${percentage}%`,
      marginTop: `${(20 - Number(strokeWidth)) / 2}px`
    }"
      >
    </div>
    <div
    class="progress-dot"
    :style="{
      width: `${dotWidth}px`,
      height: `${dotWidth}px`,
      backgroundColor: color,
      marginTop: `${(20 - Number(dotWidth)) / 2}px`,
      left: `${Number(percentage) === 100 ? `${Number(percentage) - 1.5}%` : `${percentage}%`}`
      }"
    >
    </div>
  </div>
</template>

5.1.2 CSS样式

// src/views/Play/components/ProgressBar.vue

<style scoped lang="scss">
.progress-box{
  position: relative;
  height: 20px;
  .track,
  .progress-bar{
    position: absolute;
    left: 0;
    top: 0;
    border-radius: 4px;
  }
  .track{
    width: 100%;
  }
  .progress-dot{
    position: absolute;
    left: 0;
    top: 0;
    border-radius: 50%;
  }
}
</style>

5.1.3 TS控制

// src/views/Play/components/ProgressBar.vue

<script lang='ts'>
import { defineComponent, onMounted, PropType, ref, watch } from 'vue'
export default defineComponent({
  name: 'ProgressBar',
  props: {
    strokeWidth: {
      type: String as PropType<String>,
      default: '4'
    },
    trackColor: {
      type: String as PropType<String>,
      default: '#e5e5e5'
    },
    color: {
      type: String as PropType<String>,
      default: '#ffb3a7'
    },
    width: {
      type: String as PropType<String>,
      default: '0'
    },
    dotWidth: {
      type: String as PropType<String>,
      default: '12'
    }
  },
  setup(props, ctx) {
    // const width = props.width
    const percentage = ref<string>('') // 传过来的进度条宽度
    const progressRef = ref<any>(null) // 整个progress组件 用来获取长度
    const progressWidth = ref<number>(0) // progress的长度
    let touchStart:number = 0
    let touchEnd:number = 0
    
    // 实时更新进度条的宽度
    watch(() => props.width, (newValue, oldValue) => {
      percentage.value = newValue as string
    })
    
    // 点击进度条事件
    const handleClickProgress = (event: MouseEvent):void => {
      const e = event || window.event
      const position = e.clientX - progressRef.value.offsetLeft // 当前点击位置距离进度条最左边的距离
      percentage.value = ((position / progressWidth.value) * 100).toFixed(3).toString()
      ctx.emit('progressClick', percentage.value)
    }

    // 拖动进度条事件
    const handleTouchStart = (event: TouchEvent):void => {
      console.log(`拖拽起始位置: ${event.touches[0].clientX}`)
      touchStart = event.touches[0].clientX
    }
    const handleTouchMove = (event: TouchEvent):void => {
      console.log(`拖拽到了: ${event.touches[0].clientX}`)
      let moveX = event.touches[0].clientX - progressRef.value.offsetLeft // progressRef.value.offsetLeft是进度条左边距浏览器左侧的距离 不变的
      if (moveX >= progressWidth.value) moveX = progressWidth.value
      if (moveX <= 0) moveX = 0
      percentage.value = ((moveX / progressWidth.value) * 100).toFixed(3).toString()

      // 将拖拽中的进度传递给父组件 例如用于调整音量
      ctx.emit('progressMove', percentage.value)
    }
    const handleTouchEnd = (event: TouchEvent):void => {
      console.log(`拖拽结束位置: ${event.changedTouches[0].clientX}`)
      touchEnd = event.changedTouches[0].clientX
      if (touchStart === touchEnd) { 
        // 点击事件也会触发touch事件,所以用这个条件判断可以在触发的时候什么都不做
        console.log('这是click事件触发的touch事件')
      } else {
        // 拖拽事件结束,将当前拖拽进度传递给父组件
        ctx.emit('progressTouch', percentage.value)
      }
    }

    onMounted(() => {
      // 将进度条组件的宽度赋值给变量
      progressWidth.value = progressRef.value.offsetWidth
    })

    return {
      handleClickProgress,
      handleTouchStart,
      handleTouchMove,
      handleTouchEnd,
      percentage,
      progressRef
    }
  }
})
</script>

5.2 进度条自己滚动

setup() {
    let isTouching: boolean = false // 是否正在拖动歌曲进度条

    // 处理歌曲播放进程
    const handleTimeUpdate = (e:any): void => {
      const { currentTime } = e.target
      currentTimeStr.value = handleFormatDuration(currentTime)
      if (!isTouching) {
        // 这里添加判断的目的是:进度条拖拽时,歌曲依旧正常播放。如果不加判断,歌曲会实时更新,听起来就跟磁带卡碟一样
        percentage.value = ((currentTime / duration) * 100).toFixed(3).toString()
      }
    }
    
    // 进度条拖拽事件
    const handleMoveProgress = (val: string): void => {
      console.log(`当前拖拽到的进度: ${val}%`)
      isTouching = true
    }
    
    return{
        handleTimeUpdate,
        handleMoveProgress
    }
}

6 歌曲进度条点击跳转播放以及拖拽播放

就很简单,只需给width赋个值、更新一下歌曲的时间就好了。因为前面已经把实现功能都写完了

// src/views/Play/index.vue

setup() {

    // 进度条点击事件
    const handleClickProgress = (val: string): void => {
      percentage.value = val
      // 更新歌曲时间
      audio.value.currentTime = duration * (Number(val) / 100)
    }
    
    // 进度条拖拽结束事件
    const handleTouchProgress = (val: string): void => {
      // 更新歌曲时间
      audio.value.currentTime = duration * (Number(val) / 100)
    }
    
    return {
        handleClickProgress,
        handleTouchProgress
    }
}

7 音量控制

7.1 dom结构

// src/views/Play/index.vue

<!-- 音量控制 -->
<div class="volume">
    <svg-icon iconClass='volume'/>
        <div class="progress-container">
          <progress-bar
            @progressClick="handleChangeVolume"
            @progressMove="handleChangeVolume"
            :width="volumePercentage"
          />
    </div>
</div>

7.2 TS控制

// src/views/Play/index.vue

setup() {
    
    const volumePercentage = ref<string>('')
    
    // 音量进度条的点击/拖拽事件
    const handleChangeVolume = (val: string): void => {
      const volume = parseInt(val) / 100 // 音量区间在0 - 1
      audio.value.volume = volume
      PlayerModule.setMusicVolume(volume) // 将音量存进vuex,进行全局保存
      volumePercentage.value = val
    }
    
    return {
        volumePercentage,
        handleChangeVolume
    }
}

8 实现上一首/下一首的跳转

需要注意的是获取id时的索引的边界值处理
跳转到新的一首歌时,需要自动播放,还需重置一些状态(在解析歌词时尤为重要)
直接上代码吧

// src/views/Play/index.vue

setup() {
    
    // 上一首
    const handlePrevMusic = (): void => {
      currentIndex -= 1
      if (currentIndex < 0) currentIndex = ids.length - 1
      PlayerModule.setPlayingMusicIndex(currentIndex)
      const id = ids[currentIndex].toString()
      handleGetMusic(id, ids).then(res => {
        console.log(`跳转到上一首歌 index: ${currentIndex}`)
        playingMusic.artist.name = PlayerModule.music.artist.name
        playingMusic.album.picUrl = PlayerModule.music.album.picUrl
        playingMusic.name = PlayerModule.music.name
        playingMusic.url = PlayerModule.music.url
        playingMusic.lyric = PlayerModule.music.lyric
        audio.value.autoplay = true

        // 重置状态
        handleResetMusic()
      })
    }
    
    // 下一首
    const handleNextMusic = (): void => {
      currentIndex += 1
      if (currentIndex > ids.length - 1) currentIndex = 0
      PlayerModule.setPlayingMusicIndex(currentIndex)
      const id = ids[currentIndex].toString()
      handleGetMusic(id, ids).then(res => {
        console.log(`跳转到下一首歌 index: ${currentIndex}, name: ${PlayerModule.music.name}`)
        playingMusic.artist.name = PlayerModule.music.artist.name
        playingMusic.album.picUrl = PlayerModule.music.album.picUrl
        playingMusic.name = PlayerModule.music.name
        playingMusic.url = PlayerModule.music.url
        playingMusic.lyric = PlayerModule.music.lyric

        // 重置状态
        handleResetMusic()
      })
    }
    
    // 重置歌曲状态 跳转之后
    const handleResetMusic = (): void => {
      audio.value.autoplay = true
      audio.value.play()
      isPlaying.value = true
    }
    
    return {
        handlePrevMusic,
        handleNextMusic,
        handleResetMusic
    }
}

有个困扰就是 跳转新的歌曲 所有的信息都要挨着重新赋值… 目前不知道什么原因,如果有大佬知道,请留言告知小弟。

9 总结

!!记得要在omMouted生命周期里实现歌曲的自动播放和取出全局保存的音量值哦!!

onMounted(() => {
  handleAutoPlay()
  volumePercentage.value = (PlayerModule.volume * 100).toString()
  audio.value.volume = PlayerModule.volume
})

写的很冗余,不过功能以及如何实现的都讲清楚了的,感谢你的耐心观看!

这也是我学习Vue3 + TypeScript的一个练手小demo。另外可以给源码点个star吗!万分感激!源码地址

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值