打造跳跃音波播音乐放器(Electron+Nodejs+React)

13,664 阅读8分钟

Electron可以让我们使用html,css,javascript来搭建跨平台(Windows、macOS、Linux)的桌面应用。下面通过Electron+Nodejs+React来实现一个支持播放在线音乐及本地音乐的播放器。播放器设计风格为windows的Fluent Design,win10和macOS上均可运行(如果构建打包需要不同平台区分打包),Linux上未测试。
本文主要说明开发中遇到的一些难点和关键部分,如想了解详细可以拉取代码查看。

项目地址:github.com/mai-kuraki/…

1.前期准备

  1. Electron
    推荐使用淘宝镜像,原版镜像即使使用梯子也很难装上。
npm install -g package --registry=https://registry.npm.taobao.org
  1. React(最新版即可)
  2. React-Router4(用于页面跳转)
  3. Redux/react-redux(js状态容器,储存全局状态和数据)
  4. Express(用于搭建API服务)
  5. LowDB(LowDB是基于node的纯JSON文件数据库,不需要服务器,基于内存和硬盘的存储用于缓存播放器数据)
  6. 网易云API

2.搭建网易云API

  1. 拉取网易云API的git项目
git clone https://github.com/Binaryify/NeteaseCloudMusicApi.git
  1. 进入目录启动服务
cd NeteaseCloudMusicApi
node app.js

服务默认配置是3000端口,可以在app.js中修改。
为保证NeteaseCloudMusicApi的后续更新,不建议直接修改NeteaseCloudMusicApi项目代码,自己再起一个Node服务提供API作为中转服务,有自己需要的业务逻辑可以放在自己的Node层。

3.项目搭建

目录结构

fluentApp
    |--app
        |--cache //图片缓存目录
        |--dist //js,scss打包编译文件夹
        |--font //字体文件
        |--...文件
    |--server
        |--express目录
    |--ui
        |--js //js开发文件
        |--scss //scss样式文件

1.搭建API服务

安装express依赖

npm install express --save

使用express初始化目录

express server

在routes文件夹中建立api.js和service.js分别用于定义API路由和处理业务逻辑。

启动服务

node bin/www

2.搭建Electron

1.app

app作为主进程控制着应用的生命周期

const {app} = require('electron');

app可以监听许多事件在适当的事件触发时做期望做的事情API文档 首先需要创建一个程序的主窗口,需要在Electron完成初始化时触发

app.on('ready', createWindow);

Electron中app只是创建了主进程但是要在界面上显示窗口需要用到BrowserWindow,它的作用是创建和控制浏览器窗口。

const {BrowserWindow} = require('electron');
let win//定义一个窗口
function createWindow() {
    win = new BrowserWindow({
        frame: false,
        width: 400,
        height: 670,
        transparent: true,
        resizable: false,
        maximizable: false,
        backgroundColor: '#00FFFFFF',
        webPreferences: {
            nodeIntegrationInWorker: true
        },
        icon: path.join(__dirname, 'icon.ico')
    });
}

2.BrowserWindow

实例化一个BrowserWindow对象,设置窗体的宽高,及一些其他属性。播放器窗口是完全自定义的所以要去除系统自带的头部栏,设置frame:false。具体参数参考API文档
创建完窗体相当于创建了一个浏览器需要在浏览器里显示内容还需要一个html文件。所有新建一个app.html,然后让浏览器显示这个html页面。

win.loadURL(url.format({
    pathname: path.join(__dirname, 'app.html'),
    protocol: 'file:',
    slashes: true
}));

在窗体渲染准备好后让窗口显示

win.on('ready-to-show', () => {
    win.show();
});

这样在启动Electron后就能出现一个显示app.html的无边框窗口界面了。
监听app的window-all-closed事件在所有窗口都关闭时退出主线程

app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit()
    }
});

3.调试界面

Electron的窗口相当于一个chrome浏览器界面,调试方式也和chrome一样使用控制台调试。首先修改窗口宽度400 -> 800个控制台腾出一些地方,然后再main.js添加一行代码

win.webContents.openDevTools();

