使用 electron-vue 實現(xiàn) B站上很火的 音頻可視化播放器

本人博客文章地址:點擊進入

項目地址:https://github.com/geminate/mwave
下載地址:https://pan.baidu.com/s/1boIHExuBOkMqzXVEE4u3gA 僅為測試項目

一. 啟發(fā)
經(jīng)常逛B站的小伙伴們中應該不少看到過 使用 AE 制作的音頻可視化視頻,例如


image

image

image

看起來是不是很酷炫,當初我還傻傻的在視頻下面問別人這是什么播放器QAQ,其實這種視頻都是使用 AE 做的視頻,并沒有相關(guān)的播放器實現(xiàn)這種效果。

由于本人的開發(fā)方向是前端,猛然想起之前看到的 electron 這個使用前端語言寫桌面程序的開源項目,之后點進去了解了一下,感覺可以嘗試實現(xiàn)一下上圖中的效果。于是花了幾天的閑暇時間搞出了這么個 ***僅服務于本人興趣與學習 ***的 玩意。

本人參照的模板是上圖中的第三個,未聞花名那張(B站上的視頻連接),最終完成的播放器效果上來看大約有 視頻中的 70% 左右,沒做到視頻里那么好看(主要是時間原因,粒子效果、彩色漸變和波形的細致過濾沒做),如果硬要做的話應該能實現(xiàn)的差不多,有興趣的前端小伙伴可以自己嘗試下。

下面這張是最終的實現(xiàn)效果Gif

image

二. Electron 相關(guān)

1. 項目結(jié)構(gòu)

image

項目使用的是 electron-vue 作為骨架,如上圖。其中renderer文件夾中的東西和Vue項目基本一致,多出來的東西是main文件夾和外面的IPC.js。main文件夾里面的東西是主進程文件,renderer則是渲染進程文件。IPC.js用來在在兩個進程中定義通信渠道。

2. 主進程與渲染進程

electron中一個很重要的概念就是主進程與渲染進程,簡單來說 主進程負責操作系統(tǒng)相關(guān)的操作,而渲染進程則負責使用前端語言實現(xiàn)界面展示,運行在webview中。而兩個進程之間的通信則要通過 IPC 通道實現(xiàn)。在任意一端監(jiān)聽一條消息,之后在另一端發(fā)出這條消息即可,需要注意的是,渲染進程->主進程、主進程->渲染進程的消息發(fā)送略有不同。

主進程發(fā)消息,渲染進程接收消息:

// 主進程使用minWindow發(fā)送消息
mainWindow.webContents.send(IPC.SET_MUSIC_LIST, {message:'message'});

// 渲染進程使用 electron.ipcRenderer 監(jiān)聽消息
import electron from 'electron';

electron.ipcRenderer.on(IPC.SET_MUSIC_LIST, (event, message) => {
    console.log(message);
});

渲染進程發(fā)消息,主進程接收消息:

// 渲染進程使用 electron.ipcRenderer 發(fā)送消息
import electron from 'electron';
 
electron.ipcRenderer.send(IPC.RENDER_READY,{message:'message'});
 
//主進程使用 electron.ipcMain 監(jiān)聽消息
import electron from 'electron';
 
electron.ipcMain.on(IPC.RENDER_READY, (event, arg) => {
    console.log(arg);
});

使用起來相當方便,而主進程和渲染進程內(nèi)部通信與狀態(tài)管理則分別用各自的store實現(xiàn)。主進程涉及用戶配置的可直接以文件形式保存在用戶文件夾,而Vue的狀態(tài)管理直接使用Vuex即可。

3. window 創(chuàng)建

function createWindow() {
    mainWindow = new BrowserWindow({
        height: 600,
        width: 600,
        titleBarStyle: 'hidden-inset',
        frame: false,
        transparent: true,
    });
    mainWindow.loadURL(winURL);
    mainWindow.on('closed', () => {
        mainWindow = null
    });
}

上面為主窗體創(chuàng)建的配置,由于我們的播放器需要整體透明且無上部的標題欄,因此設置 titleBarStyle: ‘hidden-inset’ 和 transparent: true ,注意在這樣設置之后,我們還需要對窗口內(nèi)的 body 設置 -webkit-app-region: drag; 的Css 使整個窗口可拖動,之后在對需要有點擊效果的地方(不需要拖動)設置-webkit-app-region: no-drag;

4. 右下角托盤圖標 創(chuàng)建

/**
 * Create Tray
 */
function createTray() {
    let iconPath = path.join(__static, 'icons/256x256.png');
    tray = new Tray(iconPath);
    const contextMenu = Menu.buildFromTemplate([
        {
            label: '選擇文件夾', type: 'normal', click: onChooseFolderClick
        },
        {label: '退出', type: 'normal', role: 'quit'}
    ]);
    contextMenu.items[1].checked = false;
    tray.setContextMenu(contextMenu);
    tray.setToolTip("mwave");
}
 
/**
 * when choose folder btn click
 */
function onChooseFolderClick() {
    const musicPaths = dialog.showOpenDialog({
        properties: ['openDirectory']
    });
    if (musicPaths != null && musicPaths != 'undefined') {
        sendMusicList(musicPaths);
    }
}

由于播放器上的按鈕有限,需要將一些功能性的按鈕放在 托盤圖標的右鍵菜單中,可使用electron的Tray對象實現(xiàn),這里主要是將文件夾選擇的功能放在了這里,文件選擇可用dialog.showOpenDialog 實現(xiàn)。

