Skip to content

virt-screen 集成示例

面向用户的产品说明见 WET 扩展屏。本文档面向开发者:提炼 virt-screen/ 中的 WetRTC 连接代码,便于理解端到端架构。

virt-screen 是 WetRTC 的生产级参考实现:Electron 主机多屏桌面捕获 + NestJS Socket.IO 信令 + 手机浏览器接收。

源码位置

模块路径
发送端 composablevirt-screen/src/renderer/composables/useMultiScreenShare.ts
接收端页面virt-screen/src/renderer/views/ReceiverView.vue
WetRTC Vue 封装virt-screen/src/renderer/composables/useWetRTC.ts
Socket.IO 信令virt-screen/src/renderer/composables/useSignalChannel.ts
信令服务端virt-screen/src/kernel/gateway/signaling.gateway.ts
低延迟常量virt-screen/src/shared/low-latency-config.ts

架构概览

mermaid
sequenceDiagram
  participant Host as Electron 主机
  participant Kernel as NestJS Kernel :3450
  participant Mobile as 手机浏览器

  Host->>Kernel: join-room screen-{displayId}
  Host->>Host: captureDesktop + WetRTC sendonly
  Host->>Kernel: connect (passive, 等 offer)

  Mobile->>Kernel: join-room screen-{displayId}
  Mobile->>Kernel: signal offer
  Kernel->>Host: signal offer
  Host->>Kernel: signal answer
  Kernel->>Mobile: signal answer
  Note over Host,Mobile: ICE / SRTP 直连或经 STUN
  Mobile->>Mobile: video 播放 + playoutDelayHint
  • 每块屏幕一个房间roomId = screen-${displayId}
  • 每房间一条 WebRTC 连接:不做 simulcast 多 viewer(viewer 离开后会 disconnect + connect 等待下一个)
  • 当前仅视频audio: false,未推音频轨

双端 WetRTC 角色(必读)

两端 initiator / polite 必须成对,否则 SDP 冲突或无人发 offer:

ts
/**
 * virt-screen 双端 WetRTC 角色对照(集成 WetRTC 时最容易配反的组合)
 */
export const VIRT_SCREEN_WETRTC_ROLES = {
  electronHost: {
    file: 'virt-screen/src/renderer/composables/useMultiScreenShare.ts',
    direction: 'sendonly' as const,
    initiator: false,
    polite: true,
    roomId: 'screen-${displayId}',
    deviceId: 'host-${displayId}',
    captures: 'Electron getUserMedia + chromeMediaSourceId',
    waitsFor: '移动端 createOffer',
  },
  mobileReceiver: {
    file: 'virt-screen/src/renderer/views/ReceiverView.vue',
    direction: 'recvonly' as const,
    initiator: true,
    polite: false,
    roomId: 'query.roomId(扫码带入)',
    deviceId: 'receiver-xxx(localStorage 持久化)',
    captures: '无(只播放远端 video track)',
    waitsFor: '无,连接后立即发 offer',
  },
}
directioninitiatorpolite谁发 offer
Electron 主机sendonlyfalsetrue手机
手机浏览器recvonlytruefalse手机

1. 信令:Socket.IO → SignalChannel

Kernel 将 signal 事件转发到同 room 其他客户端(见 signaling.gateway.tshandleSignal)。

ts
/**
 * 来源:virt-screen/src/renderer/composables/useSignalChannel.ts
 * 将 WetRTC 的 SignalChannel 接到 NestJS + Socket.IO 信令服务(kernel :3450)。
 */
import { io, type Socket } from 'socket.io-client'
import type { SignalChannel, SignalMessage } from '@wetspace/wetrtc'

export interface JoinRoomPayload {
  roomId: string
  deviceId: string
  name?: string
  platform?: string
}

export interface SignalChannelIdentity {
  deviceId: string
  name?: string
  platform?: string
}

const KERNEL_ORIGIN = 'https://localhost:3450'

export function createSignalChannel(
  roomId: string,
  getIdentity: () => SignalChannelIdentity,
) {
  const socket: Socket = io(KERNEL_ORIGIN, {
    transports: ['websocket', 'polling'],
    path: '/socket.io/',
  })

  const emitJoinRoom = () => {
    const { deviceId, name, platform } = getIdentity()
    socket.emit('join-room', { roomId, deviceId, name, platform } satisfies JoinRoomPayload)
  }

  socket.on('connect', emitJoinRoom)

  const channel: SignalChannel = {
    async send(data: SignalMessage) {
      const { deviceId } = getIdentity()
      socket.emit('signal', { roomId, ...data, from: deviceId })
    },
    onMessage(handler) {
      const listener = (msg: SignalMessage & { roomId: string }) => {
        if (msg.roomId === roomId) handler(msg)
      }
      socket.on('signal', listener)
      return () => socket.off('signal', listener)
    },
  }

  return {
    channel,
    onPeerJoined(handler: (payload: { deviceId?: string }) => void) {
      socket.on('peer-joined', handler)
      return () => socket.off('peer-joined', handler)
    },
    onPeerLeft(handler: (payload: { deviceId?: string }) => void) {
      socket.on('peer-left', handler)
      return () => socket.off('peer-left', handler)
    },
    close() {
      if (socket.connected) socket.emit('leave-room', { roomId })
      socket.disconnect()
    },
  }
}

