React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

code_mcx發表於2019-03-04

內容較長,請耐心閱讀

這一節使用Redux來管理歌曲狀態,實現核心播放功能

什麼是Redux?Redux是一個狀態的容器,是一個應用資料流框架,主要用作應用狀態的管理。它用一個單獨的常量狀態樹(物件)來管理儲存整個應用的狀態,這個物件不能直接被修改。Redux中文文件見www.redux.org.cn

Redux不與任何框架耦合,在React中使用Redux提供了react-redux

使用Redux管理歌曲相關狀態屬性

在我們的應用中有很多歌曲列表頁,點選列表頁的歌曲就會播放點選的歌曲,同時列表頁還有播放全部按鈕,點選後當前列表的所有歌曲會新增到播放列表中,每一個歌曲列表都是一個元件,相互獨立,沒有任何關係。歌曲播放元件需要播放的歌曲,歌曲列表還有一個是否顯示播放頁屬性,它們統一使用redux來管理。為了達到背景播放的目的,將歌曲播放元件放置到App元件內路由相關元件外,也就是每個列表頁元件最外層的元件,當App元件掛載後,播放元件會一直存在整個應用中不會被銷燬(除退出應用程式之外)。

首先安裝reduxreact-redux

npm install redux react-redux --save
複製程式碼

react-redux庫包含了容器元件展示元件相分離的開發思想,在最頂層元件使用redux,其餘內部元件僅僅用來展示,所有資料通過props傳入

說明 容器元件 展示元件
位置 最頂層,路由處理 中間和子元件
讀取資料 從 Redux 獲取 state 從 props 獲取資料
修改資料 向 Redux 派發 actions 從 props 呼叫回撥函式

狀態設計

通常把redux相關操作的js檔案放置在同一個資料夾下,這裡在src下新建redux目錄,然後新建actions.jsactionTypes.jsreducers.jsstore.js

actionTypes.js

//顯示或隱藏播放頁面
export const SHOW_PLAYER = "SHOW_PLAYER";
//修改當前歌曲
export const CHANGE_SONG = "CHANGE_SONG";
//從歌曲列表中移除歌曲
export const REMOVE_SONG_FROM_LIST = "REMOVE_SONG";
//設定歌曲列表
export const SET_SONGS = "SET_SONGS";
複製程式碼

actionTypes.js存放要執行的操作常量

actions.js

import * as ActionTypes from "./actionTypes"
/**
 * Action是把資料從應用傳到store的有效載荷。它是store資料的唯一來源
 */
//Action建立函式,用來建立action物件。使用action建立函式更容易被移植和測試
export function showPlayer(showStatus) {
	return {type:ActionTypes.SHOW_PLAYER, showStatus};
}
export function changeSong(song) {
 	return {type:ActionTypes.CHANGE_SONG, song};
}
export function removeSong(id) {
	return {type:ActionTypes.REMOVE_SONG_FROM_LIST, id};
}
export function setSongs(songs) {
	return {type:ActionTypes.SET_SONGS, songs};
}
複製程式碼

actions.js存放要操作的物件,必須有一個type屬性表示要執行的操作。當應用規模越來越大的時候最好分模組定義

reducers.js

import { combineReducers } from `redux`
import * as ActionTypes from "./actionTypes"

/**
 * reducer就是一個純函式,接收舊的state和action,返回新的state
 */

//需要儲存的初始狀態資料
const initialState = {
        showStatus: false,  //顯示狀態
        song: {},  //當前歌曲
        songs: []  //歌曲列表
    };

//拆分Reducer
//顯示或隱藏播放狀態
function showStatus(showStatus = initialState.showStatus, action) {
    switch (action.type) {
        case ActionTypes.SHOW_PLAYER:
            return action.showStatus;
        default:
            return showStatus;
    }
}
//修改當前歌曲
function song(song = initialState.song, action) {
    switch (action.type) {
        case ActionTypes.CHANGE_SONG:
            return action.song;
        default:
            return song;
    }
}
//新增或移除歌曲
function songs(songs = initialState.songs, action) {
    switch (action.type) {
        case ActionTypes.SET_SONGS:
            return action.songs;
        case ActionTypes.REMOVE_SONG_FROM_LIST:
            return songs.filter(song => song.id !== action.id);
        default:
            return songs;
    }
}
//合併Reducer
const reducer = combineReducers({
    showStatus,
    song,
    songs
});

export default reducer
複製程式碼

reducers.js存放用來更新當前播放歌曲,播放歌曲列表和顯示或隱藏播放頁狀態的純函式。一定要保證reducer函式的純淨,永遠不要有以下操作

  1. 修改傳入引數
  2. 執行有副作用的操作,如 API 請求和路由跳轉
  3. 呼叫非純函式,如 Date.now() 或 Math.random()

store.js

import {createStore} from "redux"
import reducer from "./reducers"

 //建立store
const store = createStore(reducer);
export default store
複製程式碼

接下來在應用中加入redux,讓App中的元件連線到redux,react-redux提供了Provider元件connect方法。Provider用來傳遞store,connect用來將元件連線到redux,任何一個從 connect() 包裝好的元件都可以得到一個 dispatch 方法作為元件的 props,以及得到全域性 state 中所需的任何內容

在components目錄下新建一個Root.js用來包裹App元件並且傳遞store

Root.js

import React from "react"
import {Provider} from "react-redux"
import store from "../redux/store"
import App from "./App"

class Root extends React.Component {
    render() {
	    return (
	        <Provider store={store}>
	            <App/>
	        </Provider>
	    );
    }
}
export default Root
複製程式碼

Provider接收一個store物件

修改index.js,將App元件換成Root元件

//import App from `./components/App`;
import Root from `./components/Root`;
//ReactDOM.render(<App />, document.getElementById(`root`));
ReactDOM.render(<Root />, document.getElementById(`root`));
複製程式碼

