فهرست منبع

优化创维183优化直播逻辑

liujintao 1 هفته پیش
والد
کامیت
6e918b3a41
3فایلهای تغییر یافته به همراه200 افزوده شده و 102 حذف شده
  1. 1 1
      .env.development
  2. 27 0
      env.d.ts
  3. 172 101
      src/components/myVideo.vue

+ 1 - 1
.env.development

@@ -5,7 +5,7 @@ VITE_PUBLIC_PATH='/'
 ## 路由模式 hash 或 html5
 VITE_ROUTER_HISTORY='html5'
 
-VITE_HOST_IP = '192.168.1.52'
+VITE_HOST_IP = '192.168.1.103'
 
 VITE_VIDEO_PORT = '8000'
 

+ 27 - 0
env.d.ts

@@ -11,6 +11,33 @@ interface ImportMeta {
   readonly env: ImportMetaEnv
 }
 
+/** WFS 播放器类型声明 */
+interface WfsStatic {
+  isSupported(): boolean
+  Events: {
+    ERROR: string
+    MANIFEST_PARSED: string
+    [key: string]: string
+  }
+  ErrorTypes: {
+    MEDIA_ERROR: string
+    NETWORK_ERROR: string
+    [key: string]: string
+  }
+  new (config?: Record<string, any>): WfsInstance
+}
+
+interface WfsInstance {
+  attachMedia(video: HTMLVideoElement, channel: string, codec: string, url: string): void
+  on(event: string, callback: (...args: any[]) => void): void
+  off(event: string, callback: (...args: any[]) => void): void
+  destroy(): void
+}
+
+interface Window {
+  Wfs: WfsStatic
+}
+
 declare module '*.vue' {
   import { DefineComponent } from 'vue'
   const component: DefineComponent<{}, {}, any>

+ 172 - 101
src/components/myVideo.vue

@@ -28,39 +28,36 @@
           <refresh />
         </el-icon>
       </div>
-
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue'
+import { onMounted, onUnmounted, readonly, ref } from 'vue'
 import { Refresh } from '@element-plus/icons-vue'
 
 const props = defineProps({
   dragFlag: {
     type: Boolean,
-    default: false  // 是否允许拖拽和缩放
+    default: false
   }
 })
 
-// 获取webSocket服务器IP地址
+// 获取 WebSocket 服务器地址
 const wsHost = import.meta.env.MODE === 'development'
   ? import.meta.env.VITE_HOST_IP
   : window.location.host
 
-// 视频流和心跳流端口
 const wsVideoPort = 8000
 const wsHeartbeatPort = 8001
 
-// Emits
 const emit = defineEmits(['video-error'])
 
 // Refs
 const videoContainer = ref<HTMLElement | null>(null)
 const videoElement = ref<HTMLVideoElement | null>(null)
 
-// 视频控制状态
+// 视频缩放/拖拽状态
 const scale = ref(1)
 const position = ref({ x: 0, y: 0 })
 let dragState = {
@@ -70,56 +67,57 @@ let dragState = {
 }
 
 // WebSocket 状态
-let wfsObj: any = null
+let wfsObj: WfsInstance | null = null
 let heartbeatWs: WebSocket | null = null
 let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
 let reconnectTimer: ReturnType<typeof setTimeout> | null = null
 let reconnectAttempts = 0
 const maxReconnectAttempts = 5
 
+// mediaError 恢复计数(不占用 reconnectAttempts 配额)
+let mediaRecoveryAttempts = 0
+const maxMediaRecoveryAttempts = 10
+
+// 防止 handleMediaError 被连续触发多次
+let isRecovering = false
+
 // 页面可见性状态
-const lastTimeStamp = ref(0)
-const lastVideoTime = ref(0)
+let lastTimeStamp = 0
+let lastVideoTime = 0
+let isPageHidden = false
+
+// ==================== 工具函数 ====================
 
-// 工具函数
-const debounce = (func: Function, wait: number) => {
+const debounce = (func: (...args: any[]) => void, wait: number) => {
   let timeout: ReturnType<typeof setTimeout>
-  return function executedFunction(...args: any[]) {
-    const later = () => {
-      clearTimeout(timeout)
-      func(...args)
-    }
+  return (...args: any[]) => {
     clearTimeout(timeout)
-    timeout = setTimeout(later, wait)
+    timeout = setTimeout(() => func(...args), wait)
   }
 }
 
-// 视频缩放和拖拽功能
+// ==================== 缩放和拖拽 ====================
+
 const zoom = (event: WheelEvent) => {
   if (!props.dragFlag) return
-
   event.preventDefault()
+
   const zoomSpeed = 0.1
   const delta = event.deltaY < 0 ? zoomSpeed : -zoomSpeed
-
   scale.value = Math.max(1, Math.min(3, scale.value + delta))
   applyTransform()
 }
 
 const 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()
 }
 
 const onDrag = (event: MouseEvent) => {
   if (!dragState.dragging || !props.dragFlag) return
-
   position.value.x = event.clientX - dragState.startX
   position.value.y = event.clientY - dragState.startY
   applyTransform()
@@ -137,21 +135,39 @@ const applyTransform = () => {
   const containerWidth = videoContainer.value.offsetWidth
   const containerHeight = videoContainer.value.offsetHeight
 
-  // 计算最大移动范围
   const maxX = Math.max(0, (videoWidth - containerWidth) / 2)
   const maxY = Math.max(0, (videoHeight - containerHeight) / 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))
 
-  // 应用变换
   videoElement.value.style.transform =
     `translate(${position.value.x}px, ${position.value.y}px) scale(${scale.value})`
 }
 
-// WebSocket 视频流管理
-const createWebSocket = async () => {
+// ==================== WFS 视频流管理 ====================
+
+/**
+ * 彻底重置 video 元素上的 MediaSource,
+ * 防止 WFS 内部 doAppending 访问残留的 mediaElement.error 导致
+ * "Cannot read properties of undefined (reading 'error')" 崩溃。
+ */
+const resetVideoElement = () => {
+  const video = videoElement.value
+  if (!video) return
+
+  // 暂停播放
+  video.pause()
+
+  // 断开 MediaSource 绑定
+  video.removeAttribute('src')
+  video.srcObject = null
+
+  // 强制浏览器释放旧的 MediaSource / SourceBuffer
+  video.load()
+}
+
+const createWebSocket = () => {
   try {
     if (!window.Wfs?.isSupported()) {
       throw new Error('WFS not supported')
@@ -162,21 +178,22 @@ const createWebSocket = async () => {
       throw new Error('Video element not found')
     }
 
-    const config = {
+    const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
+
+    wfsObj = new window.Wfs({
       wsMinPacketInterval: 2000,
       wsMaxPacketInterval: 8000
-    }
-
-    const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
+    })
 
-    wfsObj = new window.Wfs(config)
     wfsObj.attachMedia(video, 'ch1', 'H264Raw', socketURL)
 
     wfsObj.on(window.Wfs.Events.ERROR, handleWfsError)
     wfsObj.on(window.Wfs.Events.MANIFEST_PARSED, () => {
+      // 连接成功,重置所有恢复计数
       reconnectAttempts = 0
+      mediaRecoveryAttempts = 0
+      isRecovering = false
     })
-
   } catch (error) {
     console.error('Failed to create WebSocket:', error)
     emit('video-error', error)
@@ -184,29 +201,67 @@ const createWebSocket = async () => {
   }
 }
 
-const handleWfsError = (eventName: string, data: any) => {
-  console.error('WFS Error:', eventName, data)
-
-  if (data.fatal) {
-    emit('video-error', data)
-
-    switch (data.type) {
-      case window.Wfs.ErrorTypes.MEDIA_ERROR:
-        console.log('Media error occurred')
-        break
-      case window.Wfs.ErrorTypes.NETWORK_ERROR:
-        console.log('Network error occurred')
-        break
-      default:
-        console.log('Unrecoverable error occurred')
-        break
-    }
+const handleWfsError = (_eventName: string, data: any) => {
+  console.error('WFS Error:', data)
+
+  if (!data.fatal) return
+
+  emit('video-error', data)
+
+  switch (data.type) {
+    case window.Wfs.ErrorTypes.MEDIA_ERROR:
+      handleMediaError()
+      break
+    case window.Wfs.ErrorTypes.NETWORK_ERROR:
+      console.warn('Network error, scheduling reconnect...')
+      scheduleReconnect()
+      break
+    default:
+      console.error('Unrecoverable error, scheduling reconnect...')
+      scheduleReconnect()
+      break
+  }
+}
 
-    scheduleReconnect()
+/**
+ * mediaError 恢复策略:
+ *
+ * error code 3 (MEDIA_ERR_DECODE) 表示浏览器解码失败。
+ * WFS 内部在连续 3 次 doAppending 检测到 error 后触发 fatal。
+ *
+ * 恢复方式:完整重建(销毁 WFS + 重置 video + 重建 WebSocket + 重建心跳)
+ * 后端会从新的关键帧开始推流,解码器状态干净。
+ *
+ * mediaError 的恢复不消耗 reconnectAttempts 配额,
+ * 因为这不是网络问题,而是解码问题,每次重建都有很大概率成功。
+ * 给 10 次机会,持续失败才放弃。
+ */
+const handleMediaError = () => {
+  if (isRecovering) return
+  isRecovering = true
+
+  mediaRecoveryAttempts++
+
+  if (mediaRecoveryAttempts <= maxMediaRecoveryAttempts) {
+    console.warn(
+      `Media error recovery ${mediaRecoveryAttempts}/${maxMediaRecoveryAttempts}, rebuilding...`
+    )
+    cleanup()
+    setTimeout(() => {
+      isRecovering = false
+      createWebSocket()
+      createHeartbeatWs()
+    }, 300)
+  } else {
+    console.error('Media error recovery exhausted')
+    mediaRecoveryAttempts = 0
+    isRecovering = false
+    emit('video-error', { type: 'mediaRecoveryFailed', fatal: true })
   }
 }
 
-// 心跳检测WebSocket
+// ==================== 心跳 WebSocket ====================
+
 const createHeartbeatWs = () => {
   try {
     const socketURL = `ws://${wsHost}:${wsHeartbeatPort}/websocket`
@@ -229,7 +284,6 @@ const createHeartbeatWs = () => {
     heartbeatWs.onerror = (error) => {
       console.error('Heartbeat WebSocket error:', error)
     }
-
   } catch (error) {
     console.error('Failed to create heartbeat WebSocket:', error)
   }
@@ -237,22 +291,23 @@ const createHeartbeatWs = () => {
 
 const handleHeartbeatMessage = (event: MessageEvent) => {
   try {
-    let data: any
-
     if (event.data instanceof Blob) {
       const reader = new FileReader()
       reader.readAsText(event.data, 'utf-8')
       reader.onload = () => {
-        data = JSON.parse(reader.result as string)
-        processHeartbeatData(data)
+        try {
+          const data = JSON.parse(reader.result as string)
+          processHeartbeatData(data)
+        } catch (e) {
+          console.error('Failed to parse heartbeat blob:', e)
+        }
       }
     } else if (typeof event.data === 'string') {
       if (event.data.includes('pong')) {
-        // 心跳回复
         startHeartbeat()
         return
       }
-      data = JSON.parse(event.data)
+      const data = JSON.parse(event.data)
       processHeartbeatData(data)
     }
   } catch (error) {
@@ -262,8 +317,7 @@ const handleHeartbeatMessage = (event: MessageEvent) => {
 
 const processHeartbeatData = (data: any) => {
   if (data.serialNum && heartbeatWs?.readyState === WebSocket.OPEN) {
-    const response = { serialNum: data.serialNum }
-    heartbeatWs.send(JSON.stringify(response))
+    heartbeatWs.send(JSON.stringify({ serialNum: data.serialNum }))
   }
 }
 
@@ -297,19 +351,22 @@ const stopHeartbeat = () => {
 const handleHeartbeatFailure = () => {
   heartbeatConfig.retryCount++
   if (heartbeatConfig.retryCount >= heartbeatConfig.maxRetries) {
-    console.log('Heartbeat failed, reconnecting...')
+    console.warn('Heartbeat failed, reconnecting...')
     scheduleReconnect()
   }
 }
 
-// 重连机制
+// ==================== 重连机制 ====================
+
 const scheduleReconnect = () => {
   if (reconnectAttempts >= maxReconnectAttempts) {
     console.error('Max reconnection attempts reached')
+    emit('video-error', { type: 'reconnectFailed', fatal: true })
     return
   }
 
-  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000) // 指数退避,最大10秒
+  // 指数退避,最大 10 秒
+  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
   reconnectAttempts++
 
   if (reconnectTimer) {
@@ -317,28 +374,40 @@ const scheduleReconnect = () => {
   }
 
   reconnectTimer = setTimeout(() => {
-    console.log(`Reconnection attempt ${reconnectAttempts}`)
-    refreshWebSocket()
+    console.log(`Reconnection attempt ${reconnectAttempts}/${maxReconnectAttempts}`)
+    cleanup()
+    setTimeout(() => {
+      createWebSocket()
+      createHeartbeatWs()
+    }, 300)
   }, delay)
 }
 
-// 清理所有连接
-const cleanup = () => {
-  stopHeartbeat()
-
-  if (reconnectTimer) {
-    clearTimeout(reconnectTimer)
-    reconnectTimer = null
-  }
+// ==================== 清理 ====================
 
+const destroyWfs = () => {
   if (wfsObj) {
     try {
+      // 先移除事件监听,防止 destroy 过程中再触发 error 回调
+      wfsObj.off(window.Wfs.Events.ERROR, handleWfsError)
       wfsObj.destroy()
     } catch (error) {
       console.error('Error destroying WFS:', error)
     }
     wfsObj = null
   }
+}
+
+const cleanup = () => {
+  stopHeartbeat()
+
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+
+  destroyWfs()
+  resetVideoElement()
 
   if (heartbeatWs) {
     try {
@@ -350,66 +419,69 @@ const cleanup = () => {
   }
 }
 
-// 刷新连接
+// 手动刷新:重置所有计数
 const refreshWebSocket = () => {
   cleanup()
   reconnectAttempts = 0
+  mediaRecoveryAttempts = 0
+  isRecovering = false
 
   setTimeout(() => {
     createWebSocket()
     createHeartbeatWs()
-  }, 1000)
+  }, 500)
 }
 
-// 页面可见性处理
+// ==================== 页面可见性处理 ====================
+
 const handleVisibilityChange = () => {
   const video = videoElement.value
   if (!video) return
 
   if (document.hidden) {
-    lastTimeStamp.value = Date.now()
-    lastVideoTime.value = video.currentTime
-  } else {
-    const elapsed = (Date.now() - lastTimeStamp.value) / 1000
-    video.currentTime = lastVideoTime.value + elapsed
+    isPageHidden = true
+    lastTimeStamp = Date.now()
+    lastVideoTime = video.currentTime
+  } else if (isPageHidden) {
+    isPageHidden = false
+    const elapsed = (Date.now() - lastTimeStamp) / 1000
+
+    // 如果离开超过 30 秒,直接刷新连接避免 SourceBuffer 积压
+    if (elapsed > 30) {
+      console.log('Page was hidden for too long, refreshing stream...')
+      refreshWebSocket()
+    } else {
+      video.currentTime = lastVideoTime + elapsed
+    }
   }
 }
 
-// 防抖的缩放函数
-const debouncedZoom = debounce(zoom, 16) // 约60fps
+// 防抖缩放(约 60fps)
+const debouncedZoom = debounce(zoom, 16)
+
+// ==================== 生命周期 ====================
 
-// 生命周期
 onMounted(() => {
-  // 添加滚轮事件监听
   if (videoContainer.value) {
     videoContainer.value.addEventListener('wheel', debouncedZoom, { passive: false })
   }
 
-  // 页面可见性监听
   document.addEventListener('visibilitychange', handleVisibilityChange)
-
-  // 页面卸载监听
   window.addEventListener('beforeunload', cleanup)
-  window.addEventListener('unload', cleanup)
 
-  // 初始化连接
   refreshWebSocket()
 })
 
 onUnmounted(() => {
-  // 清理事件监听
   if (videoContainer.value) {
     videoContainer.value.removeEventListener('wheel', debouncedZoom)
   }
   document.removeEventListener('visibilitychange', handleVisibilityChange)
-  window.addEventListener('beforeunload', cleanup)
-  window.addEventListener('unload', cleanup)
+  window.removeEventListener('beforeunload', cleanup)
 
-  // 清理连接
   cleanup()
 })
 
-// 暴露的方法和属性
 defineExpose({
   zoom: debouncedZoom,
   startDrag,
@@ -496,7 +568,6 @@ defineExpose({
   }
 }
 
-// 响应式设计
 @media (max-width: 768px) {
   .video-control {
     height: 40px;
@@ -509,4 +580,4 @@ defineExpose({
     }
   }
 }
-</style>
+</style>