WetRTC 只关心 SignalChannel.send / onMessagejoin-roompeer-joinedpeer-left 由 virt-screen 扩展处理。

2. 低延迟配置

ts
/**
 * 来源:virt-screen/src/shared/low-latency-config.ts
 * Electron 发送端共用的 WetRTC 视频编码与 codec 偏好。
 */
import type { VideoEncodingOptions } from '@wetspace/wetrtc'

export const CAPTURE_MAX_WIDTH = 1920
export const CAPTURE_MAX_HEIGHT = 1080
export const CAPTURE_MAX_FRAME_RATE = 75

export const SCREEN_VIDEO_ENCODING: VideoEncodingOptions = {
  contentHint: 'motion',
  maxFrameRate: CAPTURE_MAX_FRAME_RATE,
  maxBitrate: 4_000_000,
  degradationPreference: 'maintain-framerate',
}

export const RECEIVER_PLAYOUT_DELAY_HINT = 0
export const PREFERRED_VIDEO_CODEC = 'h264' as const

传入 WetRTC 构造函数:

ts
videoEncoding: SCREEN_VIDEO_ENCODING,
preferredVideoCodec: PREFERRED_VIDEO_CODEC,

3. Electron 发送端

桌面捕获

ts
/**
 * 来源:virt-screen/src/renderer/utils/capture-desktop.ts
 * Electron 桌面捕获:getUserMedia + chromeMediaSourceId(非 getDisplayMedia)。
 */
import {
  CAPTURE_MAX_FRAME_RATE,
  CAPTURE_MAX_HEIGHT,
  CAPTURE_MAX_WIDTH,
} from './low-latency-config'

/** sourceId 来自主进程 desktopCapturer.getSources() */
export async function captureDesktopSource(sourceId: string): Promise<MediaStream> {
  return navigator.mediaDevices.getUserMedia({
    audio: false,
    video: {
      mandatory: {
        chromeMediaSource: 'desktop',
        chromeMediaSourceId: sourceId,
        maxWidth: CAPTURE_MAX_WIDTH,
        maxHeight: CAPTURE_MAX_HEIGHT,
        maxFrameRate: CAPTURE_MAX_FRAME_RATE,
      },
    },
  } as MediaStreamConstraints)
}

sourceId 来自主进程 desktopCapturer.getSources(),通过 IPC 传给渲染进程(见 virt-screen/src/main/screen-sources.ts)。

启动会话

ts
/**
 * 来源:virt-screen/src/renderer/composables/useMultiScreenShare.ts(startSession 核心流程)
 *
 * Electron 主机 = sendonly + 被动协商(initiator: false, polite: true)
 * 房间 ID:screen-${displayId},每块屏独立一条 WebRTC 连接。
 */
import { WetRTC } from '@wetspace/wetrtc'
import { createSignalChannel } from './signal-channel-socketio'
import { captureDesktopSource } from './capture-desktop-electron'
import { PREFERRED_VIDEO_CODEC, SCREEN_VIDEO_ENCODING } from './low-latency-config'

interface ScreenSource {
  display_id: string
  id: string
}

export async function startScreenShareSession(source: ScreenSource) {
  const roomId = `screen-${source.display_id}`

  const signalHandle = createSignalChannel(roomId, () => ({
    deviceId: `host-${source.display_id}`,
  }))

  const rtc = new WetRTC({
    signal: signalHandle.channel,
    direction: 'sendonly',
    initiator: false,   // 等移动端 recvonly 发起 offer
    polite: true,       // Perfect Negotiation 礼貌方
    videoEncoding: SCREEN_VIDEO_ENCODING,
    preferredVideoCodec: PREFERRED_VIDEO_CODEC,
  })

  rtc.on('statechange', (state, prev) => {
    console.log('[host]', roomId, prev, '→', state)
    // viewer 断开后:idle ← connected → 触发 handleViewerLeft 重新 connect 等待新 viewer
  })

  rtc.on('stats', (snap) => {
    console.log('[host] fps', snap.frameRate, 'codec', snap.codec, 'rtt', snap.roundTripTime)
  })

  signalHandle.onPeerLeft((payload) => {
    if (payload.deviceId?.startsWith('host-')) return
    // disconnect + connect:保持捕获,等待下一个手机扫码
  })

  const stream = await captureDesktopSource(source.id)
  for (const track of stream.getVideoTracks()) {
    rtc.addTrack(track, stream)
    track.addEventListener('ended', () => rtc.dispose(), { once: true })
  }

  await rtc.connect()
  return { rtc, signalHandle, stream }
}

