使用 electron 做個播放器
本文同步更新在:https://github.com/whxaxes/blog/issues/8

前言
雖然 electron 已經出來好長時間了,但是最近才玩了一下,寫篇博文記錄一下,以便日后回顧。
electron 的入門可以說是相當簡單,官方提供了一個 quick start,很流暢的就可以跑起來一個應用。
為啥要做個播放器呢,因為我在很久很久以前寫過一個網頁版的音頻可視化播放器,但是因為是在頁端,所以想播放本地音樂很麻煩,也沒法保存。因此就想到用 electron 做個播放器 App,就可以讀本地的網易雲音樂目錄了。
生成骨架
由於習慣用 vue,因此也准備用 vue 來實現這個應用。而目前就已經有個 electron-vue 的 boilplate 可以用。因此就直接通過 vue-cli 來進行初始化即可。
vue init simulatedgreg/electron-vue boom
然后就可以生成項目骨架了,結構如下:
.
├── .electron-vue
│ ├── build.js
│ ├── dev-client.js
│ ├── dev-runner.js
│ ├── webpack.main.config.js
│ └── webpack.renderer.config.js
├── dist
├── src
│ ├── index.ejs
│ ├── main
│ │ ├── index.dev.js
│ │ ├── index.js
│ └── renderer
│ ├── assets
│ ├── components
| ├── App.vue
│ ├── main.js
│ └── store.js
├── .eslintignore
├── .eslintrc.js
├── .travis.yml
├── appveyor.yml
├── .babelrc
├── package.json
├── README.md
生成好之后,就直接執行
yarn dev
就可以看到一個應用出現啦,然后就可以愉快的開始開發了。
主進程與渲染進程
在 electron 中有 main process 以及 renderer process 之分,簡單來說,main process 就是用來創建窗口之類的,類似於后台,renderer process 就是跑在 webview 中的。兩個進程中能調用的接口有部分是通用,也有一部分是獨立的。不過不管是在哪個進程中,都可以調用 node 的常用模塊,比如 fs、path 。
因此在 webview 跑的頁面代碼中,也可以通過 fs 模塊讀取本地文件,這點還是很方便的。
而且,就算在 renderer process 中想調用 main process 的接口也是可以的,可以通過 remote 模塊。比如我需要監聽當前窗口是否進入全屏,就可以這樣寫:
import { remote } from 'electron';
const win = remote.app.getCurrentWindow();
win.on('enter-full-screen', () => {
// do something
});
簡直方便至極。
創建窗口
創建窗口的邏輯是在主進程中做的,邏輯很簡單,就按照 electron 的 quick start 的方式進行創建即可。而且通過 electron-vue 生成的代碼,其實也已經幫你把這塊邏輯寫好了。就自己進行一些小修改就可以了。
import { app, BrowserWindow, screen } from 'electron'
app.on('ready', () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
cosnt win = new BrowserWindow({
height, width,
useContentSize: true,
titleBarStyle: 'hidden-inset',
frame: false,
transparent: true,
});
win.loadURL(`file://${__dirname}/index.html`);
})
由於我做的播放器,想全身是黑色風格的,因此在創建窗口時,傳入 titleBarStyle,frame,transparent這幾個參數,可以把頂部欄隱藏掉。當然,隱藏之后,窗口就沒法拖動了。所以還要在頁面上加個用來拖動的透明頂部欄,再給個 css 屬性:
-webkit-app-region: drag;
就可以實現窗口拖動了。
通信
主進程和渲染進程之間的通信是很常用的,通信是通過 IPC 通道實現的。代碼邏輯寫起來也很簡單
main process 收發消息
import { ipcMain } from 'electron';
ipcMain.on('init', (evt, arg) => {
evt.sender.send('sync-config', { msg: 'hello' })
});
renderer process 收發消息
import { ipcRenderer } from 'electron';
ipcRenderer.send('init');
ipcRenderer.on('sync-config', (evt, arg) => {
console.log(arg.msg);
});
有一點要注意的就是,ipcMain 是沒有 send 方法的,如果需要 ipcMain 主動推送消息到渲染進程,需要使用窗口對象實現:
win.webContents.send('sync-config', { msg: 'hello' });
配置保存
每個應用肯定是有一些用戶配置的,比如放音樂的目錄需要保存到配置中,下次打開就可以直接讀取那個目錄的音樂列表即可。
electron 提供了獲取相關路徑的接口 getPath 用於給應用保存數據。在 getPath 接口中,傳入相應名稱即可獲取到相應的路徑。
electron.app.getPath('home'); // 獲取用戶根目錄
electron.app.getPath('userData'); // 用於存儲 app 用戶數據目錄
electron.app.getPath('appData'); // 用於存儲 app 數據的目錄,升級會被福噶
electron.app.getPath('desktop'); // 桌面目錄
...
由於我們這些配置數據不能保存在應用下,因為如果保存在應用下,應用升級后就會被覆蓋掉,因此需要保存到 userData 下。
const electron = require('electron');
const dataPath = (electron.app || electron.remote.app).getPath('userData');
const fileUrl = path.join(dataPath, 'config.json');
let config;
if(fs.existSync(fileUrl)) {
config = JSON.parse(fs.readFileSync(fileUrl));
} else {
config = {};
fs.writeFileSync(fileUrl, '{}');
}
無論在 renderer process 中,還是在 main process 中,都是可以調用,在 main process 中就通過 electron.app 調用,否則就通過 remote 模塊調用。
雖然無論在 main process 中還是在 renderer process 中都可以讀到配置,但是考慮到兩個進程中數據同步的問題,我個人覺得,這種配置讀取與寫入,還是統一在 main process 做好,renderer process 要保存數據就通過 IPC 消息通知 main process 進行數據的更改,保證配置數據的流向是單方向的,比如容易管理。
配置菜單
可以通過 Menu 類實現。
import { Menu } from 'electron';
Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
template 的格式可直接看官方文檔,就是普通的 json 格式。
音頻播放
講完 electron 相關,然后就可以講講怎么播放音頻了。
由於在 electron 中,在前端代碼中也可以使用 node 的模塊,因此,剛開始的想法是,直接用 fs 模塊讀取音頻文件,然后再將讀取的 Buffer 轉成 Uint8Array 再轉成 AudioBuffer ,然后連接到音頻輸出上進行播放就行了。大概邏輯如下:
const AC = new window.AudioContext();
const analyser = AC.createAnalyser();
const buf = fs.readFileSync(music.url);
const uint8Buffer = Uint8Array.from(buf);
// 音頻解碼
AC.decodeAudioData(uint8Buffer.buffer)
.then(audioBuf => {
const bs = AC.createBufferSource();
bs.buffer = audioBuf;
bs.connect(analyser);
analyser.connect(AC.destination);
bs.start();
});
但是,有個問題,音頻解碼很費時間,解碼一個三四分鍾的 mp3 文件就得 2 ~ 4 秒,這樣的話我點擊播放音樂都得等兩秒以上,這簡直不能忍,所以就考慮換種方法,比如用流的方式。
抱着這種想法就去查閱了文檔,結果發現沒有支持流的解碼接口,再接着就想自己來模擬流的方式,讀出來的 buffer 分成 N 段,然后逐段進行解碼,解碼完一段就播一段,嗯...想的挺好,但是發現這樣做會導致解碼失敗,可能是粗暴的將 buffer 分段對解碼邏輯有影響。
上面的方法行不通了,當然還有方法,audio 標簽是支持流式播放的。於是就在啟動應用的時候,建個音頻服務。
function startMusicServer(callback) {
const server = http.createServer((req, res) => {
const musicUrl = decodeURIComponent(req.url);
const extname = path.extname(musicUrl);
if (allowKeys.indexOf(extname) < 0) {
return notFound(res);
}
const filename = path.basename(musicUrl);
const fileUrl = path.join(store.get(constant.MUSIC_PATH), filename);
if (!fs.existsSync(fileUrl)) {
return notFound(res);
}
const stat = fs.lstatSync(fileUrl);
const source = fs.createReadStream(fileUrl);
res.writeHead(200, {
'Content-Type': allowFiles[extname],
'Content-Length': stat.size,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=' + (365 * 24 * 60 * 60 * 1000),
'Last-Modified': String(stat.mtime).replace(/\([^\x00-\xff]+\)/g, '').trim(),
});
source.pipe(res);
}).listen(0, () => {
callback(server.address().port);
});
return server;
}
然后在前端,直接更換 audio 標簽的 src 即可,然后連接上音頻輸出:
<audio ref="audio"
:src="url"
crossorigin="anonymous"></audio>
const audio = this.$refs.audio;
const source = AC.createMediaElementSource(this.$refs.audio);
source.connect(analyser);
analyser.connect(AC.destination);
就這么愉快的實現了流式播放了。。。感覺白折騰了很久。
音頻可視化
這個其實我在以前的博客里有說過了,不過再簡單的說一下。在上一段中我會把音頻連接到一個 analyser 中,其實這個是一個音頻分析器,可以將音頻數據轉成頻率數據。我們就可以用這些頻率數據來做可視化。
只需要通過以下這段邏輯就可以獲取到頻率數據了,因為頻率數據數據都是 0 ~ 255 的大小,長度總共 1024,因此用個 Uint8Array 來存儲。
const arrayLength = analyser.frequencyBinCount;
const array = new Uint8Array(arrayLength);
analyser.getByteFrequencyData(array);
然后獲取到這個數據之后,就可以在 canvas 中把不同頻率以圖像的形式畫出來即可。具體就不贅述了,有興趣的可以看我以前寫的這篇博文。
打包
編寫完代碼之后,就可以使用 electron-packager 進行打包,在 Mac 上就會打包成 app,在 windows 應該會打成 exe 吧(沒試過)。
安裝 electron-packager (npm install electron-packager -g)之后就可以打包了。
electron-packager .
總結
electron 還是相當方便的,讓 web 開發者也可以輕松編寫桌面應用。
上述代碼均在:https://github.com/whxaxes/boom ,有興趣的可以 clone 下來跑一下玩玩。
