来场音乐之旅吧:音乐app开发实战

365 阅读4分钟

来场音乐之旅吧:音乐app开发实战

最近"网抑云"一词火了,不知道现在的你是否还会默默的翻着网易云音乐的评论,想起当年“不堪回首”的往事呢?作为前端打工仔的一员,如果能在上班的时候一边敲着代码,一边听着小歌,那就已经很美滋滋了,所以学习了这个关于音乐app的开发,让我们一起进入这场音乐之旅吧!

总体架构

目录结构

├── public                   
│   ├── index.html           // 项目的入口文件
└── src                      // 源码目录
    ├── api                  // 项目接口文件
    ├── assets               // 存放静态文件
    ├── components           // 公共组件
    │   ├── add-song 		 // 添加歌曲列表组件
    │   ├── confirm     	 // 弹框组件
    │   ├── listview		 // 歌手列表组件
    │   ├── loading			 // loading组件
    │   ├── m-header		 // header组件
    │   ├── music-list		 // 音乐列表组件
    │   ├── no-result		 // 无歌曲组件
    │   ├── player   		 // 播放组件
    │   ├── playlist         // 播放列表组件
    │   ├── progress-bar     // 横向进度条组件
    │   ├── progress-circle  // 圆圈进度条组件
    │   ├── scroll			 // 滚动组件
    │   ├── search-box		 // 搜索框组件
    │   ├── search-list	     // 搜索列表组件
    │   ├── slider			 // 轮播组件
    │   ├── song-list	     // 歌曲列表组件
    │   ├── suggest	         // 搜索结果组件
    │   ├── switches		 // 左右菜单切换组件
    │   ├── tab				 // 顶部导航栏组件
    │   └── top-tip			 // 顶部提示组件
    ├── router             	 // 路由文件
    ├── store              	 // vuex状态管理文件
    ├── style                // 存放样式文件
    │   ├── fonts            // 字体文件
    │   └── stylus           // 全局stylus样式文件
    ├── utils                // 存放全局方法文件
    └── views                // 存放页面
        ├── disc		     // 歌单详情页
        ├── rank		     // 排行页
        ├── recommend        // 推荐页
        ├── search 		     // 搜索页
        ├── singer		     // 歌手页
        ├── singer-detail    // 歌手详情页
        ├── top-list 	     // 巅峰版页面
        └── user-center	     // 用户中心

UI设计稿

整体架构图

功能组件的封装

slider(轮播组件)

平时的业务中,轮播图也是经常能见得到的,尤其在移动端和小程序中,需要提供更多的自定义需求,如自动轮播,轮播间隔,提供的图片等

<template>
  <div class="slider" ref="slider">
    <div class="slider-group" ref="sliderGroup">
      <slot></slot>
    </div>
    <div class="dots">
      <span
        class="dot"
        :class="{active: currentPageIndex === index }"
        v-for="(item, index) in dots"
        :key="index"
      ></span>
    </div>
  </div>
</template>

<script>
import { addClass } from 'utils/dom'
import BScroll from 'better-scroll'

export default {
  name: 'slider',
  props: {
    loop: { // 无缝循环轮播
      type: Boolean,
      default: true
    },
    autoPlay: { // 自动轮播
      type: Boolean,
      default: true
    },
    interval: { // 轮播间隔
      type: Number,
      default: 4000
    }
  },
  data() {
    return {
      dots: [],
      currentPageIndex: 0
    }
  },
  mounted () {
    this.$nextTick(() => {
      this._setSliderWidth()
      this._initDots()
      this._initSlider()

      if (this.autoPlay) {
        this._play()
      }
    })
    window.addEventListener('resize', () => {
      if (!this.slider) {
        return
      }
      this._setSliderWidth(true)
      this.slider.refresh()
    })
  },
  activated() { // 缓存可以防止轮播回来时抽搐的现象
    if (this.autoPlay) {
      this._play()
    }
  },
  deactivated() {
    clearTimeout(this.timer)
    window.removeEventListener('resize', () => {
      if (!this.slider) {
        return
      }
      this._setSliderWidth(true)
      this.slider.refresh()
    })
  },
  beforeDestroy() {
    clearTimeout(this.timer)
    window.removeEventListener('resize', () => {
      if (!this.slider) {
        return
      }
      this._setSliderWidth(true)
      this.slider.refresh()
    })
  },
  methods: {
    _setSliderWidth(isResize) {
      this.children = this.$refs.sliderGroup.children

      let width = 0
      let sliderWidth = this.$refs.slider.clientWidth
      for (let i = 0; i < this.children.length; i++) {
        let child = this.children[i]
        addClass(child, 'slider-item')

        child.style.width = sliderWidth + 'px'
        width += sliderWidth
      }
      if (this.loop && !isResize) {
        width += 2 * sliderWidth
      }
      this.$refs.sliderGroup.style.width = width + 'px'
    },
    _initSlider() {
      this.slider = new BScroll(this.$refs.slider, {
        scrollX: true,
        scrollY: false,
        momentum: false,
        snap: true,
        snapLoop: this.loop,
        snapThreshold: 0.3,
        snapSpeed: 400
      })

      this.slider.on('scrollEnd', () => {
        let pageIndex = this.slider.getCurrentPage().pageX
        if (this.loop) {
          pageIndex -= 1
        }
        this.currentPageIndex = pageIndex

        if (this.autoPlay) {
          this._play()
        }
      })

      this.slider.on('beforeScrollStart', () => {
        if (this.autoPlay) {
          clearTimeout(this.timer)
        }
      })
    },
    _initDots() {
      this.dots = new Array(this.children.length)
    },
    _play() {
      let pageIndex = this.currentPageIndex + 1
      if (this.loop) {
        pageIndex += 1
      }
      this.timer = setTimeout(() => {
        this.slider.goToPage(pageIndex, 0, 400)
      }, this.interval)
    }
  }
}
</script>