Vue 集成要点HomeView.vue):

ts
const activeDisplayId = computed(() => selectedSource.value?.display_id ?? null)
const { getSession, sessionRegistry } = useMultiScreenShare(
  computed(() => store.sources),
  activeDisplayId,  // 仅捕获当前选中的屏,降低 GPU 负载
)

ShareSession 暴露 statelocalStreamstats,供 ScreenStreamPanel.vue 预览与 QR 码展示。

Viewer 离开后的行为

useMultiScreenSharepeer-leftstatechange: connected → idle 时调用 handleViewerLeft

  1. rtc.disconnect() — 发 bye、重建 PeerConnection,停止桌面捕获
  2. rtc.connect() — 回到 signaling,等待下一个手机扫码

4. 手机接收端

ts
/**
 * 来源:virt-screen/src/renderer/views/ReceiverView.vue + useWetRTC.ts
 *
 * 手机浏览器 = recvonly + 主动协商(initiator: true, polite: false)
 * URL:/app/#/receiver?roomId=screen-<displayId>
 */
import { WetRTC, applyReceiverPlayoutDelay } from '@wetspace/wetrtc'
import { createSignalChannel } from './signal-channel-socketio'
import { PREFERRED_VIDEO_CODEC, RECEIVER_PLAYOUT_DELAY_HINT } from './low-latency-config'

function getReceiverDeviceProfile() {
  const key = 'wet-receiver-device-id'
  let deviceId = localStorage.getItem(key)
  if (!deviceId) {
    deviceId = `receiver-${crypto.randomUUID().slice(0, 12)}`
    localStorage.setItem(key, deviceId)
  }
  return {
    deviceId,
    name: 'Mobile 设备',
    platform: 'Mobile',
  }
}

export async function connectMobileReceiver(roomId: string, videoEl: HTMLVideoElement) {
  const signalHandle = createSignalChannel(roomId, () => getReceiverDeviceProfile())

  const rtc = new WetRTC({
    signal: signalHandle.channel,
    direction: 'recvonly',
    initiator: true,    // 主动 createOffer
    polite: false,      // 与主机 polite:true 配对
    preferredVideoCodec: PREFERRED_VIDEO_CODEC,
  })

  rtc.on('statechange', (state) => {
    if (state === 'connected') {
      applyReceiverPlayoutDelay(rtc.peerConnection!, RECEIVER_PLAYOUT_DELAY_HINT)
    }
  })

  rtc.on('track', (ev) => {
    const stream = ev.streams[0]
    if (!stream) return
    videoEl.srcObject = stream
    videoEl.playsInline = true
    videoEl.muted = true
    void videoEl.play()
    applyReceiverPlayoutDelay(rtc.peerConnection!, RECEIVER_PLAYOUT_DELAY_HINT)
  })

  await rtc.connect()

  return {
    async leave() {
      await rtc.disconnect()
      rtc.dispose()
      signalHandle.close()
    },
  }
}

// 用法:roomId 与主机 QR 码一致,例如 screen-2779098405
// await connectMobileReceiver('screen-2779098405', document.querySelector('video')!)

路由:/receiver?roomId=screen-xxx(QR 码由 QrCodePanel.vue 生成,指向 Kernel HTTPS 地址)。

useWetRTC 封装了 state / remoteStream / connect / disconnect,并在 connectedtrack 时调用 applyReceiverPlayoutDelay

5. 信令事件一览

事件方向用途
join-room客户端 → 服务端加入 Socket.IO room
signal双向(经服务端转发)WetRTC SDP / ICE
peer-joined服务端 → 同 room对端上线
peer-left服务端 → 同 room对端离线,主机触发重连等待
leave-room客户端 → 服务端页面关闭时清理
devices-changed服务端广播刷新设备列表 UI

6. 与通用示例的差异

主题屏幕分享示例virt-screen
信令内存 / 演示用Socket.IO + NestJS
捕获getDisplayMediaElectron chromeMediaSourceId
协商单端 demo 常 sendonly + initiatorsendonly 被动 / recvonly 主动
多屏单路每 displayId 独立 room,按需捕获
低延迟文档示例参数low-latency-config.ts 集中配置

7. 本地调试

bash
# 仓库根目录
cd virt-screen
npm run dev          # Electron + Kernel https://localhost:3450

手机访问:https://<局域网IP>:3450/app/#/receiver?roomId=screen-<displayId>


相关文档:低延迟屏幕共享 · 信令指南 · WetRTC API

Released under the MIT License.