5. Vue 相關(guān)組件

Vue組件并沒有什么特殊的,我這里拆成了musicAudio、musicCanvas、musicControl、musicInfo、musicName、musicProgress這幾個組件,其中進度條的處理需要稍加留意,因為涉及點擊與拖動,導致需要判斷的邏輯比較多。

三. 音頻 相關(guān)

由于electron中無論是主進程還是渲染進程中均支持node模塊,因此播放音頻十分方便,我這里是使用 mediaserver 在主進程中創(chuàng)建了一個音樂server,之后在渲染進程中使用audio標簽即可。

class MusicServer {
 
    start() {
        const server = http.createServer((req, res) => {
            this.pipeMusic(req, res);
        }).listen(8580);
        return server;
    }
 
    pipeMusic(req, res) {
        if (store.get("MUSIC_PATHS") == undefined || store.get("MUSIC_PATHS").length <= 0) {
            return this.notFound(res);
        }
        const musicUrl = decodeURIComponent(req.url);
        const fileUrl = path.join(store.get("MUSIC_PATHS")[0], musicUrl.substring(1));
        if (musicUrl.substring(1) == '' || !fs.existsSync(fileUrl)) {
            return this.notFound(res);
        }
        ms.pipe(req, res, fileUrl);
    }
 
    notFound(res) {
        res.writeHead(200);
        res.end('not found');
    }
}

四. Canvas 相關(guān)

播放器使用 Canvas 實現(xiàn)那一圈 音頻可視化效果,主要有兩個部分,外圈的柱狀條和內(nèi)圈的跳動顆粒。這里是使用了 WebAudio Api 實現(xiàn)的。

createAnalyser() {
                const AC = new (window.AudioContext || window.webkitAudioContext)();
                const analyser = AC.createAnalyser();
                const gainnode = AC.createGain();
                gainnode.gain.value = 1;
                const source = AC.createMediaElementSource(this.$refs.audio);
                source.connect(analyser);
                analyser.connect(gainnode);
                gainnode.connect(AC.destination);
                return analyser;
            }

常用的api中我們可以用 AC.createGain() 控制音頻增益(即音量大小),可以使用AC.createAnalyser()對音頻進行分析。我們在實現(xiàn)音頻可視化的時候就是使用 AC.createAnalyser().getByteFrequencyData() 生成頻率數(shù)組。具體使用方式如下

this.analyser.fftSize = 1024;
const arrayLength = this.analyser.frequencyBinCount;
const array = new Uint8Array(arrayLength);
this.analyser.getByteFrequencyData(array);

之后我們可以根據(jù) Array 里面的 頻率數(shù)據(jù)進行取值,然后繪制Canvas。繪制的過程就不再詳細說明,主要是數(shù)學上的計算,涉及到圍繞圓的半圈進行繪制,之后取鏡像。在處理時為了美觀,對內(nèi)圈數(shù)值做過濾處理,對外圈數(shù)值做發(fā)散處理。我這里只是簡單處理了一下,想要更加美觀還需要更多的數(shù)學處理。

            /**
             * 繪制內(nèi)圈 point
             */
            drawInner(array, i, ctx) {
                if (i < 136) {
                    var point = i % 9 > 4 ? (9 - i % 9) : (i % 9);
                    var value = (array[i]) * 120 / 256 * ((5 - point) / 5);
                    if (value > 70) {
                        value = ((value - 70) * 120 / 50);
                    } else {
                        value = 0;
                    }
                    ctx.moveTo(( Math.sin(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300), Math.cos(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300);
                    ctx.arc(( Math.sin(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300), Math.cos(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300, 0.6, 0, 2 * Math.PI);
 
                    ctx.moveTo((-Math.sin(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300), Math.cos(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300);
                    ctx.arc(( -Math.sin(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300), Math.cos(((i) * 4 / 3) / 180 * Math.PI) * (198 - value) + 300, 0.6, 0, 2 * Math.PI);
                }
            },
 
            /**
             * 繪制外圈 bar
             */
            drawOuter(array, i, ctx) {
                if (i > 130 && i < 271) {
                    var value = (array[i]) * 120 / 256;
                    if (value > 20) {
                        value = (value - 20) * 120 / 100;
                    } else {
                        value = 0;
                    }
                    ctx.moveTo(( Math.sin((i * 4 / 3) / 180 * Math.PI) * 200 + 300), Math.cos((i * 4 / 3) / 180 * Math.PI) * 200 + 300);
                    ctx.lineTo(( Math.sin((i * 4 / 3) / 180 * Math.PI) * (200 + value) + 300), Math.cos((i * 4 / 3) / 180 * Math.PI) * (200 + value) + 300);
 
                    ctx.moveTo(( -Math.sin((i * 4 / 3) / 180 * Math.PI) * 200 + 300), Math.cos((i * 4 / 3) / 180 * Math.PI) * 200 + 300);
                    ctx.lineTo(( -Math.sin((i * 4 / 3) / 180 * Math.PI) * (200 + value) + 300), Math.cos((i * 4 / 3) / 180 * Math.PI) * (200 + value) + 300);
 
                }
            }

最后,本項目僅是本人處于興趣與學習的目的搞出來的小玩具,很多功能不完善,以后有時間會考慮再優(yōu)化美化一下~

作者博客地址:https://liuhuihao.com
作者gitHub:https://github.com/geminate

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容