这样就能打开控制台了。如果设置了窗体透明打开控制台后窗口会变成白底,关闭控制台即可恢复。
如果想要其他chrome调试插件,也可以加载其他插件,以redux调试工具为例:
main.js中

const { default: installExtension, REDUX_DEVTOOLS } = require('electron-devtools-installer');

首先载入插件,然后

installExtension(REDUX_DEVTOOLS)
    .then((name) => console.log(`Added Extension:  ${name}`))
    .catch((err) => console.log('An error occurred: ', err));

这样就能使用调试插件了。

3.播放器核心功能

1.audio

使用html5的audio标签作为播放器播放音乐。API文档

<audio src="audio.mp3" id="audio"></audio>
let audio = document.getElementById('audio');

获取audio对象通过api进行操作。

2.Web audio/canvas

为了在播放时得到跳跃的波浪需要获取音频的波形图,通过Web audio API获取。 了解Web audio
Web Audio API使用户可以在音频上下文(AudioContext)中进行音频操作,具有模块化路由的特点。在音频节点上操作进行基础的音频, 它们连接在一起构成音频路由图。
音频节点通过它们的输入输出相互连接,形成一个链或者一个简单的网。一般来说,这个链或网起始于一个或多个音频源。
一个简单而典型的web audio流程如下:

  1. 创建音频上下文
  2. 在音频上下文里创建源 — 例如 , 振荡器, 流
  3. 创建效果节点,例如混响、双二阶滤波器、平移、压缩
  4. 为音频选择一个目地,例如你的系统扬声器
  5. 连接源到效果器,对目的地进行效果输出

有了这些概念后就可以开始后续工作了。获取到波形后要输出波形还需要借助canvas,所有先创建标签。

//获取web audio上下文
this.audioContext = new window.AudioContext();
//获取canvas节点
this.canvas = document.getElementById('waveCanvas');
//获取canvas 2d上下文
this.ctx = this.canvas.getContext('2d');
//设置canvas宽高
this.width = this.canvas.offsetWidth,
this.height = this.canvas.offsetHeight;
this.canvas.width = this.width,
this.canvas.height = this.height;
//画出矩形方框,this.baseY是方框高度相对于窗口高度的基准线
this.ctx.beginPath();
this.ctx.fillStyle = 'rgba(102,102,102,0.8)';
this.ctx.moveTo(0, this.baseY);
this.ctx.lineTo(this.width, this.baseY);
this.ctx.lineTo(this.width, this.height);
this.ctx.lineTo(0, this.height);
this.ctx.fill();

矩形方块为窗口下方灰色部分。
接下来用audio标签作为音源,使用Web audio获取声音用于分析

