vue實戰項目 vue-typescript-music 音樂項目 解析

vue vue-router  vuex vue/cli typescript 高仿網易雲音樂實現代碼全方位解析

前言:  vue全家桶 + TypeScript  構建一款移動端音樂webApp 項目的筆記

項目源碼地址   (https://github.com/lang1427/vue-typescript-music

涉及功能:  音樂播放器(播放進度條;播放列表;收藏歌單;刪除播放列表中的歌曲;頂部歌單名過長滾動(通知信息);播放模式【列表循環,隨機播放,單曲循環】;下載當前播放音樂;暫停播放;開始播放;上下首播放;左滑下一首播放;右滑上一首播放;歌詞);輪播圖;推薦歌單;新碟;歌單;專輯;登錄;註冊;播放歷史;歌單管理(新建歌單,刪除歌單);歌單添加歌曲;編輯歌單信息;下一首播放;評論(上拉加載更多評論;評論點贊/取消點贊;發表評論;回覆評論;複製評論;刪除評論;評論輸入框特效);搜索(防抖搜索處理;歷史搜索記錄;熱搜榜;熱門歌手);搜索結果(綜合,單曲,視頻,歌手,專輯,歌單,主播電臺,用戶);歌手詳情(熱門歌曲,專輯,MV)


音樂播放器

src/views/player 組件

 

音樂播放,暫停

    <audio autoplay></audio>

 當點擊音樂播放列表時,將當前的音樂列表存放在一個容器中(playList),並記錄當前播放音樂的索引值 (播放相關功能多處都要使用,許用vuex管理)

// mixin.ts
// 播放方法play(index)  設置當前的播放索引
import { PlayList } from "@/conf/playlist";
export const playMixin = {
  methods: {
    play(index) {
      this.$store.dispatch("changeCurrentPlayIndex", index);
      // 如果是播放列表中,點擊的播放,則不執行以下操作,只需改變當前播放索引即可
      if (this.songlist) {
        let playArr = [];
        this.songlist.forEach(item => {
          playArr.push(new PlayList(item));
        });
        this.$store.dispatch("changePlayList", playArr);
      }
    }
  }
}

  監聽當前播放音樂的索引值,一旦發生變化,就去請求 得到音樂的url地址

// 網絡請求   音樂是否可用 (可用的情況下獲取音樂的url)  
async getIsCanMusic(callback) {
    if (this.$store.getters.playMusicID === -1) return false;
    try {
      let res = await isCanMusic(this.$store.getters.playMusicID);
      if (res.success) {
        this.getMusicURL(callback);
      } else {
        this.$toast(res.message);
        this.isLoading = false;
        this.stop();
        this.url = null;
      }
    } catch (e) {
      this.$toast("親愛的,暫無版權");
      this.isLoading = false;
      this.url = null;
      this.stop();
    }
  }
  async getMusicURL(callback) {
    let res = await musicUrl(this.$store.getters.playMusicID);
    if (res.code === 200) {
      this.isPlay = true;
      if (res.data[0].url == null) {
        this.$toast("親愛的,暫無版權");
        this.stop();
        this.isLoading = false;
      }
      this.url = res.data[0].url;
      this.$nextTick(() => {
        // 確保刷新後不會自動播放
        callback && callback();
      });
    }
  }

  監聽url的改變,一旦改變就將新的url賦值給audio的src屬性上 因爲是autoplay 即src有新值即播放

另外需要監聽當前播放列表中當前音樂的id 去獲取音樂是否可用的網絡請求

 
  @Watch("$store.getters.playMusicID")
  changePlauMusicID() {
    this.getIsCanMusic();
  }

  @Watch("url")
  changeURL(newVal: string) {
    this.$refs.audio.src = newVal;
  }

由於採用的是本地存儲,當播放容器中數據時刷新會導致audio標籤的src爲null,需要手動在mounted生命中週期中手動調用獲取音樂是否可用等接口 (需要注意的是在回掉函數中)

  mounted() {
    this.getIsCanMusic(() => {
      this.stop();
    });
  }

無論是播放還是暫停 都是迷你版容器子組件和全屏版子組件向父組件index.vue發送事件。暫停狀態改成播放狀態,反之一樣

// 改變播放狀態的方法
  playStatus(val: boolean) {
    this.isPlay = val;
    if (val) {
      this.play();
    } else {
      this.stop();
    }
  }

 

播放模式

確定播放模式:["列表循環", "單曲循環", "隨機模式"]

默認模式取索引爲 0 =》列表循環 ,當點擊播放模式時,改變索引值(這裏通過枚舉)進行改變;同時改變其類樣式

// 定義播放的類型
export enum EPlayMode {
  listLoop, // 0列表循環
  singleLoop, // 1單曲循環
  random  // 2隨機播放
}
// 播放模式 mixin.ts
export const playModeMixin = {
  data() {
    return {
      modeName: ["列表循環", "單曲循環", "隨機模式"]
    }
  },
  computed: {
// 類樣式
    modeICON() {
      let mode = "listloop";
      switch (this.$store.state.playMode) {
        case 0:
          mode = "listloop";
          break;
        case 1:
          mode = "singleloop";
          break;
        case 2:
          mode = "random";
          break;
      }
      return mode;
    }
  }
  , methods: {
    changeMode() {
      switch (this.$store.state.playMode) {
        case 0:
          this.$store.commit("changePlayMode", EPlayMode.singleLoop);
          break;
        case 1:
          this.$store.commit("changePlayMode", EPlayMode.random);
          break;
        case 2:
          this.$store.commit("changePlayMode", EPlayMode.listLoop);
          break;
      }
// 播放模式改變的消息提示
      if (this.isShow != undefined) {
          this.isShow = true;
        let timer = window.setTimeout(() => {
          this.isShow = false;
          window.clearTimeout(timer);
        }, 1000);
      }
    }
  }
}
// 採用的是背景圖片
      &.play-mode {
        background-repeat: no-repeat;
        background-size: 40%;
        background-position: center center;
        height: 20px;
      }
      &.listloop {
        background-image: url(../image/listloop.png);
      }
      &.singleloop {
        background-image: url(../image/singleloop.png);
      }
      &.random {
        background-image: url(../image/random.png);
      }

 

上下首播放,左右滑切換播放

封裝next()  prev() 兩個方法,當觸發下一首播放時調用next方法,反之調用prev方法。

無論是上一首播放還是下一首播放,都應該考慮播放模式。

  1. 當前播放模式爲列表循環:next()方法只需要將當前播放容器的當前播放索引值進行自增即可,需要注意溢出情況當大於等於播放容器數量-1時設置爲0;prev()反之
  2. 當前播放模式爲單曲循環同上,單曲循環模式只應該在歌曲播放完成之後進行判斷是否爲單曲循環模式
  3. 當前播放模式爲隨機播放,則需要隨機生成一個0~播放容器長度-1的索引值進行設置
  prev() {
   // this.isLoading = true;  不是關鍵代碼,加載中的提示信息
    if (this.$store.state.playMode === 2) {  
// 這裏就是隨機生成匹配的索引值了
      let random = Math.floor(
        Math.random() * this.$store.getters.playListLength
      );
      this.$store.dispatch("changeCurrentPlayIndex", random);
    } else {
      let index = this.$store.state.currentPlayIndex;
      if (index <= 0) {
        this.$store.dispatch(
          "changeCurrentPlayIndex",
          this.$store.getters.playListLength - 1
        );
      } else {
        this.$store.dispatch("changeCurrentPlayIndex", index - 1);
      }
    }
  }
  next() {
   // this.isLoading = true;
    if (this.$store.state.playMode === 2) {
      let random = Math.floor(
        Math.random() * this.$store.getters.playListLength
      );
      this.$store.dispatch("changeCurrentPlayIndex", random);
    } else {
      let index = this.$store.state.currentPlayIndex;
      if (index >= this.$store.getters.playListLength - 1) {
        this.$store.dispatch("changeCurrentPlayIndex", 0);
      } else {
        this.$store.dispatch("changeCurrentPlayIndex", index + 1);
      }
    }
  }

左右滑動切換上下首,只在全屏播放容器中的cd處操作實現。需要獲取左右滑的位移  是否超過屏幕的1/4,超過即調用上下首播放的功能

full-player.vue

<!--  綁定觸摸開始與結束時間  --->
      <div class="CD-lyrics">
        <div
          :class="[playStatu?'rotate cd':'cd']"
          @touchstart="toggleStart"
          @touchend="toggleEnd"
        >
          <img :src="$store.getters.playMusicImg" alt />
        </div>
      </div>
  private touch = {
    startX: 0
  };
  toggleStart(e) {
    this.touch.startX = e.touches[0].pageX;  // 記錄觸摸開始時的X座標值
  }
  toggleEnd(e) {
// 計算位移 = 結束時x的座標值 - 開始時x的座標值
    let displacement = e.changedTouches[0].pageX - this.touch.startX; 
// 獲取全屏播放容器的寬度
    let clientWidth = (this.$refs.fullPlayer as HTMLElement).clientWidth;
    // 滑動的位移超過1/4則進行上下首切換
    if (Math.abs(displacement) > clientWidth / 4) {
      if (displacement > 0) {
        this.$emit("prev");
      } else {
        this.$emit("next");
      }
    }
  }

index.vue

    <full-player
      ref="fullPlayer"
      @prev="prev"
      @next="next"
    ></full-player>

 

播放完當前音樂後自動調用下一首  需要判斷模式是否爲單曲循環

設置當前播放音樂的當前時間爲0,而不是給audio標籤添加loop屬性 

  singleloop() {
    this.$refs.audio.currentTime = 0;
    this.play();
  }
  playEnd() {
    if (this.$store.state.playMode === 1) {
      this.singleloop();
    } else {
      this.next();
    }
  }

 

下載當前播放音樂

這個並沒有提供下載音樂的接口,這裏只是簡單把當前音樂的src路徑給放到超鏈接中即可簡單實現

          <a  class="fa-arrow-circle-o-down download"  :href="downloadUrl" 
             download  @click="downloadMusic"
          ></a>

 

播放進度條

環形進度條採用SVG的形式刻畫,線性進度條爲單純div設置寬度顏色即可。都需要一個共同的當前進度值Peraent轉遞給進度條組件。

當前播放進度通過audio標籤的timeupdate事件獲取,初始進度爲0,總進度爲音樂的時長可通過audio標籤的canplaythrough

事件獲取

// timeupdate當前播放時間一改變就會執行
  timeupdate(e) {
    if (!this.isMove) {  // 這裏是爲了阻止拖動或點擊進度條時執行
      this.currentTime = e.target.currentTime;  // 設置當前時間
    }
  }
  loadComplete(e) {
    // 獲取到歌曲總時長
    this.duration = e.target.duration;
  }

當前進度 =  當前播放時間 / 總時長

  get Peraent() {
    return this.duration === 0 ? 0 : this.currentTime / this.duration;
  }

點擊或滑動進度條 至 指定的時間段播放

只有全屏播放容器full-player.vue中才可以產生滑動或點擊進度條調整播放時間的方式,So,在該組件下需要向外發送兩個事件,而這個並不是重點,重點是full-player.vue組件下的子組件progress-bar.vue組件,此組件記錄了當前按下,滑動,鬆開一系列操作,爲了得到當前的時間

點擊與滑動進度條不同,點擊進度條則直接計算出當前的進度想外發送當前進度值,而滑動需要通過touchstart,touchmove,touchend事件的綜合,這裏的進度百分比是指  線性進度條紅色區域所佔比的值並不是當前的進度Peraent

  progressClick(e) {
    let moveLineWidth = e.pageX - this.$refs.currentTime.offsetWidth;
    this.percent = Math.max(0, moveLineWidth / e.toElement.offsetWidth); 
    this.$emit("endPercent", this.percent);
  }
  progressStart(e) {
    this.moveStatus = true;
  }
  progressMove(e) {
    if (!this.moveStatus) return;

    // 移動過程中進度條的寬度  =  當前移動過程中PageX的值 - 展示當前時間div的寬度
    let moveLineWidth =
      e.touches[0].pageX - this.$refs.currentTime.offsetWidth;
    // 進度百分比 = 移動過程中進度條的寬度 / 進度欄
    this.percent = Math.max(0, moveLineWidth / this.totalProgress);
    // 向外告知 當前移動過程中的百分比
    this.$emit("changePercent", this.percent);
  }
  progressEnd(e) {
    this.moveStatus = false;
    this.$emit("endPercent", this.percent);
  }

所以在index.vue中需要設置他的當前播放時間爲 進度百分比 * 總時長。

(設置當前播放時間即可直接改變當前進度:因爲當前進度是計算屬性獲取的當前時間/總時長的值)

  changePercent(newVal) {
    this.isMove = true;
    this.currentTime = newVal * this.duration;
  }
  endPercent(newVal) {
    this.currentTime = newVal * this.duration;
    this.$refs.audio.currentTime = this.currentTime;
    if (this.$refs.audio.paused) {
      this.stop();
    } else {
      this.play();
    }
    this.isMove = false;
  }

 

頂部歌單名過長滾動(通知信息)

這個感覺沒什麼好說的,第三方庫很多,我是根據 vant ,來寫的noticeBar組件,比較簡單,也比較實用。需要注意的是一個潛在的問題,我已經Pull requests這個問題。 https://github.com/youzan/vant/pull/6069

感興趣的可以看看我寫的noticeBar組件,

問題在於 :

  1. 因爲頂部的noticeBar在隱藏的情況下是沒有寬度的,需要監聽到隱藏狀態,當是全屏播放容器時則需要開啓滾動
  2. 沒有重置滾動

So,使用時,full-player.vue組件需要監聽是否是全屏容器,進行手動調用滾動方法

  watch: {
    "$parent.isMiniShow": {
      handler(val) {
        if (val === false) {
          this.$refs.noticeBar.startScroll();
        }
      },
      immediate: true
    }
  }

 

播放列表;收藏歌單;刪除播放列表中的歌曲

好像沒什麼難度,就是一些數據請求 Dom渲染。不過需要注意滑動播放列表時,上一層(非底部彈出層)的內容也隨之滾動,還好解決了這個問題。 產生的問題及解決方式

解決方式的原理:給body添加overflow:hidden

<script> 
 @Watch("popupShow")
  changePopupShow(newVal: boolean) {
    if (newVal === true) {
      document.body.classList.add("hidden");
    } else {
      document.body.classList.remove("hidden");
    }
  }
}
</script>
<style lang='less'>
// 不讓cssModules 添加 哈希值的方式 : 不要 scoped
.hidden {
  overflow: hidden;
}
</style>

 另外在說說刪除播放容器中的歌曲的實現方式吧,如下:

當點擊了x或則那個垃圾桶都會調用remove方法,remove方法接受一個參數,參數爲當前播放列表點擊的索引值或則-1,-1代表刪除所有播放列表中的歌曲即清空操作。當然這裏刪除所有歌曲的話會有一個確認清空的彈框。

  confirmRemove() {
    this.$store.dispatch("removePlayList", -1);
  }
  remove(val: number) {
    if (val === -1) {
      this.confirmShow = true;
      return false;
    }
    this.$store.dispatch("removePlayList", val);
  }

接下來就是異步的vuex處理了


  removePlayList(context, newVal) {
    return new Promise((resolve, reject) => {
      if (newVal === -1) {
        context.commit('removeAll')
        resolve('清空播放列表')
      } else {
        context.commit('removeCurrent', newVal)
        resolve('刪除當前選中歌曲')
      }
    })

  },

以上都比較簡單,至於爲啥使用了Promise,完全是個誤會,可以省略。當初是爲了想要接着做一些其他的操作,然後發現不需要。

接下來纔是真正的刪除播放列表功能的代碼

// 當刪除的索引值 小於 當前播放的索引值時 需要對當前的索引值 -1

  // 當刪除的索引值 等於 當前播放的索引值 並且 是最後一個時, 需要對當前的索引值設爲 0
  // 這裏的length 不用減1  是因爲 上面splice時已經去掉了一個元素

  removeAll(state) {
    state.playList = []
    window.localStorage.setItem('playlist', JSON.stringify([]))
    mutations.changePlayIndex(state, -1)
  },
  removeCurrent(state, newVal) {
    state.playList.splice(newVal, 1)
    window.localStorage.setItem('playlist', JSON.stringify(state.playList))
    // 當刪除的索引值 小於 當前播放的索引值時 需要對當前的索引值 -1
    if (newVal < state.currentPlayIndex) {
      mutations.changePlayIndex(state, state.currentPlayIndex - 1)
    }
    // 當刪除的索引值 等於 當前播放的索引值 並且 是最後一個時, 需要對當前的索引值設爲 0
    // 這裏的length 不用減1  是因爲 上面splice時已經去掉了一個元素
    if (newVal === state.currentPlayIndex && newVal === state.playList.length) {
      mutations.changePlayIndex(state, 0)
    }
  },

 

 歌詞