操作狀態

使用connect方法將上一節開發好的Album元件連線到Redux,為了區分容器元件和ui元件,我們把需要連線redux的容器元件放置到一個單獨的目錄中,只需引入ui元件即可。在src下新建containers目錄,然後新建Album.js對應ui元件Album

Album.js

import {connect} from "react-redux"
import {showPlayer, changeSong, setSongs} from "../redux/actions"
import Album from "../components/album/Album"

//對映dispatch到props上
const mapDispatchToProps = (dispatch) => ({
    showMusicPlayer: (status) => {
        dispatch(showPlayer(status));
    },
    changeCurrentSong: (song) => {
        dispatch(changeSong(song));
    },
    setSongs: (songs) => {
        dispatch(setSongs(songs));
    }
});

export default connect(null, mapDispatchToProps)(Album)
複製程式碼

上訴程式碼中connect第一個引數用來對映store到元件props上,第二個引數是對映dispatch到props上,然後把Album元件傳入,這裡不需要獲取store的狀態,傳入null

回到components下的Album.js元件中,增加歌曲列表點選事件

/**
 * 選擇歌曲
 */
selectSong(song) {
    return (e) => {
        this.props.setSongs([song]);
        this.props.changeCurrentSong(song);
    };
}
複製程式碼
let songs = this.state.songs.map((song) => {
    return (
        <div className="song" key={song.id} onClick={this.selectSong(song)}>
            ...
        </div>
    );
});
複製程式碼

上訴程式碼中的setSongschangeCurrentSong都是通過mapDispatchToProps對映到元件的props上

在Recommend.js中將匯入Album.js修改為containers下的Album.js

//import Album from "../album/Album"
import Album from "@/containers/Album"
複製程式碼

為了測試是否可以修改狀態,先在components下面新建play目錄然後新建Player.js用來獲取狀態相關資訊

import React from "react"

class Player extends React.Component {
    render() {
        console.log(this.props.currentSong);
        console.log(this.props.playSongs);
    }
}

export default Player
複製程式碼

在containers目錄下新建對應的容器元件Player.js

import {connect} from "react-redux"
import {showPlayer, changeSong} from "../redux/actions"
import Player from "../components/play/Player"

//對映Redux全域性的state到元件的props上
const mapStateToProps = (state) => ({
    showStatus: state.showStatus,
    currentSong: state.song,
    playSongs: state.songs
});

//對映dispatch到props上
const mapDispatchToProps = (dispatch) => ({
    showMusicPlayer: (status) => {
        dispatch(showPlayer(status));
    },
    changeCurrentSong: (song) => {
        dispatch(changeSong(song));
    }
});

//將ui元件包裝成容器元件
export default connect(mapStateToProps, mapDispatchToProps)(Player)
複製程式碼

mapStateToProps函式將store的狀態對映到元件的props上,Player元件會訂閱store,當store的狀態發生修改時會呼叫render方法觸發更新

在App.js中引入容器元件Player

import Player from "../containers/Player"
複製程式碼

放到如下位置

<Router>
  <div className="app">
    ...
     <div className="music-view">
        ...
    </div>
    <Player/>
  </div>
</Router>
複製程式碼

啟動應用後開啟控制檯,檢視狀態

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

在專輯頁點選歌曲後檢視狀態

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

封裝Progress元件

這個專案中會有兩個地方用到歌曲播放進度展示,一個是播放元件,另一個就是mini播放元件。我們把進度條功能抽取出來,根據業務需要傳遞props

在components下的play目錄新建Progress.jsprogress.styl

Progress.js

import React from "react"

import "./progress.styl"

class Progress extends React.Component {
    componentDidUpdate() {

    }
    componentDidMount() {

    }
    render() {
        return (
            <div className="progress-bar">
                <div className="progress" style={{width:"20%"}}></div>
                <div className="progress-button" style={{left:"70px"}}></div>
            </div>
        );
    }
}

export default Progress
複製程式碼

progress.styl請檢視原始碼,結尾有原始碼地址

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

Progress元件接收進度(progress),是否禁用按鈕(disableButton),是否禁用拖拽(disableButton),開始拖拽回撥函式(onDragStart),拖拽中回撥函式(onDrag)和拖拽接受回撥函式(onDragEnd)等屬性

接下來給Progress元件加上進度和拖拽功能

使用prop-types給傳入的props進行型別校驗,匯入prop-types

import PropTypes from "prop-types"
複製程式碼
Progress.propTypes = {
    progress: PropTypes.number.isRequired,
    disableButton: PropTypes.bool,
    disableDrag: PropTypes.bool,
    onDragStart: PropTypes.func,
    onDrag: PropTypes.func,
    onDragEnd: PropTypes.func
};
複製程式碼

注意:prop-types已經在前幾節安裝了

給元素加上ref

<div className="progress-bar" ref="progressBar">
    <div className="progress" style={{width:"20%"}} ref="progress"></div>
    <div className="progress-button" style={{left:"70px"}} ref="progressBtn"></div>
</div>
複製程式碼

匯入react-dom,在componentDidMount中獲取dom和進度條總長度

let progressBarDOM = ReactDOM.findDOMNode(this.refs.progressBar);
let progressDOM = ReactDOM.findDOMNode(this.refs.progress);
let progressBtnDOM = ReactDOM.findDOMNode(this.refs.progressBtn);
this.progressBarWidth = progressBarDOM.offsetWidth;
複製程式碼

在render方法中獲取props,替換寫死的style中的值。程式碼修改如下

//進度值:範圍 0-1
let {progress, disableButton}  = this.props;
if (!progress) progress = 0;

//按鈕left值
let progressButtonOffsetLeft = 0;
if(this.progressBarWidth){
	progressButtonOffsetLeft = progress * this.progressBarWidth;
}

