myVideo.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. <template>
  2. <div class="preview-container">
  3. <div ref="videoContainer" class="video_container">
  4. <video
  5. id="player"
  6. ref="videoElement"
  7. autoplay
  8. muted
  9. oncontextmenu="return false;"
  10. playsinline
  11. poster="../assets/black.jpg"
  12. @mousedown="startDrag"
  13. @mousemove="onDrag"
  14. @mouseup="endDrag"
  15. @mouseleave="endDrag"
  16. />
  17. </div>
  18. </div>
  19. <div class="video-control">
  20. <div class="video-control-left">
  21. <!--刷新键-->
  22. <div class="control-btn pull-left" title="刷新">
  23. <el-icon
  24. class="control-btn"
  25. color="inherit"
  26. @click="refreshWebSocket"
  27. >
  28. <refresh />
  29. </el-icon>
  30. </div>
  31. </div>
  32. </div>
  33. </template>
  34. <script lang="ts" setup>
  35. import { onMounted, onUnmounted, readonly, ref } from 'vue'
  36. import { Refresh } from '@element-plus/icons-vue'
  37. const props = defineProps({
  38. dragFlag: {
  39. type: Boolean,
  40. default: false
  41. },
  42. channel: {
  43. type: String,
  44. default: 'ch1'
  45. }
  46. })
  47. // 获取 WebSocket 服务器地址
  48. const wsHost = import.meta.env.MODE === 'development'
  49. ? import.meta.env.VITE_HOST_IP
  50. : window.location.host
  51. const wsVideoPort = 8000
  52. const wsHeartbeatPort = 8001
  53. const emit = defineEmits(['video-error'])
  54. // Refs
  55. const videoContainer = ref<HTMLElement | null>(null)
  56. const videoElement = ref<HTMLVideoElement | null>(null)
  57. // 视频缩放/拖拽状态
  58. const scale = ref(1)
  59. const position = ref({ x: 0, y: 0 })
  60. let dragState = {
  61. dragging: false,
  62. startX: 0,
  63. startY: 0
  64. }
  65. // WebSocket 状态
  66. let wfsObj: WfsInstance | null = null
  67. let heartbeatWs: WebSocket | null = null
  68. let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
  69. let reconnectTimer: ReturnType<typeof setTimeout> | null = null
  70. let reconnectAttempts = 0
  71. const maxReconnectAttempts = 5
  72. // mediaError 恢复计数(不占用 reconnectAttempts 配额)
  73. let mediaRecoveryAttempts = 0
  74. const maxMediaRecoveryAttempts = 10
  75. // 防止 handleMediaError 被连续触发多次
  76. let isRecovering = false
  77. // 页面可见性状态
  78. let lastTimeStamp = 0
  79. let lastVideoTime = 0
  80. let isPageHidden = false
  81. // ==================== 工具函数 ====================
  82. const debounce = (func: (...args: any[]) => void, wait: number) => {
  83. let timeout: ReturnType<typeof setTimeout>
  84. return (...args: any[]) => {
  85. clearTimeout(timeout)
  86. timeout = setTimeout(() => func(...args), wait)
  87. }
  88. }
  89. // ==================== 缩放和拖拽 ====================
  90. const zoom = (event: WheelEvent) => {
  91. if (!props.dragFlag) return
  92. event.preventDefault()
  93. const zoomSpeed = 0.1
  94. const delta = event.deltaY < 0 ? zoomSpeed : -zoomSpeed
  95. scale.value = Math.max(1, Math.min(3, scale.value + delta))
  96. applyTransform()
  97. }
  98. const startDrag = (event: MouseEvent) => {
  99. if (!props.dragFlag) return
  100. dragState.dragging = true
  101. dragState.startX = event.clientX - position.value.x
  102. dragState.startY = event.clientY - position.value.y
  103. event.preventDefault()
  104. }
  105. const onDrag = (event: MouseEvent) => {
  106. if (!dragState.dragging || !props.dragFlag) return
  107. position.value.x = event.clientX - dragState.startX
  108. position.value.y = event.clientY - dragState.startY
  109. applyTransform()
  110. }
  111. const endDrag = () => {
  112. dragState.dragging = false
  113. }
  114. const applyTransform = () => {
  115. if (!videoElement.value || !videoContainer.value || !props.dragFlag) return
  116. const videoWidth = videoElement.value.offsetWidth * scale.value
  117. const videoHeight = videoElement.value.offsetHeight * scale.value
  118. const containerWidth = videoContainer.value.offsetWidth
  119. const containerHeight = videoContainer.value.offsetHeight
  120. const maxX = Math.max(0, (videoWidth - containerWidth) / 2)
  121. const maxY = Math.max(0, (videoHeight - containerHeight) / 2)
  122. position.value.x = Math.min(maxX, Math.max(-maxX, position.value.x))
  123. position.value.y = Math.min(maxY, Math.max(-maxY, position.value.y))
  124. videoElement.value.style.transform =
  125. `translate(${position.value.x}px, ${position.value.y}px) scale(${scale.value})`
  126. }
  127. // ==================== WFS 视频流管理 ====================
  128. /**
  129. * 彻底重置 video 元素上的 MediaSource,
  130. * 防止 WFS 内部 doAppending 访问残留的 mediaElement.error 导致
  131. * "Cannot read properties of undefined (reading 'error')" 崩溃。
  132. */
  133. const resetVideoElement = () => {
  134. const video = videoElement.value
  135. if (!video) return
  136. // 暂停播放
  137. video.pause()
  138. // 重置缩放和位移状态
  139. scale.value = 1
  140. position.value = { x: 0, y: 0 }
  141. video.style.transform = ''
  142. // 断开 MediaSource 绑定
  143. video.removeAttribute('src')
  144. video.srcObject = null
  145. // 强制浏览器释放旧的 MediaSource / SourceBuffer
  146. video.load()
  147. }
  148. const createWebSocket = () => {
  149. try {
  150. if (!window.Wfs?.isSupported()) {
  151. throw new Error('WFS not supported')
  152. }
  153. const video = videoElement.value
  154. if (!video) {
  155. throw new Error('Video element not found')
  156. }
  157. const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
  158. wfsObj = new window.Wfs({
  159. wsMinPacketInterval: 2000,
  160. wsMaxPacketInterval: 8000
  161. })
  162. // console.log("props.channel",props.channel)
  163. wfsObj.attachMedia(video, props.channel, 'H264Raw', socketURL)
  164. wfsObj.on(window.Wfs.Events.ERROR, handleWfsError)
  165. wfsObj.on(window.Wfs.Events.MANIFEST_PARSED, () => {
  166. // 连接成功,重置所有恢复计数
  167. reconnectAttempts = 0
  168. mediaRecoveryAttempts = 0
  169. isRecovering = false
  170. })
  171. } catch (error) {
  172. console.error('Failed to create WebSocket:', error)
  173. emit('video-error', error)
  174. scheduleReconnect()
  175. }
  176. }
  177. const handleWfsError = (_eventName: string, data: any) => {
  178. console.error('WFS Error:', data)
  179. if (!data.fatal) return
  180. emit('video-error', data)
  181. switch (data.type) {
  182. case window.Wfs.ErrorTypes.MEDIA_ERROR:
  183. handleMediaError()
  184. break
  185. case window.Wfs.ErrorTypes.NETWORK_ERROR:
  186. console.warn('Network error, scheduling reconnect...')
  187. scheduleReconnect()
  188. break
  189. default:
  190. console.error('Unrecoverable error, scheduling reconnect...')
  191. scheduleReconnect()
  192. break
  193. }
  194. }
  195. /**
  196. * mediaError 恢复策略:
  197. *
  198. * error code 3 (MEDIA_ERR_DECODE) 表示浏览器解码失败。
  199. * WFS 内部在连续 3 次 doAppending 检测到 error 后触发 fatal。
  200. *
  201. * 恢复方式:完整重建(销毁 WFS + 重置 video + 重建 WebSocket + 重建心跳)
  202. * 后端会从新的关键帧开始推流,解码器状态干净。
  203. *
  204. * mediaError 的恢复不消耗 reconnectAttempts 配额,
  205. * 因为这不是网络问题,而是解码问题,每次重建都有很大概率成功。
  206. * 给 10 次机会,持续失败才放弃。
  207. */
  208. const handleMediaError = () => {
  209. if (isRecovering) return
  210. isRecovering = true
  211. mediaRecoveryAttempts++
  212. if (mediaRecoveryAttempts <= maxMediaRecoveryAttempts) {
  213. console.warn(
  214. `Media error recovery ${mediaRecoveryAttempts}/${maxMediaRecoveryAttempts}, rebuilding...`
  215. )
  216. cleanup()
  217. setTimeout(() => {
  218. isRecovering = false
  219. createWebSocket()
  220. createHeartbeatWs()
  221. }, 300)
  222. } else {
  223. console.error('Media error recovery exhausted')
  224. mediaRecoveryAttempts = 0
  225. isRecovering = false
  226. emit('video-error', { type: 'mediaRecoveryFailed', fatal: true })
  227. }
  228. }
  229. // ==================== 心跳 WebSocket ====================
  230. const createHeartbeatWs = () => {
  231. try {
  232. const socketURL = `ws://${wsHost}:${wsHeartbeatPort}/websocket`
  233. heartbeatWs = new WebSocket(socketURL)
  234. heartbeatWs.onopen = () => {
  235. console.log('Heartbeat WebSocket connected')
  236. startHeartbeat()
  237. }
  238. heartbeatWs.onmessage = (event: MessageEvent) => {
  239. handleHeartbeatMessage(event)
  240. }
  241. heartbeatWs.onclose = () => {
  242. console.log('Heartbeat WebSocket disconnected')
  243. stopHeartbeat()
  244. }
  245. heartbeatWs.onerror = (error) => {
  246. console.error('Heartbeat WebSocket error:', error)
  247. }
  248. } catch (error) {
  249. console.error('Failed to create heartbeat WebSocket:', error)
  250. }
  251. }
  252. const handleHeartbeatMessage = (event: MessageEvent) => {
  253. try {
  254. if (event.data instanceof Blob) {
  255. const reader = new FileReader()
  256. reader.readAsText(event.data, 'utf-8')
  257. reader.onload = () => {
  258. try {
  259. const data = JSON.parse(reader.result as string)
  260. processHeartbeatData(data)
  261. } catch (e) {
  262. console.error('Failed to parse heartbeat blob:', e)
  263. }
  264. }
  265. } else if (typeof event.data === 'string') {
  266. if (event.data.includes('pong')) {
  267. startHeartbeat()
  268. return
  269. }
  270. const data = JSON.parse(event.data)
  271. processHeartbeatData(data)
  272. }
  273. } catch (error) {
  274. console.error('Failed to parse heartbeat message:', error)
  275. }
  276. }
  277. const processHeartbeatData = (data: any) => {
  278. if (data.serialNum && heartbeatWs?.readyState === WebSocket.OPEN) {
  279. heartbeatWs.send(JSON.stringify({ serialNum: data.serialNum }))
  280. }
  281. }
  282. // 心跳机制
  283. const heartbeatConfig = {
  284. timeout: 1000,
  285. maxRetries: 3,
  286. retryCount: 0
  287. }
  288. const startHeartbeat = () => {
  289. stopHeartbeat()
  290. heartbeatTimer = setTimeout(() => {
  291. if (heartbeatWs?.readyState === WebSocket.OPEN) {
  292. heartbeatWs.send('ping')
  293. heartbeatConfig.retryCount = 0
  294. } else {
  295. handleHeartbeatFailure()
  296. }
  297. }, heartbeatConfig.timeout)
  298. }
  299. const stopHeartbeat = () => {
  300. if (heartbeatTimer) {
  301. clearTimeout(heartbeatTimer)
  302. heartbeatTimer = null
  303. }
  304. }
  305. const handleHeartbeatFailure = () => {
  306. heartbeatConfig.retryCount++
  307. if (heartbeatConfig.retryCount >= heartbeatConfig.maxRetries) {
  308. console.warn('Heartbeat failed, reconnecting...')
  309. scheduleReconnect()
  310. }
  311. }
  312. // ==================== 重连机制 ====================
  313. const scheduleReconnect = () => {
  314. if (reconnectAttempts >= maxReconnectAttempts) {
  315. console.error('Max reconnection attempts reached')
  316. emit('video-error', { type: 'reconnectFailed', fatal: true })
  317. return
  318. }
  319. // 指数退避,最大 10 秒
  320. const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
  321. reconnectAttempts++
  322. if (reconnectTimer) {
  323. clearTimeout(reconnectTimer)
  324. }
  325. reconnectTimer = setTimeout(() => {
  326. console.log(`Reconnection attempt ${reconnectAttempts}/${maxReconnectAttempts}`)
  327. cleanup()
  328. setTimeout(() => {
  329. createWebSocket()
  330. createHeartbeatWs()
  331. }, 300)
  332. }, delay)
  333. }
  334. // ==================== 清理 ====================
  335. const destroyWfs = () => {
  336. if (wfsObj) {
  337. try {
  338. // 先移除事件监听,防止 destroy 过程中再触发 error 回调
  339. wfsObj.off(window.Wfs.Events.ERROR, handleWfsError)
  340. wfsObj.destroy()
  341. } catch (error) {
  342. console.error('Error destroying WFS:', error)
  343. }
  344. wfsObj = null
  345. }
  346. }
  347. const cleanup = () => {
  348. stopHeartbeat()
  349. if (reconnectTimer) {
  350. clearTimeout(reconnectTimer)
  351. reconnectTimer = null
  352. }
  353. destroyWfs()
  354. resetVideoElement()
  355. if (heartbeatWs) {
  356. try {
  357. heartbeatWs.close()
  358. } catch (error) {
  359. console.error('Error closing heartbeat WebSocket:', error)
  360. }
  361. heartbeatWs = null
  362. }
  363. }
  364. // 手动刷新:重置所有计数
  365. const refreshWebSocket = () => {
  366. cleanup()
  367. reconnectAttempts = 0
  368. mediaRecoveryAttempts = 0
  369. isRecovering = false
  370. setTimeout(() => {
  371. createWebSocket()
  372. createHeartbeatWs()
  373. }, 500)
  374. }
  375. // ==================== 页面可见性处理 ====================
  376. const handleVisibilityChange = () => {
  377. const video = videoElement.value
  378. if (!video) return
  379. if (document.hidden) {
  380. isPageHidden = true
  381. lastTimeStamp = Date.now()
  382. lastVideoTime = video.currentTime
  383. } else if (isPageHidden) {
  384. isPageHidden = false
  385. const elapsed = (Date.now() - lastTimeStamp) / 1000
  386. // 如果离开超过 30 秒,直接刷新连接避免 SourceBuffer 积压
  387. if (elapsed > 30) {
  388. console.log('Page was hidden for too long, refreshing stream...')
  389. refreshWebSocket()
  390. } else {
  391. video.currentTime = lastVideoTime + elapsed
  392. }
  393. }
  394. }
  395. // 防抖缩放(约 60fps)
  396. const debouncedZoom = debounce(zoom, 16)
  397. // ==================== 生命周期 ====================
  398. onMounted(() => {
  399. if (videoContainer.value) {
  400. videoContainer.value.addEventListener('wheel', debouncedZoom, { passive: false })
  401. }
  402. document.addEventListener('visibilitychange', handleVisibilityChange)
  403. window.addEventListener('beforeunload', cleanup)
  404. refreshWebSocket()
  405. })
  406. onUnmounted(() => {
  407. if (videoContainer.value) {
  408. videoContainer.value.removeEventListener('wheel', debouncedZoom)
  409. }
  410. document.removeEventListener('visibilitychange', handleVisibilityChange)
  411. window.removeEventListener('beforeunload', cleanup)
  412. cleanup()
  413. })
  414. defineExpose({
  415. zoom: debouncedZoom,
  416. startDrag,
  417. onDrag,
  418. endDrag,
  419. refreshWebSocket,
  420. scale: readonly(scale),
  421. position: readonly(position)
  422. })
  423. </script>
  424. <style lang="scss" scoped>
  425. .preview-container {
  426. position: relative;
  427. width: 100%;
  428. height: 100%;
  429. }
  430. .video_container {
  431. overflow: hidden;
  432. position: relative;
  433. width: 100%;
  434. height: 100%;
  435. background-color: #000;
  436. video {
  437. width: 100%;
  438. height: 100%;
  439. object-fit: contain;
  440. cursor: grab;
  441. &:active {
  442. cursor: grabbing;
  443. }
  444. }
  445. }
  446. .video-control {
  447. position: absolute;
  448. width: 100%;
  449. height: 32px;
  450. line-height: 32px;
  451. background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.3));
  452. left: 0;
  453. right: 0;
  454. bottom: 0;
  455. backdrop-filter: blur(4px);
  456. transition: opacity 0.3s ease;
  457. &:hover {
  458. opacity: 1;
  459. }
  460. .video-control-left {
  461. position: absolute;
  462. left: 8px;
  463. height: 32px;
  464. display: flex;
  465. align-items: center;
  466. }
  467. .control-btn {
  468. display: flex;
  469. align-items: center;
  470. justify-content: center;
  471. width: 24px;
  472. height: 24px;
  473. background: rgba(255, 255, 255, 0.1);
  474. cursor: pointer;
  475. font-size: 16px;
  476. color: white;
  477. border-radius: 4px;
  478. transition: all 0.2s ease;
  479. border: none;
  480. &:hover {
  481. background: rgba(255, 255, 255, 0.2);
  482. transform: scale(1.1);
  483. }
  484. &:active {
  485. transform: scale(0.95);
  486. }
  487. }
  488. }
  489. @media (max-width: 768px) {
  490. .video-control {
  491. height: 40px;
  492. line-height: 40px;
  493. .control-btn {
  494. width: 32px;
  495. height: 32px;
  496. font-size: 18px;
  497. }
  498. }
  499. }
  500. </style>