歌詞的解析通過lyric-parser.js 

網易雲音樂Api 歌詞數據 與  lyric-parser源代碼 這裏的有所衝突,不曉得別的api數據有沒有,推薦你使用我utils目錄下的lyric-parser.js   另外需要使用 require的形式導入 而非import,當然你也可以自己改成自己想要導入導出模塊的形式

我改變其導出的方式是因爲在ts中比較嚴格,會有一些錯誤信息提示,但並非錯誤

Could not find a declaration file for module '@/utils/lyric-parser'. 'F:/music/vue-typescript-music/src/utils/lyric-parser.js' implicitly has an 'any' type.

 

好像沒什麼太大的注意事項,無非就是調用提供的Api,所以自己看吧。

1.獲取到歌曲url後就獲取歌詞

2.點擊暫停或則播放觸發提供的togglePlay() 切換播放模式的方法

3.單曲循環是調用seek()方法

4.滑動進度條時同樣調用seek方法,傳入正確的時間

        <div class="lyrics" v-show="currentShow==='lyrics'" @click="currentShow='cd'">
          <lyric-scroll class="lyric-scroll-wrapper" ref="lyricScroll">
            <div class="lyric-content">
              <div class="current-lyric" v-if="lyricData">
                <p
                 ref="lyricLine"
                  v-for="(line,index) of lyricData.lines"
                  :key="line.key"
                  :class="{'current':$parent.currnetLineNum===index}"
                   class="text"
                >{{ line.txt }}</p>
              </div>
              <p v-if="lyricData === null" class="lyric-state">{{ lyricState }}</p>
            </div>
          </lyric-scroll>
        </div>

