| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- <template>
- <div class="preview-container">
- <div ref="videoContainer" class="video_container">
- <video
- id="player"
- ref="videoElement"
- autoplay
- muted
- oncontextmenu="return false;"
- playsinline
- poster="../assets/black.jpg"
- @mousedown="startDrag"
- @mousemove="onDrag"
- @mouseup="endDrag"
- @mouseleave="endDrag"
- />
- </div>
- </div>
- <div class="video-control">
- <div class="video-control-left">
- <!--刷新键-->
- <div class="control-btn pull-left" title="刷新">
- <el-icon
- class="control-btn"
- color="inherit"
- @click="refreshWebSocket"
- >
- <refresh />
- </el-icon>
- </div>
- </div>
- </div>
- </template>
- <script lang="ts" setup>
- import { onMounted, onUnmounted, readonly, ref } from 'vue'
- import { Refresh } from '@element-plus/icons-vue'
- const props = defineProps({
- dragFlag: {
- type: Boolean,
- default: false
- },
- channel: {
- type: String,
- default: 'ch1'
- }
- })
- // 获取 WebSocket 服务器地址
- const wsHost = import.meta.env.MODE === 'development'
- ? import.meta.env.VITE_HOST_IP
- : window.location.host
- const wsVideoPort = 8000
- const wsHeartbeatPort = 8001
- 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 = {
- dragging: false,
- startX: 0,
- startY: 0
- }
- // WebSocket 状态
- 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
- // 页面可见性状态
- let lastTimeStamp = 0
- let lastVideoTime = 0
- let isPageHidden = false
- // ==================== 工具函数 ====================
- const debounce = (func: (...args: any[]) => void, wait: number) => {
- let timeout: ReturnType<typeof setTimeout>
- return (...args: any[]) => {
- clearTimeout(timeout)
- 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()
- }
- const endDrag = () => {
- dragState.dragging = false
- }
- const applyTransform = () => {
- if (!videoElement.value || !videoContainer.value || !props.dragFlag) return
- const videoWidth = videoElement.value.offsetWidth * scale.value
- const videoHeight = videoElement.value.offsetHeight * scale.value
- 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})`
- }
- // ==================== 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()
- // 重置缩放和位移状态
- scale.value = 1
- position.value = { x: 0, y: 0 }
- video.style.transform = ''
- // 断开 MediaSource 绑定
- video.removeAttribute('src')
- video.srcObject = null
- // 强制浏览器释放旧的 MediaSource / SourceBuffer
- video.load()
- }
- const createWebSocket = () => {
- try {
- if (!window.Wfs?.isSupported()) {
- throw new Error('WFS not supported')
- }
- const video = videoElement.value
- if (!video) {
- throw new Error('Video element not found')
- }
- const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
- wfsObj = new window.Wfs({
- wsMinPacketInterval: 2000,
- wsMaxPacketInterval: 8000
- })
- // console.log("props.channel",props.channel)
- wfsObj.attachMedia(video, props.channel, '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)
- scheduleReconnect()
- }
- }
- 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
- }
- }
- /**
- * 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 ====================
- const createHeartbeatWs = () => {
- try {
- const socketURL = `ws://${wsHost}:${wsHeartbeatPort}/websocket`
- heartbeatWs = new WebSocket(socketURL)
- heartbeatWs.onopen = () => {
- console.log('Heartbeat WebSocket connected')
- startHeartbeat()
- }
- heartbeatWs.onmessage = (event: MessageEvent) => {
- handleHeartbeatMessage(event)
- }
- heartbeatWs.onclose = () => {
- console.log('Heartbeat WebSocket disconnected')
- stopHeartbeat()
- }
- heartbeatWs.onerror = (error) => {
- console.error('Heartbeat WebSocket error:', error)
- }
- } catch (error) {
- console.error('Failed to create heartbeat WebSocket:', error)
- }
- }
- const handleHeartbeatMessage = (event: MessageEvent) => {
- try {
- if (event.data instanceof Blob) {
- const reader = new FileReader()
- reader.readAsText(event.data, 'utf-8')
- reader.onload = () => {
- 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
- }
- const data = JSON.parse(event.data)
- processHeartbeatData(data)
- }
- } catch (error) {
- console.error('Failed to parse heartbeat message:', error)
- }
- }
- const processHeartbeatData = (data: any) => {
- if (data.serialNum && heartbeatWs?.readyState === WebSocket.OPEN) {
- heartbeatWs.send(JSON.stringify({ serialNum: data.serialNum }))
- }
- }
- // 心跳机制
- const heartbeatConfig = {
- timeout: 1000,
- maxRetries: 3,
- retryCount: 0
- }
- const startHeartbeat = () => {
- stopHeartbeat()
- heartbeatTimer = setTimeout(() => {
- if (heartbeatWs?.readyState === WebSocket.OPEN) {
- heartbeatWs.send('ping')
- heartbeatConfig.retryCount = 0
- } else {
- handleHeartbeatFailure()
- }
- }, heartbeatConfig.timeout)
- }
- const stopHeartbeat = () => {
- if (heartbeatTimer) {
- clearTimeout(heartbeatTimer)
- heartbeatTimer = null
- }
- }
- const handleHeartbeatFailure = () => {
- heartbeatConfig.retryCount++
- if (heartbeatConfig.retryCount >= heartbeatConfig.maxRetries) {
- 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
- }
- // 指数退避,最大 10 秒
- const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
- reconnectAttempts++
- if (reconnectTimer) {
- clearTimeout(reconnectTimer)
- }
- reconnectTimer = setTimeout(() => {
- console.log(`Reconnection attempt ${reconnectAttempts}/${maxReconnectAttempts}`)
- cleanup()
- setTimeout(() => {
- createWebSocket()
- createHeartbeatWs()
- }, 300)
- }, delay)
- }
- // ==================== 清理 ====================
- 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 {
- heartbeatWs.close()
- } catch (error) {
- console.error('Error closing heartbeat WebSocket:', error)
- }
- heartbeatWs = null
- }
- }
- // 手动刷新:重置所有计数
- const refreshWebSocket = () => {
- cleanup()
- reconnectAttempts = 0
- mediaRecoveryAttempts = 0
- isRecovering = false
- setTimeout(() => {
- createWebSocket()
- createHeartbeatWs()
- }, 500)
- }
- // ==================== 页面可见性处理 ====================
- const handleVisibilityChange = () => {
- const video = videoElement.value
- if (!video) return
- if (document.hidden) {
- 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
- }
- }
- }
- // 防抖缩放(约 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)
- refreshWebSocket()
- })
- onUnmounted(() => {
- if (videoContainer.value) {
- videoContainer.value.removeEventListener('wheel', debouncedZoom)
- }
- document.removeEventListener('visibilitychange', handleVisibilityChange)
- window.removeEventListener('beforeunload', cleanup)
- cleanup()
- })
- defineExpose({
- zoom: debouncedZoom,
- startDrag,
- onDrag,
- endDrag,
- refreshWebSocket,
- scale: readonly(scale),
- position: readonly(position)
- })
- </script>
- <style lang="scss" scoped>
- .preview-container {
- position: relative;
- width: 100%;
- height: 100%;
- }
- .video_container {
- overflow: hidden;
- position: relative;
- width: 100%;
- height: 100%;
- background-color: #000;
- video {
- width: 100%;
- height: 100%;
- object-fit: contain;
- cursor: grab;
- &:active {
- cursor: grabbing;
- }
- }
- }
- .video-control {
- position: absolute;
- width: 100%;
- height: 32px;
- line-height: 32px;
- background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.3));
- left: 0;
- right: 0;
- bottom: 0;
- backdrop-filter: blur(4px);
- transition: opacity 0.3s ease;
- &:hover {
- opacity: 1;
- }
- .video-control-left {
- position: absolute;
- left: 8px;
- height: 32px;
- display: flex;
- align-items: center;
- }
- .control-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- background: rgba(255, 255, 255, 0.1);
- cursor: pointer;
- font-size: 16px;
- color: white;
- border-radius: 4px;
- transition: all 0.2s ease;
- border: none;
- &:hover {
- background: rgba(255, 255, 255, 0.2);
- transform: scale(1.1);
- }
- &:active {
- transform: scale(0.95);
- }
- }
- }
- @media (max-width: 768px) {
- .video-control {
- height: 40px;
- line-height: 40px;
- .control-btn {
- width: 32px;
- height: 32px;
- font-size: 18px;
- }
- }
- }
- </style>
|