Webrtc+Electron+Vue同屏涂鸦功能WebRTC + Electron + Vue + Canvas 同屏
WebRTC + Electron + Vue + Canvas 同屏涂鸦功能
图片来自于chatGPT 不好看的话我也没办法
前言
因为我司是给政府做项目的,最近接到一个需求是实现同屏涂鸦功能。具体来说,就是 B 将电脑的屏幕分享出来,然后 A 在平板的页面上进行涂鸦,涂鸦内容实时显示在 B 的电脑屏幕上。这个功能类似于 ToDesk 的标注功能,以下是 ToDesk 的标注功能示例:
由于政府部门的使用场景,他们不允许安装 ToDesk、向日葵等远程控制软件。而且,很多政府项目运行在国产系统(如 UOS 统信),这些远程控制软件往往不适配。因此,我们需要设计一个基于 WebRTC 的涂鸦解决方案。
技术选型
WebRTC
WebRTC 是一种支持浏览器之间进行实时音频、视频和数据共享的技术。它的主要优势在于无需安装插件或额外软件,能够实现点对点的实时通信。为了实现同屏涂鸦功能,我们将利用 WebRTC 的数据通道特性,实时传输涂鸦数据。
LiveKit
为了简化 WebRTC 的实现过程,我们选择使用 LiveKit。LiveKit 是一个开源的实时音视频 SDK,提供简单易用的 API 来实现实时通信。它支持屏幕共享、音视频通话和数据通道功能,非常适合我们的项目需求。
Electron
Electron 是一个基于 Chromium 和 Node.js 的框架,可以让我们使用 Web 技术(HTML、CSS 和 JavaScript)构建跨平台桌面应用。它允许我们在桌面应用中使用 WebRTC,从而实现屏幕共享和涂鸦功能。
Vue.js
作为一个现代的前端框架,Vue.js 让我们能够构建灵活且高效的用户界面。我们将使用 Vue.js 来创建涂鸦界面,方便用户进行操作。
实现步骤
-
创建 Electron 应用:首先,我们需要搭建一个基础的 Electron 应用。可以使用 Vue CLI 和 Vite 来构建前端部分。
-
集成 LiveKit:
- 在项目中安装 LiveKit SDK。
- 初始化 LiveKit 客户端,并连接到房间。
- 使用 LiveKit 的 API 实现屏幕共享和数据通道。
-
实现涂鸦功能:
- 在 Vue 组件中创建一个 Canvas,用于显示涂鸦内容。
- 监听用户的绘图事件(如鼠标移动、点击等),将绘制的路径数据通过 LiveKit 的数据通道发送给 B 端。
- 在 B 端接收数据并在 Canvas 上绘制。
-
实时更新:确保 A 的涂鸦内容实时同步到 B 的屏幕上。
代码示例
以下是一个简单的代码示例,展示如何在 Vue 中使用 LiveKit 实现数据通道:
npm install livekit-client
初始化livekit代码,因为我们是涉及到webrtc 发送消息,所以我监听了 DataReceived 事件
<template>
<div class="container">
<video autoplay="true" id="video"></video>
<div class="canvasBox">
<canvas id="canvas"></canvas>
<canvas id="disabledCanvas"></canvas>
</div>
<div class="app-loading" v-if="loading">
<div class="app-loading-wrap">
<img src="@/assets/login-icon.png" class="app-loading-logo" alt="Logo" />
<div class="app-loading-dots">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
</div>
<div class="app-loading-title">
视频加载中...
</div>
</div>
</div>
</div>
</template>
<script setup name="ScreenControls">
import { ref, onMounted, onUnmounted } from 'vue'
import { LogLevel, Room, RoomEvent, setLogExtension, Track } from 'livekit-client'
const webrtcWss = ref('')
const webrtcToken = ref('')
let room = null//房间实例
const participants = ref([])//记录房间内人员变化
/**
* @description: 初始化房间
* @param {*} roomName
*/
const liveKitRoomInit = async function () {
try {
// 初始化房间
room = new Room({
adaptiveStream: false,
dynacast: false,
publishDefaults: {
videoCodec: 'h264',
},
})
// 轨道订阅
room.on(RoomEvent.TrackSubscribed, await handleTrackSubscribed)
// 取消订阅
.on(RoomEvent.TrackUnsubscribed, await handleTrackSubscribed)
.on(RoomEvent.ParticipantConnected, onParticipantsChanged)
.on(RoomEvent.ParticipantDisconnected, onParticipantsChanged)
//连接质量修改
.on(RoomEvent.ConnectionQualityChanged, onParticipantsChanged)
// 断开连接
.on(RoomEvent.Disconnected, handleDisconnect)
// 房间元数据已更改
.on(RoomEvent.RoomMetadataChanged, onParticipantsChanged)
// 跟踪流状态已更改
.on(RoomEvent.TrackStreamStateChanged, onParticipantsChanged)
// 轨道出版
.on(RoomEvent.TrackPublished, onParticipantsChanged)
// 收到的数据
.on(RoomEvent.DataReceived, DataReceived)
// 本地曲目未发布
.on(RoomEvent.LocalTrackPublished, onParticipantsChanged)
.on(RoomEvent.LocalTrackUnpublished, onParticipantsChanged)
//连接状态发生更改
.on(RoomEvent.ConnectionStateChanged, connectionStateChange)
// connect to room
await room.connect(webrtcWss.value, webrtcToken.value)
.then(async () => {
console.log('房间连接成功', room.localParticipant)
})
.catch((error) => {
console.warn(error)
})
// 房间的状态
// 设置日志
setLogExtension((level, msg, context) => {
const enhancedContext = {
...context,
timeStamp: Date.now(),
}
if (level >= LogLevel.debug) {
console.log(level, msg, enhancedContext)
}
})
} catch (e) {
console.log(e)
}
const handleTrackSubscribed = async function (track) {
onParticipantsChanged()
}
/**
* @description 人员变化
*/
const onParticipantsChanged = function () {
if (!room) return
const remotes = Array.from(room.remoteParticipants.values())
participants.value = remotes
}
/**
* @description:数据接收
* @param {*} payload
* @param participant
* @param kind
*/
const DataReceived = function (payload, participant, kind) {
const decoder = new TextDecoder()
const strData = decoder.decode(payload)
//获取数据
let newData = JSON.parse(strData)
}
const handleDisconnect = function (reason) {
console.log('disconnected from room')
}
</script>
然后就是我们的加入canvas ,其实我只是在 Video 上面加了一个蒙层,其实还有一种做法就是将视频帧渲染到 Canvas 上面,这个后续有时间了,写一篇文章说一下。
因为利用Canvas是没办法画到PC 端的 底部的菜单栏和 Mac 头部的状态栏的,所以我在房间人员变化的时候,Electron端的屏幕分享会发送过来一个消息,告诉平板端当前的地步菜单栏占的高度的百分比,我会在收 到消息后给底部的相对于平板端的距离减掉
const onParticipantsChanged = function () {
if (!room) return
const remotes = Array.from(room.remoteParticipants.values())
participants.value = remotes
console.log(participants.value)
let screenShare = participants.value.filter((item) => {
return item.getTrackPublication(Track.Source.ScreenShare) || item.getTrackPublication(Track.Source.Camera)
})
if (screenShare.length) {
const videoDom = document.getElementById('video')
let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight
let screenTrack = screenShare[0].getTrackPublication(Track.Source.ScreenShare) || screenShare[0].getTrackPublication(Track.Source.Camera)
if (screenTrack) {
let { width, height } = screenTrack?.trackInfo
if (width && height) {
if (screenWidth / screenHeight < width / height) {
videoDom.style.width = '100%'
} else {
videoDom.style.height = '100%'
}
screenTrack.track?.attach(videoDom)
loading.value = false
}
}
}
}
/**
* @description:数据接收
* @param {*} payload
* @param participant
* @param kind
*/
const DataReceived = function (payload, participant, kind) {
const decoder = new TextDecoder()
const strData = decoder.decode(payload)
console.log('strData', strData)
let newData = JSON.parse(strData)
if (newData.type == 'tabBarCoefficient') {
tabBarCoefficient.value = newData.data
const canvas = document.getElementById('canvas');
const disabledCanvas = document.getElementById('disabledCanvas');
const video = document.getElementById('video');
// 视频加载后设置 canvas 宽高
canvas.width = video.clientWidth
canvas.height = video.clientHeight - tabBarCoefficient.value * video.clientHeight
disabledCanvas.width = video.clientWidth
disabledCanvas.height = tabBarCoefficient.value * video.clientHeight
}
}
因为在平板端起始位置的坐标可能会出现偏差
// 获取触摸事件的坐标
function getTouchPos (e) {
const rect = canvas.getBoundingClientRect();
return { x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top
};
}
然后就是记录Canvas的路径问题了,这一步其实没什么难的,就是记录手指的移动路径问题
onMounted(async () => {
let { token, wss } = await getGraffitiToken()
webrtcToken.value = token
webrtcWss.value = wss
liveKitRoomInit()
const video = document.getElementById('video');
video.addEventListener('loadedmetadata', () => {
console.log('视频元数据加载完成')
const canvas = document.getElementById('canvas');
const disabledCanvas = document.getElementById('disabledCanvas');
const canvasBox = document.getElementsByClassName('canvasBox')[0]
canvasBox.style.width = video.clientWidth + 'px'
canvasBox.style.height = video.clientHeight + 'px'
const ctx = canvas.getContext('2d');
// 视频加载后设置 canvas 宽高
canvas.width = video.clientWidth
canvas.height = video.clientHeight - tabBarCoefficient.value * video.clientHeight
disabledCanvas.width = video.clientWidth
disabledCanvas.height = tabBarCoefficient.value * video.clientHeight
ctx.fillStyle = 'rgba(0,0,0,0,0.01)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.clearRect(0, 0, canvas.width, canvas.height);
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let drawPath = [];
function draw (e) {
if (!isDrawing) return;
const { x, y } = getTouchPos(e);
ctx.strokeStyle = 'red';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
ctx.stroke();
const messageData = JSON.stringify({
action: 'Move',
param: [{ startX: lastX, startY: lastY, endX: x, endY: y, videoWidth: canvas.width, videoHeight: canvas.height, devicePixelRatio: window.devicePixelRatio }],
})
const data = encoder.encode(messageData)
room.localParticipant.publishData(data, { reliable: true }
drawPath.push({ startX: lastX, startY: lastY, endX: x, endY: y, videoWidth: canvas.width, videoHeight: canvas.height, devicePixelRatio: window.devicePixelRatio });
[lastX, lastY] = [x, y];
// 更新缓存
if (!canvasCache.value.length) {
canvasCache.value.push(_.cloneDeep(drawPath));
} else {
canvasCache.value[canvasCache.value.length - 1] = _.cloneDeep(drawPath);
}
}
canvas.addEventListener('touchstart', (e) => {
isDrawing = true;
const { x, y } = getTouchPos(e);
[lastX, lastY] = [x, y];
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止默认的滚动行为
draw(e);
});
canvas.addEventListener('touchend', () => {
isDrawing = false;
if (drawPath.length) {
canvasCache.value[canvasCache.value.length - 1].push({ endTime: new Date().getTime() })
canvasCache.value.push([])
}
//
console.log('canvasCache.value', canvasCache.value, drawPath.length)
// if (!cleanCanvasTimer) {
//定时器添加
// scheduleCleanCanvas()
// }
const messageData = JSON.stringify({
action: 'End',
})
const data = encoder.encode(messageData)
console.log('发送结束数据', messageData)
room.localParticipant.publishData(data, { reliable: true })
drawPath = [];
});
const scheduleCleanCanvas = function () {
// cancelCleanCanvasTimer()
cleanCanvasTimer = window.setInterval(() => {
console.log('canvasCache.value', canvasCache.value)
if (canvasCache.value.length > 1 || (canvasCache.value.length === 1 && isDrawing === false)) {
let deletePath = canvasCache.value[0]
let deletePathEndTime = deletePath[deletePath.length - 1] ? deletePath[deletePath.length - 1].endTime : 0
if (deletePathEndTime && new Date() - deletePathEndTime >= 5000) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvasCache.value.shift()
canvasCache.value.map(item => {
item.map(item => {
ctx.strokeStyle = 'red'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.lineWidth = 2
ctx.beginPath();
ctx.moveTo(item.startX, item.startY);
ctx.lineTo(item.endX, item.endY);
ctx.stroke();
})
})
}
}
// scheduleCleanCanvas()
}, 1000)
}
scheduleCleanCanvas()
// 取消清理画布的定时器
const cancelCleanCanvasTimer = () => {
if (cleanCanvasTimer) {
clearTimeout(cleanCanvasTimer)
cleanCanvasTimer = null
}
}
window.addEventListener('resize', () => {
canvas.width = video.clientWidth;
canvas.height = video.clientHeight;
});
})
})
onUnmounted(async () => {
if (reconnectTimer) {
window.clearInterval(reconnectTimer)
reconnectTimer = null
}
if (room) {
console.log('离开房间')
await room.disconnect()
}
})
这个是控制端的事例代码,后面会更新被控端的 Electron代码
感谢阅读!
感谢大家观看本次分享,希望对你们有所帮助。如果有任何问题或建议,请随时联系我。期待与你们的进一步交流与合作!
转载自:https://juejin.cn/post/7418085392466870307