js腳本就不往上添了,看源碼吧,比較簡單。

 

歌單 專輯

歌單和專輯都是通過同一個組件 musicList

如路由以下配置:

const album = () => import(/*webpackChunkName:'album'*/'views/musicList/index.vue')
const songsheet = () => import(/*webpackChunkName:'songsheet'*/'views/musicList/index.vue')

export default [
    {
        path: '/album/:id',
        name: 'album',
        component: album
    },
    {
        path: '/songsheet/:id',
        name: 'songsheet',
        component: songsheet
    }
]

請求數據時 只需要對路由做一個判斷,請求對應得路由即可

  created() {
    if (this.$route.path.match(/\/album\//)) {
      this.getAlbumContent();
    } else if (this.$route.path.match(/\/songsheet\//)) {
      this.getSongsDetail();
      this.getUserSongsheet();
    }
  }

頂部標題 等一系列內容都是通過路由進行區分

 

 登錄 註冊

郵箱登陸就算了,已經屏蔽了,屏蔽原因:獲取的很多信息都不是正確的。

  1. 獲取用戶的手機號,輸入過程中+86的顏色變深,根據輸入框的值是否長度大於1並且是數字,效驗手機號等操作
  2. 輸入完手機號 就會發送短信 至你的手機號 ,輸入正確的驗證碼(驗證碼也做了數字或字母等限制,以封裝完成)
  3. 驗證驗證碼是否正確,正確則判斷用戶是否爲註冊,未註冊則去註冊,註冊了則輸入密碼進行登陸
  4. 註冊需要輸入密碼與暱稱

1.

    <div class="phone-box">
      <span class="ico" :class="isActive ? 'active':''">+86</span>
      <input class="phone-input" type="tel"  maxlength="11"  v-model.trim="phoneNumber"  placeholder="請輸入手機號" 
          @keyup="isNumber"/>
    </div>
  private phoneNumber:string = ''
  get isActive(){
    if(this.phoneNumber.length>=1 && this.phoneNumber.match(/^\d/)){
      return true
    }
  }
  isNumber(){
   this.phoneNumber =  this.phoneNumber.replace(/[^\d]/g,'')
  }

2,3  一旦進入輸入驗證碼界面,計時器就開始,注意初始值爲59而不是60,因爲判斷條件是!=60才顯示倒計時,而且進入看到60並不是這個需求。另外,登錄時需要用到驗證碼,這裏沒有對路由保存驗證碼或手機號等信息,所以需要在離開這個組件之前beforeDestory通過事件總線$bus將驗證碼發射出去,登陸時接受即可。而手機號是通過vuex進行了保存。

    <div class="send-out">
      <div class="info">
        <p>驗證碼已發送至</p>
        <p class="num">
          <span class="ico">+86</span>
          {{ $store.getters.encodeLoginAccount }}
        </p>
      </div>
      <div class="timer">
        <p class="time" v-if="timer!=60">{{ timer }} s</p>
        <p class="regain" v-else @click="Regain">重新獲取</p>
      </div>
    </div>
    <v-code :count="4" @inputComplete="getTestVerifyCode" />
  timer= 59;
  verifycodeVal= "";
  pawd= "";
  pawdShow= false;
  created() {
    this.startTimer();
    this.getSendVerifyCode();  // 發送請求 獲取驗證碼至手機號
  }
  beforeDestory() {
    window.clearInterval(this.flagTimer);
    this.$bus.$emit("verifycodeVal", this.verifycodeVal);
  }
  startTimer() {
    this.flagTimer = window.setInterval(() => {
      this.timer--;
      if (this.timer === 0) {
        this.timer = 60;
        window.clearInterval(this.flagTimer);
      }
    }, 1000);
  }
  Regain() {
    this.startTimer();
    this.getSendVerifyCode();
  }

  async getSendVerifyCode() {
    let res = await sendVerifyCode(this.$store.state.loginAccount);
    if (res.code === 200) this.$toast("已發送驗證碼");
  }
  async getTestVerifyCode(inputVal: string) {
    this.verifycodeVal = inputVal;
    let res = await testVerifyCode(this.$store.state.loginAccount, inputVal);
    if (res.code === 200) this.getTestIsRegister();
    else this.$toast(res.message);
  }
  async getTestIsRegister() {
    let res = await testIsRegister(this.$store.state.loginAccount);
    if (res.code === 200) {
      if (res.exist === -1) {
        this.$router.push("/register");
      } else {
        // 手機登陸
        this.pawdShow = true;
      }
    }
  }

4. 註冊之後即爲登錄。這裏只要在created時將驗證碼值接收,完了發生請求即可

  created() {
    this.$bus.$on('verifycodeVal',(code)=>{
      this.verifyCode = code
    })
  }

重點分享下自己封裝的驗證碼組件  (代碼就不貼了,請看verify-code組件)

1.首先是input框的數量,通過count可自定義數量,必須傳入的綁定值,因爲通過count進行遍歷得到input框的數量

2.通過各種事件對input的框進行效驗,不能輸入中文等

3.通過mode自定義input框的模式爲只能輸入數字,或者只能輸入字母,或者能輸入數字與字母。默認爲只輸入數字

4.當前輸入框完成輸入後跳轉到下一個輸入框,刪除當前輸入框內容跳轉到上一個輸入框

5.對佈局採用特定的計算方式,input框的寬度,無需設置樣式,當然如果你覺得不合自己胃口,可以自行更改

6.當確保了所有輸入框的內容都有且有一個值時,想外發送一個事件inputComplete,並將當前所輸入完成的值傳遞出去

 

播放歷史

限制:最近播放200條數據

沒有提供serviceApi那就自己存在本地,與播放相關,所以需要在audio標籤事件中進行,這裏以當歌曲加載完畢既添加播放歷史爲例。

    <audio ref="audio" autoplay @canplaythrough="loadComplete"></audio>
  loadComplete(e) {
    // 200條歷史播放功能
    const { playMusicID, playMusicName, playMusicImg } = this.$store.getters;
    this.$store.dispatch("operationPlayHistory", {
      id: playMusicID,
      name: playMusicName,
      imgURL: playMusicImg
    });
  }

 邏輯有點繞,but 仔細捋一下就會很清晰

首先 判斷 當前播放列表是否是最近播放中的數據,如果是則直接返回

如果操作播放歷史的方法傳入-1,即代表清空最近播放記錄(當然目前界面上沒有去寫播放記錄中的此項操作)

如果最近播放列表中的數據不等於0需要查找一下當前需要添加的最近播放的數據是否包含在最近播放列表中,

如果包含即findIndex的結果!=-1,則需要刪除掉原最近播放中需要添加的這項。

另外直接判斷最近播放列表數據是否已超過200條。

不管有沒有超過,在上一個判斷中如果刪除了原最近播放中需要添加的這項數據,就都需要插入回去,而沒有超過200條可直接在數組中往前插入unshift,

當然,如果上一個判斷已經刪除掉了一個,則肯定沒有200條數據了。

如果超過了,則需要將原有的數據中的最後一個刪除,將新的數據插入在最前面。

  operationPlayHistory(context, newVal) {

    if (lodash.isEqual(context.state.playList, context.state.playHistory)) return false

    if (newVal === -1) {
      context.commit('clearPlayHistory')
      return false
    }

    if (context.state.playHistory.length != 0) {
      let res = context.state.playHistory.findIndex((item) => {
        return item.id === newVal.id
      })
      if (res !== -1) {
        context.commit('removeCurrentPlayHistory', res)
      }
    }

    if (context.state.playHistory.length < 200) {
      context.commit('unshiftPlayHistory', newVal)
    } else {
      context.commit('splicePlayHistory', newVal)
    }
  }
// mutationins
  clearPlayHistory(state) {
    state.playHistory = []
    window.localStorage.setItem('playHistory', JSON.stringify([]))
  },
  removeCurrentPlayHistory(state, newVal) {
    state.playHistory.splice(newVal, 1)
    window.localStorage.setItem('playHistory', JSON.stringify(state.playHistory))
  },
  unshiftPlayHistory(state, newVal) {
    state.playHistory.unshift(newVal)
    window.localStorage.setItem('playHistory', JSON.stringify(state.playHistory))
  },
  splicePlayHistory(state, newVal) {
    state.playHistory.splice(state.playHistory.length - 1, 1)
    mutations.unshiftPlayHistory(state, newVal)
  }

 

歌單管理 (需登錄)

新建歌單

很單純的一個彈框加網絡請求,沒什麼難度

刪除歌單

涉及複選框的全選和反全選,單選等操作,還是說一下吧

1.首先複選框v-module雙向綁定選擇的值,當然值會是一個數組,動態綁定value值爲每項的id

<input type="checkbox" class="checkbox-items" v-model="isChecks" :value="item.id" />
  private isChecks: number[] = [];

 2.全選和反選的文字替換:根據計算屬性,計算選中的數量的個數是否等於原數量的個數,等於則需要顯示爲取消全選否則全選

  get isAllCheckText() {
    if (this.isChecks.length === (this as any).mySongsList.length) {
      return "取消全選";
    }
    return "全選";
  }

3.點擊全選或取消全選都觸發allCheck方法,方法中獲取到每項複選框,如果顯示的是全選則將每項的value值push進雙向綁定的數組中即可;如果顯示的是取消全選,則將雙向綁定的數組設爲空數組

  allCheck() {
    let checkBox = this.$refs.songlistREF;
    let checkItems = (checkBox as HTMLElement).querySelectorAll(
      ".checkbox-items"
    );
    if (this.isAllCheckText === "全選") {
      this.isChecks = [];
      for (let item of checkItems) {
        this.isChecks.push((item as any).value);
      }
    } else {
      this.isChecks = [];
    }
  }

 

歌單添加歌曲(自身歌單纔有的操作)

1. 默認顯示最近播放中的歌曲進行添加,

    <div class="recently-played" v-if="searchRes.length===0">
      <h6 class="title">最近播放</h6>
      <ul class="list">
        <li
          class="list-items"
          v-for="item of playHistoryList"
          :key="item.id"
          @click="add(item.id)"
        >{{ item.name }}</li>
      </ul>
    </div>
  get playHistoryList() {
    return this.$store.state.playHistory;
  }

2.搜索後(只搜單曲),顯示搜索後的歌曲信息

3.搜索有個防抖處理  和搜索界面一樣,這裏先說防抖,後面搜索就不說了

debounce防抖函數,通過監聽搜索內容的變化,調用防抖函數,將網絡請求傳遞進去,當500ms後,輸入框的值還沒有發生變化,就向服務器發送請求,請求搜索的歌曲

  debounce(fn: any, delay: number = 500) {
    if (this.timer) clearTimeout(this.timer);
    this.timer = window.setTimeout(() => {
      fn.call(this, this.searchContent);
    }, delay);
  }
  @Watch("searchContent")
  changeSearchContent(newVal: string) {
    if (newVal.trim().length !== 0) {
      this.debounce(this.getSearchSuggest);
    }
  }

4.添加歌曲到歌單中:點擊當前項,拿到當前項的id發送請求

 

另外說一下,這裏的縫隙是怎麼寫的,整體採用flex佈局,左邊一個固定寬度,輸入框盒子爲1,輸入框寬度爲calc(100% - 25px),至於右側的縫隙是另寫了一個沒有內容的div設置一個小小的寬度即可實現。

 

 

編輯歌單信息(自身歌單纔有的操作)

無論是編輯啥,都是通過路由傳參的形式將需要的數據傳遞進去

  goEditName() {
    this.$router.push({
      path: "/songmanage/update/editname",
      query: {
        songid: this.id + "",
        songname: this.songName
      }
    });
  }
  goEditTags() {
    this.$router.push({
      path: "/songmanage/update/edittags",
      query: {
        songid: this.id + "",
        tags: this.songTags
      }
    });
  }
  goEditDesc() {
    this.$router.push({
      path: "/songmanage/update/editdesc",
      query: {
        songid: this.id + "",
        desc: this.songDesc
      }
    });
  }

編輯歌單名稱

編輯保存後,爲了防止頁面刷新,內容還是之前爲被編輯的名稱,所以需要更改songname參數的值

  async setUpdateSongName() {
    let res = await updateSongName(this.id, <string>this.songName);
    if (res.code === 200) {
      this.$toast("修改成功");
      // this.$route.query.songname = this.songName 
        // query.songname改變了 but 地址欄中的songname並沒有發生變化   *錯誤方式*
      this.$router.replace({
        query: { ...this.$route.query, songname: this.songName }
      });
    }
  }

編輯歌單tags

 爲了實現 那種佈局 模式,所以採用了很多個table,實在沒有想到可遍歷的形式將那種佈局給填充出來,如果在看的各位,有實現思路,歡迎提出!

所以爲了實現編輯歌單tags的需求,不得已手動操作Dom。廢話不多說了,說實現思路:

1.頁面進來需要獲取到選中了的數量,爲了展示在提示消息中

  private checkCount: number = 0;
  get tipMes() {
    return `請選擇合適的標籤,最多選擇3個,已選${this.checkCount}個`;
  }
  mounted() {
    this.setCheckCount();
  }
  setCheckCount() {
    let tagsList = (<HTMLElement>this.$refs.tagsBody).querySelectorAll(
      ".tags-active"
    );
    this.checkCount = tagsList.length;
  }

 2.點擊選中時,判斷是否選中,如果選中,則需要移除類樣式與相關文本內容

3.如果沒有被選中,需要判斷是否超過了3個,沒有則被選中,否則給出提示消息,不讓選中

  isCheck(e: any) {
    if (e.target.classList.contains("tags-active")) {
      this.songTags = (this.songTags as string).replace(
        htmlDecode(e.target.innerHTML),
        ""
      );
      e.target.classList.remove("tags-active");
    } else {
      if (this.checkCount >= 3) {
        this.$toast("最多選擇3個");
        return false;
      }
      this.songTags = (this.songTags as string).replace(
        "",
        htmlDecode(e.target.innerHTML)
      );
      e.target.classList.add("tags-active");
    }

    this.setCheckCount();
  }

4.保存時,在將所有被選中的內容保存起來向後臺發送請求

  async setUpdateSongTags() {
    let tagsList = this.$refs.tagsBody.querySelectorAll(
      ".tags-active"
    );
    let tags = [];
    tagsList.forEach(item => {
      tags.push(htmlDecode(item.innerHTML));
    });
    tags = tags.join(";");
    let res = await updateSongTags(this.id, tags);
    if (res.code === 200) {
      this.$toast("修改成功");
      this.$router.replace({
        query: { ...this.$route.query, tags }
      });
    }
  }

特別要注意的是 可能 因爲 & 等轉義符而改變了原有內容,需要反轉義一下

編輯歌單描述

需求:唯一一個保存之後回到上一個頁面。沒什麼別的難度

 

下一首播放

彈框是songlist-operation組件

邏輯:將當前點擊項相關信息插入到播放列表中當前播放的下一個中即可。

  nextPlay(playInfo) {
    let obj = {
      id: playInfo.songsId,
      imgURL: playInfo.imgUrl,
      name: playInfo.songsName
    };
    this.$store.commit("insertPlaylist", obj);
    this.operationShow = false;
  }
  insertPlaylist(state, newVal) {
    (state.playList as []).splice(state.currentPlayIndex + 1, 0, (<never>newVal))
    window.localStorage.setItem('playlist', JSON.stringify(state.playList))
  },

 

 

評論

先說一下評論的數據獲取,獲取的數據格式回覆他人評論的數據居然不是放在該評論裏,而是單獨在來一個評論,看了一下真實的網易雲音樂App的評論,無法理解網易雲的評論爲啥要搞成這樣,感覺顛覆了我的思想,於是我改成了我想要的樣子,這效果類似於QQ空間的說說,主要我是對評論和回覆的數據進行了過濾,在將回複評論給對應的評論中

你也可以根據你自己的想法來,but 這不是一個bug,請不要在使用過程中對我提這是bug

回覆中的parentCommentId等於評論中的commentId,即爲此評論中的回覆

爲了防止循環遍歷而創建多個元素,所以這裏採用template的形式進行循環,當然:key是不能在template中進行綁定的

        <div class="list-items" v-for="comment of commentData" :key="comment.commentId">
          <div
            class="comment-content">{{ comment.commentContent }}</div>
          <div class="reply">
            <template v-for="reply of replyData">
              <div :key="reply.commentId" v-if="reply.parentCommentId === comment.commentId">
                <span class="name">{{ reply.userName }} :&nbsp;</span>
                <span>{{ reply.commentContent }}</span>
              </div>
            </template>
          </div>
        </div>
  get commentData() {     // 評論
    return this.commentList.filter(item => {
      return item.parentCommentId === 0;
    });
  }
  get replyData() {      // 回覆
    return this.commentList.filter(item => {
      return item.parentCommentId !== 0;
    });
  }

評論點贊、取消點贊(需登錄)

            <div class="liked">
              <span
                @click="setLikeComment(comment.commentId)"
                :class="comment.commentLiked ? 'fa-thumbs-o-up liked-active' : 'fa-thumbs-o-up' "
              >{{ comment.commentLikedCount !== 0 ? comment.commentLikedCount : '' }} &nbsp;&nbsp;</span>
            </div>
  async setLikeComment(cid: number) {
    this.$parent.testLogin();

    let domEvent = event;
    let res = await likeComment(
      parseInt(this.$route.query.id),
      cid,
      domEvent.target.classList.contains("liked-active") ? 0 : 1,
      this.$parent.commentType
    );
    if (res.code === 200) {
      if (!domEvent.target.classList.contains("liked-active")) {
        domEvent.target.classList.add("liked-active");
        if (domEvent.target.innerHTML == "") {
          domEvent.target.innerHTML = 1;
        } else {
          domEvent.target.innerHTML =
            domEvent.target.innerHTML * 1 + 1;
        }
      } else {
        domEvent.target.classList.remove("liked-active");
        domEvent.target.innerHTML -= 1;
        if (domEvent.target.innerHTML == 0) {
          domEvent.target.innerHTML = "";
        }
      }
    }
  }
// 將font-awesome 的 fa-thumbs-o-u(豎大拇指)字體圖標 改成 after 顯示
.fa-thumbs-o-up:before {
  content: "";
}
.fa-thumbs-o-up:after {
  content: "  \f087";
}

1.原本Dom節點爲兩個span,第一個span是點贊數量,第二個span是大拇指的小圖標,至於爲什麼改成一個span了,是因爲兩個span會導致很多不必要的麻煩;當然,當你改成一個span時,你的小圖標顯示在你文字的前面,很明顯不服合界面美觀。於是將font-awesome的大拇指圖標從before改成after即可

2.點贊或取消點贊都需要登錄狀態下,所以一開始就要調用testLogin()

3.第三個參數爲是否點贊,1爲點贊,0爲取消點贊,原本是根據commentLiked來判斷1或者0,但是呢,考慮到對數據的處理,這裏並不好在直接對原有的commentNewData數據進行$set()的操作了,所以採用了操作dom的形式

如果感興趣我之前的想法的話,請看原有操作錯誤的想法 

4.操作dom需要注意的是,需要在axios請求之前將dom的event保存起來,因爲在axios裏面的event是網絡請求相關event事件對象了,並不是dom的event事件對象

5.需要對innerHTML中的值進行判斷處理

 

發表,回覆,刪除等評論操作都是通過同一個接口operationComment 

 發表評論、回覆評論(需要登錄)

1.通過輸入框值進行發送send()操作,發送時向外傳遞是發表評論還是回覆評論

2.如果是回覆評論,則在回覆過程中需要點擊當前回覆的評論對象執行reply()方法,該方法向事件總線$bus.$emit("replyComment")傳遞對應的評論id及用戶名稱,那麼在發送輸入框內容之前,就會將當前傳遞的id和name通過接受$bus.$on("replyComment"),將接受到的用戶名稱用於inputplaceholder屬性中,當發送時進行判斷placeholder是否還是默認值時,如果不是則爲回覆評論。否則爲發表評論

3.send()之後需要對input以及回覆的id進行重置

  reply(replyID, userName) {
    this.$bus.$emit("replyComment", replyID, userName);
  }
  mounted() {
    this.$bus.$on("replyComment", (rid, name) => {
      (this.$refs.commentInput as HTMLInputElement) &&
        (this.$refs.commentInput as HTMLInputElement).focus();
      this.$refs.commentInput
        ? ((this.$refs.commentInput as HTMLInputElement).placeholder =
            "回覆" + name)
        : null;
      this.replyID = rid;
    });
  }
  send() {
    if (this.commentVal.trim() === "") {
      return false;
    }
    let operationType = 1;
    if (
      (this.$refs.commentInput as HTMLInputElement).placeholder !==
      "隨樂而起,有感而發"
    ) {
      operationType = 2;
    }
    this.$emit(
      "sendComment",
      operationType,
      this.commentVal,
      this.replyID !== -1 ? this.replyID : null
    );
    this.commentVal = "";
    this.replyID = -1;
    this.$refs.commentInput
      ? ((this.$refs.commentInput as HTMLInputElement).placeholder =
          "隨樂而起,有感而發")
      : null;
  }

這裏並不是通過點擊發送按鈕進行驗證是否登錄,而是通過當input獲取到焦點時

      <input type="text" placeholder="隨樂而起,有感而發" ref="commentInput"  v-model="commentVal"
        @focus="$parent.testLogin()"
      />

 

複製評論和刪除評論都需要對當前評論長按纔會出現此功能,當然刪除評論只能是自身發表的評論

先說一下長按事件,移動端經常會有的操作,但是卻沒有原生的長按事件,So,需要自己手寫一個。

長按事件的構成:touchstart、touchmove、touchend事件的綜合;

start時啓動一個300ms的一次性定時器,一次性定時器內部執行你的內部代碼,一旦move或者end則關閉一次性定時器。

我已經封裝成自定義指令了,請看源碼中longpress.ts文件

          <div
            class="comment-content"
            v-longpress="{'methods':longpressDialog,'params':{content:comment.commentContent,userId:comment.userId,commentId:comment.commentId}}"
            @click="!longpress && reply(comment.commentId,comment.userName)"
          >{{ comment.commentContent }}</div>
   private longpress: boolean = false; // 用於阻止長按事件與點擊事件衝突 
  longpressDialog(obj) {
    this.longpress = true;
    this.isDialogShow = true;
    this.content = obj.content;
    this.commentUserID = obj.userId;
    this.commentContentId = obj.commentId;
  }

以上代碼是長按時出現對話框(複製評論、刪除評論)

因爲長按事件執行時同時還綁定了點擊事件,很明顯兩者事件有衝突,需要解決這個問題;

通過定義一個longpress的boolean變量來決定點擊事件是否可執行即可解決此問題,完美!!!

 

複製評論

    <!--  type不能是hidden-->
    <input type="text" style="position: absolute;top: 0;left: 0;opacity: 0;z-index: -10;" id="copyinput" />

瀏覽器自帶的複製內容的事件,當然需要是text 的類型

  copyComment() {
    let input = document.getElementById("copyinput");
    input.value = this.content;
    input.select();
    document.execCommand("copy");
    this.resetDialog();
  }

刪除評論

沒什麼好說的,就是直接調刪除的接口

 

都說完了評論,回覆,刪除等操作,當然這些操作之後都需要對原有的數據進行處理,刷新肯定是不可取的。所以,只能在這些操作請求成功之後,對數據進行處理

  async setOperationComment(t, content, commentId) {
    let res = await operationComment(
      t,
      this.commentType,
      parseInt(this.$route.query.id),
      content,
      commentId
    );
    if (res.code === 200) {
      switch (t) {
        case 0:
          let index = this.commentNewData.findIndex(item => {
            return item.commentId === commentId;
          });
          this.commentNewData.splice(index, 1);
          this.commentTotal -= 1;
          break;
        case 1:
          this.commentNewData.unshift(new CommentClass(res.comment));
          this.commentTotal += 1;
          break;
        case 2:
          this.commentNewData.unshift(new CommentClass(res.comment));
          this.commentNewData[0].parentCommentId = commentId;
          break;
      }
    } else {
      this.$toast(res.msg);
    }
  }

 

評論輸入框特效

通過四個span,進行動畫,需要注意的是外層盒子爲relative,input的寬度不能和外層盒子同寬度,同寬度的話會導致輸入框內容顯示不下時動畫異常,同時需要將外層盒子和input的背景色弄成一致,以達到無縫效果

    <div class="input-box">
      <input
        type="text"
        placeholder="隨樂而起,有感而發"
        ref="commentInput"
        v-model="commentVal"
        @focus="$parent.testLogin()"
      />
      <span></span>
      <span></span>
      <span></span>
      <span></span>
    </div>
  .input-box {
    margin: 0 8px;
    flex: 1;
    position: relative;
    overflow: hidden;
    background: white;
    input {
      width: 95%;
      height: 30px;
      padding-left: 6px;
      border: none;
      background: white;
      outline: none;
    }
    span {
      position: absolute;
      &:nth-of-type(1) {
        width: 100%;
        height: 2px;
        background: -webkit-linear-gradient(left, transparent, #03e9f4);
        left: -100%;
        top: 0;
        animation: line1 1s linear infinite;
      }
      &:nth-of-type(2) {
        height: 100%;
        width: 2px;
        background: -webkit-linear-gradient(top, transparent, #03e9f4);
        top: -100%;
        right: 0;
        animation: line2 1s 0.35s linear infinite;
      }
      &:nth-of-type(3) {
        width: 100%;
        height: 2px;
        background: -webkit-linear-gradient(left, transparent, #03e9f4);
        left: 100%;
        bottom: 0;
        animation: line3 1s 0.45s linear infinite;
      }
      &:nth-of-type(4) {
        height: 100%;
        width: 2px;
        background: -webkit-linear-gradient(top, transparent, #03e9f4);
        top: 100%;
        left: 0px;
        animation: line4 1s 0.8s linear infinite;
      }
    }
  }
@keyframes line1 {
  50%,
  100% {
    left: 100%;
  }
}
@keyframes line2 {
  50%,
  100% {
    top: 100%;
  }
}
@keyframes line3 {
  50%,
  100% {
    left: -100%;
  }
}
@keyframes line4 {
  50%,
  100% {
    top: -100%;
  }
}

 

搜索

 

防抖搜索處理

上面已經說過了,這裏就不說了,代碼是一樣的

歷史搜索記錄

就是將搜索過的值保存在本地而已,當然也是要做數據處理判斷搜索名是否已存在

熱搜榜

單純的網絡請求+界面排序,前三個的排名加個紅色字體的類樣式,沒了

 

都沒多大難度,另外搜索輸入框有值時,會顯示搜索推薦的內容,當不對其做操作,而是滑動熱搜榜時,需要將搜索推薦的內容給隱藏,

做法:定義一個isActive的變量用於控制其顯示與隱藏,當再次點擊到輸入框時設置回顯示

 

熱門歌手

歌手區域的滑動是通過better-scroll,首字母拼音通過touch事件自己聯動效果  拼音插件

關鍵組件:scroll-list-view(聯動效果)    singer(數據處理 )

看源碼把,註釋我已經寫得很清晰了。

 

 

就到這裏吧,其他的真沒什麼難度,可能在寫的時候會遇到問題,但是,寫下來之後發現還是挺簡單的一個項目

 

補充:

  1. 當有播放容器時需要對App.vue中router-view內部的元素加上一個margin-bottom;視情況而定,當然也有部分纔有better-scroll中的元素,需要設置bottom
  2. 子路由帶來的問題:如從歌手詳情返回到熱門歌手中沒有數據、無法滾動等bug 。。。

當然以上問題都被解決了,看源碼吧,解析結束

 

結語:文章內容有點長,感謝您所看的廢話,最後希望您喜歡

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章