return (
	<div className="progress-bar" ref="progressBar">
		<div className="progress-load"></div>
		<div className="progress" style={{width:`${progress * 100}%`}} ref="progress"></div>
		{
			disableButton === true ? "" : 
			<div className="progress-button" style={{left:progressButtonOffsetLeft}} ref="progressBtn"></div>
		}
	</div>
);
複製程式碼

上訴程式碼中progress用來控制當前走過的進度值,progressButtonOffsetLeft用來控制按鈕距離進度條開始的位置。當disableButton為true時渲染一個空字串,不為true則渲染按鈕元素

拖拽功能利用移動端的touchstart、touchmove和touchend來實現。在componentDidMount中增加以下程式碼

let {disableButton, disableDrag, onDragStart, onDrag, onDragEnd} = this.props;
if (disableButton !== true && disableDrag !== true) {
	//觸控開始位置
	let downX = 0;
	//按鈕left值
	let buttonLeft = 0;

	progressBtnDOM.addEventListener("touchstart", (e) => {
		let touch = e.touches[0];
		downX = touch.clientX;
		buttonLeft = parseInt(touch.target.style.left, 10);

		if (onDragStart) {
		    onDragStart();
		}
	});
	progressBtnDOM.addEventListener("touchmove", (e) => {
		e.preventDefault();

		let touch = e.touches[0];
		let diffX = touch.clientX - downX;
		
		let btnLeft = buttonLeft + diffX;
		if (btnLeft > progressBarDOM.offsetWidth) {
		    btnLeft = progressBarDOM.offsetWidth;
		} else if (btnLeft < 0) {
		    btnLeft = 0;
		}
		//設定按鈕left值
		touch.target.style.left = btnLeft + "px";
		//設定進度width值
		progressDOM.style.width = btnLeft / this.progressBarWidth * 100 + "%";

		if (onDrag) {
		    onDrag(btnLeft / this.progressBarWidth);
		}
	});
	progressBtnDOM.addEventListener("touchend", (e) => {
		if (onDragEnd) {
		    onDragEnd();
		}
	});
}
複製程式碼

先判斷按鈕和拖拽功能是否啟用,然後給progressBtnDOM新增touchstart、touchmove和touchend事件。拖拽開始記錄觸控開始的位置downX和按鈕的left值buttonLeft,拖拽中計算拖拽的距離diffx,然後重新設定按鈕left值為btnLeft。btnLeft就是拖拽後距離進度條最左邊開始的距離,除以總進度長就是當前進度比。這個值乘以100就是progressDOM的width。在拖拽中調事件物件preventDefault函式阻止有些瀏覽器觸控移動時視窗會前進後退的預設行為。在每個事件的最後呼叫對應的回撥事件,onDrag回撥函式中傳入當前進度值

最後在componentDidUpdate中加入以下程式碼,解決元件更新後不能正確獲取總進度長

//元件更新後重新獲取進度條總寬度
if (!this.progressBarWidth) {
    this.progressBarWidth = ReactDOM.findDOMNode(this.refs.progressBar).offsetWidth;
}
複製程式碼

開發播放元件

播放功能主要使用H5的audio元素來實現,結合canplaytimeupdateendederror事件。當音訊可以播放的時候會觸發canplay事件。在播放中會觸發timeupdate事件,timeupdate事件中可以獲取歌曲的當前播放事件和總時長,利用這兩個來更新元件的播放狀態。播放完成後會觸發ended事件,在這個事件中根據播放模式進行歌曲切換

歌曲播放

在components下的play目錄中新建player.styl樣式檔案,Player.js在上面測試狀態的時候已經建好了。Player元件中有切換歌曲播放模式、上一首、下一首、播放、暫停等功能。我們把當前播放時間currentTime,播放進度playProgress,播放狀態playStatus和當前播放模式currentPlayMode交給播放元件的state管理,把當前播放歌曲currentSong, 當前播放歌曲的位置currentIndex交給自身

Player.js

import React from "react"
import ReactDOM from "react-dom"
import {Song} from "@/model/song"

import "./player.styl"

class Player extends React.Component {
    constructor(props) {
        super(props);

        this.currentSong = new Song( 0, "", "", "", 0, "", "");
        this.currentIndex = 0;

        //播放模式: list-列表 single-單曲 shuffle-隨機
        this.playModes = ["list", "single", "shuffle"];

        this.state = {
            currentTime: 0,
            playProgress: 0,
            playStatus: false,
            currentPlayMode: 0
        }
    }
    componentDidMount() {
        this.audioDOM = ReactDOM.findDOMNode(this.refs.audio);
        this.singerImgDOM = ReactDOM.findDOMNode(this.refs.singerImg);
        this.playerDOM = ReactDOM.findDOMNode(this.refs.player);
        this.playerBgDOM = ReactDOM.findDOMNode(this.refs.playerBg);
    }
    render() {
        let song = this.currentSong;

        let playBg = song.img ? song.img : require("@/assets/imgs/play_bg.jpg");

        //播放按鈕樣式
        let playButtonClass = this.state.playStatus === true ? "icon-pause" : "icon-play";

        song.playStatus = this.state.playStatus;
        return (
            <div className="player-container">
                <div className="player" ref="player">
                    ...
                    <div className="singer-middle">
                        <div className="singer-img" ref="singerImg">
                            <img src={playBg} alt={song.name} onLoad={
                                (e) => {
                                    /*圖片載入完成後設定背景,防止圖片載入過慢導致沒有背景*/
                                    this.playerBgDOM.style.backgroundImage = `url("${playBg}")`;
                                }
                            }/>
                        </div>
                    </div>
                    <div className="singer-bottom">
                        ...
                    </div>
                    <div className="player-bg" ref="playerBg"></div>
                    <audio ref="audio"></audio>
                </div>
            </div>
        );
    }
}

export default Player
複製程式碼

