Electron 如何播放rtsp 流
1.前景
在前面学习了如何使用ffmpeg 搭建本地 rtsp 流播放,在业务需求记录下我如何在 electron 中播放 rtsp 流。
2.实现流程
- 在主进程中加载ffmpeg 软件环境服务配置
- electron 窗口主进程启动时,在生命周期中同时启动 WebSocket 服务
- ui层发起 WebSocket 连接, WebSocket 服务中使用 ffmpeg 命令将 rtsp 流转换为 flv 流
- ui层的播放组件使用 flv.js 播放 flv 流

3.代码实现
3.1 主进程代码
JavaScript
// WebSocket 服务 + ffmpeg 解析插件引用
const express = require("express");
const expressWebSocket = require("express-ws");
const ffmpeg = require("fluent-ffmpeg");
const webSocketStream = require("websocket-stream/stream");JavaScript
// 这里主要是为了解决ffmpeg软件在开发/生产时环境的加载问题,
// 配置ffmpeg 软件环境服务
function getFfmpegPath() {
if (process.env.NODE_ENV === "development") {
return "ffmpeg";
}
const platform = process.platform;
const exeName = platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
const possiblePaths = [
path.join(process.resourcesPath, "bin", exeName),
path.join(__dirname, "resources", "bin", exeName),
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
return "ffmpeg";
}
ffmpeg.setFfmpegPath(getFfmpegPath());
//打包配置
// electron-builder.json5
{
extraResources: [
{
from: "bin", // 这里是ffmpeg 软件的可执行文件目录
to: "bin",
},
],
}JavaScript
// 启动流服务器
function startStreamServer() {
const app = express();
// 配置跨域
app.use((req: any, res: any, next: any) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS, PUT, PATCH, DELETE");
res.setHeader("Access-Control-Allow-Headers","X-Requested-With,content-type");
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
expressWebSocket(app, null, { perMessageDeflate: true });
// HTTP 端点: 获取媒体信息
app.get("/api/media-info", (req: any, res: any) => {
const rtspUrl = req.query.url;
const streamId = req.query.id;
if (!rtspUrl) {
return res.status(400).json({ error: "缺少 rtspUrl 参数" });
}
// 获取rtsp媒体信息
ffmpeg.ffprobe(rtspUrl,{"-f": "rtsp",},(err: any, metadata: any) => {
if (err) {
console.error(`[${streamId}] get rtsp stream media info failed:`,err,);
return res.status(500).json({ error: "获取媒体信息失败" });
}
// 解析视频信息
const videoStream = metadata.streams.find(
(s: any) => s.codec_type === "video",
);
const audioStream = metadata.streams.find(
(s: any) => s.codec_type === "audio",
);
// 构建媒体信息对象
const mediaInfo = {
streamId: streamId,
video: {
codec: videoStream?.codec_name || "unknown",
resolution: videoStream
? `${videoStream.width}x${videoStream.height}`
: "unknown",
framerate: videoStream?.r_frame_rate || "unknown",
bitrate: metadata.format.bit_rate || "unknown",
},
audio: {
codec: audioStream?.codec_name || "none",
channels: audioStream?.channels || 0,
sample_rate: audioStream?.sample_rate || 0,
},
format: metadata.format.format_name || "unknown",
};
res.json(mediaInfo);
},
);
});
// WebSocket 路由: ws://localhost:7777/rtsp/:id?url=RTSP_ADDRESS
app.ws("/rtsp/:id", (ws: any, req: any) => {
// 将 WebSocket 转换为一个可写流
const stream = webSocketStream(ws, { binary: true });
const rtspUrl = req.query.url;
const streamId = req.params.id;
console.log(`[Stream ${streamId}] WebSocket loading.....: ${rtspUrl}`);
// 配置 FFmpeg 命令
const command = ffmpeg(rtspUrl)
// 输入优化参数:增大缓冲区, 减少延迟
.addInputOption("-buffer_size", "102400")
.addInputOption("-max_delay", "100")
// 事件监听
.on("start", (cmd: any) =>console.log(`[${streamId}] FFmpeg start: ${cmd}`))
.on("codecData", () => console.log(`[${streamId}] codecData`)) // 可以在此通知前端摄像头在线
.on("error", (err: any) =>console.error(`[${streamId}] error: ${err.message}`))
.on("end", () => console.log(`[${streamId}] end`)) // 可以在此通知前端摄像头离线
// 输出配置:直接封装为 FLV,视频流直接复制以降低 CPU 消耗,不处理音频
.outputFormat("flv")
.videoCodec("copy")
.noAudio();
// 将 FFmpeg 的输出通过管道连接到 WebSocket 流
try {
command.pipe(stream);
} catch (error) {
console.error(`[${streamId}] WebSocket pipe error:`, error);
}
// 当 WebSocket 关闭时,主动终止 FFmpeg 进程
ws.on("close", () => {
console.log(`[${streamId}] WebSocket closed, FFmpeg process terminated`);
command.kill("SIGTERM");
});
});
// 启动服务器,监听 7777 端口
app.listen(7777, () => {
console.log("WebSocket server started on port 7777");
});
}
// 在应用准备就绪时启动流服务器
app.whenReady().then(async () => {
startStreamServer(); // 先启动流媒体websoket服务
});3.1 视频播放组件
vue
<!-- src/components/RtspPlayer.vue -->
<template>
<div class="rtsp-player" :class="{ 'fullscreen': isFullscreen }">
<!-- 视频容器 -->
<div class="video-container" ref="videoContainer">
<video ref="videoElement" class="video-element" :muted="muted" :controls="showControls" :autoplay="autoplay"
@loadeddata="handleLoadedData" @error="handleVideoError"></video>
<!-- 加载状态 overlay -->
<div v-if="loading" class="overlay loading-overlay">
<div class="spinner"></div>
<span>{{ loadingText }}</span>
</div>
<!-- 错误状态 overlay -->
<div v-if="error" class="overlay error-overlay">
<span class="error-icon">⚠️</span>
<span >视频播放失败,请播放重试!</span>
<!-- <button v-if="showRetry" @click="retry" class="retry-btn">
重试
</button> -->
</div>
<!-- 播放状态指示器 -->
<div v-if="!loading && !error && !isPlaying" class="overlay paused-overlay">
<span>已暂停</span>
</div>
</div>
<!-- 控制栏 -->
<div class="controls" v-if="showControlsBar">
<div class="controls-center">
<span class="latency-info" v-if="latency">
延迟:{{ latency }}ms
</span>
<button @click="refreshStream" class="control-btn" :disabled="refreshing" title="重新加载">
⟳
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue';
import flvjs from 'flv.js';
// Props 定义
const props = defineProps({
// RTSP 流地址
rtspUrl: {
type: String,
required: true
},
// 流 ID(用于 WebSocket 路径)
streamId: {
type: String,
default: () => `stream_${Math.random().toString(36).substr(2, 9)}`
},
// WebSocket 服务器地址
wsServerUrl: {
type: String,
default: 'ws://localhost:7777'
},
// 是否自动播放
autoplay: {
type: Boolean,
default: true
},
// 是否静音
muted: {
type: Boolean,
default: true
},
// 是否显示原生控制条
showControls: {
type: Boolean,
default: false
},
// 是否显示自定义控制栏
showControlsBar: {
type: Boolean,
default: true
},
// 是否显示重试按钮
showRetry: {
type: Boolean,
default: true
},
// 延迟控制间隔(毫秒)
latencyControlInterval: {
type: Number,
default: 30000
},
// 最大允许缓冲(秒)
maxBuffered: {
type: Number,
default: 1.5
}
});
// Emits 定义
const emit = defineEmits([
'play',
'pause',
'error',
'streamOnline',
'streamOffline',
'fullscreenChange',
'mediaInfo'
]);
// --- 状态变量 ---
const videoElement = ref(null);
const videoContainer = ref(null);
const flvPlayer = ref(null);
const loading = ref(true);
const error = ref('');
const isPlaying = ref(false);
const isStreamOnline = ref(false);
const isFullscreen = ref(false);
const latency = ref(null);
const refreshing = ref(false);
const loadingText = ref('正在连接流媒体服务...');
const mediaInfo = ref(null);
// 定时器句柄
let latencyControlTimer = null;
let statusCheckTimer = null;
// --- 计算 WebSocket URL ---
const wsUrl = computed(() => {
return `${props.wsServerUrl}/rtsp/${props.streamId}/?url=${encodeURIComponent(props.rtspUrl)}`;
});
// --- 获取媒体信息 ---
const fetchMediaInfo = async () => {
try {
const httpServerUrl = props.wsServerUrl.replace('ws://', 'http://');
const response = await fetch(`${httpServerUrl}/api/media-info?url=${encodeURIComponent(props.rtspUrl)}&id=${props.streamId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
mediaInfo.value = data;
emit('mediaInfo', data);
} catch (err) {
}
};
// --- 初始化播放器 ---
const initPlayer = async () => {
if (!flvjs.isSupported()) {
error.value = '当前浏览器不支持 flv.js';
loading.value = false;
emit('error', error.value);
return;
}
try {
// 确保 video 元素已渲染
await nextTick();
if (!videoElement.value) {
throw new Error('视频元素未找到');
}
// 清理旧的播放器
destroyPlayer();
loading.value = true;
error.value = '';
loadingText.value = '正在获取媒体信息...';
// 先获取媒体信息
await fetchMediaInfo();
loadingText.value = '正在连接流媒体服务...';
// 创建 flv.js 播放器
flvPlayer.value = flvjs.createPlayer({
type: 'flv',
isLive: true,
url: wsUrl.value
}, {
enableStashBuffer: false, // 禁用缓存,降低延迟
stashInitialSize: 0, // 初始缓存大小为0
isLive: true,
lazyLoad: false, // 禁用懒加载
autoCleanupSourceBuffer: true, // 自动清理 SourceBuffer
fixAudioTimestampGap: false, // 不修复音频时间戳间隙
});
// 绑定事件
flvPlayer.value.attachMediaElement(videoElement.value);
// 加载并播放
flvPlayer.value.load();
if (props.autoplay) {
await flvPlayer.value.play();
isPlaying.value = true;
emit('play');
}
// 监听 flv.js 事件
flvPlayer.value.on(flvjs.Events.ERROR, handleFlvError);
flvPlayer.value.on(flvjs.Events.RECOVERED_EARLY_EOF, handleRecovered);
flvPlayer.value.on(flvjs.Events.LOADING_COMPLETE, () => {
//流信息断开
console.log('流信息断开');
});
loading.value = false;
isStreamOnline.value = true;
emit('streamOnline');
// 启动延迟控制
startLatencyControl();
} catch (err) {
console.error('播放器初始化失败:', err);
error.value = `播放失败: ${err.message}`;
loading.value = false;
isStreamOnline.value = false;
emit('error', err.message);
emit('streamOffline');
}
};
const handleFlvError = (errType, errDetail) => {
console.error('flv.js 错误:', errType, errDetail);
// 根据错误类型更新状态
if (errType === flvjs.ErrorTypes.NETWORK_ERROR) {
isStreamOnline.value = false;
emit('streamOffline');
error.value = '网络连接断开';
} else if (errType === flvjs.ErrorTypes.MEDIA_ERROR) {
error.value = '媒体解码错误';
}
emit('error', { type: errType, detail: errDetail });
};
const handleRecovered = () => {
isStreamOnline.value = true;
error.value = '';
emit('streamOnline');
};
const handleLoadedData = () => {
// console.log('视频数据已加载');
};
const handleVideoError = (event) => {
error.value = '视频播放错误';
isStreamOnline.value = false;
emit('streamOffline');
};
// --- 播放控制 ---
const togglePlay = async () => {
if (!flvPlayer.value) return;
try {
if (isPlaying.value) {
flvPlayer.value.pause();
isPlaying.value = false;
emit('pause');
} else {
await flvPlayer.value.play();
isPlaying.value = true;
emit('play');
}
} catch (err) {
console.error('播放控制失败:', err);
error.value = `操作失败: ${err.message}`;
}
};
const toggleMute = () => {
if (videoElement.value) {
videoElement.value.muted = !videoElement.value.muted;
}
};
// --- 全屏控制 ---
const toggleFullscreen = () => {
if (!videoContainer.value) return;
if (!isFullscreen.value) {
if (videoContainer.value.requestFullscreen) {
videoContainer.value.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};
// 监听全屏变化
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
emit('fullscreenChange', isFullscreen.value);
};
// --- 刷新流 ---
const refreshStream = async () => {
refreshing.value = true;
error.value = '';
try {
await initPlayer();
} finally {
refreshing.value = false;
}
};
// --- 延迟控制 ---
const startLatencyControl = () => {
if (latencyControlTimer) {
clearInterval(latencyControlTimer);
}
latencyControlTimer = setInterval(() => {
if (!flvPlayer.value || !flvPlayer.value.buffered.length) return;
try {
const buffered = flvPlayer.value.buffered;
const bufferedEnd = buffered.end(buffered.length - 1);
const currentTime = flvPlayer.value.currentTime;
const diff = bufferedEnd - currentTime;
// 计算延迟(假设是直播,currentTime 应该接近 bufferedEnd)
if (diff > 0) {
latency.value = Math.round(diff * 1000); // 转换为毫秒
// 如果缓冲过多,跳转到缓冲区末尾附近
if (diff >= props.maxBuffered) {
flvPlayer.value.currentTime = bufferedEnd - 0.1;
// console.log(`延迟控制:缓冲 ${diff.toFixed(2)}s,跳转同步`);
}
}
} catch (err) {
console.warn('延迟控制计算错误:', err);
}
}, 5000);
};
// --- 状态检查 ---
const startStatusCheck = () => {
if (statusCheckTimer) {
clearInterval(statusCheckTimer);
}
statusCheckTimer = setInterval(() => {
if (flvPlayer.value) {
// 检查播放器状态
const isBufferEmpty = flvPlayer.value.buffered.length === 0;
if (isBufferEmpty && !loading.value && !error.value) {
// 可能断流了
console.warn('检测到缓冲区为空,可能断流');
isStreamOnline.value = false;
emit('streamOffline');
}
}
}, 10000);
};
// --- 销毁播放器 ---
const destroyPlayer = () => {
if (flvPlayer.value && typeof flvPlayer.value === 'object') {
try {
if (typeof flvPlayer.value.pause === 'function') {
flvPlayer.value.pause();
}
if (typeof flvPlayer.value.unload === 'function') {
flvPlayer.value.unload();
}
if (typeof flvPlayer.value.detachMediaElement === 'function') {
flvPlayer.value.detachMediaElement();
}
if (typeof flvPlayer.value.destroy === 'function') {
flvPlayer.value.destroy();
}
} catch (err) {
console.warn('销毁播放器时出错:', err);
} finally {
flvPlayer.value = null;
}
}
};
// --- 截图功能 ---
const captureScreenshot = () => {
return new Promise((resolve, reject) => {
if (!videoElement.value || !isPlaying.value) {
const errorMsg = '无法截图:视频未播放';
console.warn(errorMsg);
reject(new Error(errorMsg));
return;
}
try {
const video = videoElement.value;
const canvas = document.createElement('canvas');
// 设置画布尺寸与视频一致
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
const errorMsg = '无法获取 canvas 上下文';
reject(new Error(errorMsg));
return;
}
// 绘制当前视频帧到 canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 转换为 Base64
const base64 = canvas.toDataURL('image/png', 1.0);
// 返回 Base64 字符串
resolve(base64);
} catch (err) {
console.error('截图失败:', err);
error.value = `截图失败:${err.message}`;
reject(err);
}
});
};
// --- 重试 ---
const retry = () => {
refreshStream();
};
// --- 生命周期钩子 ---
onMounted(() => {
// initPlayer();
// 监听全屏变化
document.addEventListener('fullscreenchange', handleFullscreenChange);
// 启动状态检查
startStatusCheck();
});
onBeforeUnmount(() => {
// 清理定时器
if (latencyControlTimer) {
clearInterval(latencyControlTimer);
latencyControlTimer = null;
}
if (statusCheckTimer) {
clearInterval(statusCheckTimer);
statusCheckTimer = null;
}
// 移除事件监听
document.removeEventListener('fullscreenchange', handleFullscreenChange);
// 销毁播放器
destroyPlayer();
});
// 监听 rtspUrl 变化
watch(() => props.rtspUrl, () => {
refreshStream();
});
// 监听自动播放变化
watch(() => props.autoplay, (newVal) => {
if (newVal && flvPlayer.value && !isPlaying.value) {
flvPlayer.value.play();
}
});
// 暴露方法给父组件
defineExpose({
play: () => flvPlayer.value?.play(),
pause: () => flvPlayer.value?.pause(),
refresh: refreshStream,
togglePlay,
toggleMute,
captureScreenshot,
destroyPlayer
});
</script>
<style scoped>
.rtsp-player {
position: relative;
width: 100%;
height: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
}
.video-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Overlay 样式 */
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
color: white;
z-index: 10;
}
.loading-overlay {
gap: 16px;
}
.error-overlay {
gap: 12px;
}
.error-icon {
font-size: 32px;
margin-bottom: 8px;
}
.paused-overlay {
background: rgba(0, 0, 0, 0.3);
font-size: 14px;
}
/* 加载动画 */
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 重试按钮 */
.retry-btn {
padding: 8px 16px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 8px;
transition: background 0.2s;
}
.retry-btn:hover {
background: #45a049;
}
.retry-btn:disabled {
background: #cccccc;
cursor: not-allowed;
}
/* 控制栏 */
.controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.7);
color: white;
display: flex;
justify-content: flex-end;
align-items: center;
z-index: 20;
}
.controls-center {
display: flex;
align-items: center;
gap: 16px;
}
.control-btn {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 流状态指示器 */
.stream-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.5);
}
.stream-status.online {
color: #4CAF50;
}
.stream-status.offline {
color: #f44336;
}
/* 延迟信息 */
.latency-info {
font-size: 12px;
background: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-radius: 12px;
}
/* 全屏模式 */
.rtsp-player.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
border-radius: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.controls {
padding: 8px;
}
.control-btn {
font-size: 16px;
padding: 4px;
}
.stream-status,
.latency-info {
font-size: 11px;
padding: 2px 6px;
}
}
</style>