React全家桶构建一款Web音乐App实战(五):歌曲状态管理及播放功能实现

9,671 阅读18分钟

内容较长,请耐心阅读

这一节使用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>

启动应用后打开控制台,查看状态 在专辑页点击歌曲后查看状态

封装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分支