|
@@ -28,39 +28,36 @@
|
|
|
<refresh />
|
|
<refresh />
|
|
|
</el-icon>
|
|
</el-icon>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
-import { onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
|
|
|
+import { onMounted, onUnmounted, readonly, ref } from 'vue'
|
|
|
import { Refresh } from '@element-plus/icons-vue'
|
|
import { Refresh } from '@element-plus/icons-vue'
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
const props = defineProps({
|
|
|
dragFlag: {
|
|
dragFlag: {
|
|
|
type: Boolean,
|
|
type: Boolean,
|
|
|
- default: false // 是否允许拖拽和缩放
|
|
|
|
|
|
|
+ default: false
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// 获取webSocket服务器IP地址
|
|
|
|
|
|
|
+// 获取 WebSocket 服务器地址
|
|
|
const wsHost = import.meta.env.MODE === 'development'
|
|
const wsHost = import.meta.env.MODE === 'development'
|
|
|
? import.meta.env.VITE_HOST_IP
|
|
? import.meta.env.VITE_HOST_IP
|
|
|
: window.location.host
|
|
: window.location.host
|
|
|
|
|
|
|
|
-// 视频流和心跳流端口
|
|
|
|
|
const wsVideoPort = 8000
|
|
const wsVideoPort = 8000
|
|
|
const wsHeartbeatPort = 8001
|
|
const wsHeartbeatPort = 8001
|
|
|
|
|
|
|
|
-// Emits
|
|
|
|
|
const emit = defineEmits(['video-error'])
|
|
const emit = defineEmits(['video-error'])
|
|
|
|
|
|
|
|
// Refs
|
|
// Refs
|
|
|
const videoContainer = ref<HTMLElement | null>(null)
|
|
const videoContainer = ref<HTMLElement | null>(null)
|
|
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
|
|
|
|
|
|
|
-// 视频控制状态
|
|
|
|
|
|
|
+// 视频缩放/拖拽状态
|
|
|
const scale = ref(1)
|
|
const scale = ref(1)
|
|
|
const position = ref({ x: 0, y: 0 })
|
|
const position = ref({ x: 0, y: 0 })
|
|
|
let dragState = {
|
|
let dragState = {
|
|
@@ -70,56 +67,57 @@ let dragState = {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// WebSocket 状态
|
|
// WebSocket 状态
|
|
|
-let wfsObj: any = null
|
|
|
|
|
|
|
+let wfsObj: WfsInstance | null = null
|
|
|
let heartbeatWs: WebSocket | null = null
|
|
let heartbeatWs: WebSocket | null = null
|
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
let reconnectAttempts = 0
|
|
let reconnectAttempts = 0
|
|
|
const maxReconnectAttempts = 5
|
|
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>
|
|
let timeout: ReturnType<typeof setTimeout>
|
|
|
- return function executedFunction(...args: any[]) {
|
|
|
|
|
- const later = () => {
|
|
|
|
|
- clearTimeout(timeout)
|
|
|
|
|
- func(...args)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return (...args: any[]) => {
|
|
|
clearTimeout(timeout)
|
|
clearTimeout(timeout)
|
|
|
- timeout = setTimeout(later, wait)
|
|
|
|
|
|
|
+ timeout = setTimeout(() => func(...args), wait)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 视频缩放和拖拽功能
|
|
|
|
|
|
|
+// ==================== 缩放和拖拽 ====================
|
|
|
|
|
+
|
|
|
const zoom = (event: WheelEvent) => {
|
|
const zoom = (event: WheelEvent) => {
|
|
|
if (!props.dragFlag) return
|
|
if (!props.dragFlag) return
|
|
|
-
|
|
|
|
|
event.preventDefault()
|
|
event.preventDefault()
|
|
|
|
|
+
|
|
|
const zoomSpeed = 0.1
|
|
const zoomSpeed = 0.1
|
|
|
const delta = event.deltaY < 0 ? zoomSpeed : -zoomSpeed
|
|
const delta = event.deltaY < 0 ? zoomSpeed : -zoomSpeed
|
|
|
-
|
|
|
|
|
scale.value = Math.max(1, Math.min(3, scale.value + delta))
|
|
scale.value = Math.max(1, Math.min(3, scale.value + delta))
|
|
|
applyTransform()
|
|
applyTransform()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const startDrag = (event: MouseEvent) => {
|
|
const startDrag = (event: MouseEvent) => {
|
|
|
if (!props.dragFlag) return
|
|
if (!props.dragFlag) return
|
|
|
-
|
|
|
|
|
dragState.dragging = true
|
|
dragState.dragging = true
|
|
|
dragState.startX = event.clientX - position.value.x
|
|
dragState.startX = event.clientX - position.value.x
|
|
|
dragState.startY = event.clientY - position.value.y
|
|
dragState.startY = event.clientY - position.value.y
|
|
|
-
|
|
|
|
|
- // 防止选中文本
|
|
|
|
|
event.preventDefault()
|
|
event.preventDefault()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const onDrag = (event: MouseEvent) => {
|
|
const onDrag = (event: MouseEvent) => {
|
|
|
if (!dragState.dragging || !props.dragFlag) return
|
|
if (!dragState.dragging || !props.dragFlag) return
|
|
|
-
|
|
|
|
|
position.value.x = event.clientX - dragState.startX
|
|
position.value.x = event.clientX - dragState.startX
|
|
|
position.value.y = event.clientY - dragState.startY
|
|
position.value.y = event.clientY - dragState.startY
|
|
|
applyTransform()
|
|
applyTransform()
|
|
@@ -137,21 +135,39 @@ const applyTransform = () => {
|
|
|
const containerWidth = videoContainer.value.offsetWidth
|
|
const containerWidth = videoContainer.value.offsetWidth
|
|
|
const containerHeight = videoContainer.value.offsetHeight
|
|
const containerHeight = videoContainer.value.offsetHeight
|
|
|
|
|
|
|
|
- // 计算最大移动范围
|
|
|
|
|
const maxX = Math.max(0, (videoWidth - containerWidth) / 2)
|
|
const maxX = Math.max(0, (videoWidth - containerWidth) / 2)
|
|
|
const maxY = Math.max(0, (videoHeight - containerHeight) / 2)
|
|
const maxY = Math.max(0, (videoHeight - containerHeight) / 2)
|
|
|
|
|
|
|
|
- // 限制移动范围
|
|
|
|
|
position.value.x = Math.min(maxX, Math.max(-maxX, position.value.x))
|
|
position.value.x = Math.min(maxX, Math.max(-maxX, position.value.x))
|
|
|
position.value.y = Math.min(maxY, Math.max(-maxY, position.value.y))
|
|
position.value.y = Math.min(maxY, Math.max(-maxY, position.value.y))
|
|
|
|
|
|
|
|
- // 应用变换
|
|
|
|
|
videoElement.value.style.transform =
|
|
videoElement.value.style.transform =
|
|
|
`translate(${position.value.x}px, ${position.value.y}px) scale(${scale.value})`
|
|
`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 {
|
|
try {
|
|
|
if (!window.Wfs?.isSupported()) {
|
|
if (!window.Wfs?.isSupported()) {
|
|
|
throw new Error('WFS not supported')
|
|
throw new Error('WFS not supported')
|
|
@@ -162,21 +178,22 @@ const createWebSocket = async () => {
|
|
|
throw new Error('Video element not found')
|
|
throw new Error('Video element not found')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const config = {
|
|
|
|
|
|
|
+ const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
|
|
|
|
|
+
|
|
|
|
|
+ wfsObj = new window.Wfs({
|
|
|
wsMinPacketInterval: 2000,
|
|
wsMinPacketInterval: 2000,
|
|
|
wsMaxPacketInterval: 8000
|
|
wsMaxPacketInterval: 8000
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const socketURL = `ws://${wsHost}:${wsVideoPort}/websocket`
|
|
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
- wfsObj = new window.Wfs(config)
|
|
|
|
|
wfsObj.attachMedia(video, 'ch1', 'H264Raw', socketURL)
|
|
wfsObj.attachMedia(video, 'ch1', 'H264Raw', socketURL)
|
|
|
|
|
|
|
|
wfsObj.on(window.Wfs.Events.ERROR, handleWfsError)
|
|
wfsObj.on(window.Wfs.Events.ERROR, handleWfsError)
|
|
|
wfsObj.on(window.Wfs.Events.MANIFEST_PARSED, () => {
|
|
wfsObj.on(window.Wfs.Events.MANIFEST_PARSED, () => {
|
|
|
|
|
+ // 连接成功,重置所有恢复计数
|
|
|
reconnectAttempts = 0
|
|
reconnectAttempts = 0
|
|
|
|
|
+ mediaRecoveryAttempts = 0
|
|
|
|
|
+ isRecovering = false
|
|
|
})
|
|
})
|
|
|
-
|
|
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('Failed to create WebSocket:', error)
|
|
console.error('Failed to create WebSocket:', error)
|
|
|
emit('video-error', 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 = () => {
|
|
const createHeartbeatWs = () => {
|
|
|
try {
|
|
try {
|
|
|
const socketURL = `ws://${wsHost}:${wsHeartbeatPort}/websocket`
|
|
const socketURL = `ws://${wsHost}:${wsHeartbeatPort}/websocket`
|
|
@@ -229,7 +284,6 @@ const createHeartbeatWs = () => {
|
|
|
heartbeatWs.onerror = (error) => {
|
|
heartbeatWs.onerror = (error) => {
|
|
|
console.error('Heartbeat WebSocket error:', error)
|
|
console.error('Heartbeat WebSocket error:', error)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('Failed to create heartbeat WebSocket:', error)
|
|
console.error('Failed to create heartbeat WebSocket:', error)
|
|
|
}
|
|
}
|
|
@@ -237,22 +291,23 @@ const createHeartbeatWs = () => {
|
|
|
|
|
|
|
|
const handleHeartbeatMessage = (event: MessageEvent) => {
|
|
const handleHeartbeatMessage = (event: MessageEvent) => {
|
|
|
try {
|
|
try {
|
|
|
- let data: any
|
|
|
|
|
-
|
|
|
|
|
if (event.data instanceof Blob) {
|
|
if (event.data instanceof Blob) {
|
|
|
const reader = new FileReader()
|
|
const reader = new FileReader()
|
|
|
reader.readAsText(event.data, 'utf-8')
|
|
reader.readAsText(event.data, 'utf-8')
|
|
|
reader.onload = () => {
|
|
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') {
|
|
} else if (typeof event.data === 'string') {
|
|
|
if (event.data.includes('pong')) {
|
|
if (event.data.includes('pong')) {
|
|
|
- // 心跳回复
|
|
|
|
|
startHeartbeat()
|
|
startHeartbeat()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- data = JSON.parse(event.data)
|
|
|
|
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
processHeartbeatData(data)
|
|
processHeartbeatData(data)
|
|
|
}
|
|
}
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -262,8 +317,7 @@ const handleHeartbeatMessage = (event: MessageEvent) => {
|
|
|
|
|
|
|
|
const processHeartbeatData = (data: any) => {
|
|
const processHeartbeatData = (data: any) => {
|
|
|
if (data.serialNum && heartbeatWs?.readyState === WebSocket.OPEN) {
|
|
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 = () => {
|
|
const handleHeartbeatFailure = () => {
|
|
|
heartbeatConfig.retryCount++
|
|
heartbeatConfig.retryCount++
|
|
|
if (heartbeatConfig.retryCount >= heartbeatConfig.maxRetries) {
|
|
if (heartbeatConfig.retryCount >= heartbeatConfig.maxRetries) {
|
|
|
- console.log('Heartbeat failed, reconnecting...')
|
|
|
|
|
|
|
+ console.warn('Heartbeat failed, reconnecting...')
|
|
|
scheduleReconnect()
|
|
scheduleReconnect()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 重连机制
|
|
|
|
|
|
|
+// ==================== 重连机制 ====================
|
|
|
|
|
+
|
|
|
const scheduleReconnect = () => {
|
|
const scheduleReconnect = () => {
|
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
|
console.error('Max reconnection attempts reached')
|
|
console.error('Max reconnection attempts reached')
|
|
|
|
|
+ emit('video-error', { type: 'reconnectFailed', fatal: true })
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000) // 指数退避,最大10秒
|
|
|
|
|
|
|
+ // 指数退避,最大 10 秒
|
|
|
|
|
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
|
|
|
reconnectAttempts++
|
|
reconnectAttempts++
|
|
|
|
|
|
|
|
if (reconnectTimer) {
|
|
if (reconnectTimer) {
|
|
@@ -317,28 +374,40 @@ const scheduleReconnect = () => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = setTimeout(() => {
|
|
|
- console.log(`Reconnection attempt ${reconnectAttempts}`)
|
|
|
|
|
- refreshWebSocket()
|
|
|
|
|
|
|
+ console.log(`Reconnection attempt ${reconnectAttempts}/${maxReconnectAttempts}`)
|
|
|
|
|
+ cleanup()
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ createWebSocket()
|
|
|
|
|
+ createHeartbeatWs()
|
|
|
|
|
+ }, 300)
|
|
|
}, delay)
|
|
}, delay)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 清理所有连接
|
|
|
|
|
-const cleanup = () => {
|
|
|
|
|
- stopHeartbeat()
|
|
|
|
|
-
|
|
|
|
|
- if (reconnectTimer) {
|
|
|
|
|
- clearTimeout(reconnectTimer)
|
|
|
|
|
- reconnectTimer = null
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// ==================== 清理 ====================
|
|
|
|
|
|
|
|
|
|
+const destroyWfs = () => {
|
|
|
if (wfsObj) {
|
|
if (wfsObj) {
|
|
|
try {
|
|
try {
|
|
|
|
|
+ // 先移除事件监听,防止 destroy 过程中再触发 error 回调
|
|
|
|
|
+ wfsObj.off(window.Wfs.Events.ERROR, handleWfsError)
|
|
|
wfsObj.destroy()
|
|
wfsObj.destroy()
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('Error destroying WFS:', error)
|
|
console.error('Error destroying WFS:', error)
|
|
|
}
|
|
}
|
|
|
wfsObj = null
|
|
wfsObj = null
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const cleanup = () => {
|
|
|
|
|
+ stopHeartbeat()
|
|
|
|
|
+
|
|
|
|
|
+ if (reconnectTimer) {
|
|
|
|
|
+ clearTimeout(reconnectTimer)
|
|
|
|
|
+ reconnectTimer = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ destroyWfs()
|
|
|
|
|
+ resetVideoElement()
|
|
|
|
|
|
|
|
if (heartbeatWs) {
|
|
if (heartbeatWs) {
|
|
|
try {
|
|
try {
|
|
@@ -350,66 +419,69 @@ const cleanup = () => {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 刷新连接
|
|
|
|
|
|
|
+// 手动刷新:重置所有计数
|
|
|
const refreshWebSocket = () => {
|
|
const refreshWebSocket = () => {
|
|
|
cleanup()
|
|
cleanup()
|
|
|
reconnectAttempts = 0
|
|
reconnectAttempts = 0
|
|
|
|
|
+ mediaRecoveryAttempts = 0
|
|
|
|
|
+ isRecovering = false
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
createWebSocket()
|
|
createWebSocket()
|
|
|
createHeartbeatWs()
|
|
createHeartbeatWs()
|
|
|
- }, 1000)
|
|
|
|
|
|
|
+ }, 500)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 页面可见性处理
|
|
|
|
|
|
|
+// ==================== 页面可见性处理 ====================
|
|
|
|
|
+
|
|
|
const handleVisibilityChange = () => {
|
|
const handleVisibilityChange = () => {
|
|
|
const video = videoElement.value
|
|
const video = videoElement.value
|
|
|
if (!video) return
|
|
if (!video) return
|
|
|
|
|
|
|
|
if (document.hidden) {
|
|
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(() => {
|
|
onMounted(() => {
|
|
|
- // 添加滚轮事件监听
|
|
|
|
|
if (videoContainer.value) {
|
|
if (videoContainer.value) {
|
|
|
videoContainer.value.addEventListener('wheel', debouncedZoom, { passive: false })
|
|
videoContainer.value.addEventListener('wheel', debouncedZoom, { passive: false })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 页面可见性监听
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
|
-
|
|
|
|
|
- // 页面卸载监听
|
|
|
|
|
window.addEventListener('beforeunload', cleanup)
|
|
window.addEventListener('beforeunload', cleanup)
|
|
|
- window.addEventListener('unload', cleanup)
|
|
|
|
|
|
|
|
|
|
- // 初始化连接
|
|
|
|
|
refreshWebSocket()
|
|
refreshWebSocket()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
- // 清理事件监听
|
|
|
|
|
if (videoContainer.value) {
|
|
if (videoContainer.value) {
|
|
|
videoContainer.value.removeEventListener('wheel', debouncedZoom)
|
|
videoContainer.value.removeEventListener('wheel', debouncedZoom)
|
|
|
}
|
|
}
|
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
|
- window.addEventListener('beforeunload', cleanup)
|
|
|
|
|
- window.addEventListener('unload', cleanup)
|
|
|
|
|
|
|
+ window.removeEventListener('beforeunload', cleanup)
|
|
|
|
|
|
|
|
- // 清理连接
|
|
|
|
|
cleanup()
|
|
cleanup()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// 暴露的方法和属性
|
|
|
|
|
defineExpose({
|
|
defineExpose({
|
|
|
zoom: debouncedZoom,
|
|
zoom: debouncedZoom,
|
|
|
startDrag,
|
|
startDrag,
|
|
@@ -496,7 +568,6 @@ defineExpose({
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 响应式设计
|
|
|
|
|
@media (max-width: 768px) {
|
|
@media (max-width: 768px) {
|
|
|
.video-control {
|
|
.video-control {
|
|
|
height: 40px;
|
|
height: 40px;
|
|
@@ -509,4 +580,4 @@ defineExpose({
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-</style>
|
|
|
|
|
|
|
+</style>
|