likes
comments
collection
share

Webrtc+Electron+Vue同屏涂鸦功能WebRTC + Electron + Vue + Canvas 同屏

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

WebRTC + Electron + Vue + Canvas 同屏涂鸦功能

图片来自于chatGPT 不好看的话我也没办法

Webrtc+Electron+Vue同屏涂鸦功能WebRTC + Electron + Vue + Canvas 同屏

前言

因为我司是给政府做项目的,最近接到一个需求是实现同屏涂鸦功能。具体来说,就是 B 将电脑的屏幕分享出来,然后 A 在平板的页面上进行涂鸦,涂鸦内容实时显示在 B 的电脑屏幕上。这个功能类似于 ToDesk 的标注功能,以下是 ToDesk 的标注功能示例:

Webrtc+Electron+Vue同屏涂鸦功能WebRTC + Electron + Vue + Canvas 同屏

由于政府部门的使用场景,他们不允许安装 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 来创建涂鸦界面,方便用户进行操作。

实现步骤

  1. 创建 Electron 应用:首先,我们需要搭建一个基础的 Electron 应用。可以使用 Vue CLI 和 Vite 来构建前端部分。

  2. 集成 LiveKit

    • 在项目中安装 LiveKit SDK。
    • 初始化 LiveKit 客户端,并连接到房间。
    • 使用 LiveKit 的 API 实现屏幕共享和数据通道。
  3. 实现涂鸦功能

    • 在 Vue 组件中创建一个 Canvas,用于显示涂鸦内容。
    • 监听用户的绘图事件(如鼠标移动、点击等),将绘制的路径数据通过 LiveKit 的数据通道发送给 B 端。
    • 在 B 端接收数据并在 Canvas 上绘制。
  4. 实时更新:确保 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
评论
请登录