上述省略部分程式碼,完整程式碼在原始碼中檢視

player.styl程式碼省略

匯入Progress元件,然後放到以下位置傳入playProgress

import Progress from "./Progress"
複製程式碼
<div className="play-progress">
    <Progress progress={this.state.playProgress}/>
</div>
複製程式碼

在render方法開頭增加以下程式碼,同時在componentDidMount給audio元素新增canplay和timeupdate事件。判斷當前歌曲是否已切換,如果歌曲切換設定新的src,隨後能夠播放觸發canplay事件播放音訊。播放的時候更新進度狀態和當前播放時間

//從redux中獲取當前播放歌曲
if (this.props.currentSong && this.props.currentSong.url) {
    //當前歌曲發發生變化
    if (this.currentSong.id !== this.props.currentSong.id) {
        this.currentSong = this.props.currentSong;
        this.audioDOM.src = this.currentSong.url;
        //載入資源,ios需要呼叫此方法
        this.audioDOM.load();
    }
}
複製程式碼
this.audioDOM.addEventListener("canplay", () => {
    this.audioDOM.play();
    this.startImgRotate();

    this.setState({
        playStatus: true
    });

}, false);

this.audioDOM.addEventListener("timeupdate", () => {
	if (this.state.playStatus === true) {
	    this.setState({
	        playProgress: this.audioDOM.currentTime / this.audioDOM.duration,
	        currentTime: this.audioDOM.currentTime
	    });
	}
}, false);
複製程式碼

audio在移動端未觸控螢幕第一次是無法自動播放的,在constructor建構函式內增加一個isFirstPlay屬性,在元件更新後判斷這個屬性是否為true,如果為tru就開始播放,然後設定為false

this.isFirstPlay = true;
複製程式碼
componentDidUpdate() {
	//相容手機端canplay事件觸發後第一次呼叫play()方法無法自動播放的問題
	if (this.isFirstPlay === true) {
		this.audioDOM.play();
		this.isFirstPlay = false;
	}
}
複製程式碼

回到Album.js中給播放全部按鈕新增事件

/**
 * 播放全部
 */
playAll = () => {
    if (this.state.songs.length > 0) {
        //新增播放歌曲列表
        this.props.setSongs(this.state.songs);
        this.props.changeCurrentSong(this.state.songs[0]);
        this.props.showMusicPlayer(true);
    }
}
複製程式碼
<div className="play-button" onClick={this.playAll}>
    <i className="icon-play"></i>
    <span>播放全部</span>
</div>
複製程式碼

給Player元件的player元素增加style控制顯示和隱藏

<div className="player" ref="player" style={{display:this.props.showStatus === true ? "block" : "none"}}>
  ...
</div>
複製程式碼

點選後會顯示播放元件,然後進行歌曲播放

歌曲控制

下面給播放元件增加歌曲模式切、上一首、下一首和播放暫停和換功能

歌曲模式切換,給元素新增點選事件

changePlayMode = () => {
	if (this.state.currentPlayMode === this.playModes.length - 1) {
		this.setState({currentPlayMode:0});
	} else {
		this.setState({currentPlayMode:this.state.currentPlayMode + 1});
	}
}
複製程式碼
<div className="play-model-button"  onClick={this.changePlayMode}>
    <i className={"icon-" + this.playModes[this.state.currentPlayMode] + "-play"}></i>
</div>
複製程式碼

播放或暫停

playOrPause = () => {
	if(this.audioDOM.paused){
		this.audioDOM.play();
		this.startImgRotate();

		this.setState({
			playStatus: true
		});
	}else{
		this.audioDOM.pause();
		this.stopImgRotate();

		this.setState({
			playStatus: false
		});
	}
}
複製程式碼
<div className="play-button" onClick={this.playOrPause}>
    <i className={playButtonClass}></i>
</div>
複製程式碼

上一首、下一首

previous = () => {
    if (this.props.playSongs.length > 0 && this.props.playSongs.length !== 1) {
        let currentIndex = this.currentIndex;
        if (this.state.currentPlayMode === 0) {  //列表播放
            if(currentIndex === 0){
                currentIndex = this.props.playSongs.length - 1;
            }else{
                currentIndex = currentIndex - 1;
            }
        } else if (this.state.currentPlayMode === 1) {  //單曲迴圈
            currentIndex = this.currentIndex;
        } else {  //隨機播放
            let index = parseInt(Math.random() * this.props.playSongs.length, 10);
            currentIndex = index;
        }
        this.props.changeCurrentSong(this.props.playSongs[currentIndex]);
        this.currentIndex = currentIndex;
    }
}
next = () => {
    if (this.props.playSongs.length > 0  && this.props.playSongs.length !== 1) {
        let currentIndex = this.currentIndex;
        if (this.state.currentPlayMode === 0) {  //列表播放
            if(currentIndex === this.props.playSongs.length - 1){
                currentIndex = 0;
            }else{
                currentIndex = currentIndex + 1;
            }
        } else if (this.state.currentPlayMode === 1) {  //單曲迴圈
            currentIndex = this.currentIndex;
        } else {  //隨機播放
            let index = parseInt(Math.random() * this.props.playSongs.length, 10);
            currentIndex = index;
        }
        this.props.changeCurrentSong(this.props.playSongs[currentIndex]);
        this.currentIndex = currentIndex;
    }
}
複製程式碼
<div className="previous-button" onClick={this.previous}>
    <i className="icon-previous"></i>
</div>
...
<div className="next-button" onClick={this.next}>
    <i className="icon-next"></i>
</div>
複製程式碼

上一首下一首先判斷播放模式,當播放模式為列表播放是直接當前位置+1,然後獲取下一首歌曲,下一首反之。當播放模式為單曲迴圈的時候,繼續播放當前歌曲。當播放模式為隨機播放的時候獲取0到歌曲列表長度中的一個隨機整數進行播放。如果當前只有一首歌曲的時候播放模式不起作用

