Browse Source

webrtc直播方案

liujintao 6 days ago
parent
commit
9b57124b64
11 changed files with 1150 additions and 119 deletions
  1. 7 1
      .env.development
  2. 3 3
      .env.production
  3. 2 0
      components.d.ts
  4. 2 0
      env.d.ts
  5. 50 0
      package-lock.json
  6. 1 0
      package.json
  7. 1018 0
      src/components/WebRTCVideo.vue
  8. 10 1
      src/main.ts
  9. 3 2
      src/utils/index.ts
  10. 2 2
      src/views/login/index.vue
  11. 52 110
      src/views/preview/index.vue

+ 7 - 1
.env.development

@@ -5,7 +5,7 @@ VITE_PUBLIC_PATH='/'
 ## 路由模式 hash 或 html5
 VITE_ROUTER_HISTORY='html5'
 
-VITE_HOST_IP = '192.168.32.103'
+VITE_HOST_IP = '192.168.1.103'
 
 VITE_VIDEO_PORT = '8000'
 
@@ -13,3 +13,9 @@ VITE_HEART_BEAT_PORT = '8001'
 
 ## 自动获取ip开关 开:true 关:false
 VITE_AUTOMATIC_IP_FLAG=true
+
+#WebRTC 信令服务器地址
+VITE_WEBRTC_SIGNAL_IP=192.168.1.20
+
+## WebRTC 信令服务器端口
+VITE_WEBRTC_SIGNAL_PORT=8080

+ 3 - 3
.env.production

@@ -1,8 +1,5 @@
 # 生产环境自定义的环境变量(命名必须以 VITE_ 开头)
 
-## 后端接口公共路径
-VITE_BASE_API='http://192.168.0.111:7000'
-
 ## 路由模式 hash 或 html5
 VITE_ROUTER_HISTORY='html5'
 
@@ -15,3 +12,6 @@ VITE_AUTOMATIC_IP_FLAG=true
 VITE_VIDEO_PORT = '8000'
 
 VITE_HEART_BEAT_PORT = '8001'
+
+## WebRTC信令服务器端口
+VITE_WEBRTC_SIGNAL_PORT = '8080'

+ 2 - 0
components.d.ts

