Electron:使用数据流的形式加载本地视频

1.引言

在桌面应用开发中,本地视频的高效加载与播放是提升用户体验的关键。本文介绍如何通过Electron的协议处理器与Node.js流式API,实现高性能的本地视频加载方案。

自定义协议流传输的优势

特性 文件路径 Blob 流式传输
内存占用 高(线性增长) 极高(全量驻留) 低(动态分片)
大文件支持
首帧时间
拖拽播放 卡顿 卡顿 流畅
内存泄漏风险

2. 核心实现原理

2.1 协议层架构设计

sequenceDiagram Renderer->>Main: 请求 media://id.mp4 Main->>FileSystem: 创建可读流 FileSystem->>Main: 返回文件流 Main->>Renderer: 流式传输响应

2.2 关键技术选型

  • Electron Protocol API :注册自定义协议替代file://
  • Node.js Stream:实现分块读取与传输
  • HTTP Range:支持视频播放器范围请求

3. 技术实现

3.1 安全映射机制

在主进程中实现一个map将真实路径存放起来,当使用electron dialog选择文件后,可以生成一个id返回给renderer端,renderer端通过id来获取

ts 复制代码
const mediaFileMap = new Map<string, string>();

export const addMediaFile = (id: string, path: string) => {
  mediaFileMap.set(id, path);
};

export const getMediaFile = (id: string) => {
  return mediaFileMap.get(id);
};

export const removeMediaFile = (id: string) => {
  mediaFileMap.delete(id);
};

export const clearMediaFileMap = () => {
  mediaFileMap.clear();
};

这样做的好处有两点:

  • 避免暴露真实路径
  • 如果路径中有空格等特殊字符串,经过encodeURIComponent后,会传递不到给自定义的Protocol

3.2 协议注册与配置

ts 复制代码
import { protocol } from 'electron';

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'media', // 自定义协议名
    privileges: {
      standard: true, // 标准权限
      secure: true, // 安全上下文
      stream: true, // 支持流式传输
      bypassCSP: true, // 绕过内容安全策略
    },
  },
]);

3.3 路径获取

ts 复制代码
import path from 'path';
import { existsSync } from 'fs';
import { getMediaFile } from '../utils/store/media-file-map';

const getMediaFilePath = (reqUrl: string) => {
  const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
  // 渲染端通过传id.[ext]来获取对应的路径
  const [id] = filePath.split('.');
  const mediaFilePath = getMediaFile(id);

  if (!mediaFilePath) {
    return '';
  }

  const normalizedPath = path.normalize(mediaFilePath);

  if (!existsSync(normalizedPath)) {
    return '';
  }

  return normalizedPath;
};

3.4 获取range

ts 复制代码
import { promises } from 'fs';

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
  const { size } = await promises.stat(normalizedPath);
  let start = 0,
    end = size - 1;
  if (rangeHeader) {
    const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
    if (match) {
      start = match[1] ? parseInt(match[1], 10) : start;
      end = match[2] ? parseInt(match[2], 10) : end;
    }
  }
  const chunkSize = (end || size - 1) - start + 1;

  return { start, end, chunkSize, size };
};

3.5 实现流式响应

ts 复制代码
export const initMediaProtocol = () => {
  protocol.handle('media', async (req) => {
    try {
      const normalizedPath = getMediaFilePath(req.url);

      if (!normalizedPath) {
        return new Response('File not found', { status: 404 });
      }

      const rangeHeader = req.headers.get('Range');

      const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

      const stream = createReadStream(normalizedPath, { start, end });
      stream.on('error', (error) => {
        stream.destroy();
        return new Response(`Error: ${error.message}`, { status: 500 });
      });
      return new Response(stream as any, {
        status: rangeHeader ? 206 : 200,
        headers: {
          'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': chunkSize.toString(),
          'Content-Type': getContentType(normalizedPath),
        },
      });
    } catch (error: any) {
      console.error('Error handling media protocol:', error);
      return new Response(`Error: ${error.message}`, { status: 500 });
    }
  });
};

initMediaProtocol这个方法要在app when ready后执行。

完整代码实现

ts 复制代码
import { protocol } from 'electron';
import path from 'path';
import { getMediaFile } from '../utils/store/media-file-map';
import { getContentType } from '../utils/content-type';
import { createReadStream, existsSync, promises } from 'fs';

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'media',
    privileges: {
      standard: true, // 标准权限
      secure: true, // 安全上下文
      stream: true, // 支持流式传输
      bypassCSP: true, // 绕过内容安全策略
    },
  },
]);

const getMediaFilePath = (reqUrl: string) => {
  const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
  const [id] = filePath.split('.');
  const mediaFilePath = getMediaFile(id);

  if (!mediaFilePath) {
    return '';
  }

  const normalizedPath = path.normalize(mediaFilePath);

  if (!existsSync(normalizedPath)) {
    return '';
  }

  return normalizedPath;
};

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
  const { size } = await promises.stat(normalizedPath);
  let start = 0,
    end = size - 1;
  if (rangeHeader) {
    const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
    if (match) {
      start = match[1] ? parseInt(match[1], 10) : start;
      end = match[2] ? parseInt(match[2], 10) : end;
    }
  }
  const chunkSize = (end || size - 1) - start + 1;

  return { start, end, chunkSize, size };
};

export const initMediaProtocol = () => {
  protocol.handle('media', async (req) => {
    try {
      const normalizedPath = getMediaFilePath(req.url);

      if (!normalizedPath) {
        return new Response('File not found', { status: 404 });
      }

      const rangeHeader = req.headers.get('Range');

      const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

      const stream = createReadStream(normalizedPath, { start, end });
      stream.on('error', (error) => {
        stream.destroy();
        return new Response(`Error: ${error.message}`, { status: 500 });
      });
      return new Response(stream as any, {
        status: rangeHeader ? 206 : 200,
        headers: {
          'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': chunkSize.toString(),
          'Content-Type': getContentType(normalizedPath),
        },
      });
    } catch (error: any) {
      console.error('Error handling media protocol:', error);
      return new Response(`Error: ${error.message}`, { status: 500 });
    }
  });
};

4. 渲染端应用

渲染端中只要拼接好对应协议即可

5. 效果

使用这种流式数据播放,能够随时拖动时间轴,每次也只会拿时间轴开始到一定范围的数据,减少内存占用。

6. 不足

如上图效果所示,缓冲区的end都是到视频最大的size,这样不太合理。前端应该拿到content-range后将通过切分做进步的优化,比如分多段来获取缓存。

相关推荐
萌萌哒草头将军5 分钟前
⚓️ Oxlint 1.0 版本发布,比 ESLint 快50 到 100 倍!🚀🚀🚀
前端·javascript·vue.js
ak啊9 分钟前
WebGL入门教程:实现场景中相机的视角与位置移动
前端·webgl
天天打码13 分钟前
Sass具有超能力的CSS预处理器
前端·css·sass
Yana.nice21 分钟前
sysctl优先级顺序
服务器·前端·网络
米花丶24 分钟前
异步加载弹出层动画丢失问题
前端
小桥风满袖26 分钟前
Three.js-硬要自学系列31之专项学习动画混合
前端·css·three.js
Lanqing_076029 分钟前
淘宝商品详情图API接口返回参数说明
java·服务器·前端·api·电商
karshey39 分钟前
【Element Plus】Menu组件:url访问页面时高亮对应菜单栏
前端·javascript·vue.js
xiaogg367839 分钟前
系统网站首页三种常见布局vue+elementui
前端·vue.js·elementui