歌曲進度的拖拽使用Progress的props回撥函式,給Progress元件新增onDragonDragEnd屬性,並新增函式處理,constructor建構函式內增加dragProgress屬性記錄拖拽的進度

this.dragProgress = 0;
複製程式碼
<Progress progress={this.state.playProgress}
      onDrag={this.handleDrag}
      onDragEnd={this.handleDragEnd}/>
複製程式碼
handleDrag = (progress) => {
        if (this.audioDOM.duration > 0) {
            this.audioDOM.pause();
            this.stopImgRotate();

            this.setState({
                playStatus: false
            });
            this.dragProgress = progress;
        }
    }
handleDragEnd = () => {
    if (this.audioDOM.duration > 0) {
        let currentTime = this.audioDOM.duration * this.dragProgress;
        this.setState({
            playProgress: this.dragProgress,
            currentTime: currentTime
        }, () => {
            this.audioDOM.currentTime = currentTime;
            this.audioDOM.play();
            this.startImgRotate();

            this.setState({
                playStatus: true
            });
            this.dragProgress = 0;
        });
    }
}
複製程式碼

拖拽中記錄拖拽的進度,當拖拽結束後獲取拖拽後的播放時間和拖拽進度更新Player元件,元件更新後從拖拽後的時間繼續播放

給audio新增ended事件,進行播放完成後的處理。同時新增error事件處理

this.audioDOM.addEventListener("ended", () => {
    if (this.props.playSongs.length > 1) {
        let currentIndex = this.currentIndex;
        if (this.state.currentPlayMode === 0) {  //列表播放
            if(currentIndex === this.props.playSongs.length - 1){
                currentIndex = 0;
            }else{
                currentIndex = currentIndex + 1;
            }
        } else if (this.state.currentPlayMode === 1) {  //單曲迴圈
            //繼續播放當前歌曲
            this.audioDOM.play();
            return;
        } else {  //隨機播放
            let index = parseInt(Math.random() * this.props.playSongs.length, 10);
            currentIndex = index;
        }
        this.props.changeCurrentSong(this.props.playSongs[currentIndex]);
        this.currentIndex = currentIndex;

    } else {
        if (this.state.currentPlayMode === 1) {  //單曲迴圈
            //繼續播放當前歌曲
            this.audioDOM.play();
        } else {
            //暫停
            this.audioDOM.pause();
            this.stopImgRotate();

            this.setState({
                playProgress: 0,
                currentTime: 0,
                playStatus: false
            });
        }
    }
}, false);

this.audioDOM.addEventListener("error", () => {alert("載入歌曲出錯!")}, false);
複製程式碼

error事件只是簡單的做了一個提示,其實是可以自動切換下一首歌曲的

效果圖如下

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

開發Mini播放元件

Mini播放元件依賴audio標籤還有歌曲的當前播放時間、總是長、播放進度。這些相關屬性在Player元件中已經被使用到了,所以這裡把Mini元件作為Player元件的子元件

在play目錄下新建MiniPlayer.jsminiplayer.styl。MiniPlayer接收當前歌曲song,和播放進度。同時也需要引入Progress展示播放進度

MiniPlayer.js

import React from "react"
import Progress from "./Progress"

import "./miniplayer.styl"

class MiniPlayer extends React.Component {
    render() {
        let song = this.props.song;

        let playerStyle = {};
        if (this.props.showStatus === true) {
            playerStyle = {display:"none"};
        }
        if (!song.img) {
            song.img = require("@/assets/imgs/music.png");
        }

        let imgStyle = {};
        if (song.playStatus === true) {
            imgStyle["WebkitAnimationPlayState"] = "running";
            imgStyle["animationPlayState"] = "running";
        } else {
            imgStyle["WebkitAnimationPlayState"] = "paused";
            imgStyle["animationPlayState"] = "paused";
        }

        let playButtonClass = song.playStatus === true ? "icon-pause" : "icon-play";
        return (
            <div className="mini-player" style={playerStyle}>
                <div className="player-img rotate" style={imgStyle}>
                    <img src={song.img} alt={song.name}/>
                </div>
                <div className="player-center">
                    <div className="progress-wrapper">
                        <Progress disableButton={true} progress={this.props.progress}/>
                    </div>
                    <span className="song">
	                    {song.name}
	                </span>
                    <span className="singer">
	                    {song.singer}
	                </span>
                </div>
                <div className="player-right">
                    <i className={playButtonClass}></i>
                    <i className="icon-next ml-10"></i>
                </div>
                <div className="filter"></div>
            </div>
        );
    }
}

export default MiniPlayer
複製程式碼

miniplayer.styl程式碼請在原始碼中檢視

在Player元件中匯入MiniPlayer

import MiniPlayer from "./MiniPlayer"
複製程式碼

放置在如下位置,傳入songplayProgress

<div className="player-container">
    ...
    <MiniPlayer song={song} progress={this.state.playProgress}/>
</div>
複製程式碼

在MiniPlayer元件中呼叫父元件的播放暫停和下一首方法控制歌曲。先在MiniPlayer中編寫處理點選事件的方法

handlePlayOrPause = (e) => {
	e.stopPropagation();
	if (this.props.song.url) {
		//呼叫父元件的播放或暫停方法
		this.props.playOrPause();
	}
}
handleNext = (e) => {
	e.stopPropagation();
	if (this.props.song.url) {
		//呼叫父元件播放下一首方法
		this.props.next();
	}
}
複製程式碼

新增點選事件

<div className="player-right">
    <i className={playButtonClass} onClick={this.handlePlayOrPause}></i>
    <i className="icon-next ml-10" onClick={this.handleNext}></i>
</div>
複製程式碼

在Player元件中傳入playOrPausenext方法

