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

內容較長,請耐心閱讀css

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

什麼是Redux?Redux是一個狀態的容器,是一個應用數據流框架,主要用做應用狀態的管理。它用一個單獨的常量狀態樹(對象)來管理保存整個應用的狀態,這個對象不能直接被修改。Redux中文文檔見www.redux.org.cnios

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

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

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

首先安裝reduxreact-reduxgithub

npm install redux react-redux --save
複製代碼

react-redux庫包含了容器組件展現組件相分離的開發思想,在最頂層組件使用redux,其他內部組件僅僅用來展現,全部數據經過props傳入web

說明 容器組件 展現組件
位置 最頂層,路由處理 中間和子組件
讀取數據 從 Redux 獲取 state 從 props 獲取數據
修改數據 向 Redux 派發 actions 從 props 調用回調函數

狀態設計

一般把redux相關操做的js文件放置在同一個文件夾下,這裏在src下新建redux目錄,而後新建actions.jsactionTypes.jsreducers.jsstore.jsnpm

actionTypes.jsredux

//顯示或隱藏播放頁面
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>
複製代碼

啓動應用後打開控制檯,查看狀態 在專輯頁點擊歌曲後查看狀態

封裝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請查看源代碼,結尾有源碼地址

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事件只是簡單的作了一個提示,實際上是能夠自動切換下一首歌曲的

效果圖以下

開發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軸平移的值先過渡到負值(一個負的終點值)而後再過渡到目標值。這裏我調的貝塞爾曲線以下

能夠到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

效果圖以下

開發播放列表

考慮到播放列表有不少列表數據,若是放在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/dxx/mango-m…

歡迎star

本章節代碼在chapter5分支

相關文章
相關標籤/搜索