@@ -24,9 +24,11 @@ declare module 'vue' {
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
     MyVideo: typeof import('./src/components/myVideo.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    WebRTCVideo: typeof import('./src/components/WebRTCVideo.vue')['default']
   }
   export interface ComponentCustomProperties {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']

+ 2 - 0
env.d.ts

@@ -7,6 +7,8 @@ declare interface ImportMetaEnv {
   readonly VITE_HOST_IP: string
   readonly VITE_VIDEO_PORT: string
   readonly VITE_HEART_BEAT_PORT: string
+  readonly VITE_WEBRTC_SIGNAL_IP: string
+  readonly VITE_WEBRTC_SIGNAL_PORT: string
 }
 
 interface ImportMeta {

+ 50 - 0
package-lock.json

@@ -15,6 +15,7 @@
         "nprogress": "^0.2.0",
         "pinia": "^2.1.7",
         "qrcode.vue": "^3.6.0",
+        "vconsole": "^3.15.1",
         "vue": "^3.3.11",
         "vue-router": "^4.2.5"
       },
@@ -52,6 +53,15 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@ctrl/tinycolor": {
       "version": "3.6.1",
       "resolved": "https://mirrors.huaweicloud.com/repository/npm/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
@@ -1600,6 +1610,29 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/copy-text-to-clipboard": {
+      "version": "3.2.2",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
+      "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/core-js": {
+      "version": "3.49.0",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/core-js/-/core-js-3.49.0.tgz",
+      "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.1.3",
       "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
@@ -2124,6 +2157,11 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/mutation-observer": {
+      "version": "1.0.3",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/mutation-observer/-/mutation-observer-1.0.3.tgz",
+      "integrity": "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA=="
+    },
     "node_modules/nanoid": {
       "version": "3.3.11",
       "resolved": "https://mirrors.huaweicloud.com/repository/npm/nanoid/-/nanoid-3.3.11.tgz",
@@ -2780,6 +2818,18 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
+    "node_modules/vconsole": {
+      "version": "3.15.1",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/vconsole/-/vconsole-3.15.1.tgz",
+      "integrity": "sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "copy-text-to-clipboard": "^3.0.1",
+        "core-js": "^3.11.0",
+        "mutation-observer": "^1.0.3"
+      }
+    },
     "node_modules/vite": {
       "version": "5.4.21",
       "resolved": "https://mirrors.huaweicloud.com/repository/npm/vite/-/vite-5.4.21.tgz",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "nprogress": "^0.2.0",
     "pinia": "^2.1.7",
     "qrcode.vue": "^3.6.0",
+    "vconsole": "^3.15.1",
     "vue": "^3.3.11",
     "vue-router": "^4.2.5"
   },

+ 1018 - 0
src/components/WebRTCVideo.vue

@@ -0,0 +1,1018 @@
+<template>
+  <div class="webrtc-container">
+    <div ref="videoContainerRef" class="webrtc-video-wrapper">
+      <video
+        ref="videoRef"
+        :muted="!speakerEnabled"
+        preload="auto"
+        autoplay
+        playsinline
+        webkit-playsinline
+        x5-video-player-type="h5"
+        x5-video-player-fullscreen="true"
+        poster=""
+        @mousedown="startDrag"
+        @mousemove="onDrag"
+        @mouseup="endDrag"
+        @mouseleave="endDrag"
+      />
+      <div v-if="!isPlaying" class="webrtc-loading">
+        <div class="webrtc-spinner" />
+      </div>
+      <div v-if="showStatus" class="webrtc-status">
+        {{ statusText }}
+      </div>
+    </div>
+    <div class="webrtc-controls">
+      <div class="webrtc-controls-group">
+        <el-tooltip content="刷新" placement="top">
+          <el-icon class="ctrl-btn" @click="reconnect"><Refresh /></el-icon>
+        </el-tooltip>
+        <el-tooltip content="截图" placement="top">
+          <el-icon class="ctrl-btn" @click="captureSnapshot"><Camera /></el-icon>
+        </el-tooltip>
+        <el-tooltip :content="speakerEnabled ? '关闭声音' : '开启声音'" placement="top">
+          <el-icon class="ctrl-btn" @click="toggleSpeaker"><Microphone /></el-icon>
+        </el-tooltip>
+      </div>
+    </div>
+    <canvas ref="canvasRef" style="display:none" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
+import { Refresh, Camera, Microphone } from '@element-plus/icons-vue'
+
+// ======================== Props / Emits ========================
+
+const props = defineProps<{
+  /** 信令服务器设备序列号 */
+  serno: string
+  /** 信令 WebSocket 地址,不传则自动推导 */
+  wsUrl?: string
+  /** ICE 服务器配置 */
+  iceServers?: RTCIceServer[]
+  /** 连接模式 live / playback */
+  mode?: string
+  /** 码流 MainStream / SubStream */
+  source?: string
+  /** 是否启用 DataChannel */
+  enableDataChannel?: boolean
+  /** 是否允许拖拽缩放 */
+  dragFlag?: boolean
+  /** 会话类型,默认 IE */
+  sessionType?: string
+  /** 认证用户名 */
+  user?: string
+  /** 认证密码 */
+  pwd?: string
+}>()
+
+const emit = defineEmits<{
+  (e: 'connected'): void
+  (e: 'disconnected'): void
+  (e: 'error', err: Error | string): void
+  (e: 'datachannel-message', msg: any): void
+}>()
+
+// ======================== Refs ========================
+
+const videoRef = ref<HTMLVideoElement | null>(null)
+const videoContainerRef = ref<HTMLElement | null>(null)
+const canvasRef = ref<HTMLCanvasElement | null>(null)
+
+const isPlaying = ref(false)
+const speakerEnabled = ref(false)
+const micEnabled = ref(false)
+const connectionStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'>('idle')
+
+const showStatus = computed(() =>
+  connectionStatus.value !== 'connected' && connectionStatus.value !== 'idle'
+)
+const statusText = computed(() => {
+  const map: Record<string, string> = {
+    connecting: '正在连接...',
+    disconnected: '已断开,正在重连...',
+    failed: '连接失败'
+  }
+  return map[connectionStatus.value] ?? ''
+})
+
+// ======================== 内部状态 ========================
+
+let ws: WebSocket | null = null
+let pc: RTCPeerConnection | null = null
+let dataChannel: RTCDataChannel | null = null
+let localStream: MediaStream | null = null
+
+let meid = ''
+let sessionId = ''
+let isWsConnecting = false
+let isPcCreated = false
+let startCalled = false
+let isLocalAudioTrack = false
+let isReconnect = false
+let isFailCreate = false
+
+// watchdog
+let watchdogTimer: ReturnType<typeof setInterval> | null = null
+let lastBytesReceived = 0
+let freezeCount = 0
+
+// heartbeat / reconnect
+let heartbeatTimer: ReturnType<typeof setInterval> | null = null
+let heartbeatDelay = 0
+let wsConnectStartTime = 0
+const HEARTBEAT_INTERVAL = 2000
+const WS_CONNECT_TIMEOUT = 3000 // WebSocket 握手超时 3 秒(正常只需几毫秒)
+
+// 拖拽
+const scale = ref(1)
+const position = ref({ x: 0, y: 0 })
+let dragState = { dragging: false, startX: 0, startY: 0 }
+
+// ICE 缓存
+let iceCandidateQueue: any[] = []
+
+// props 默认值
+const sType = computed(() => props.sessionType ?? 'IE')
+const authUser = computed(() => props.user ?? 'admin')
+const authPwd = computed(() => props.pwd ?? 'admin')
+
+// 默认 ICE 配置
+let currentIceConfig: RTCConfiguration = {
+  iceServers: props.iceServers ?? [{ urls: 'stun:stun.l.google.com:19302' }]
+}
+
+// ======================== 工具函数 ========================
+
+/** 连接起始时间,用于计算耗时 */
+let connectStartTs = 0
+
+function log(tag: string, ...args: any[]) {
+  const elapsed = connectStartTs > 0 ? `+${Date.now() - connectStartTs}ms` : ''
+  console.log(`[WebRTC][${tag}]${elapsed}`, ...args)
+}
+
+function newGuid(): string {
+  const s4 = () => (65536 * (1 + Math.random()) | 0).toString(16).substring(1)
+  return `${s4()}${s4()}-${s4()}-4${s4().substring(0, 3)}-${s4()}-${s4()}${s4()}${s4()}`.toUpperCase()
+}
+
+function getWsUrl(): string {
+  if (props.wsUrl) return props.wsUrl
+  const isHttps = location.protocol === 'https:'
+  const host = location.host
+  const isDev = import.meta.env.MODE === 'development'
+  const targetHost = isDev ? (import.meta.env.VITE_WEBRTC_SIGNAL_IP) : host.split(':')[0]
+  const signalPort = import.meta.env.VITE_WEBRTC_SIGNAL_PORT ?? '80'
+  const scheme = isHttps ? 'wss' : 'ws'
+  return `${scheme}://${targetHost}:${signalPort}/wswebclient/${meid}`
+}
+
+function sendToServer(message: Record<string, any>) {
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    ws.send(JSON.stringify(message))
+  }
+}
+
+// ======================== getUserMedia ========================
+
+/** 获取本地媒体,仅在需要时调用(如开启麦克风对讲) */
+async function acquireLocalMedia(): Promise<boolean> {
+  if (localStream) return true
+  try {
+    localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+    localStream.getTracks().forEach(track => {
+      if (track.kind === 'audio') {
+        isLocalAudioTrack = true
+        track.enabled = micEnabled.value
+      }
+    })
+    // 如果 PeerConnection 已存在,动态添加音频轨道
+    if (pc && localStream) {
+      localStream.getTracks().forEach(track => {
+        pc!.addTrack(track, localStream!)
+      })
+    }
+    console.log('[WebRTC] local media acquired')
+    return true
+  } catch {
+    localStream = null
+    isLocalAudioTrack = false
+    console.log('[WebRTC] no local media available')
+    return false
+  }
+}
+
+// ======================== WebSocket 信令 ========================
+
+function initWebSocket() {
+  if (isWsConnecting) return
+  isWsConnecting = true
+  connectStartTs = Date.now()
+
+  const url = getWsUrl()
+  log('ws', 'connecting...', url)
+
+  if (ws) {
+    try { ws.close() } catch { /* */ }
+    ws = null
+  }
+
+  ws = new WebSocket(url)
+
+  ws.onopen = () => {
+    log('ws', 'opened')
+    isWsConnecting = false
+    wsConnectStartTime = 0
+    // 重置状态,确保新连接能正常发起 call
+    startCalled = false
+    isReconnect = false
+    sendConnect()
+  }
+
+  ws.onmessage = (e: MessageEvent) => {
+    const data = JSON.parse(e.data)
+    if (data.eventName !== '_ping') {
+      log('msg', data.eventName)
+    }
+    switch (data.eventName) {
+      case '_create': handleCreate(data.data); break
+      case '_call': handleCall(data.data); break
+      case '_offer': handleOffer(data.data); break
+      case '_answer': handleAnswer(data.data); break
+      case '_ice_candidate': handleCandidate(data.data); break
+      case '_session_disconnected': handleDisconnect(data.data); break
+      case '_post_message': handlePostMessage(data.data); break
+      case '_connectinfo': console.log('[WebRTC] ConnectInfo:', data.data.message); break
+      case '_session_failed': handleSessionFailed(data.data); break
+      case '_ping': break
+      default: console.log('[WebRTC] Unknown message', data); break
+    }
+  }
+
+  ws.onerror = () => {
+    log('ws', 'error')
+    isWsConnecting = false
+    wsConnectStartTime = 0
+    ws = null
+  }
+
+  ws.onclose = () => {
+    log('ws', 'closed')
+    isWsConnecting = false
+    wsConnectStartTime = 0
+    ws = null
+  }
+
+  // 记录连接开始时间,让心跳来判断是否超时
+  wsConnectStartTime = Date.now()
+}
+
+function sendConnect() {
+  log('signal', 'sendConnect to', props.serno)
+  sendToServer({
+    eventName: '__connectto',
+    data: {
+      sessionId, sessionType: sType.value,
+      messageId: newGuid(), from: meid, to: props.serno
+    }
+  })
+}
+
+function sendDisconnect() {
+  sendToServer({
+    eventName: '__disconnected',
+    data: {
+      sessionId, sessionType: sType.value,
+      messageId: newGuid(), from: meid, to: props.serno
+    }
+  })
+}
+
+// ======================== 信令处理 ========================
+
+function handleCreate(data: any) {
+  log('signal', '_create state:', data.state)
+  if (data.state !== 'online' && data.state !== 'sleep') {
+    console.log('[WebRTC] device offline', data.from)
+    return
+  }
+  // 更新 ICE 配置
+  if (data.iceServers) {
+    const servers = typeof data.iceServers === 'string'
+      ? JSON.parse(data.iceServers)
+      : JSON.parse(JSON.stringify(data.iceServers))
+    currentIceConfig = servers
+  }
+  if (!startCalled) {
+    callDevice()
+  }
+}
+
+function callDevice() {
+  log('signal', 'callDevice')
+  startCalled = true
+  connectionStatus.value = 'connecting'
+  // 监控场景默认 recvonly,有本地媒体时才 sendrecv
+  const audio = isLocalAudioTrack ? 'sendrecv' : 'recvonly'
+  const video = 'recvonly'
+  const dc = props.enableDataChannel !== false ? 'true' : 'false'
+
+  sendToServer({
+    eventName: '__call',
+    data: {
+      sessionId,
+      sessionType: sType.value,
+      messageId: newGuid(),
+      from: meid,
+      to: props.serno,
+      mode: props.mode ?? 'live',
+      source: props.source ?? 'MainStream',
+      datachannel: dc, audio, video,
+      user: authUser.value,
+      pwd: authPwd.value,
+      iceservers: JSON.stringify(currentIceConfig)
+    }
+  })
+}
+
+async function handleCall(data: any) {
+  log('signal', '_call received, creating offer')
+  // 服务端要求我方创建 offer
+  if (data.iceServers) {
+    currentIceConfig = typeof data.iceServers === 'string'
+      ? JSON.parse(data.iceServers)
+      : JSON.parse(JSON.stringify(data.iceServers))
+  }
+  isReconnect = false
+  if (!isPcCreated) initPeerConnection()
+  if (!isPcCreated) { sendDisconnect(); isFailCreate = true; return }
+
+  try {
+    const offer = await pc!.createOffer()
+    await pc!.setLocalDescription(offer)
+    sendToServer({
+      eventName: '__offer',
+      data: {
+        sessionId, sessionType: sType.value,
+        messageId: newGuid(), from: meid, to: props.serno,
+        type: offer.type, sdp: offer.sdp,
+        iceservers: JSON.stringify(currentIceConfig)
+      }
+    })
+  } catch (err: any) {
+    console.error('[WebRTC] createOffer error', err.message)
+    sendDisconnect()
+    isReconnect = true
+    isFailCreate = true
+  }
+}
+
+async function handleOffer(data: any) {
+  log('signal', '_offer received, creating answer')
+  isReconnect = false
+  if (!isPcCreated) initPeerConnection()
+  if (!isPcCreated) { sendDisconnect(); isFailCreate = true; return }
+
+  try {
+    await pc!.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: data.sdp }))
+    const answer = await pc!.createAnswer()
+    await pc!.setLocalDescription(answer)
+    sendToServer({
+      eventName: '__answer',
+      data: {
+        sessionId, sessionType: sType.value,
+        messageId: newGuid(), from: meid, to: props.serno,
+        type: answer.type, sdp: answer.sdp
+      }
+    })
+  } catch (err: any) {
+    console.error('[WebRTC] handleOffer error', err.message)
+    isReconnect = true
+    isFailCreate = true
+  }
+}
+
+async function handleAnswer(data: any) {
+  log('signal', '_answer received')
+  if (!pc) return
+  try {
+    await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: data.sdp }))
+  } catch (err: any) {
+    console.error('[WebRTC] setRemoteDescription error', err.message)
+  }
+}
+
+function handleCandidate(data: any) {
+  const obj = JSON.parse(data.candidate)
+  if (pc && isPcCreated) {
+    pc.addIceCandidate(new RTCIceCandidate({
+      sdpMLineIndex: obj.sdpMLineIndex,
+      candidate: obj.candidate
+    }))
+  } else {
+    iceCandidateQueue.push(obj)
+  }
+}
+
+function handleDisconnect(data: any) {
+  if (data.sessionId === sessionId) {
+    if (isPcCreated) handleLeave()
+  }
+}
+
+function handleSessionFailed(data: any) {
+  log('signal', '_session_failed')
+  if (data.sessionId === sessionId) {
+    if (isPcCreated) { handleLeave(); sendDisconnect() }
+    // 立即用新 session 重试
+    sessionId = newGuid()
+    startCalled = false
+    if (ws && ws.readyState === WebSocket.OPEN) {
+      sendConnect()
+    }
+  }
+}
+
+function handlePostMessage(data: any) {
+  emit('datachannel-message', data.message)
+}
+
+// ======================== PeerConnection ========================
+
+function initPeerConnection() {
+  try {
+    pc = new RTCPeerConnection(currentIceConfig)
+
+    // 本地轨道按需添加(用户开启麦克风时通过 acquireLocalMedia 动态添加)
+
+    // 远端轨道
+    pc.ontrack = (e: RTCTrackEvent) => {
+      log('track', 'remote track received, kind:', e.track.kind)
+      if (!videoRef.value) return
+      e.streams[0].getTracks().forEach(track => {
+        if (track.kind === 'audio') track.enabled = speakerEnabled.value
+        // 监听 track unmute 自动恢复播放
+        track.onunmute = () => {
+          if (track.kind === 'video') {
+            isPlaying.value = false
+            tryPlay()
+          }
+        }
+      })
+      videoRef.value.srcObject = e.streams[0]
+    }
+
+    // DataChannel
+    pc.ondatachannel = (ev) => {
+      const ch = ev.channel
+      ch.onmessage = (event) => emit('datachannel-message', event.data)
+    }
+
+    if (props.enableDataChannel !== false) {
+      try {
+        dataChannel = pc.createDataChannel('mydatachannel')
+        dataChannel.onopen = () => console.log('[WebRTC] DataChannel open')
+        dataChannel.onclose = () => console.log('[WebRTC] DataChannel close')
+        dataChannel.onmessage = (msg) => emit('datachannel-message', msg.data)
+      } catch { /* ignore */ }
+    }
+
+    // ICE
+    pc.onicecandidate = (event) => {
+      if (!event.candidate) return
+      sendToServer({
+        eventName: '__ice_candidate',
+        data: {
+          sessionId, sessionType: sType.value,
+          messageId: newGuid(), to: props.serno, from: meid,
+          candidate: JSON.stringify({
+            candidate: event.candidate.candidate,
+            sdpMid: event.candidate.sdpMid,
+            sdpMLineIndex: event.candidate.sdpMLineIndex
+          })
+        }
+      })
+    }
+
+    pc.oniceconnectionstatechange = () => {
+      if (!pc) return
+      log('ice', pc.iceConnectionState)
+      switch (pc.iceConnectionState) {
+        case 'connected':
+        case 'completed':
+          connectionStatus.value = 'connected'
+          emit('connected')
+          tryPlay()
+          break
+        case 'failed':
+        case 'disconnected':
+          connectionStatus.value = 'disconnected'
+          if (isPcCreated) { handleLeave(); sendDisconnect() }
+          emit('disconnected')
+          break
+        case 'closed':
+          releasePc()
+          break
+      }
+    }
+
+    pc.onicegatheringstatechange = () => {
+      if (pc) console.log('[WebRTC] ICE gathering:', pc.iceGatheringState)
+    }
+
+    pc.onsignalingstatechange = () => {
+      if (pc?.signalingState === 'closed') releasePc()
+    }
+
+    isPcCreated = true
+
+    // 处理缓存的 ICE candidate
+    iceCandidateQueue.forEach(obj => {
+      pc!.addIceCandidate(new RTCIceCandidate({
+        sdpMLineIndex: obj.sdpMLineIndex,
+        candidate: obj.candidate
+      }))
+    })
+    iceCandidateQueue = []
+
+    log('pc', 'PeerConnection created')
+  } catch (err: any) {
+    console.error('[WebRTC] PeerConnection create failed', err.message)
+    isPcCreated = false
+  }
+}
+
+function handleLeave() {
+  if (dataChannel) {
+    dataChannel.close()
+    dataChannel.onopen = null
+    dataChannel.onclose = null
+    dataChannel.onmessage = null
+    dataChannel = null
+  }
+  if (pc) {
+    // 先移除所有事件监听,防止 close() 触发回调导致状态混乱
+    pc.onicecandidate = null
+    pc.ontrack = null
+    pc.oniceconnectionstatechange = null
+    pc.onicegatheringstatechange = null
+    pc.onsignalingstatechange = null
+    pc.getSenders().forEach(s => { try { pc!.removeTrack(s) } catch { /* */ } })
+    try { pc.close() } catch { /* */ }
+    pc = null
+  }
+  isPlaying.value = false
+  isPcCreated = false
+  startCalled = false
+  freezeCount = 0
+  lastBytesReceived = 0
+  iceCandidateQueue = []
+}
+
+function releasePc() {
+  log('pc', 'released by ICE event, will reconnect')
+  if (pc) {
+    pc.onicecandidate = null
+    pc.ontrack = null
+    pc.oniceconnectionstatechange = null
+    pc.onicegatheringstatechange = null
+    pc.onsignalingstatechange = null
+    try { pc.close() } catch { /* */ }
+    pc = null
+  }
+  isPcCreated = false
+  // ws 还活着就立即重连
+  if (!isFailCreate) {
+    startCalled = false
+    sessionId = newGuid()
+    if (ws && ws.readyState === WebSocket.OPEN) {
+      sendConnect()
+    } else {
+      isReconnect = true
+    }
+  }
+}
+
+// ======================== 播放控制 ========================
+
+function tryPlay() {
+  const video = videoRef.value
+  if (!video) return
+  if (video.paused || video.ended || !isPlaying.value) {
+    log('play', 'attempting video.play()')
+    isPlaying.value = true
+    const p = video.play()
+    if (p) {
+      p.then(() => { isPlaying.value = true; log('play', 'playing!') })
+       .catch(() => { isPlaying.value = false })
+    }
+  }
+}
+
+// ======================== Watchdog ========================
+
+function startWatchdog() {
+  stopWatchdog()
+  watchdogTimer = setInterval(() => {
+    if (!pc || !isPcCreated || !isPlaying.value) {
+      freezeCount = 0
+      lastBytesReceived = 0
+      return
+    }
+    // 检查 video 是否暂停
+    if (videoRef.value && (videoRef.value.paused || videoRef.value.ended)) {
+      console.log('[watchdog] video paused, forcing play')
+      isPlaying.value = false
+      tryPlay()
+      return
+    }
+    // 通过 getStats 检测数据流
+    pc.getStats(null).then(stats => {
+      let currentBytes = 0
+      stats.forEach(report => {
+        if (report.type === 'inbound-rtp' && report.kind === 'video') {
+          currentBytes = report.bytesReceived ?? 0
+        }
+      })
+      if (lastBytesReceived > 0 && currentBytes <= lastBytesReceived) {
+        freezeCount++
+        console.log(`[watchdog] no new data, freeze count: ${freezeCount}`)
+        if (freezeCount >= 3) {
+          console.log('[watchdog] frozen ~9s, ICE restart...')
+          freezeCount = 0
+          requestIceRestart()
+        }
+      } else {
+        freezeCount = 0
+      }
+      lastBytesReceived = currentBytes
+    }).catch(() => {})
+  }, 3000)
+}
+
+function stopWatchdog() {
+  if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null }
+}
+
+async function requestIceRestart() {
+  if (!pc || !isPcCreated) return
+  try {
+    const offer = await pc.createOffer({ iceRestart: true })
+    await pc.setLocalDescription(offer)
+    sendToServer({
+      eventName: '__offer',
+      data: {
+        sessionId, sessionType: sType.value,
+        messageId: newGuid(), from: meid, to: props.serno,
+        type: offer.type, sdp: offer.sdp,
+        iceservers: JSON.stringify(currentIceConfig)
+      }
+    })
+  } catch {
+    // ICE restart 失败,完全重连
+    handleLeave()
+    sendDisconnect()
+    isReconnect = true
+  }
+}
+
+// ======================== 心跳 / 重连定时器 ========================
+
+function startHeartbeat() {
+  stopHeartbeat()
+  heartbeatDelay = 0
+  heartbeatTimer = setInterval(() => {
+    heartbeatDelay++
+    if (ws && ws.readyState === WebSocket.OPEN) {
+      if (isReconnect) {
+        console.log('[WebRTC] reconnecting...')
+        if (isPcCreated) handleLeave()
+        startCalled = false
+        sessionId = newGuid()
+        sendConnect()
+        isReconnect = false
+      } else if (heartbeatDelay > 15) {
+        heartbeatDelay = 0
+        sendToServer({
+          eventName: '__ping',
+          data: { sessionId, sessionType: sType.value, messageId: newGuid(), from: meid, to: props.serno }
+        })
+      }
+    } else if (ws && ws.readyState === WebSocket.CONNECTING) {
+      // 还在握手中,只有超过 15 秒才放弃
+      if (wsConnectStartTime > 0 && Date.now() - wsConnectStartTime > WS_CONNECT_TIMEOUT) {
+        log('ws', 'connect timeout, rebuilding...')
+        try { ws.close() } catch { /* */ }
+        ws = null
+        isWsConnecting = false
+        wsConnectStartTime = 0
+        // 短暂延迟后重建,避免浏览器/服务器还没释放资源
+        setTimeout(() => {
+          meid = newGuid()
+          initWebSocket()
+        }, 500)
+      }
+    } else {
+      // ws 已断开或不存在,重建
+      if (!isWsConnecting) {
+        if (ws) { try { ws.close() } catch { /* */ }; ws = null }
+        if (isPcCreated) handleLeave()
+        meid = newGuid()
+        initWebSocket()
+      }
+    }
+  }, HEARTBEAT_INTERVAL)
+}
+
+function stopHeartbeat() {
+  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null }
+}
+
+// ======================== 扬声器 / 麦克风 ========================
+
+function toggleSpeaker() {
+  speakerEnabled.value = !speakerEnabled.value
+  if (videoRef.value) videoRef.value.muted = !speakerEnabled.value
+  if (pc) {
+    pc.getReceivers().forEach(r => {
+      if (r.track?.kind === 'audio') r.track.enabled = speakerEnabled.value
+    })
+  }
+}
+
+async function toggleMic() {
+  micEnabled.value = !micEnabled.value
+  if (micEnabled.value && !localStream) {
+    // 首次开启麦克风,按需获取本地媒体
+    await acquireLocalMedia()
+  }
+  if (pc) {
+    pc.getSenders().forEach(s => {
+      if (s.track?.kind === 'audio') s.track.enabled = micEnabled.value
+    })
+  }
+}
+
+// ======================== 截图 ========================
+
+function captureSnapshot() {
+  if (!isPlaying.value || !videoRef.value || !canvasRef.value) return
+  const video = videoRef.value
+  const canvas = canvasRef.value
+  canvas.width = video.videoWidth
+  canvas.height = video.videoHeight
+  const ctx = canvas.getContext('2d')!
+  ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
+  const dataUrl = canvas.toDataURL('image/png')
+  // 自动下载
+  const link = document.createElement('a')
+  link.href = dataUrl
+  link.download = `snapshot_${Date.now()}.png`
+  link.click()
+}
+
+// ======================== 拖拽缩放 ========================
+
+function onWheel(event: WheelEvent) {
+  if (!props.dragFlag) return
+  event.preventDefault()
+  const delta = event.deltaY < 0 ? 0.1 : -0.1
+  scale.value = Math.max(1, Math.min(3, scale.value + delta))
+  applyTransform()
+}
+
+function 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()
+}
+
+function onDrag(event: MouseEvent) {
+  if (!dragState.dragging || !props.dragFlag) return
+  position.value.x = event.clientX - dragState.startX
+  position.value.y = event.clientY - dragState.startY
+  applyTransform()
+}
+
+function endDrag() {
+  dragState.dragging = false
+}
+
+function applyTransform() {
+  const video = videoRef.value
+  const container = videoContainerRef.value
+  if (!video || !container || !props.dragFlag) return
+  const vw = video.offsetWidth * scale.value
+  const vh = video.offsetHeight * scale.value
+  const cw = container.offsetWidth
+  const ch = container.offsetHeight
+  const maxX = Math.max(0, (vw - cw) / 2)
+  const maxY = Math.max(0, (vh - ch) / 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))
+  video.style.transform = `translate(${position.value.x}px, ${position.value.y}px) scale(${scale.value})`
+}
+
+// ======================== 公开方法 ========================
+
+/** 手动重连 */
+function reconnect() {
+  log('action', 'reconnect requested')
+  // 通知服务器断开旧 session
+  sendDisconnect()
+  // 清理本地资源(handleLeave 已经不会触发异步回调了)
+  stopWatchdog()
+  stopHeartbeat()
+  if (isPcCreated) handleLeave()
+  if (ws) { try { ws.close() } catch { /* */ }; ws = null }
+  isWsConnecting = false
+  isReconnect = false
+  isFailCreate = false
+
+  sessionId = newGuid()
+  meid = newGuid()
+  connectionStatus.value = 'connecting'
+  initWebSocket()
+  startHeartbeat()
+  startWatchdog()
+}
+
+/** 发送 DataChannel 消息 */
+function sendDataChannelMessage(msg: string | Record<string, any>) {
+  if (dataChannel?.readyState === 'open') {
+    dataChannel.send(typeof msg === 'string' ? msg : JSON.stringify(msg))
+  }
+}
+
+function cleanup() {
+  stopWatchdog()
+  stopHeartbeat()
+  if (isPcCreated) handleLeave()
+  if (ws) { try { ws.close() } catch { /* */ }; ws = null }
+  isWsConnecting = false
+  startCalled = false
+  connectionStatus.value = 'idle'
+}
+
+// ======================== 生命周期 ========================
+
+onMounted(() => {
+  sessionId = newGuid()
+  meid = newGuid()
+
+  if (videoContainerRef.value) {
+    videoContainerRef.value.addEventListener('wheel', onWheel, { passive: false })
+  }
+
+  // 直接建立信令连接,不请求本地媒体(监控场景默认 recvonly)
+  // 本地媒体仅在用户开启麦克风时按需获取
+  initWebSocket()
+  startHeartbeat()
+  startWatchdog()
+})
+
+onUnmounted(() => {
+  if (videoContainerRef.value) {
+    videoContainerRef.value.removeEventListener('wheel', onWheel)
+  }
+  // 通知服务器断开,让服务器立即清理 session
+  sendDisconnect()
+  cleanup()
+  // 释放本地媒体
+  if (localStream) {
+    localStream.getTracks().forEach(t => t.stop())
+    localStream = null
+  }
+})
+
+defineExpose({
+  reconnect,
+  captureSnapshot,
+  sendDataChannelMessage,
+  toggleSpeaker,
+  toggleMic,
+  isPlaying: readonly(isPlaying),
+  speakerEnabled: readonly(speakerEnabled),
+  micEnabled: readonly(micEnabled),
+  connectionStatus: readonly(connectionStatus)
+})
+</script>
+
+<style lang="scss" scoped>
+.webrtc-container {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+
+.webrtc-video-wrapper {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background-color: #000;
+
+  video {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    cursor: grab;
+    &:active { cursor: grabbing; }
+  }
+}
+
+.webrtc-loading {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 5;
+}
+
+.webrtc-spinner {
+  width: 44px;
+  height: 44px;
+  border-radius: 50%;
+  border: 3px solid transparent;
+  border-top-color: #ffea29;
+  animation: spin 1s linear infinite;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 4px; left: 4px; right: 4px; bottom: 4px;
+    border-radius: 50%;
+    border: 3px solid transparent;
+    border-top-color: #8d29ff;
+    animation: spin 2s linear infinite reverse;
+  }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.webrtc-status {
+  position: absolute;
+  bottom: 48px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(0, 0, 0, 0.7);
+  color: #fff;
+  padding: 6px 16px;
+  border-radius: 4px;
+  font-size: 13px;
+  z-index: 5;
+  white-space: nowrap;
+}
+
+.webrtc-controls {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 36px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 8px;
+  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
+  opacity: 0;
+  transition: opacity 0.25s ease;
+  z-index: 10;
+
+  .webrtc-container:hover & {
+    opacity: 1;
+  }
+}
+
+.webrtc-controls-group {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.ctrl-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  color: #fff;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 16px;
+  transition: background 0.2s;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.25);
+  }
+}
+</style>