<MiniPlayer song={song} progress={this.state.playProgress}
    playOrPause={this.playOrPause}
    next={this.next}/>
複製程式碼

Player和MiniPlayer兩個元件的顯示狀態是相反的,在某一個時候只會有一個顯示另一個隱藏,接下來處理Player元件和MiniPlayer元件的顯示和隱藏。在Player元件中增加顯示和隱藏的兩個方法

hidePlayer = () => {
    this.props.showMusicPlayer(false);
}
showPlayer = () => {
    this.props.showMusicPlayer(true);
}
複製程式碼
<div className="header">
    <span className="header-back" onClick={this.hidePlayer}>
        ...
    </span>
    ...
</div>
複製程式碼

將顯示狀態showStatus和顯示的方法showPlayer傳給MiniPlayer元件

<MiniPlayer song={song} progress={this.state.playProgress}
    playOrPause={this.playOrPause}
    next={this.next}
    showStatus={this.props.showStatus}
    showMiniPlayer={this.showPlayer}/>
複製程式碼

Player中的hidePlayer呼叫後,更新redux的showStatus為false,觸發render,將showStatus傳給MiniPlayer,MiniPlayer根據showStatus來決定顯示還是隱藏

播放元件和歌曲列表點選動畫

播放元件顯示和隱藏動畫

單純的讓播放元件顯示和隱藏太生硬了,我們為播放元件的顯示和隱藏的動畫。這裡會用到上一節介紹的react-transition-group,這個外掛的簡單使用請戳上一節實現動畫。這個動畫會用到react-transition-group中提供的鉤子函式,具體請看下面

在Player元件中引入CSSTransition動畫元件

import { CSSTransition } from "react-transition-group"
複製程式碼

用CSSTransition包裹播放元件

<div className="player-container">
    <CSSTransition in={this.props.showStatus} timeout={300} classNames="player-rotate">
    <div className="player" ref="player" style={{display:this.props.showStatus === true ? "block" : "none"}}>
        ...
    </div>
    </CSSTransition>
    <MiniPlayer song={song} progress={this.state.playProgress}
                playOrPause={this.playOrPause}
                next={this.next}
                showStatus={this.props.showStatus}
                showMiniPlayer={this.showPlayer}/>
</div>
複製程式碼

這個時候將player元素的樣式交給CSSTransition的鉤子函式來控制,去掉player元素的style

<div className="player" ref="player">
...
</div
複製程式碼

然後給CSSTransition新增onEnteronExited鉤子函式,onEnter在in為true,元件開始變成進入狀態時回撥,onExited在in為false,元件狀態已經變成離開狀態時回撥

<CSSTransition in={this.props.showStatus} timeout={300} classNames="player-rotate"
   onEnter={() => {
       this.playerDOM.style.display = "block";
   }}
   onExited={() => {
       this.playerDOM.style.display = "none";
   }}>
   ...
</CSSTransition>
複製程式碼

player樣式如下

.player
  position: fixed
  top: 0
  left: 0
  z-index: 1001
  width: 100%
  height: 100%
  color: #FFFFFF
  background-color: #212121
  display: none
  transform-origin: 0 bottom
  &.player-rotate-enter
    transform: rotateZ(90deg)
    &.player-rotate-enter-active
      transition: transform .3s
      transform: rotateZ(0deg)
  &.player-rotate-exit
    transform: rotateZ(0deg) translate3d(0, 0, 0)
    &.player-rotate-exit-active
      transition: all .3s
      transform: rotateZ(90deg) translate3d(100%, 0, 0)
複製程式碼

歌曲點選音符下落動畫

回到Album元件中,在每一首歌曲點選的時候我們在每個點選的位置出現一個音符,然後開始以拋物線軌跡下落。利用x軸和y軸的translate進行過渡,使用兩個元素,外層元素y軸平移,內層元素x軸平移。過渡完成後使用css3的transitionend用來監聽元素過渡完成,然後將位置進行重置,以便下一次運動

在src下新建util目錄然後新建event.js用來獲取transitionend事件名稱,相容低版本webkit核心瀏覽器

event.js

function getTransitionEndName(dom){
    let cssTransition = ["transition", "webkitTransition"];
    let transitionEnd = {
        "transition": "transitionend",
        "webkitTransition": "webkitTransitionEnd"
    };
    for(let i = 0; i < cssTransition.length; i++){
        if(dom.style[cssTransition[i]] !== undefined){
            return transitionEnd[cssTransition[i]];
        }
    }
    return undefined;
}

export {getTransitionEndName}
複製程式碼

Album中匯入event.js

import {getTransitionEndName} from "@/util/event"
複製程式碼

在Album中放三個音符元素,將音符元素的樣式寫在app.styl中,方便後面公用這個樣式

<CSSTransition in={this.state.show} timeout={300} classNames="translate">
<div className="music-album">
	...
	<div className="music-ico" ref="musicIco1">
		<div className="icon-fe-music"></div>
	</div>
	<div className="music-ico" ref="musicIco2">
		<div className="icon-fe-music"></div>
	</div>
	<div className="music-ico" ref="musicIco3">
		<div className="icon-fe-music"></div>
	</div>
</div>
</CSSTransition>
複製程式碼

app.styl中增加

.music-ico
  position: fixed
  z-index: 1000
  margin-top: -7px
  margin-left: -7px
  color: #FFD700
  font-size: 14px
  display: none
  transition: transform 1s cubic-bezier(.59, -0.1, .83, .67)
  transform: translate3d(0, 0, 0)
  div
    transition: transform 1s
複製程式碼

.music-ico過渡型別為貝塞爾曲線型別,這個值會使y軸平移的值先過渡到負值(一個負的終點值)然後再過渡到目標值。這裡我調的貝塞爾曲線如下

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

可以到cubic-bezier.com地址選擇自己想要的貝塞爾值

編寫初始化音符和啟動音符下落動畫的方法


