|
@@ -0,0 +1,1018 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="webrtc-container">
|
|
|
|
|
+ <div ref="videoContainerRef" class="webrtc-video-wrapper">
|
|
|
|
|
+ <video
|
|
|
|
|
+ ref="videoRef"
|
|
|
|
|
+ :muted="!speakerEnabled"
|
|
|
|
|
+ preload="auto"
|
|
|
|
|
+ autoplay
|
|
|
|
|
+ playsinline
|
|
|
|
|
+ webkit-playsinline
|
|
|
|
|
+ x5-video-player-type="h5"
|
|
|
|
|
+ x5-video-player-fullscreen="true"
|
|
|
|
|
+ poster=""
|
|
|
|
|
+ @mousedown="startDrag"
|
|
|
|
|
+ @mousemove="onDrag"
|
|
|
|
|
+ @mouseup="endDrag"
|
|
|
|
|
+ @mouseleave="endDrag"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div v-if="!isPlaying" class="webrtc-loading">
|
|
|
|
|
+ <div class="webrtc-spinner" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="showStatus" class="webrtc-status">
|
|
|
|
|
+ {{ statusText }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="webrtc-controls">
|
|
|
|
|
+ <div class="webrtc-controls-group">
|
|
|
|
|
+ <el-tooltip content="刷新" placement="top">
|
|
|
|
|
+ <el-icon class="ctrl-btn" @click="reconnect"><Refresh /></el-icon>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip content="截图" placement="top">
|
|
|
|
|
+ <el-icon class="ctrl-btn" @click="captureSnapshot"><Camera /></el-icon>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <el-tooltip :content="speakerEnabled ? '关闭声音' : '开启声音'" placement="top">
|
|
|
|
|
+ <el-icon class="ctrl-btn" @click="toggleSpeaker"><Microphone /></el-icon>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <canvas ref="canvasRef" style="display:none" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
|
|
|
|
+import { Refresh, Camera, Microphone } from '@element-plus/icons-vue'
|
|
|
|
|
+
|
|
|
|
|
+// ======================== Props / Emits ========================
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps<{
|
|
|
|
|
+ /** 信令服务器设备序列号 */
|
|
|
|
|
+ serno: string
|
|
|
|
|
+ /** 信令 WebSocket 地址,不传则自动推导 */
|
|
|
|
|
+ wsUrl?: string
|
|
|
|
|
+ /** ICE 服务器配置 */
|
|
|
|
|
+ iceServers?: RTCIceServer[]
|
|
|
|
|
+ /** 连接模式 live / playback */
|
|
|
|
|
+ mode?: string
|
|
|
|
|
+ /** 码流 MainStream / SubStream */
|
|
|
|
|
+ source?: string
|
|
|
|
|
+ /** 是否启用 DataChannel */
|
|
|
|
|
+ enableDataChannel?: boolean
|
|
|
|
|
+ /** 是否允许拖拽缩放 */
|
|
|
|
|
+ dragFlag?: boolean
|
|
|
|
|
+ /** 会话类型,默认 IE */
|
|
|
|
|
+ sessionType?: string
|
|
|
|
|
+ /** 认证用户名 */
|
|
|
|
|
+ user?: string
|
|
|
|
|
+ /** 认证密码 */
|
|
|
|
|
+ pwd?: string
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ (e: 'connected'): void
|
|
|
|
|
+ (e: 'disconnected'): void
|
|
|
|
|
+ (e: 'error', err: Error | string): void
|
|
|
|
|
+ (e: 'datachannel-message', msg: any): void
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+// ======================== Refs ========================
|
|
|
|
|
+
|
|
|
|
|
+const videoRef = ref<HTMLVideoElement | null>(null)
|
|
|
|
|
+const videoContainerRef = ref<HTMLElement | null>(null)
|
|
|
|
|
+const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+const isPlaying = ref(false)
|
|
|
|
|
+const speakerEnabled = ref(false)
|
|
|
|
|
+const micEnabled = ref(false)
|
|
|
|
|
+const connectionStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'>('idle')
|
|
|
|
|
+
|
|
|
|
|
+const showStatus = computed(() =>
|
|
|
|
|
+ connectionStatus.value !== 'connected' && connectionStatus.value !== 'idle'
|
|
|
|
|
+)
|
|
|
|
|
+const statusText = computed(() => {
|
|
|
|
|
+ const map: Record<string, string> = {
|
|
|
|
|
+ connecting: '正在连接...',
|
|
|
|
|
+ disconnected: '已断开,正在重连...',
|
|
|
|
|
+ failed: '连接失败'
|
|
|
|
|
+ }
|
|
|
|
|
+ return map[connectionStatus.value] ?? ''
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 内部状态 ========================
|
|
|
|
|
+
|
|
|
|
|
+let ws: WebSocket | null = null
|
|
|
|
|
+let pc: RTCPeerConnection | null = null
|
|
|
|
|
+let dataChannel: RTCDataChannel | null = null
|
|
|
|
|
+let localStream: MediaStream | null = null
|
|
|
|
|
+
|
|
|
|
|
+let meid = ''
|
|
|
|
|
+let sessionId = ''
|
|
|
|
|
+let isWsConnecting = false
|
|
|
|
|
+let isPcCreated = false
|
|
|
|
|
+let startCalled = false
|
|
|
|
|
+let isLocalAudioTrack = false
|
|
|
|
|
+let isReconnect = false
|
|
|
|
|
+let isFailCreate = false
|
|
|
|
|
+
|
|
|
|
|
+// watchdog
|
|
|
|
|
+let watchdogTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
+let lastBytesReceived = 0
|
|
|
|
|
+let freezeCount = 0
|
|
|
|
|
+
|
|
|
|
|
+// heartbeat / reconnect
|
|
|
|
|
+let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
+let heartbeatDelay = 0
|
|
|
|
|
+let wsConnectStartTime = 0
|
|
|
|
|
+const HEARTBEAT_INTERVAL = 2000
|
|
|
|
|
+const WS_CONNECT_TIMEOUT = 3000 // WebSocket 握手超时 3 秒(正常只需几毫秒)
|
|
|
|
|
+
|
|
|
|
|
+// 拖拽
|
|
|
|
|
+const scale = ref(1)
|
|
|
|
|
+const position = ref({ x: 0, y: 0 })
|
|
|
|
|
+let dragState = { dragging: false, startX: 0, startY: 0 }
|
|
|
|
|
+
|
|
|
|
|
+// ICE 缓存
|
|
|
|
|
+let iceCandidateQueue: any[] = []
|
|
|
|
|
+
|
|
|
|
|
+// props 默认值
|
|
|
|
|
+const sType = computed(() => props.sessionType ?? 'IE')
|
|
|
|
|
+const authUser = computed(() => props.user ?? 'admin')
|
|
|
|
|
+const authPwd = computed(() => props.pwd ?? 'admin')
|
|
|
|
|
+
|
|
|
|
|
+// 默认 ICE 配置
|
|
|
|
|
+let currentIceConfig: RTCConfiguration = {
|
|
|
|
|
+ iceServers: props.iceServers ?? [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 工具函数 ========================
|
|
|
|
|
+
|
|
|
|
|
+/** 连接起始时间,用于计算耗时 */
|
|
|
|
|
+let connectStartTs = 0
|
|
|
|
|
+
|
|
|
|
|
+function log(tag: string, ...args: any[]) {
|
|
|
|
|
+ const elapsed = connectStartTs > 0 ? `+${Date.now() - connectStartTs}ms` : ''
|
|
|
|
|
+ console.log(`[WebRTC][${tag}]${elapsed}`, ...args)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function newGuid(): string {
|
|
|
|
|
+ const s4 = () => (65536 * (1 + Math.random()) | 0).toString(16).substring(1)
|
|
|
|
|
+ return `${s4()}${s4()}-${s4()}-4${s4().substring(0, 3)}-${s4()}-${s4()}${s4()}${s4()}`.toUpperCase()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getWsUrl(): string {
|
|
|
|
|
+ if (props.wsUrl) return props.wsUrl
|
|
|
|
|
+ const isHttps = location.protocol === 'https:'
|
|
|
|
|
+ const host = location.host
|
|
|
|
|
+ const isDev = import.meta.env.MODE === 'development'
|
|
|
|
|
+ const targetHost = isDev ? (import.meta.env.VITE_WEBRTC_SIGNAL_IP) : host.split(':')[0]
|
|
|
|
|
+ const signalPort = import.meta.env.VITE_WEBRTC_SIGNAL_PORT ?? '80'
|
|
|
|
|
+ const scheme = isHttps ? 'wss' : 'ws'
|
|
|
|
|
+ return `${scheme}://${targetHost}:${signalPort}/wswebclient/${meid}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sendToServer(message: Record<string, any>) {
|
|
|
|
|
+ if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ ws.send(JSON.stringify(message))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== getUserMedia ========================
|
|
|
|
|
+
|
|
|
|
|
+/** 获取本地媒体,仅在需要时调用(如开启麦克风对讲) */
|
|
|
|
|
+async function acquireLocalMedia(): Promise<boolean> {
|
|
|
|
|
+ if (localStream) return true
|
|
|
|
|
+ try {
|
|
|
|
|
+ localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
|
|
|
+ localStream.getTracks().forEach(track => {
|
|
|
|
|
+ if (track.kind === 'audio') {
|
|
|
|
|
+ isLocalAudioTrack = true
|
|
|
|
|
+ track.enabled = micEnabled.value
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ // 如果 PeerConnection 已存在,动态添加音频轨道
|
|
|
|
|
+ if (pc && localStream) {
|
|
|
|
|
+ localStream.getTracks().forEach(track => {
|
|
|
|
|
+ pc!.addTrack(track, localStream!)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log('[WebRTC] local media acquired')
|
|
|
|
|
+ return true
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ localStream = null
|
|
|
|
|
+ isLocalAudioTrack = false
|
|
|
|
|
+ console.log('[WebRTC] no local media available')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== WebSocket 信令 ========================
|
|
|
|
|
+
|
|
|
|
|
+function initWebSocket() {
|
|
|
|
|
+ if (isWsConnecting) return
|
|
|
|
|
+ isWsConnecting = true
|
|
|
|
|
+ connectStartTs = Date.now()
|
|
|
|
|
+
|
|
|
|
|
+ const url = getWsUrl()
|
|
|
|
|
+ log('ws', 'connecting...', url)
|
|
|
|
|
+
|
|
|
|
|
+ if (ws) {
|
|
|
|
|
+ try { ws.close() } catch { /* */ }
|
|
|
|
|
+ ws = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ws = new WebSocket(url)
|
|
|
|
|
+
|
|
|
|
|
+ ws.onopen = () => {
|
|
|
|
|
+ log('ws', 'opened')
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ wsConnectStartTime = 0
|
|
|
|
|
+ // 重置状态,确保新连接能正常发起 call
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ isReconnect = false
|
|
|
|
|
+ sendConnect()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ws.onmessage = (e: MessageEvent) => {
|
|
|
|
|
+ const data = JSON.parse(e.data)
|
|
|
|
|
+ if (data.eventName !== '_ping') {
|
|
|
|
|
+ log('msg', data.eventName)
|
|
|
|
|
+ }
|
|
|
|
|
+ switch (data.eventName) {
|
|
|
|
|
+ case '_create': handleCreate(data.data); break
|
|
|
|
|
+ case '_call': handleCall(data.data); break
|
|
|
|
|
+ case '_offer': handleOffer(data.data); break
|
|
|
|
|
+ case '_answer': handleAnswer(data.data); break
|
|
|
|
|
+ case '_ice_candidate': handleCandidate(data.data); break
|
|
|
|
|
+ case '_session_disconnected': handleDisconnect(data.data); break
|
|
|
|
|
+ case '_post_message': handlePostMessage(data.data); break
|
|
|
|
|
+ case '_connectinfo': console.log('[WebRTC] ConnectInfo:', data.data.message); break
|
|
|
|
|
+ case '_session_failed': handleSessionFailed(data.data); break
|
|
|
|
|
+ case '_ping': break
|
|
|
|
|
+ default: console.log('[WebRTC] Unknown message', data); break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ws.onerror = () => {
|
|
|
|
|
+ log('ws', 'error')
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ wsConnectStartTime = 0
|
|
|
|
|
+ ws = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ws.onclose = () => {
|
|
|
|
|
+ log('ws', 'closed')
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ wsConnectStartTime = 0
|
|
|
|
|
+ ws = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 记录连接开始时间,让心跳来判断是否超时
|
|
|
|
|
+ wsConnectStartTime = Date.now()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sendConnect() {
|
|
|
|
|
+ log('signal', 'sendConnect to', props.serno)
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__connectto',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), from: meid, to: props.serno
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sendDisconnect() {
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__disconnected',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), from: meid, to: props.serno
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 信令处理 ========================
|
|
|
|
|
+
|
|
|
|
|
+function handleCreate(data: any) {
|
|
|
|
|
+ log('signal', '_create state:', data.state)
|
|
|
|
|
+ if (data.state !== 'online' && data.state !== 'sleep') {
|
|
|
|
|
+ console.log('[WebRTC] device offline', data.from)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 更新 ICE 配置
|
|
|
|
|
+ if (data.iceServers) {
|
|
|
|
|
+ const servers = typeof data.iceServers === 'string'
|
|
|
|
|
+ ? JSON.parse(data.iceServers)
|
|
|
|
|
+ : JSON.parse(JSON.stringify(data.iceServers))
|
|
|
|
|
+ currentIceConfig = servers
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!startCalled) {
|
|
|
|
|
+ callDevice()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function callDevice() {
|
|
|
|
|
+ log('signal', 'callDevice')
|
|
|
|
|
+ startCalled = true
|
|
|
|
|
+ connectionStatus.value = 'connecting'
|
|
|
|
|
+ // 监控场景默认 recvonly,有本地媒体时才 sendrecv
|
|
|
|
|
+ const audio = isLocalAudioTrack ? 'sendrecv' : 'recvonly'
|
|
|
|
|
+ const video = 'recvonly'
|
|
|
|
|
+ const dc = props.enableDataChannel !== false ? 'true' : 'false'
|
|
|
|
|
+
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__call',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId,
|
|
|
|
|
+ sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(),
|
|
|
|
|
+ from: meid,
|
|
|
|
|
+ to: props.serno,
|
|
|
|
|
+ mode: props.mode ?? 'live',
|
|
|
|
|
+ source: props.source ?? 'MainStream',
|
|
|
|
|
+ datachannel: dc, audio, video,
|
|
|
|
|
+ user: authUser.value,
|
|
|
|
|
+ pwd: authPwd.value,
|
|
|
|
|
+ iceservers: JSON.stringify(currentIceConfig)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleCall(data: any) {
|
|
|
|
|
+ log('signal', '_call received, creating offer')
|
|
|
|
|
+ // 服务端要求我方创建 offer
|
|
|
|
|
+ if (data.iceServers) {
|
|
|
|
|
+ currentIceConfig = typeof data.iceServers === 'string'
|
|
|
|
|
+ ? JSON.parse(data.iceServers)
|
|
|
|
|
+ : JSON.parse(JSON.stringify(data.iceServers))
|
|
|
|
|
+ }
|
|
|
|
|
+ isReconnect = false
|
|
|
|
|
+ if (!isPcCreated) initPeerConnection()
|
|
|
|
|
+ if (!isPcCreated) { sendDisconnect(); isFailCreate = true; return }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const offer = await pc!.createOffer()
|
|
|
|
|
+ await pc!.setLocalDescription(offer)
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__offer',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), from: meid, to: props.serno,
|
|
|
|
|
+ type: offer.type, sdp: offer.sdp,
|
|
|
|
|
+ iceservers: JSON.stringify(currentIceConfig)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error('[WebRTC] createOffer error', err.message)
|
|
|
|
|
+ sendDisconnect()
|
|
|
|
|
+ isReconnect = true
|
|
|
|
|
+ isFailCreate = true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleOffer(data: any) {
|
|
|
|
|
+ log('signal', '_offer received, creating answer')
|
|
|
|
|
+ isReconnect = false
|
|
|
|
|
+ if (!isPcCreated) initPeerConnection()
|
|
|
|
|
+ if (!isPcCreated) { sendDisconnect(); isFailCreate = true; return }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await pc!.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: data.sdp }))
|
|
|
|
|
+ const answer = await pc!.createAnswer()
|
|
|
|
|
+ await pc!.setLocalDescription(answer)
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__answer',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), from: meid, to: props.serno,
|
|
|
|
|
+ type: answer.type, sdp: answer.sdp
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error('[WebRTC] handleOffer error', err.message)
|
|
|
|
|
+ isReconnect = true
|
|
|
|
|
+ isFailCreate = true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleAnswer(data: any) {
|
|
|
|
|
+ log('signal', '_answer received')
|
|
|
|
|
+ if (!pc) return
|
|
|
|
|
+ try {
|
|
|
|
|
+ await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: data.sdp }))
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error('[WebRTC] setRemoteDescription error', err.message)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleCandidate(data: any) {
|
|
|
|
|
+ const obj = JSON.parse(data.candidate)
|
|
|
|
|
+ if (pc && isPcCreated) {
|
|
|
|
|
+ pc.addIceCandidate(new RTCIceCandidate({
|
|
|
|
|
+ sdpMLineIndex: obj.sdpMLineIndex,
|
|
|
|
|
+ candidate: obj.candidate
|
|
|
|
|
+ }))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ iceCandidateQueue.push(obj)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleDisconnect(data: any) {
|
|
|
|
|
+ if (data.sessionId === sessionId) {
|
|
|
|
|
+ if (isPcCreated) handleLeave()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleSessionFailed(data: any) {
|
|
|
|
|
+ log('signal', '_session_failed')
|
|
|
|
|
+ if (data.sessionId === sessionId) {
|
|
|
|
|
+ if (isPcCreated) { handleLeave(); sendDisconnect() }
|
|
|
|
|
+ // 立即用新 session 重试
|
|
|
|
|
+ sessionId = newGuid()
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ sendConnect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handlePostMessage(data: any) {
|
|
|
|
|
+ emit('datachannel-message', data.message)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== PeerConnection ========================
|
|
|
|
|
+
|
|
|
|
|
+function initPeerConnection() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ pc = new RTCPeerConnection(currentIceConfig)
|
|
|
|
|
+
|
|
|
|
|
+ // 本地轨道按需添加(用户开启麦克风时通过 acquireLocalMedia 动态添加)
|
|
|
|
|
+
|
|
|
|
|
+ // 远端轨道
|
|
|
|
|
+ pc.ontrack = (e: RTCTrackEvent) => {
|
|
|
|
|
+ log('track', 'remote track received, kind:', e.track.kind)
|
|
|
|
|
+ if (!videoRef.value) return
|
|
|
|
|
+ e.streams[0].getTracks().forEach(track => {
|
|
|
|
|
+ if (track.kind === 'audio') track.enabled = speakerEnabled.value
|
|
|
|
|
+ // 监听 track unmute 自动恢复播放
|
|
|
|
|
+ track.onunmute = () => {
|
|
|
|
|
+ if (track.kind === 'video') {
|
|
|
|
|
+ isPlaying.value = false
|
|
|
|
|
+ tryPlay()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ videoRef.value.srcObject = e.streams[0]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // DataChannel
|
|
|
|
|
+ pc.ondatachannel = (ev) => {
|
|
|
|
|
+ const ch = ev.channel
|
|
|
|
|
+ ch.onmessage = (event) => emit('datachannel-message', event.data)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (props.enableDataChannel !== false) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ dataChannel = pc.createDataChannel('mydatachannel')
|
|
|
|
|
+ dataChannel.onopen = () => console.log('[WebRTC] DataChannel open')
|
|
|
|
|
+ dataChannel.onclose = () => console.log('[WebRTC] DataChannel close')
|
|
|
|
|
+ dataChannel.onmessage = (msg) => emit('datachannel-message', msg.data)
|
|
|
|
|
+ } catch { /* ignore */ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ICE
|
|
|
|
|
+ pc.onicecandidate = (event) => {
|
|
|
|
|
+ if (!event.candidate) return
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__ice_candidate',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), to: props.serno, from: meid,
|
|
|
|
|
+ candidate: JSON.stringify({
|
|
|
|
|
+ candidate: event.candidate.candidate,
|
|
|
|
|
+ sdpMid: event.candidate.sdpMid,
|
|
|
|
|
+ sdpMLineIndex: event.candidate.sdpMLineIndex
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pc.oniceconnectionstatechange = () => {
|
|
|
|
|
+ if (!pc) return
|
|
|
|
|
+ log('ice', pc.iceConnectionState)
|
|
|
|
|
+ switch (pc.iceConnectionState) {
|
|
|
|
|
+ case 'connected':
|
|
|
|
|
+ case 'completed':
|
|
|
|
|
+ connectionStatus.value = 'connected'
|
|
|
|
|
+ emit('connected')
|
|
|
|
|
+ tryPlay()
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'failed':
|
|
|
|
|
+ case 'disconnected':
|
|
|
|
|
+ connectionStatus.value = 'disconnected'
|
|
|
|
|
+ if (isPcCreated) { handleLeave(); sendDisconnect() }
|
|
|
|
|
+ emit('disconnected')
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'closed':
|
|
|
|
|
+ releasePc()
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pc.onicegatheringstatechange = () => {
|
|
|
|
|
+ if (pc) console.log('[WebRTC] ICE gathering:', pc.iceGatheringState)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pc.onsignalingstatechange = () => {
|
|
|
|
|
+ if (pc?.signalingState === 'closed') releasePc()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ isPcCreated = true
|
|
|
|
|
+
|
|
|
|
|
+ // 处理缓存的 ICE candidate
|
|
|
|
|
+ iceCandidateQueue.forEach(obj => {
|
|
|
|
|
+ pc!.addIceCandidate(new RTCIceCandidate({
|
|
|
|
|
+ sdpMLineIndex: obj.sdpMLineIndex,
|
|
|
|
|
+ candidate: obj.candidate
|
|
|
|
|
+ }))
|
|
|
|
|
+ })
|
|
|
|
|
+ iceCandidateQueue = []
|
|
|
|
|
+
|
|
|
|
|
+ log('pc', 'PeerConnection created')
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error('[WebRTC] PeerConnection create failed', err.message)
|
|
|
|
|
+ isPcCreated = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleLeave() {
|
|
|
|
|
+ if (dataChannel) {
|
|
|
|
|
+ dataChannel.close()
|
|
|
|
|
+ dataChannel.onopen = null
|
|
|
|
|
+ dataChannel.onclose = null
|
|
|
|
|
+ dataChannel.onmessage = null
|
|
|
|
|
+ dataChannel = null
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pc) {
|
|
|
|
|
+ // 先移除所有事件监听,防止 close() 触发回调导致状态混乱
|
|
|
|
|
+ pc.onicecandidate = null
|
|
|
|
|
+ pc.ontrack = null
|
|
|
|
|
+ pc.oniceconnectionstatechange = null
|
|
|
|
|
+ pc.onicegatheringstatechange = null
|
|
|
|
|
+ pc.onsignalingstatechange = null
|
|
|
|
|
+ pc.getSenders().forEach(s => { try { pc!.removeTrack(s) } catch { /* */ } })
|
|
|
|
|
+ try { pc.close() } catch { /* */ }
|
|
|
|
|
+ pc = null
|
|
|
|
|
+ }
|
|
|
|
|
+ isPlaying.value = false
|
|
|
|
|
+ isPcCreated = false
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ freezeCount = 0
|
|
|
|
|
+ lastBytesReceived = 0
|
|
|
|
|
+ iceCandidateQueue = []
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function releasePc() {
|
|
|
|
|
+ log('pc', 'released by ICE event, will reconnect')
|
|
|
|
|
+ if (pc) {
|
|
|
|
|
+ pc.onicecandidate = null
|
|
|
|
|
+ pc.ontrack = null
|
|
|
|
|
+ pc.oniceconnectionstatechange = null
|
|
|
|
|
+ pc.onicegatheringstatechange = null
|
|
|
|
|
+ pc.onsignalingstatechange = null
|
|
|
|
|
+ try { pc.close() } catch { /* */ }
|
|
|
|
|
+ pc = null
|
|
|
|
|
+ }
|
|
|
|
|
+ isPcCreated = false
|
|
|
|
|
+ // ws 还活着就立即重连
|
|
|
|
|
+ if (!isFailCreate) {
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ sessionId = newGuid()
|
|
|
|
|
+ if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ sendConnect()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ isReconnect = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 播放控制 ========================
|
|
|
|
|
+
|
|
|
|
|
+function tryPlay() {
|
|
|
|
|
+ const video = videoRef.value
|
|
|
|
|
+ if (!video) return
|
|
|
|
|
+ if (video.paused || video.ended || !isPlaying.value) {
|
|
|
|
|
+ log('play', 'attempting video.play()')
|
|
|
|
|
+ isPlaying.value = true
|
|
|
|
|
+ const p = video.play()
|
|
|
|
|
+ if (p) {
|
|
|
|
|
+ p.then(() => { isPlaying.value = true; log('play', 'playing!') })
|
|
|
|
|
+ .catch(() => { isPlaying.value = false })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== Watchdog ========================
|
|
|
|
|
+
|
|
|
|
|
+function startWatchdog() {
|
|
|
|
|
+ stopWatchdog()
|
|
|
|
|
+ watchdogTimer = setInterval(() => {
|
|
|
|
|
+ if (!pc || !isPcCreated || !isPlaying.value) {
|
|
|
|
|
+ freezeCount = 0
|
|
|
|
|
+ lastBytesReceived = 0
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 检查 video 是否暂停
|
|
|
|
|
+ if (videoRef.value && (videoRef.value.paused || videoRef.value.ended)) {
|
|
|
|
|
+ console.log('[watchdog] video paused, forcing play')
|
|
|
|
|
+ isPlaying.value = false
|
|
|
|
|
+ tryPlay()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 通过 getStats 检测数据流
|
|
|
|
|
+ pc.getStats(null).then(stats => {
|
|
|
|
|
+ let currentBytes = 0
|
|
|
|
|
+ stats.forEach(report => {
|
|
|
|
|
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
|
|
|
|
+ currentBytes = report.bytesReceived ?? 0
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (lastBytesReceived > 0 && currentBytes <= lastBytesReceived) {
|
|
|
|
|
+ freezeCount++
|
|
|
|
|
+ console.log(`[watchdog] no new data, freeze count: ${freezeCount}`)
|
|
|
|
|
+ if (freezeCount >= 3) {
|
|
|
|
|
+ console.log('[watchdog] frozen ~9s, ICE restart...')
|
|
|
|
|
+ freezeCount = 0
|
|
|
|
|
+ requestIceRestart()
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ freezeCount = 0
|
|
|
|
|
+ }
|
|
|
|
|
+ lastBytesReceived = currentBytes
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+ }, 3000)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stopWatchdog() {
|
|
|
|
|
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requestIceRestart() {
|
|
|
|
|
+ if (!pc || !isPcCreated) return
|
|
|
|
|
+ try {
|
|
|
|
|
+ const offer = await pc.createOffer({ iceRestart: true })
|
|
|
|
|
+ await pc.setLocalDescription(offer)
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__offer',
|
|
|
|
|
+ data: {
|
|
|
|
|
+ sessionId, sessionType: sType.value,
|
|
|
|
|
+ messageId: newGuid(), from: meid, to: props.serno,
|
|
|
|
|
+ type: offer.type, sdp: offer.sdp,
|
|
|
|
|
+ iceservers: JSON.stringify(currentIceConfig)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ICE restart 失败,完全重连
|
|
|
|
|
+ handleLeave()
|
|
|
|
|
+ sendDisconnect()
|
|
|
|
|
+ isReconnect = true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 心跳 / 重连定时器 ========================
|
|
|
|
|
+
|
|
|
|
|
+function startHeartbeat() {
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+ heartbeatDelay = 0
|
|
|
|
|
+ heartbeatTimer = setInterval(() => {
|
|
|
|
|
+ heartbeatDelay++
|
|
|
|
|
+ if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ if (isReconnect) {
|
|
|
|
|
+ console.log('[WebRTC] reconnecting...')
|
|
|
|
|
+ if (isPcCreated) handleLeave()
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ sessionId = newGuid()
|
|
|
|
|
+ sendConnect()
|
|
|
|
|
+ isReconnect = false
|
|
|
|
|
+ } else if (heartbeatDelay > 15) {
|
|
|
|
|
+ heartbeatDelay = 0
|
|
|
|
|
+ sendToServer({
|
|
|
|
|
+ eventName: '__ping',
|
|
|
|
|
+ data: { sessionId, sessionType: sType.value, messageId: newGuid(), from: meid, to: props.serno }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
|
|
|
+ // 还在握手中,只有超过 15 秒才放弃
|
|
|
|
|
+ if (wsConnectStartTime > 0 && Date.now() - wsConnectStartTime > WS_CONNECT_TIMEOUT) {
|
|
|
|
|
+ log('ws', 'connect timeout, rebuilding...')
|
|
|
|
|
+ try { ws.close() } catch { /* */ }
|
|
|
|
|
+ ws = null
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ wsConnectStartTime = 0
|
|
|
|
|
+ // 短暂延迟后重建,避免浏览器/服务器还没释放资源
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ meid = newGuid()
|
|
|
|
|
+ initWebSocket()
|
|
|
|
|
+ }, 500)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // ws 已断开或不存在,重建
|
|
|
|
|
+ if (!isWsConnecting) {
|
|
|
|
|
+ if (ws) { try { ws.close() } catch { /* */ }; ws = null }
|
|
|
|
|
+ if (isPcCreated) handleLeave()
|
|
|
|
|
+ meid = newGuid()
|
|
|
|
|
+ initWebSocket()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }, HEARTBEAT_INTERVAL)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stopHeartbeat() {
|
|
|
|
|
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 扬声器 / 麦克风 ========================
|
|
|
|
|
+
|
|
|
|
|
+function toggleSpeaker() {
|
|
|
|
|
+ speakerEnabled.value = !speakerEnabled.value
|
|
|
|
|
+ if (videoRef.value) videoRef.value.muted = !speakerEnabled.value
|
|
|
|
|
+ if (pc) {
|
|
|
|
|
+ pc.getReceivers().forEach(r => {
|
|
|
|
|
+ if (r.track?.kind === 'audio') r.track.enabled = speakerEnabled.value
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function toggleMic() {
|
|
|
|
|
+ micEnabled.value = !micEnabled.value
|
|
|
|
|
+ if (micEnabled.value && !localStream) {
|
|
|
|
|
+ // 首次开启麦克风,按需获取本地媒体
|
|
|
|
|
+ await acquireLocalMedia()
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pc) {
|
|
|
|
|
+ pc.getSenders().forEach(s => {
|
|
|
|
|
+ if (s.track?.kind === 'audio') s.track.enabled = micEnabled.value
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 截图 ========================
|
|
|
|
|
+
|
|
|
|
|
+function captureSnapshot() {
|
|
|
|
|
+ if (!isPlaying.value || !videoRef.value || !canvasRef.value) return
|
|
|
|
|
+ const video = videoRef.value
|
|
|
|
|
+ const canvas = canvasRef.value
|
|
|
|
|
+ canvas.width = video.videoWidth
|
|
|
|
|
+ canvas.height = video.videoHeight
|
|
|
|
|
+ const ctx = canvas.getContext('2d')!
|
|
|
|
|
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
|
|
|
|
+ const dataUrl = canvas.toDataURL('image/png')
|
|
|
|
|
+ // 自动下载
|
|
|
|
|
+ const link = document.createElement('a')
|
|
|
|
|
+ link.href = dataUrl
|
|
|
|
|
+ link.download = `snapshot_${Date.now()}.png`
|
|
|
|
|
+ link.click()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 拖拽缩放 ========================
|
|
|
|
|
+
|
|
|
|
|
+function onWheel(event: WheelEvent) {
|
|
|
|
|
+ if (!props.dragFlag) return
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ const delta = event.deltaY < 0 ? 0.1 : -0.1
|
|
|
|
|
+ scale.value = Math.max(1, Math.min(3, scale.value + delta))
|
|
|
|
|
+ applyTransform()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function startDrag(event: MouseEvent) {
|
|
|
|
|
+ if (!props.dragFlag) return
|
|
|
|
|
+ dragState.dragging = true
|
|
|
|
|
+ dragState.startX = event.clientX - position.value.x
|
|
|
|
|
+ dragState.startY = event.clientY - position.value.y
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onDrag(event: MouseEvent) {
|
|
|
|
|
+ if (!dragState.dragging || !props.dragFlag) return
|
|
|
|
|
+ position.value.x = event.clientX - dragState.startX
|
|
|
|
|
+ position.value.y = event.clientY - dragState.startY
|
|
|
|
|
+ applyTransform()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function endDrag() {
|
|
|
|
|
+ dragState.dragging = false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyTransform() {
|
|
|
|
|
+ const video = videoRef.value
|
|
|
|
|
+ const container = videoContainerRef.value
|
|
|
|
|
+ if (!video || !container || !props.dragFlag) return
|
|
|
|
|
+ const vw = video.offsetWidth * scale.value
|
|
|
|
|
+ const vh = video.offsetHeight * scale.value
|
|
|
|
|
+ const cw = container.offsetWidth
|
|
|
|
|
+ const ch = container.offsetHeight
|
|
|
|
|
+ const maxX = Math.max(0, (vw - cw) / 2)
|
|
|
|
|
+ const maxY = Math.max(0, (vh - ch) / 2)
|
|
|
|
|
+ position.value.x = Math.min(maxX, Math.max(-maxX, position.value.x))
|
|
|
|
|
+ position.value.y = Math.min(maxY, Math.max(-maxY, position.value.y))
|
|
|
|
|
+ video.style.transform = `translate(${position.value.x}px, ${position.value.y}px) scale(${scale.value})`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 公开方法 ========================
|
|
|
|
|
+
|
|
|
|
|
+/** 手动重连 */
|
|
|
|
|
+function reconnect() {
|
|
|
|
|
+ log('action', 'reconnect requested')
|
|
|
|
|
+ // 通知服务器断开旧 session
|
|
|
|
|
+ sendDisconnect()
|
|
|
|
|
+ // 清理本地资源(handleLeave 已经不会触发异步回调了)
|
|
|
|
|
+ stopWatchdog()
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+ if (isPcCreated) handleLeave()
|
|
|
|
|
+ if (ws) { try { ws.close() } catch { /* */ }; ws = null }
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ isReconnect = false
|
|
|
|
|
+ isFailCreate = false
|
|
|
|
|
+
|
|
|
|
|
+ sessionId = newGuid()
|
|
|
|
|
+ meid = newGuid()
|
|
|
|
|
+ connectionStatus.value = 'connecting'
|
|
|
|
|
+ initWebSocket()
|
|
|
|
|
+ startHeartbeat()
|
|
|
|
|
+ startWatchdog()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 发送 DataChannel 消息 */
|
|
|
|
|
+function sendDataChannelMessage(msg: string | Record<string, any>) {
|
|
|
|
|
+ if (dataChannel?.readyState === 'open') {
|
|
|
|
|
+ dataChannel.send(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cleanup() {
|
|
|
|
|
+ stopWatchdog()
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+ if (isPcCreated) handleLeave()
|
|
|
|
|
+ if (ws) { try { ws.close() } catch { /* */ }; ws = null }
|
|
|
|
|
+ isWsConnecting = false
|
|
|
|
|
+ startCalled = false
|
|
|
|
|
+ connectionStatus.value = 'idle'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ======================== 生命周期 ========================
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ sessionId = newGuid()
|
|
|
|
|
+ meid = newGuid()
|
|
|
|
|
+
|
|
|
|
|
+ if (videoContainerRef.value) {
|
|
|
|
|
+ videoContainerRef.value.addEventListener('wheel', onWheel, { passive: false })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 直接建立信令连接,不请求本地媒体(监控场景默认 recvonly)
|
|
|
|
|
+ // 本地媒体仅在用户开启麦克风时按需获取
|
|
|
|
|
+ initWebSocket()
|
|
|
|
|
+ startHeartbeat()
|
|
|
|
|
+ startWatchdog()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ if (videoContainerRef.value) {
|
|
|
|
|
+ videoContainerRef.value.removeEventListener('wheel', onWheel)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 通知服务器断开,让服务器立即清理 session
|
|
|
|
|
+ sendDisconnect()
|
|
|
|
|
+ cleanup()
|
|
|
|
|
+ // 释放本地媒体
|
|
|
|
|
+ if (localStream) {
|
|
|
|
|
+ localStream.getTracks().forEach(t => t.stop())
|
|
|
|
|
+ localStream = null
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+defineExpose({
|
|
|
|
|
+ reconnect,
|
|
|
|
|
+ captureSnapshot,
|
|
|
|
|
+ sendDataChannelMessage,
|
|
|
|
|
+ toggleSpeaker,
|
|
|
|
|
+ toggleMic,
|
|
|
|
|
+ isPlaying: readonly(isPlaying),
|
|
|
|
|
+ speakerEnabled: readonly(speakerEnabled),
|
|
|
|
|
+ micEnabled: readonly(micEnabled),
|
|
|
|
|
+ connectionStatus: readonly(connectionStatus)
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.webrtc-container {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-video-wrapper {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background-color: #000;
|
|
|
|
|
+
|
|
|
|
|
+ video {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ object-fit: contain;
|
|
|
|
|
+ cursor: grab;
|
|
|
|
|
+ &:active { cursor: grabbing; }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-loading {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
|
|
+ z-index: 5;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-spinner {
|
|
|
|
|
+ width: 44px;
|
|
|
|
|
+ height: 44px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ border: 3px solid transparent;
|
|
|
|
|
+ border-top-color: #ffea29;
|
|
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
|
|
+
|
|
|
|
|
+ &::before {
|
|
|
|
|
+ content: '';
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 4px; left: 4px; right: 4px; bottom: 4px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ border: 3px solid transparent;
|
|
|
|
|
+ border-top-color: #8d29ff;
|
|
|
|
|
+ animation: spin 2s linear infinite reverse;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@keyframes spin {
|
|
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-status {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ bottom: 48px;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ transform: translateX(-50%);
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.7);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ padding: 6px 16px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ z-index: 5;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-controls {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ height: 36px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ padding: 0 8px;
|
|
|
|
|
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ transition: opacity 0.25s ease;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+
|
|
|
|
|
+ .webrtc-container:hover & {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.webrtc-controls-group {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.ctrl-btn {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ width: 28px;
|
|
|
|
|
+ height: 28px;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+
|
|
|
|
|
+ &:hover {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.25);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|