this.audio = document.getElementById('audio');
//使用audio标签作为音源
this.source = this.audioContext.createMediaElementSource(this.audio);
//创建一个分析器
this.analyser = this.audioContext.createAnalyser();
//串联起分析器节点和音源输出
this.analyser.connect(this.audioContext.destination);
this.source.connect(this.analyser);
//从分析器中获取当前播放的频率数据
let array = new Uint8Array(_this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(array);

获取到数据后开始绘制波形图,直接获取的数据是默认长度为1024的Uint8Array数组,循环数据后在baseY基准线的基础上加上当前的频率这样绘制出来就是波形图了。

this.ctx.beginPath();
this.ctx.moveTo(0,this.baseY);
for(let i = 0;i < array.length; i++) {
    this.ctx.lineTo(i, this.baseY - array[i]);
}
this.ctx.lineTo(this.width, this.baseY);
this.ctx.lineTo(this.width, 0);
this.ctx.lineTo(0, 0);
this.ctx.fill();

如果使用1024个点这样画出的波形图是非常密集的而且两点之间是直线连接,类似于这种

所以还需要优化一下。
总共1024个数据以步长为50取1024个点中的20个点作为关键点。由于大部分的歌曲低频总比高频多这就导致了波浪总是左边高右边低,高频处大部分时间都是平的,而且左侧起点总是0,波形会显得很生硬
为了使波形平缓均匀,取20个关键点的前十个放置在窗口中间,两边预留出空间,然后取随取两组连续的五个点,一组拼接在关键点数组左侧另一组放右侧,绘制波形区域时略大于屏幕宽度,这样绘制出的点将不再生硬的以0为起始点

let waveArr1 = [],waveArr2 = [],waveTemp = [],leftTemp = [],rightTemp = [],waveStep = 50,leftStep = 70, rightStep = 90;
array.map((data, k) => {
    if(waveStep == 50 && waveTemp.length < 9) {
        waveTemp.push(data / 2.6);
        waveStep = 0;
    }else{
        waveStep ++;
    }
    if(leftStep == 0 && leftTemp.length < 5) {
        leftTemp.unshift(Math.floor(data / 4.8));
        leftStep = 70;
    }else {
        leftStep --;
    }
    if(rightStep == 0 && rightTemp.length < 5) {
        rightTemp.push(Math.floor(data / 4.8));
        rightStep = 90;
    }else {
        rightStep --;
    }
});
waveArr1 = leftTemp.concat(waveTemp).concat(rightTemp);
waveArr2 = leftTemp.concat(rightTemp);
waveArr2.map((data, k) => {
    waveArr2[k] = data * 1.8;
});
let waveWidth = Math.ceil(this.width / (waveArr1.length - 3));
let waveWidth2 =  Math.ceil(this.width / (waveArr2.length - 3));

此时的波形只是跨度均匀但是关键点直接任是直线链接,没有波浪的感觉,所以接下来需要用曲线来链接相邻的两个点。
用曲线连接一系列离散的点有很多方法,这里采用的是三次函数来解决。

三次函数的图像是一条曲线回归式抛物线,当Δ>0时存在一个极大值一个极小值,两个值相连的是一条曲线。这样就可以通过取关键点相邻的两个点作为极值绘制一条平滑曲线来获得波浪了。

this.ctx.beginPath();
this.ctx.fillStyle = 'rgba(102,102,102,0.8)';
this.ctx.moveTo(-waveWidth * 2, this.baseY - waveArr1[0]);
for(let i = 1; i < waveArr1.length - 2; i ++) {
    let p0 = {x: (i - 2) * waveWidth, y:waveArr1[i - 1]};
    let p1 = {x: (i - 1) * waveWidth, y:waveArr1[i]};
    let p2 = {x: (i) * waveWidth, y:waveArr1[i + 1]};
    let p3 = {x: (i + 1) * waveWidth, y:waveArr1[i + 2]};

    for(let j = 0; j < 100; j ++) {
        let t = j * (1.0 / 100);
        let tt = t * t;
        let ttt = tt * t;
        let CGPoint ={};
        CGPoint.x = 0.5 * (2*p1.x+(p2.x-p0.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*tt + (3*p1.x-p0.x-3*p2.x+p3.x)*ttt);
        CGPoint.y = 0.5 * (2*p1.y+(p2.y-p0.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*tt + (3*p1.y-p0.y-3*p2.y+p3.y)*ttt);
        this.ctx.lineTo(CGPoint.x, this.baseY - CGPoint.y);
    }
    this.ctx.lineTo(p2.x, this.baseY - p2.y);
}
this.ctx.lineTo((waveArr1.length) * waveWidth, this.baseY - waveArr1[waveArr1.length - 1]);
this.ctx.lineTo(this.width + waveWidth * 2, this.baseY);
this.ctx.lineTo(this.width + waveWidth * 2, this.height);
this.ctx.lineTo(-2 * waveWidth, this.height);
this.ctx.fill();

画出平滑波形后适当调整左右两侧过度点相对于关键点的百分比,使过渡点平缓一些看着自然一些。
一组波浪绘制成功以后,使用左右两组过渡点直接组合绘制一个浅色波形,增加波浪跳动层次。

3.播放进度条及拖动

播放进度条采用环形进度条,支持拖动选择播放位置。
环形进度使用svg绘制,绘制轨道与已播放部分

<svg width="32vw" height="32vw">
    <circle cx="16vw" cy="16vw" r="15.5vw" strokeWidth="3" stroke="#DDD" fill="none"></circle>
    <circle cx="16vw" cy="16vw" r="15.5vw" strokeWidth="3" stroke="#666" fill="none" strokeDasharray={`${this.calcCir()}vw 2000`}></circle>
</svg>

stroke-dasharray属性用于创建虚线,设置单段虚线长度大小,只要虚线之间的间距足够大就能得到一段可变长度圆弧的显示效果了。
接下来开始解决拖动问题,为了使拖拽按钮点的移到比较平缓流畅,使用css3的角度变换来实现。

<div className="dot-wrap" id="dotWrap" style={{transform: `rotate(${this.calcDeg()}deg)`}}>
    <div className="dot" onMouseDown={this.dotMouseDown.bind(this)}></div>
</div>

dot-wrap长宽与环形进度条一致,拖动按钮点位于顶部中间位置,当鼠标拖动按钮时计算当前鼠标位置与顶部中间位置的差值,计算出两点之间以环形圆心为中心所形成的夹角,根据这个角度旋转dot-wrap,在知道角度后就能获取到角度占360°的百分比,根据百分比去设置当前播放歌曲的播放时间。

4.播放器界面

播放器列表界面分为三部分:

  1. 主页的四个tab,分别是推荐歌单、最新单曲、新碟上架和本地歌曲
  2. 歌单及专家详情界面,两者详情页样式一致

3. 搜索界面

主页中的歌单和专辑点击进入详情使用react-route跳转,搜索页需要保持搜索结果状态使用悬浮层显示详情页。

5.扫描本地歌曲

普通浏览器中JavaScript是没有权限也没有api去读取扫描文件夹的,但是在Electron中html页面中的JavaScript可以使用Nodejs中的模块,可以使用fs模块开扫描文件夹。

import {remote} from 'electron';
const fs = remote.require('fs');

通过remote来引入Nodejs模块。
这里采用另一种方法来实现扫描文件,通过Electron的进程通信,在界面中触发事件通知主进程来做扫描的事情。 首先要扫描文件夹需要知道扫描哪个路径,通过Electron调用原生dialog来选择路径

 remote.dialog.showOpenDialog({
    title: '选择添加目录',
    properties: ['openDirectory', 'multiSelections'],
}, (files) => {
    if(!files) return;
    ...
})

选择完要扫描的路径就可以通知主进程开始扫描了。由于扫描可能耗时较长会引起UI渲染的顿卡,所有使用Nodejs的子线程来扫描。

const {ipcMain} = require('electron');
const child_process = require('child_process');
//主进程监听scanningDir,当渲染进程触发scanningDir时主进程开始进行扫描
ipcMain.on('scanningDir', (e, dirs) => {
    const cp = child_process.fork('./scanFile.js');
    cp.on('message', () => {
        e.sender.send('scanningEnd');
        cp.disconnect();
    });
    cp.send(dirs);
});

扫描结束后获取到路径下所有符合后缀的音乐文件。由于大部分音乐平台下载的音乐文件都包含音乐信息及专辑封面,所以需要提前音乐信息这里推荐使用jsmediatags模块

const jsmediatags = require('jsmediatags');
const btoa = require('btoa');
const fs = require('fs');
//对扫描到的所有音乐文件进行循环出来
songItem.map((data, k) => {
    let name = getFileName(data);
    jsmediatags.read(data, {
        onSuccess: (tag) => {
            //文件内包含的专辑封面是base64格式的图片,获取后转成jpeg格式缓存到cache文件夹内。
            let image = tag.tags.picture;
            let filename = `cache/albumCover/${createRandomId()}.jpeg`;
            let base64String = "";
            image.data.map((d, j) => {
                base64String += String.fromCharCode(d);
            });
            let dataBuffer = new Buffer(btoa(base64String), 'base64');
            fs.writeFile(filename, dataBuffer, (err) => {
               ...
            });
        },
        onError: (error) => {
            ...
        }
    });
});

处理完后得到一个包含文件路径及信息的数组,使用lowdb将数据存储。

const low = require('lowdb');
const FileAsync = require('lowdb/adapters/FileAsync');
const adapter = new FileAsync('db.json');
const db = low(adapter);

...
//读取一下数据库
db.then(db => {
    db.set('localPlayList', data).write().then(() => {
        process.send('');
    })
});
...

lowdb有个坑,实例化db对象后db内容将是当前这个实例下的数据状态,如果在主进程中对db进行写入操作,UI进程的db在写入数据时数据库的信息将还是老数据,会造成数据不同步。所以每次写入时先读一次数据库。

db.read().get('key').value();

6.生成更新当前播放列表及随机播放。

列表生成:
当点击播放歌曲时获取当前歌曲id(本地扫描歌曲时也会生成一个随机ID),对比是否已经存在于列表中如果不存在则添加,另一种添加方式是在专辑或者歌单内点击批量添加。当打开列表时如有正在播放的歌曲将列表滑动定位到正在播放的歌曲位置。

随机播放列表的生成:
一般市面上的播放器随机播放并不是真的点下一曲随机选一首歌,而是通过洗牌算法生成一个打乱顺序的歌单来进行随机播放,还可以根据每首歌播放次数做权重来增加歌曲被播放的概率,这里简单的使用shuffle-array模块来通过一个现有的数组生成一个打款顺序的数组。
当程序载入时如果当前播放模式是随机模式则初始化生成一个随机播放列表缓存到redux中,如果有新增歌曲则在随机列表中找个随机位置插入,更新列表。如果通过手动切换到随机播放,则先判断redux中是否存在随机播放列表,如果没有再生成一个进行缓存。

import shuffleArray from 'shuffle-array';
//创建随机列表
createShuffeList() {
    let playlist = db.get('playList').value() || [];
    let shuffleList = shuffleArray(playlist, {copy: true });
    store.dispatch(Actions.setShuffleList(shuffleList));
}

//将新增歌曲插入到随机列表
insertSongToShuffleList(item) {
    if(!item) return;
    let shuffleList = store.getState().main.shuffleList;
    let len = shuffleList.length;
    (item || []).map((data, k) => {
        let insertPosition = Math.floor(len * Math.random());
        shuffleList = shuffleList.splice(insertPosition, 0, data);
    });
    store.dispatch(Actions.setShuffleList(shuffleList));
}

7.windows平台下系统托盘按钮控制

Electron有API来配置Windows任务栏中的应用自定义缩略图和工具栏。接下来为播放器添加上一曲、播放/暂停、下一曲托盘按钮。 在main.js文件中,主要用到webContents来实现主进程向渲染进程发送通信。

let thumbarButtons = [
    {
        tooltip: '上一曲',
        icon: path.join(__dirname, 'prev.png'),
        flags: [
            'nobackground'
        ],
        click: () => {
            win.webContents.send('pre');
        }
    },
    {
        tooltip: '播放',
        icon: path.join(__dirname, 'play.png'),
        flags: [
            'nobackground'
        ],
        click: () => {
            win.webContents.send('switch');
        }
    },
    {
        tooltip: '下一曲',
        icon: path.join(__dirname, 'next.png'),
        flags: [
            'nobackground'
        ],
        click: () => {
            win.webContents.send('next');
        }
    }
]
 win.setThumbarButtons(thumbarButtons);
 ...
ipcMain.on('playSwitch', (e, state) => {
    let icon = state?'paused.png':'play.png';
    thumbarButtons[1].icon = path.join(__dirname, icon);
    win.setThumbarButtons(thumbarButtons);
});

托盘按钮有click事件,通过点击向渲染进程发送通信信息切换歌曲状态,状态切换成功后渲染进程通知主进程更改托盘按钮图标。

3.代理服务

Web audio不能获取跨域音频资源的上下文,在播放在线音乐时需要自己搭建一层代理,使用Nodejs的http-proxy模块。

let http = require('http');
let https = require('https');
let httpProxy = require('http-proxy');
let url = require('url');
router.use('*', (req, res) => {
    req.url = req.originalUrl.replace('/proxy', '');
    let proxy = httpProxy.createProxy({});
    proxy.on('error', (err) => {
        console.log('ERROR');
        console.log(err);
    });
    let finalUrl = 'http://m10.music.126.net';
    let finalAgent = null;
    let parsedUrl = url.parse(finalUrl);
    if (parsedUrl.protocol === 'https:') {
        finalAgent = https.globalAgent;
    } else {
        finalAgent = http.globalAgent;
    }
    proxy.web(req, res, {
        target: finalUrl,
        agent: finalAgent,
        headers: { host: parsedUrl.hostname },
        prependPath: false,
        xfwd : true,
        hostRewrite: finalUrl.host,
        protocolRewrite: parsedUrl.protocol
    });
});

4.前端构建

react使用ES6语法,css使用Scss编写所有需要编译运行。 Scss推荐使用webstrom自带的编译构建,可以实时编译。
添加File Watcher,设置css文件输出路径。

--no-cache --update $FileName$:$ProjectFileDir$/app/dist/$FileNameWithoutExtension$.css

js编译使用webpack,首先进入ui目录,运行webpack来编译js文件,开发时建议注释掉打包压缩配置,可以提高webpack -w实时编译译速度。

plugins: [
    new ExtractTextPlugin("bundle_style.css"),
    //new webpack.DefinePlugin({
    //    'process.env': {
    //        'NODE_ENV': JSON.stringify('production')
    //    }
    //}),
    //new UglifyJSPlugin()
]

5.开发环境启动应用

js通过webpack打包后输出到app目录下dist文件夹,cd到app目录运行

electron .

来启动应用。或者在跟目录下建立package.json通过配置script用npm来启动

"scripts": {
    "start": "electron app/main.js",
},
//运行 npm start

6.打包构建应用

在开发时可以在目录使用electron .目录来启动应用,如果要把应用发布出去就不能这样了,需要将应用构建成对应平台的执行文件。 这里推荐使用electron-packager模块来构建应用。

npm install electron-packager -g

打包命令

electron-packager <sourcedir> <appname> --platform=<platform> --arch=<arch> [optional flags...]

以打包成window x64平台为例

electron-packager ./ fluentApp --platform=win32 --out=../../build --arch=x64 --electron-version=1.4.13 --icon=./icon.ico --ignore=/"(cache|db.json)" --overwrite

打包完成后会生成一个fluentApp-win32-x64文件夹里面有fluentApp.exe可执行文件,可以直接打开。
如果希望吧fluentApp-win32-x64目录打包成一个安装包exe文件,可以使用grunt-electron-installer打包。

npm install grunt --save
npm install grunt-electron-installer --save

创建Gruntfile.js文件

const grunt = require("grunt");
grunt.config.init({
    pkg: grunt.file.readJSON('package.json'),
    'create-windows-installer': {
        x64: {
            appDirectory: './fluentApp-win32-x64',
            authors: 'maikuraki.',
            exe: 'fluentApp.exe',
            description:"music app",
        }
    }
});

grunt.loadNpmTasks('grunt-electron-installer');
grunt.registerTask('default', ['create-windows-installer']);

使用安装包安装应用需要为应用创建桌面快捷方式和卸载处理。在main.js中增加对应的处理

let handleStartupEvent = () => {
    let install = () => {
        let updateDotExe = path.resolve(path.dirname(process.execPath), '..', 'update.exe');
        let target = path.basename(process.execPath);
        let child = child_process.spawn(updateDotExe, ["--createShortcut", target], {detached: true});
        child.on('close', (code) => {
            app.quit();
        });
    };
    let uninstall = () => {
        let updateDotExe = path.resolve(path.dirname(process.execPath), '..', 'update.exe');
        let target = path.basename(process.execPath);
        let child = child_process.spawn(updateDotExe, ["--removeShortcut", target], {detached: true});
        child.on('close', (code) => {
            app.quit();
        });
    };

    if (process.platform !== 'win32') {
        return false;
    }

    let squirrelCommand = process.argv[1];

    switch (squirrelCommand) {
        case '--squirrel-install':
        case '--squirrel-updated':
            install();
            return true;
        case '--squirrel-uninstall':
            uninstall();
            app.quit();
            return true;
        case '--squirrel-obsolete':
            app.quit();
            return true;
    };

};

if (handleStartupEvent()) {
    return;
}

其他平台的构建可以参考Electron文档Linux,macOS