initMusicIco() {
	this.musicIcos = [];
	this.musicIcos.push(ReactDOM.findDOMNode(this.refs.musicIco1));
	this.musicIcos.push(ReactDOM.findDOMNode(this.refs.musicIco2));
	this.musicIcos.push(ReactDOM.findDOMNode(this.refs.musicIco3));

	this.musicIcos.forEach((item) => {
		//初始化狀態
		item.run = false;
		let transitionEndName = getTransitionEndName(item);
		item.addEventListener(transitionEndName, function() {
			this.style.display = "none";
			this.style["webkitTransform"] = "translate3d(0, 0, 0)";
			this.style["transform"] = "translate3d(0, 0, 0)";
			this.run = false;

			let icon = this.querySelector("div");
			icon.style["webkitTransform"] = "translate3d(0, 0, 0)";
			icon.style["transform"] = "translate3d(0, 0, 0)";
		}, false);
	});
}
startMusicIcoAnimation({clientX, clientY}) {
	if (this.musicIcos.length > 0) {
		for (let i = 0; i < this.musicIcos.length; i++) {
			let item = this.musicIcos[i];
			//選擇一個未在動畫中的元素開始動畫
			if (item.run === false) {
				item.style.top = clientY + "px";
				item.style.left = clientX + "px";
				item.style.display = "inline-block";
				setTimeout(() => {
					item.run = true;
					item.style["webkitTransform"] = "translate3d(0, 1000px, 0)";
					item.style["transform"] = "translate3d(0, 1000px, 0)";

					let icon = item.querySelector("div");
					icon.style["webkitTransform"] = "translate3d(-30px, 0, 0)";
					icon.style["transform"] = "translate3d(-30px, 0, 0)";
				}, 10);
				break;
			}
		}
	}
}
複製程式碼

獲取所有的音符元素新增到musicIcos陣列中,然後遍歷給每個元素新增transitionEnd事件,事件處理函式中將音符元素的位置重置。給每一個音符dom物件新增一個自定義的屬性run標記當前的元素是否在運動中。在啟動音符動畫時遍歷musicIcos陣列,找到一個run為false的元素根據事件物件的clientXclientY設定lefttop,開始過渡動畫,隨後立即停止迴圈。這樣做是為了連續點選時前一個元素未運動完成,使用下一個未運動的元素運動,當運動完成後run變為false,下次點選時繼續使用

我們在componentDidMount中呼叫initMusicIco

this.initMusicIco();
複製程式碼

然後在歌曲點選事件中呼叫startMusicIcoAnimation

selectSong(song) {
    return (e) => {
        this.props.setSongs([song]);
        this.props.changeCurrentSong(song);
        this.startMusicIcoAnimation(e.nativeEvent);
    };
}
複製程式碼

e.nativeEvent獲取的是原生的事件物件,這裡是由Scroll元件中的better-scroll派發的,在1.6.0版本以前better-scroll並未傳遞clientX和clientY,現已將better-scroll升級到1.6.0

效果圖如下

React全家桶構建一款Web音樂App實戰(五):歌曲狀態管理及播放功能實現

開發播放列表

考慮到播放列表有很多列表資料,如果放在Player元件中每次更新播放進度都會呼叫render函式,對列表進行遍歷,影響效能,所以把播放列表元件和播放元件分成兩個元件並放到MusicPlayer元件中,它們之間通過父元件MusicPlayer來進行資料互動

在play目錄下新建MusicPlayer.js,然後匯入Player.js

MusicPlayer.js

import React from "react"
import Player from "@/containers/Player"

class MusicPlayer extends React.Component {
    constructor(props) {
        super(props);
    }
    render() {
        return (
            <div className="music-player">
                <Player/>
            </div>
        );
    }
}
export default MusicPlayer;
複製程式碼

在App.js中匯入MusicPlayer.js替換掉原來的Player元件

//import Player from "../containers/Player"
import MusicPlayer from "./play/MusicPlayer"
複製程式碼
<Router>
  <div className="app">
    ...
    {/*<Player/>*/}
    <MusicPlayer/>
  </div>
</Router>
複製程式碼

繼續在play下新建PlayerList.jsplayerlist.styl

PlayerList.js

import React from "react"
import ReactDOM from "react-dom"

import "./playerlist.styl"

class PlayerList extends React.Component {

    render() {
        return (
            <div className="player-list">
            </div>
        );
    }
}
export default PlayerList
複製程式碼

playerlist.styl程式碼在原始碼中檢視

PlayerList需要從redux中獲取歌曲列表,所以先把PlayerList包裝成容器元件。在containers目錄下新建PlayerList.js,程式碼如下

import {connect} from "react-redux"
import {changeSong, removeSong} from "../redux/actions"
import PlayerList from "../components/play/PlayerList"

//對映Redux全域性的state到元件的props上
const mapStateToProps = (state) => ({
    currentSong: state.song,
    playSongs: state.songs
});

//對映dispatch到props上
const mapDispatchToProps = (dispatch) => ({
    changeCurrentSong: (song) => {
        dispatch(changeSong(song));
    },
    removeSong: (id) => {
        dispatch(removeSong(id));
    }
});

//將ui元件包裝成容器元件
export default connect(mapStateToProps, mapDispatchToProps)(PlayerList)
複製程式碼

然後在MusicPlayer.js中匯入PlayerList容器元件

import PlayerList from "@/containers/PlayerList"
複製程式碼
<div className="music-player">
    <Player/>
    <PlayerList/>
</div>
複製程式碼

這時PlayerList元件可以從redux獲取播放列表資料了。歌曲列表同樣會用到Scroll滾動元件,在歌曲播放元件中點選歌曲列表按鈕會顯示歌曲列表,把這個屬性放到父元件MusicPlayer中,PlayerList通過props獲取這個屬性來啟動顯示和隱藏動畫,PlayerList自身也可以關閉。同時它們共同使用獲取當前播放歌曲位置屬性和改變歌曲位置的函式,通過MsuciPlayer元件傳入