<style scoped lang="stylus">
@import '~@/style/stylus/variable'

.slider {
  min-height: 1px;

  .slider-group {
    position: relative;
    overflow: hidden;
    white-space: nowrap;

    .slider-item {
      float: left;
      box-sizing: border-box;
      overflow: hidden;
      text-align: center;

      a {
        display: block;
        width: 100%;
        overflow: hidden;
        text-decoration: none;
      }

      img {
        display: block;
        width: 100%;
      }
    }
  }

  .dots {
    position: absolute;
    right: 0;
    left: 0;
    bottom: 12px;
    text-align: center;
    font-size: 0;

    .dot {
      display: inline-block;
      margin: 0 4px;
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: $color-text-l;

      &.active {
        width: 20px;
        border-radius: 5px;
        background: $color-text-ll;
      }
    }
  }
}
</style>

自己封装轮播组件时,会经常遇到的一个小bug,就是当你离开当前轮播组件页面在回来时,轮播图会出现闪烁或抽搐现象,主要时因为js会一直执行的原因,因此需要将该组件缓存,即用'keep-alive'组件包裹。

scroll(滚动组件)

app中也经常用到滚动列表的效果:

<template>
  <div ref="wrapper">
    <slot></slot>
  </div>
</template>

<script>
import BScroll from 'better-scroll'

export default {
  props: {
    probeType: {
      type: Number,
      default: 1
    },
    click: {
      type: Boolean,
      default: true
    },
    listenScroll: {
      type: Boolean,
      default: false
    },
    data: {
      type: Array,
      default: null
    },
    pullup: {
      type: Boolean,
      default: false
    },
    beforeScroll: {
      type: Boolean,
      default: false
    },
    refreshDelay: {
      type: Number,
      default: 20
    }
  },
  mounted() {
    this.$nextTick(() => {
      this._initScroll()
    })
  },
  methods: {
    _initScroll() {
      this.scroll = new BScroll(this.$refs.wrapper, {
        probeType: this.probeType,
        click: this.click
      })

      if (this.listenScroll) {
        let me = this
        this.scroll.on('scroll', (pos) => {
          me.$emit('scroll', pos)
        })
      }

      if (this.pullup) {
        this.scroll.on('scrollEnd', () => {
          if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
            this.$emit('scrollToEnd')
          }
        })
      }

      if (this.beforeScroll) {
        this.scroll.on('beforeScrollStart', () => {
          this.$emit('beforeScroll')
        })
      }
    },
    disable() {
      this.scroll && this.scroll.disable()
    },
    enable() {
      this.scroll && this.scroll.enable()
    },
    refresh() {
      this.scroll && this.scroll.refresh()
    },
    scrollTo() {
      this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments)
    },
    scrollToElement() {
      this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments)
    }
  },
  watch: {
    data() {
      setTimeout(() => {
        this.refresh()
      }, this.refreshDelay)
    }
  }
}
</script>

progress-bar(进度条组件)

<template>
  <div class="progress-bar" ref="progressBar" @click="progressClick">
    <div class="bar-inner">
      <div class="progress" ref="progress"></div>
      <div
        class="progress-btn-wrapper"
        ref="progressBtn"
        @touchstart.prevent="progressTouchStart"
        @touchmove.prevent="progressTouchMove"
        @touchend="progressTouchEnd"
      >
        <div class="progress-btn"></div>
      </div>
    </div>
  </div>