+ 10 - 1
src/main.ts

@@ -4,11 +4,18 @@ import App from './App.vue'
 import router, { setRouter } from './router'
 import '@/router/permission'
 
-import { View, Setting, User, Lock, Expand, Fold, Refresh } from '@element-plus/icons-vue'
+import { View, Setting, User, Lock, Expand, Fold, Refresh, Camera, Microphone } from '@element-plus/icons-vue'
 
 import './assets/main.css'
 import 'element-plus/dist/index.css'
 
+// 开发环境启用 vConsole 方便调试
+if (import.meta.env.MODE === 'development') {
+  import('vconsole').then(({ default: VConsole }) => {
+    new VConsole()
+  })
+}
+
 const app = createApp(App)
 
 // 注册需要的图标组件
@@ -19,6 +26,8 @@ app.component('Lock', Lock)
 app.component('Expand', Expand)
 app.component('Fold', Fold)
 app.component('Refresh', Refresh)
+app.component('Camera', Camera)
+app.component('Microphone', Microphone)
 
 app.use(createPinia())
 setRouter()

+ 3 - 2
src/utils/index.ts

@@ -4,14 +4,15 @@ export const getNetworkIp = (protocol: string, port?: number) => {
     if (protocol === 'ws') {
       return import.meta.env.VITE_WEBSOCKET_IP
     }
-    return import.meta.env.VITE_BASE_API
+    return null
+    // return import.meta.env.VITE_BASE_API
   } else {
     let needHost: string = '' // 打开的 host
     try {
       // 使用 window.location 获取客户端的 IP 地址
       needHost = window.location.hostname
     } catch (e) {
-      needHost = '192.168.1.130/'
+      needHost = 'localhost/'
     }
     //本地环境判断
     const devFlag = import.meta.env.DEV

+ 2 - 2
src/views/login/index.vue

@@ -32,7 +32,7 @@
               type="password"
             />
             <div class="forget-password">
-              <el-link type="primary" @click="dialogVisible = true" :underline="false">Forgot Password</el-link>
+              <el-link type="primary" @click="dialogVisible = true" underline="never">Forgot Password</el-link>
             </div>
           </el-form-item>
           <el-button
@@ -145,7 +145,7 @@ const handleDialogClose = () => {
 
 async function getDeviceInfo(){
   const res = await getCameraDeviceInfo()
-  devicePassword.value = res.data.MacAddr.replace(/:/g, '')
+  devicePassword.value = res.data.MacAddr?.replace(/:/g, '') ?? ''
 }
 
 onMounted(() => {

+ 52 - 110
src/views/preview/index.vue

@@ -2,14 +2,17 @@
   <div
     ref="containerRef"
     class="video-container"
-    :class="{ 'is-fullscreen': isFullscreen }"
   >
     <div class="video-wrapper">
-      <MyVideo
-        ref="videoRef"
+      <WebRTCVideo
+        ref="webrtcRef"
+        :serno="webrtcSerno"
         :drag-flag="true"
-        @video-error="handleVideoError"
+        @connected="onWebRTCConnected"
+        @disconnected="onWebRTCDisconnected"
+        @error="handleVideoError"
       />
+
       <!-- 全屏按钮 -->
       <button
         class="fullscreen-btn"
@@ -38,36 +41,43 @@
 </template>
 
 <script setup lang="ts">
-import {ref, onMounted, onUnmounted} from 'vue'
-import MyVideo from '@/components/myVideo.vue'
+import { ref, onMounted, onUnmounted } from 'vue'
+import WebRTCVideo from '@/components/WebRTCVideo.vue'
 
-const videoRef = ref()
+const webrtcRef = ref()
 const isFullscreen = ref(false)
 const containerRef = ref<HTMLElement>()
 
-// 全屏切换
+// 设备序列号,可通过 URL 参数 ?serno=xxx 传入
+const webrtcSerno = ref('ANSJ-00-I7N3-QINU-00000027')
+
+function initFromUrl() {
+  const params = new URLSearchParams(window.location.search)
+  const serno = params.get('serno')
+  if (serno) webrtcSerno.value = serno
+}
+
+function onWebRTCConnected() {
+  console.log('WebRTC 已连接')
+}
+
+function onWebRTCDisconnected() {
+  console.log('WebRTC 已断开')
+}
+
 const toggleFullscreen = () => {
   if (!containerRef.value) return
-
   if (!isFullscreen.value) {
-    // 进入全屏
-    if (containerRef.value.requestFullscreen) {
-      containerRef.value.requestFullscreen()
-    }
+    containerRef.value.requestFullscreen?.()
   } else {
-    // 退出全屏
-    if (document.exitFullscreen) {
-      document.exitFullscreen()
-    }
+    document.exitFullscreen?.()
   }
 }
 
-// 监听全屏状态变化
 const handleFullscreenChange = () => {
   isFullscreen.value = !!document.fullscreenElement
 }
 
-// 键盘快捷键
 const handleKeydown = (event: KeyboardEvent) => {
   switch (event.code) {
     case 'KeyF':
@@ -76,20 +86,17 @@ const handleKeydown = (event: KeyboardEvent) => {
       toggleFullscreen()
       break
     case 'Escape':
-      if (isFullscreen.value) {
-        toggleFullscreen()
-      }
+      if (isFullscreen.value) toggleFullscreen()
       break
   }
 }
 
-// 处理视频错误
 const handleVideoError = (error: any) => {
   console.error('视频播放错误:', error)
-  // 这里可以添加用户友好的错误提示
 }
 
 onMounted(() => {
+  initFromUrl()
   document.addEventListener('fullscreenchange', handleFullscreenChange)
   document.addEventListener('keydown', handleKeydown)
 })
@@ -102,74 +109,52 @@ onUnmounted(() => {
 
 <style lang="scss" scoped>
 .video-container {
-  width: 100%;
-  height: calc(100vh - 90px);
-  display: flex;
-  margin-top: 20px;
-  justify-content: center;
-  align-items: center;
+  width: calc(100% - 40px);
+  margin: 20px;
   position: relative;
   overflow: hidden;
+  background: #000;
+  border-radius: 8px;
+  aspect-ratio: 16 / 9;
+  max-height: calc(100vh - var(--v3-header-height, 50px) - 40px);
+}
 
-  // 全屏状态
-  &.is-fullscreen {
-    position: fixed;
-    top: 0;
-    left: 0;
-    z-index: 9999;
-    width: 100vw;
-    height: 100vh;
-  }
+.video-container:fullscreen {
+  max-height: none !important;
+  aspect-ratio: auto !important;
+  width: 100vw !important;
+  height: 100vh !important;
 }
 
 .video-wrapper {
   position: relative;
   width: 100%;
   height: 100%;
-  max-width: min(100vw, calc((100vh - 90px) * 16 / 9));
-  max-height: min(100vh - 90px, calc(100vw / 16 * 9));
-  aspect-ratio: 16/9;
 
-  // 确保视频组件填满容器
-  :deep(.preview-container) {
+  :deep(.webrtc-container) {
     width: 100%;
     height: 100%;
   }
 
-  :deep(.video_container) {
+  :deep(.webrtc-video-wrapper) {
     width: 100%;
     height: 100%;
     background: #000;
-    border-radius: 5px;
     overflow: hidden;
-    //box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
-
-    // 全屏时移除圆角和阴影
-    .video-container.is-fullscreen & {
-      border-radius: 0;
-      box-shadow: none;
-    }
   }
 
-  :deep(#player) {
+  :deep(video) {
     width: 100%;
     height: 100%;
-    //object-fit: cover;
-    object-fit: contain;
+    object-fit: fill;
   }
 
-  // 优化控制条样式
-  :deep(.video-control) {
+  :deep(.webrtc-controls) {
     opacity: 0;
     transition: opacity 0.3s ease;
-
-    &:hover {
-      opacity: 1;
-    }
   }
 
-  // 鼠标悬停时显示控制条
-  &:hover :deep(.video-control) {
+  &:hover :deep(.webrtc-controls) {
     opacity: 1;
   }
 }
@@ -202,13 +187,11 @@ onUnmounted(() => {
     transform: scale(0.95);
   }
 
-  // 鼠标悬停在容器上时显示
   .video-wrapper:hover & {
     opacity: 1;
   }
 
-  // 全屏状态下的样式调整
-  .video-container.is-fullscreen & {
+  .video-container:fullscreen & {
     top: 20px;
     right: 20px;
     width: 48px;
@@ -216,20 +199,9 @@ onUnmounted(() => {
   }
 }
 
-// 响应式设计
 @media (max-width: 768px) {
   .video-container {
     height: 100vh;
-    padding: 0;
-  }
-
-  .video-wrapper {
-    max-width: 100vw;
-    max-height: 100vh;
-
-    :deep(.video_container) {
-      border-radius: 0;
-    }
   }
 
   .fullscreen-btn {
@@ -238,44 +210,14 @@ onUnmounted(() => {
     top: 12px;
     right: 12px;
 
-    svg {
-      width: 14px;
-      height: 14px;
-    }
-  }
-}
-
-@media (max-width: 480px) {
-  .video-wrapper {
-    :deep(.video-control) {
-      height: 48px;
-
-      .control-btn {
-        width: 40px;
-        height: 40px;
-        font-size: 20px;
-      }
-    }
+    svg { width: 14px; height: 14px; }
   }
 }
 
-// 减少动画以提升性能
 @media (prefers-reduced-motion: reduce) {
   * {
     transition: none !important;
     animation: none !important;
   }
 }
-
-// 高对比度模式支持
-@media (prefers-contrast: custom) {
-  .video-wrapper :deep(.video_container) {
-    border: 2px solid white;
-  }
-
-  .fullscreen-btn {
-    border: 1px solid white;
-    background: black;
-  }
-}
-</style>
+</style>