likes
comments
collection
share

Electron最佳实践 | 从零开始打造一个本地视频播放器

作者站长头像
站长
· 阅读数 34

本文从零开始,一步一步打造一个本地视频播放器。通过阅读本文,读者可以了解到以下知识:

  • 视频相关概念(HTML Video)
  • 如何获取视频信息(时长等)
  • 生成视频封面与截屏(canvas相关,base64转存文件)
  • 常用视频播放插件(videojs/xgplayer)
  • Electron 文件拖拽播放
  • Electron 配置数据存储
  • Electron 程序文件关联
  • Electron 实现最近播放列表(Window JumpList/MacOS Dock Menu)
  • 基于Ffmpeg的视频处理技术

在开始之前我们先来看看效果图:

Electron最佳实践 | 从零开始打造一个本地视频播放器

视频相关概念

在开始打造视频播放器之前,我们先了解一些视频知识:

  • 视频质量

    • 帧率: 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打包发布。

项目代码:github.com/alex8088/Ev…

注:项目产品名称为 EvPlayer

打开本地视频文件

EvPlayer 实现两种视频文件打开方式。第一种,通过 Electron 的 dialog.showOpenDialog 方法实现。第二种,通过文件拖拽实现,获得更好的用户体验,这也是 Electron 开发中常常遇到的处理技术。下面我们看看如何实现:

<script setup lang="ts">
//...
const handleDrop = async (e: DragEvent): Promise<void> => {
  e.preventDefault()

  let filesVideoFile[] = []
  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 中需要阻止 dragenterdragover 这两个事件的默认行为,drop 事件才能触发。
  • 通过 dataTransfer 获取视频文件的信息。在这里要特别注意的是,普通的 Web 开发中 File 是无 path 属性的,而在 Electron 中向其添加了这个属性,允许我们获得视频文件的真实路径。在 TypeScript 开发中,我们还需要对 Filepath 属性进行全局声明,才不会提示错误。如下面代码所示:
interface File {
  /**
   * The real path to the file on the users filesystem
   */
  path: string
}

获取视频信息并生成封面

获取视频信息和生成视频封面需要利用到两个 HTML 对象: HTMLVideoElementHTMLCanvasElement。相关代码如下:

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 Videoonloadedmetadataonseeked 两个事件获取视频信息。onloadedmetadata 事件在视频元数据加载完成时触发,即可获得视频的时长,长宽,音视频轨道等信息。onseeked 事件在视频加载到指定位置时触发。在这里我们需要特别注意的是在视频元数据加载后需要将视频时间 currentTime 设置为 1,从而避免抓取的第一帧封面是黑屏
  • 通过 Canvas 来绘制视频封面。这个过程我们需要进行优化,根据视频的长宽和我们所需要的封面尺寸来计算优化。因为通常的视频分辨率都是非常高的有的甚至可以达到4k,如不优化生成的封面图片将非常大,这是不必要的,同时也会降低程序的整体性能。

通过 Canvas 生成的视频封面为 base64 数据,需要落地存储,这在 Electron 中实现是非常简单的。如下面代码所示:

export const saveBase64Image = (datastring): 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, { recursivetrue })
    }

    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 开发中常备的插件,一般用于储存程序的配置信息等。对大型的用户数据等,并不建议使用它来存储,你可以借助 indexDBlowDBsqlite等等。

import Store, { Schema } from 'electron-store'

interface VideoInfo {
  pathstring
  namestring
  posterstring
  durationstring
  currentnumber
}

interface Entity {
  playlistVideoInfo[]
}

const schemaSchema<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(05)
    videos.forEach((v) => {
      jumpItemList.push({
        type: 'task',
        title: v.name.substring(0255),
        description: v.path.substring(0255),
        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(05)

  if (videos.length) {
    videos.reverse().forEach((v) => app.addRecentDocument(v.path))
  }
}

这一项功能本质与用户双击视频文件打开程序相似,更多内容在“文件关联”小节中阐述。

视频播放处理

这一部分功能,我们借助第三方的播放插件 video.js 实现,并对其UI和热键功能进行优化,增强用户体验。

// ...
onMounted(() => {
  if (playerRef.value) {
    player.value = videojs(playerRef.value, {
      controlstrue,
      autoplaytrue,
      filltrue,
      controlBar: {
        volumePanel: { inlinefalse, volumeControl: { verticaltrue } },
        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
  namevideo
  roleEditor
...

实现关联只是第一步,程序内部还需要特别处理:

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 还需从 appopen-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 portnumber

  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, { endtrue })
      }
    })
    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 ,各位感兴趣的小伙伴可以前往参考研究或者参与开发。

github.com/alex8088/Ev…