MusicPlayer.js增加兩個state,一個改變歌曲播放位置和一個改變播放列表顯示狀態的方法

constructor(props) {
    super(props);
    this.state = {
        currentSongIndex: 0,
        show: false,  //控制播放列表顯示和隱藏
    }
}
changeCurrentIndex = (index) => {
    this.setState({
        currentSongIndex: index
    });
}
showList = (status) => {
    this.setState({
        show: status
    });
}
複製程式碼

把狀態和方法同props傳遞給子元件

<Player currentIndex={this.state.currentSongIndex}
        showList={this.showList}
        changeCurrentIndex={this.changeCurrentIndex}/>
<PlayerList currentIndex={this.state.currentSongIndex}
            showList={this.showList}
            changeCurrentIndex={this.changeCurrentIndex}
            show={this.state.show}/>
複製程式碼

PlayerList使用一個state來控制顯示和隱藏,通過CSSTransition的鉤子函式來修改狀態

this.state = {
    showList: false
};
複製程式碼
<div className="player-list">
    <CSSTransition in={this.props.show} classNames="fade" timeout={500}
                   onEnter={() => {
                       this.setState({showList:true});
                   }}
                   onEntered={() => {
                       this.refs.scroll.refresh();
                   }}
                   onExited={() => {
                       this.setState({showList:false});
                   }}>
    <div className="play-list-bg" style={this.state.showList === true ? {display:"block"} : {display:"none"}}>
        ...
    </div>
    </CSSTransition>
</div>
複製程式碼

在Player.js中上一首、下一首和音訊播放結束中的this.currentIndex = currentIndex修改為呼叫changeCurrentIndex方法,同時在render函式中的第一行獲取播放歌曲的位

//this.currentIndex = currentIndex;

//呼叫父元件修改當前歌曲位置
this.props.changeCurrentIndex(currentIndex);
複製程式碼
this.currentIndex = this.props.currentIndex;
複製程式碼

給Player元件中的播放列表按鈕新增事件,呼叫父元件的showList

showPlayList = () => {
    this.props.showList(true);
}
複製程式碼
<div className="play-list-button" onClick={this.showPlayList}>
    <i className="icon-play-list"></i>
</div>
複製程式碼

給PlayerList元件中的遮罩背景和關閉按鈕也新增點選事件,用來隱藏播放列表

showOrHidePlayList = () => {
    this.props.showList(false);
}
複製程式碼
<div className="play-list-bg" style={this.state.showList === true ? {display:"block"} : {display:"none"}}
     onClick={this.showOrHidePlayList}>
    {/*播放列表*/}
    <div className="play-list-wrap">
        <div className="play-list-head">
            <span className="head-title">播放列表</span>
            <span className="close" onClick={this.showOrHidePlayList}>關閉</span>
        </div>
        ...
    </div>
</div>
複製程式碼

在播放列表中點選歌曲也是可以播放當前歌曲,點選刪除按鈕把歌曲從歌曲列表中移除,接下來處理這兩個事件。給PlayerList元件新增播放歌曲和移除歌曲的兩個方法,並給歌曲包裹元素和刪除按鈕新增點選事件

playSong(song, index) {
    return () => {
        this.props.changeCurrentSong(song);
        this.props.changeCurrentIndex(index);

        this.showOrHidePlayList();
    };
}
removeSong(id, index) {
    return () => {
        if (this.props.currentSong.id !== id) {
            this.props.removeSong(id);
            if (index < this.props.currentIndex) {
                //呼叫父元件修改當前歌曲位置
                this.props.changeCurrentIndex(this.props.currentIndex - 1);
            }
        }
    };
}
複製程式碼
<div className="item-right">
    <div className={isCurrent ? "song current" : "song"} onClick={this.playSong(song, index)}>
        <span className="song-name">{song.name}</span>
        <span className="song-singer">{song.singer}</span>
    </div>
    <i className="icon-delete delete" onClick={this.removeSong(song.id, index)}></i>
</div>
複製程式碼

這裡有一個小問題點選刪除按鈕後,播放列表會被關閉,因為點選事件會傳播到它的上級元素.play-list-bg上。在.play-list-bg的第一個子元素.play-list-wrap增加點選事件然後阻止事件傳播

<div className="play-list-wrap" onClick={e => e.stopPropagation()}>
    ...
</div>
複製程式碼

到此核心播放功能元件已經完成

總結

這一節是最核心的功能,內容比較長,邏輯也很複雜。做音樂播放主要是使用H5的audio標籤的play、pause方法,canplay、timeupdate、ended等事件,結合ui框架,在適當的時候更新ui。audio在移動端第一次載入頁面後如果沒有觸控螢幕它是無法自動播放的,因為這裡渲染App元件的時候就已經觸控了很多次螢幕,所以這裡只需要呼叫play方法即可進行播放。使用React的時候儘可能的把元件細化,React元件更新入口只有一個render方法,而render中都是渲染ui,如果render頻繁呼叫的話,就需要把不需要頻繁更新的子元件抽取出來,避免不必要的效能消耗

歌曲播放元件出現和隱藏動畫主要使用react-transition-group這個庫,結合鉤子函式onEnter、onExited,在onEnter時元件剛開始進入,讓播放元件顯示。在onExited時元件已經離開後表示離開狀態過渡已經完成,把播放元件隱藏。音符動畫主要利用貝塞爾曲線過渡型別,貝塞爾曲線也可以做新增購物車動畫,調整貝塞爾曲線值,然後目標translate位置通過目標元素和購物車位置計算出來即可

完整專案地址:github.com/code-mcx/ma…

歡迎star

本章節程式碼在chapter5分支

後續更新中…

相關文章