</template>

<script>
import { prefixStyle } from 'utils/dom'

const progressBtnWidth = 16
const transform = prefixStyle('transform')

export default {
  props: {
    percent: {
      type: Number,
      default: 0
    }
  },
  created() {
    this.touch = {}
  },
  methods: {
    progressTouchStart(e) {
      this.touch.initiated = true
      this.touch.startX = e.touches[0].pageX
      this.touch.left = this.$refs.progress.clientWidth
    },
    progressTouchMove(e) {
      if (!this.touch.initiated) {
        return
      }
      const deltaX = e.touches[0].pageX - this.touch.startX
      const offsetWidth = Math.min(
        this.$refs.progressBar.clientWidth - progressBtnWidth,
        Math.max(0, this.touch.left + deltaX)
      )
      this._offset(offsetWidth)
    },
    progressTouchEnd() {
      this.touch.initiated = false
      this._triggerPercent()
    },
    progressClick(e) {
      const rect = this.$refs.progressBar.getBoundingClientRect()
      const offsetWidth = e.pageX - rect.left
      this._offset(offsetWidth)
      // 这里当我们点击 progressBtn 的时候,e.offsetX 获取不对
      // this._offset(e.offsetX)
      this._triggerPercent()
    },
    _triggerPercent() {
      const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
      const percent = this.$refs.progress.clientWidth / barWidth
      this.$emit('percentChange', percent)
    },
    _offset(offsetWidth) {
      this.$refs.progress.style.width = `${offsetWidth}px`
      this.$refs.progressBtn.style[
        transform
      ] = `translate3d(${offsetWidth}px,0,0)`
    }
  },
  watch: {
    percent(newPercent) {
      if (newPercent >= 0 && !this.touch.initiated) {
        const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
        const offsetWidth = newPercent * barWidth
        this._offset(offsetWidth)
      }
    }
  }
}
</script>

<style scoped lang="stylus">
@import '~@/style/stylus/variable'

.progress-bar {
  height: 30px;

  .bar-inner {
    position: relative;
    top: 13px;
    height: 4px;
    background: rgba(0, 0, 0, 0.3);

    .progress {
      position: absolute;
      height: 100%;
      background: $color-theme;
    }

    .progress-btn-wrapper {
      position: absolute;
      left: -8px;
      top: -13px;
      width: 30px;
      height: 30px;

      .progress-btn {
        position: relative;
        top: 7px;
        left: 7px;
        box-sizing: border-box;
        width: 16px;
        height: 16px;
        border: 3px solid $color-text;
        border-radius: 50%;
        background: $color-theme;
      }
    }
  }
}
</style>

progress-circle(圆形进度条组件)

<template>
  <div class="progress-circle">
    <svg
      :width="radius"
      :height="radius"
      viewBox="0 0 100 100"
      version="1.1"
      xmlns="http://www.w3.org/2000/svg"
    >
      <circle class="progress-background" r="50" cx="50" cy="50" fill="transparent" />
      <circle
        class="progress-bar"
        r="50"
        cx="50"
        cy="50"
        fill="transparent"
        :stroke-dasharray="dashArray"
        :stroke-dashoffset="dashOffset"
      />
    </svg>
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    radius: {
      type: Number,
      default: 100
    },
    percent: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      dashArray: Math.PI * 100
    }
  },
  computed: {
    dashOffset() {
      return (1 - this.percent) * this.dashArray
    }
  }
}
</script>

<style scoped lang="stylus">
@import '~@/style/stylus/variable'

.progress-circle {
  position: relative;

  circle {
    stroke-width: 8px;
    transform-origin: center;

    &.progress-background {
      transform: scale(0.9);
      stroke: $color-theme-d;
    }

    &.progress-bar {
      transform: scale(0.9) rotate(-90deg);
      stroke: $color-theme;
    }
  }
}
</style>

这里用了svg的circle标签,利用stroke-dasharraystroke-dashoffset两个属性实现的进度的展示,想要了解更多的关于这两个属性,可以点击这里

左右菜单联动组件

这里也是采用better-scroll做封装

vuex设计

从以上图中可以看到在歌曲播放上vuex大概需要这几个全局state:

const state = {
  playing: false, // 播放状态
  fullScreen: false, // 是否全屏显示
  playlist: [], // 顺序/顺序播放列表
  sequenceList: [], // 顺序播放列表
  mode: playMode.sequence, // 播放模式:随机/顺序/单曲循环
  currentIndex: -1 // 当前播放歌曲索引
}

