|
@@ -1,583 +0,0 @@
|
|
|
-<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
|
|
|
|
|
- }
|
|
|
|
|
-})
|
|
|
|
|
-
|
|
|
|
|
-// 获取 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()
|
|
|
|
|
-
|
|
|
|
|
- // 断开 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
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- 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)
|
|
|
|
|
- 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>
|
|
|