Electron最佳实践 | 从零开始打造一个本地视频播放器
本文从零开始,一步一步打造一个本地视频播放器。通过阅读本文,读者可以了解到以下知识:
- 视频相关概念(HTML Video)
- 如何获取视频信息(时长等)
- 生成视频封面与截屏(canvas相关,base64转存文件)
- 常用视频播放插件(videojs/xgplayer)
- Electron 文件拖拽播放
- Electron 配置数据存储
- Electron 程序文件关联
- Electron 实现最近播放列表(Window JumpList/MacOS Dock Menu)
- 基于Ffmpeg的视频处理技术
在开始之前我们先来看看效果图:
视频相关概念
在开始打造视频播放器之前,我们先了解一些视频知识:
-
视频质量
:帧率
: FPS ,一个视频的帧数通常是30帧/秒,一般不低于24帧/秒。HD视频可以达到50,60甚至是120的帧率。帧率越高,给人的视觉就越流畅。画质
:可分为标清(SD)、高清(HD)、1080p、超高清(UHD,4k),不同国家或者行业标准略有不同。
-
视频格式
:常见格式包括有 MP4、AVI、MOV、WMV、FLV、RMVB、WEBM、OGG等。 -
视频压缩与编解码
:主要包括 H.26x 系列、 MPEG-x 系列。而在浏览器端以 Chrome 为例,只支持H.264、AV1、VP8(WebM)、VP9(WebM)和 Ogg Theora,因此 HTML Video 只支持 MP4,WebM 和 Ogg 格式的视频播放。
项目搭建
本项目通过 create-electron 创建, 选择 Vue3
+ TypeScript
模板。
npm init @quick-start/electron
基于 electron-vite 进行代码构建,并通过 electron-builder
打包发布。
注:项目产品名称为 EvPlayer
打开本地视频文件
EvPlayer 实现两种视频文件打开方式。第一种,通过 Electron 的 dialog.showOpenDialog
方法实现。第二种,通过文件拖拽实现,获得更好的用户体验,这也是 Electron 开发中常常遇到的处理技术。下面我们看看如何实现:
<script setup lang="ts">
//...
const handleDrop = async (e: DragEvent): Promise<void> => {
e.preventDefault()
let files: VideoFile[] = []
if (e.dataTransfer) {
for (const f of e.dataTransfer.files) {
if (f.type.startsWith('video')) {
files.push({
path: f.path,
name: f.name
})
}
}
}
// ...
}
//...
</script>
<template>
<div class="player" @drop="handleDrop" @dragenter.prevent @dragover.prevent>
<video ref="playerRef" class="video-js"></video>
</div>
</template>
代码解读:
- 通过
drop
事件来监视文件拖放,需要注意的是在Vue
中需要阻止dragenter
和dragover
这两个事件的默认行为,drop 事件才能触发。- 通过
dataTransfer
获取视频文件的信息。在这里要特别注意的是,普通的 Web 开发中File
是无path
属性的,而在 Electron 中向其添加了这个属性,允许我们获得视频文件的真实路径。在TypeScript
开发中,我们还需要对File
的path
属性进行全局声明,才不会提示错误。如下面代码所示:
interface File {
/**
* The real path to the file on the users filesystem
*/
path: string
}
获取视频信息并生成封面
获取视频信息和生成视频封面需要利用到两个 HTML 对象: HTMLVideoElement
和 HTMLCanvasElement
。相关代码如下:
const getVideoInfo = (name: string, src: string): Promise<VideoInfo | null> => {
return new Promise((resolve) => {
const video = document.createElement('video')
video.setAttribute('src', `file:///${src}`)
video.onloadedmetadata = (): void => {
video.currentTime = 1
}
video.onseeked = (): void => {
const { duration, videoHeight, videoWidth } = video
let w = videoWidth
let h = videoHeight
if (w > h) {
if (w > 640) {
const scale = 640 / videoWidth
w = 640
h = Math.ceil(h * scale)
}
} else {
const scale = 480 / videoHeight
h = 480
w = Math.ceil(w * scale)
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = w
canvas.height = h
ctx?.drawImage(video, 0, 0, w, h)
const dataUrl = ctx?.canvas.toDataURL('image/jpeg', 0.9) || ''
const min = Math.floor(duration / 60)
const sec = Math.floor(duration % 60)
resolve({
path: src,
name,
duration: (min >= 10 ? min : `0${min}`) + ':' + (sec >= 10 ? sec : `0${sec}`),
current: 0,
poster: dataUrl
})
}
video.onerror = (): void => {
resolve(null)
}
})
}
代码解读:
- 通过
HTML Video
的onloadedmetadata
和onseeked
两个事件获取视频信息。onloadedmetadata
事件在视频元数据加载完成时触发,即可获得视频的时长,长宽,音视频轨道等信息。onseeked
事件在视频加载到指定位置时触发。在这里我们需要特别注意的是在视频元数据加载后需要将视频时间currentTime
设置为1
,从而避免抓取的第一帧封面是黑屏。- 通过
Canvas
来绘制视频封面。这个过程我们需要进行优化,根据视频的长宽和我们所需要的封面尺寸来计算优化。因为通常的视频分辨率都是非常高的有的甚至可以达到4k,如不优化生成的封面图片将非常大,这是不必要的,同时也会降低程序的整体性能。
通过 Canvas 生成的视频封面为 base64 数据,需要落地存储,这在 Electron 中实现是非常简单的。如下面代码所示:
export const saveBase64Image = (data: string): string => {
try {
const raw = data.replace(/^data:image/jpeg;base64,/, '')
const buffer = Buffer.from(raw, 'base64')
const temp = path.join(app.getPath('userData'), 'posters')
if (!fs.existsSync(temp)) {
fs.mkdirSync(temp, { recursive: true })
}
const absPath = path.join(temp, `/${+new Date()}.jpg`)
fs.writeFileSync(absPath, buffer)
return absPath
} catch {
return ''
}
}
扩展:很多视频播放器都支持截屏功能,我们既然能够截取第一帧,截屏功能自然也不在话下。
播放历史实现
记忆播放历史,是提升用户体验的重要功能。EvPlayer 当然也要支持。包含以下几个方面实现:
- 程序内播放列表实现
- 在 Windows 中,实现任务栏右键跳转列表
- 在 MacOS 中,实现 Dock 菜单最近播放列表
对于程序播放列表的实现,不做过多阐述。我们通过借助 electron-store
来存储播放历史数据。electron-store 是 Electron 开发中常备的插件,一般用于储存程序的配置信息等。对大型的用户数据等,并不建议使用它来存储,你可以借助 indexDB
、lowDB
和 sqlite
等等。
import Store, { Schema } from 'electron-store'
interface VideoInfo {
path: string
name: string
poster: string
duration: string
current: number
}
interface Entity {
playlist: VideoInfo[]
}
const schema: Schema<Entity> = {
playlist: {
type: 'array',
default: []
}
}
export const store = new Store<Entity>({ schema })
对于 Windows 跳转列表和 MacOS Dock 菜单实现如下:
const setWindowJumpList = (videoList?: VideoInfo[]): void => {
if (!platform.isWindows) {
return
}
const jumpList: JumpListCategory[] = []
let videos = videoList || store.get('playlist')
const jumpItemList: JumpListItem[] = []
if (videos.length) {
videos = videos.slice(0, 5)
videos.forEach((v) => {
jumpItemList.push({
type: 'task',
title: v.name.substring(0, 255),
description: v.path.substring(0, 255),
program: process.execPath,
args: `--uri=${v.path}`,
iconPath: process.execPath,
iconIndex: 0
})
})
}
if (jumpItemList.length) {
jumpList.push({
type: 'custom',
name: '最近播放',
items: jumpItemList
})
}
if (jumpList.length) {
app.setJumpList(jumpList)
}
}
const setMacOSRecentDocuments = (videoList?: VideoInfo[]): void => {
if (!platform.isMacOS) {
return
}
app.clearRecentDocuments()
let videos = videoList || store.get('playlist')
videos = videos.slice(0, 5)
if (videos.length) {
videos.reverse().forEach((v) => app.addRecentDocument(v.path))
}
}
这一项功能本质与用户双击视频文件打开程序相似,更多内容在“文件关联”小节中阐述。
视频播放处理
这一部分功能,我们借助第三方的播放插件 video.js
实现,并对其UI和热键功能进行优化,增强用户体验。
// ...
onMounted(() => {
if (playerRef.value) {
player.value = videojs(playerRef.value, {
controls: true,
autoplay: true,
fill: true,
controlBar: {
volumePanel: { inline: false, volumeControl: { vertical: true } },
children: [
'playToggle',
'volumePanel',
'currentTimeDisplay',
'progressControl',
'durationDisplay',
'fullscreenToggle'
]
},
userActions: {
hotkeys: function (event): void {
if (player.value) {
Keyboard.handlerKeyCode(player.value, event.keyCode)
}
}
}
})
const keyboard = new Keyboard(player.value)
keyboard.bind()
}
})
文件关联
文件关联即将播放器支持的文件类型与程序关联起来,为视频文件指定“打开方式”。这一部分功能依靠 electron-builder
来实现。在 electron-builder 中增加以下配置:
// electron-builder.yml
...
fileAssociations:
ext:
- mp4
- ogg
- webm
name: video
role: Editor
...
实现关联只是第一步,程序内部还需要特别处理:
let mainWindow: BrowserWindow | null
function createWindow(): void {
// ...
mainWindow.webContents.on('dom-ready', () => {
playVideo()
})
// ...
}
function initApp(): void {
// Make this app a single instance app.
const lock = app.requestSingleInstanceLock()
if (!lock) {
app.quit()
} else {
app.on('second-instance', (_, argv: string[]) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
playVideo(argv)
}
})
app.whenReady().then(() => {
// ...
})
// For macOS open file
app.on('open-file', (e, path) => {
e.preventDefault()
macOpenVideoURI = path
if (mainWindow) {
mainWindow.show()
playVideo()
} else {
createWindow()
}
})
}
}
代码解读:
EvPlayer 为单例程序,不支持同时播放多个视频,这也是大多数视频软件的做法。
获得要播放视频的地址:
- 程序启动进程参数
process.argv
中最后一个参数获得- 第二实例
second-instance
事件传递的参数中最后一个参数获得- 针对 MacOS 还需从
app
的open-file
事件监听获得视频播放路径解析,如下面代码所示:
function resolveOpenedPathFromArgs(argv?: string[]): string {
if (platform.isMacOS) {
const uri = macOpenVideoURI
macOpenVideoURI = ''
return uri
}
const args = argv || process.argv
const uri = args.find((arg) => arg.startsWith('--uri='))
if (uri) {
return uri.substring(6)
}
return args.pop() || ''
}
基于Ffmpeg的视频处理技术
在这个章节之前,我们打造的播放器,已具备基本的功能。但回到第一小节,我们知道 HTML Video 只支持MP4, WebM 和 Ogg 这些视频格式。为了让 EvPlayer 具有更好的竞争力,我们需要支持更多的视频格式。
说到视频的处理技术,自然避开 Ffmpeg
这个强大的视频工具库。整体的实现思路如下:
- 在主进程中开启 http server 服务
- 利用 Ffmpeg 将不能直接通过HTML Video播放的视频转码为MP4格式的视频流
- 渲染进程端即播放器通过本地服务点播视频
对于 Ffmpeg
在 nodejs 中有对应的模块插件 fluent-ffmpeg
和 @ffmpeg-installer/ffmpeg
。
fluent-ffmpeg
,提供操作 Ffmpeg 命令的API函数@ffmpeg-installer/ffmpeg
,下载对应平台的 Ffmpeg 处理程序
整体实现代码如下:
// server.ts
import http from 'node:http'
import Ffmpeg from './ffmpeg'
class StreamServer {
private port: number
constructor(port = 9555) {
this.port = port
}
start(): void {
const ffmpeg = new Ffmpeg()
ffmpeg.init()
const server = http.createServer((request, response) => {
console.log(request.url)
ffmpeg.kill()
const url = new URL(request.url || '', `http://${request.headers.host}`)
const input = url.searchParams.get('v')
if (input) {
ffmpeg.create(input).pipe(response, { end: true })
}
})
server.listen(this.port, () => {
console.log(`video server listen on ${this.port}`)
})
}
}
// ffmpeg.ts
import { app } from 'electron'
import path from 'node:path'
import ffmpeg from 'fluent-ffmpeg'
import { VideoInfo } from '../common/types'
class Ffmepg {
private instance: ffmpeg.FfmpegCommand | null = null
init(): void {
ffmpeg.setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)
}
create(input: string): ffmpeg.FfmpegCommand {
this.instance = ffmpeg()
.input(input)
.nativeFramerate()
.videoCodec('libx264')
.audioCodec('copy')
.format('mp4')
.outputOptions('-movflags', 'frag_keyframe+empty_moov+faststart')
.on('progress', function (progress) {
console.log('Timemark: ' + progress.timemark)
})
.on('error', function (err) {
console.log('An error occurred: ' + err.message)
})
.on('end', function () {
console.log('Processing finished!')
})
return this.instance
}
kill(): void {
this.instance?.kill('')
}
}
结语
由于篇幅原因,本文并未展示所有代码,完整的项目代码已经开源至 Github ,各位感兴趣的小伙伴可以前往参考研究或者参与开发。