那么当前歌曲currentSong就可以利用playList和currentIndex计算出来了:

const getters = {
  currentSong: state => state.playlist[state.currentIndex] || {}
}

当我们点击一首歌播放时,执行多个mutation:

selectPlay ({ commit, state }, {list, index}) {
  commit(types.SET_SEQUENCE_LIST, list) // 改变suquenceList
  if (state.mode === playMode.random) {
    let randomList = shuffle(list) // shuffle为随机方法
    commit(types.SET_PLAYLIST, randomList) // 如果为随机播放,则设置playList为randomList
    index = findIndex(randomList, list[index]) // 从randomList中找到歌曲索引
  } else {
    commit(types.SET_PLAYLIST, list) // 顺序播放设置playList为list
  }
  commit(types.SET_CURRENT_INDEX, index) // 设置当前歌曲索引currentIndex
  commit(types.SET_FULL_SCREEN, true) // 设置全屏
  commit(types.SET_PLAYING_STATE, true) // 设置播放状态
}

当我们需要从播放列表中插入其他歌曲或者从播放列表中删除时,需要怎么做呢?

const actions = {
  randomPlay ({commit}, {list}) {
    commit(types.SET_PLAY_MODE, playMode.random)
    commit(types.SET_SEQUENCE_LIST, list)
    let randomList = shuffle(list)
    commit(types.SET_PLAYLIST, randomList)
    commit(types.SET_CURRENT_INDEX, 0)
    commit(types.SET_FULL_SCREEN, true)
    commit(types.SET_PLAYING_STATE, true)
  },
  insertSong ({commit, state}, song) {
  	let playlist = state.playlist.slice() // 浅拷贝一份playList
    let sequenceList = state.sequenceList.slice() // 浅拷贝一份sequenceList
    let currentIndex = state.currentIndex
    // 记录当前歌曲
    let currentSong = playlist[currentIndex]
    // 查找当前列表中是否有待插入的歌曲并返回其索引
    let fpIndex = findIndex(playlist, song)
    // 因为是插入歌曲,所以索引+1
    currentIndex++
    // 插入这首歌到当前索引位置
    playlist.splice(currentIndex, 0, song)
    // 如果已经包含了这首歌
    if (fpIndex > -1) {
      // 如果当前插入的序号大于列表中的序号
      if (currentIndex > fpIndex) {
        playlist.splice(fpIndex, 1)
        currentIndex--
      } else {
        playlist.splice(fpIndex + 1, 1)
      }
    }
    let currentSIndex = findIndex(sequenceList, currentSong) + 1
    let fsIndex = findIndex(sequenceList, song)
    sequenceList.splice(currentSIndex, 0, song)
    if (fsIndex > -1) {
      if (currentSIndex > fsIndex) {
        sequenceList.splice(fsIndex, 1)
      } else {
        sequenceList.splice(fsIndex + 1, 1)
      }
    }
    commit(types.SET_PLAYLIST, playlist)
    commit(types.SET_SEQUENCE_LIST, sequenceList)
    commit(types.SET_CURRENT_INDEX, currentIndex)
    commit(types.SET_FULL_SCREEN, true)
    commit(types.SET_PLAYING_STATE, true)
  },
  deleteSong ({commit, state}, song) {
  	let playlist = state.playlist.slice()
    let sequenceList = state.sequenceList.slice()
    let currentIndex = state.currentIndex
    let pIndex = findIndex(playlist, song)
    playlist.splice(pIndex, 1)
    let sIndex = findIndex(sequenceList, song)
    sequenceList.splice(sIndex, 1)
    // 如果删除的歌曲索引比当前歌曲索引小或者当前播放歌曲为最后一首时,当前播放歌曲索引需要减1
    if (currentIndex > pIndex || currentIndex === playlist.length) {
      currentIndex--
    }
    commit(types.SET_PLAYLIST, playlist)
    commit(types.SET_SEQUENCE_LIST, sequenceList)
    commit(types.SET_CURRENT_INDEX, currentIndex)
    if (!playlist.length) { // 如果列表没有歌曲了,改变播放状态
      commit(types.SET_PLAYING_STATE, false)
    } else {
      commit(types.SET_PLAYING_STATE, true)
    }
  }
}

从这里可以看出,当一个全局变量会导致多个变量都需要变动的时候,就需要我们有一个全局的观念,考虑到各种可能出现的情况,这种时候往往先设计vuex,再来把数据放进页面会比一边写页面,一边考虑vuex会好思考一些。

总结

其实关于音乐app的项目见过很多教程,他们实现功能大同小异,但是也会有很多细节需要处理,因此自己动手敲一遍代码就会有新的体会。