Electron可以讓我們使用html,css,javascript來搭建跨平臺(Windows、macOS、Linux)的桌面應用。下面通過Electron+Nodejs+React來實現一個支援播放線上音樂及本地音樂的播放器。播放器設計風格為windows的Fluent Design,win10和macOS上均可執行(如果構建打包需要不同平臺區分打包),Linux上未測試。
本文主要說明開發中遇到的一些難點和關鍵部分,如想了解詳細可以拉取程式碼檢視。
1.前期準備
- Electron
推薦使用淘寶映象,原版映象即使使用梯子也很難裝上。
npm install -g package --registry=https://registry.npm.taobao.org
複製程式碼
- React(最新版即可)
- React-Router4(用於頁面跳轉)
- Redux/react-redux(js狀態容器,儲存全域性狀態和資料)
- Express(用於搭建API服務)
- LowDB(LowDB是基於node的純JSON檔案資料庫,不需要伺服器,基於記憶體和硬碟的儲存用於快取播放器資料)
- 網易雲API
2.搭建網易雲API
- 拉取網易雲API的git專案
git clone https://github.com/Binaryify/NeteaseCloudMusicApi.git
複製程式碼
- 進入目錄啟動服務
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流程如下:
- 建立音訊上下文
- 在音訊上下文裡建立源 — 例如
- 建立效果節點,例如混響、雙二階濾波器、平移、壓縮
- 為音訊選擇一個目地,例如你的系統揚聲器
- 連線源到效果器,對目的地進行效果輸出
//獲取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));
複製程式碼
此時的波形只是跨度均勻但是關鍵點直接任是直線連結,沒有波浪的感覺,所以接下來需要用曲線來連結相鄰的兩個點。
用曲線連線一系列離散的點有很多方法,這裡採用的是三次函式來解決。
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.播放器介面
播放器列表介面分為三部分:
- 主頁的四個tab,分別是推薦歌單、最新單曲、新碟上架和本地歌曲
- 歌單及專家詳情介面,兩者詳情頁樣式一致
主頁中的歌單和專輯點選進入詳情使用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;
}
複製程式碼