feat: 视频监控

This commit is contained in:
zhanghao 2026-07-14 16:35:15 +08:00
parent 4540dfb3d7
commit 97ca74a225
5 changed files with 183 additions and 92 deletions

View File

@ -12,7 +12,7 @@ export const conf = {
startTime: 0, // 点播模式下,初始起播时间
videoAttributes: {}, // video扩展属性暂且不配置
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
fluid: true, // 是否流式布局宽高优先于流失布局默认16:9注掉上方宽高看效果
fluid: false, // 是否流式布局宽高优先于流失布局默认16:9注掉上方宽高看效果
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
videoFillMode: 'auto', // 宽高不够自动底色填充fill拉伸填充等...
seekedStatus: 'play', // 跳转后继续播放

View File

@ -9,10 +9,14 @@ import { ref, onMounted } from 'vue';
import Player, { Events } from 'xgplayer'; // 西
import 'xgplayer/dist/index.min.css'; // 西
const { videoUrl } = defineProps({
const { videoUrl, isLive } = defineProps({
videoUrl: {
type: String,
default: ''
},
isLive: {
type: Boolean,
default: false
}
})
@ -29,7 +33,7 @@ const init = () => {
el: xgPlayerRef.value,
// width: 600,
// height: 400, //
isLive: false,
isLive: isLive,
url: videoUrl, //
// poster: "http://ashuai.work/static/img/avantar.png", //
...conf,
@ -53,8 +57,11 @@ const init = () => {
.player_container {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
.xgplayer {
:deep(.xgplayer) {
width: 100% !important;
height: 100% !important;
}
}

View File

@ -58,16 +58,6 @@
<span>{{ statusMeta.description }}</span>
</div>
<el-alert
v-if="errorMessage"
class="error-alert"
:title="errorMessage"
type="error"
:closable="true"
show-icon
@close="errorMessage = ''"
/>
<div class="feature-tags">
<span>高清语音</span>
<span>低延迟</span>
@ -87,8 +77,11 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import { ElMessage, ElNotification } from 'element-plus'
//
const SIGNALING_SERVER_URL = 'wss://your-signaling-server.com'
const ROOM_NAME = 'voice-room'
const SIGNALING_SERVER_URL = 'ws://192.168.1.222:13080/ws/signaling'
//
const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd'
const DEVICE_ID = '1234'
const OPERATOR_ID = 'user001'
const RTC_CONFIGURATION = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
@ -107,12 +100,13 @@ const isStarting = ref(false)
const isConnected = ref(false)
const isMuted = ref(false)
const connectionStatus = ref('idle')
const errorMessage = ref('')
// 使
let audioContext = null
let analyser = null
let volumeAnimationFrame = null
// joined ID OfferCandidate
let signalingSessionId = ''
// SDP ICE addIceCandidate
let pendingIceCandidates = []
//
@ -129,18 +123,6 @@ const statusMeta = computed(() => {
return statusMap[connectionStatus.value] || statusMap.idle
})
/**
* 生成当前房间的 WebSocket 信令地址
* 将固定房间名放入 URL 查询参数使信令服务器可以把消息转发给同房间的另一端
* @returns {string} room 参数的完整 WebSocket 地址
*/
function getSignalingUrl() {
// 使 & 使 ?
const separator = SIGNALING_SERVER_URL.includes('?') ? '&' : '?'
// URL WebSocket
return `${SIGNALING_SERVER_URL}${separator}room=${encodeURIComponent(ROOM_NAME)}`
}
/**
* 统一处理通话过程中的异常
* 该方法会记录错误更新页面状态并使用 Element Plus 通知用户
@ -150,8 +132,6 @@ function getSignalingUrl() {
function showError(message, error) {
// 便
console.error(message, error || '')
// 便
errorMessage.value = message
//
connectionStatus.value = 'error'
// WebRTC
@ -180,7 +160,7 @@ function sendSignalingMessage(message) {
/**
* 创建并初始化 RTCPeerConnection
* 作用包括加入本地麦克风轨道接收并播放远端音频发送 ICE 候选
* 作用包括接收并播放远端音频发送 ICE 候选
* 以及监听连接状态来同步页面上的连接中/通话中/未连接状态
* @param {number} sessionId 本次通话的会话编号用于忽略过期回调
* @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例
@ -196,12 +176,6 @@ function setupPeerConnection(sessionId) {
// audio
if (remoteAudio.value) remoteAudio.value.srcObject = remoteStream.value
// WebRTC
localStream.value.getTracks().forEach((track) => {
// addTrack SDP
pc.addTrack(track, localStream.value)
})
// audio
pc.ontrack = (event) => {
//
@ -227,10 +201,13 @@ function setupPeerConnection(sessionId) {
try {
//
sendSignalingMessage({
type: 'ice-candidate',
candidate: event.candidate.candidate,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid
type: 'candidate',
sessionId: signalingSessionId,
candidate: {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
}
})
} catch (error) {
//
@ -248,8 +225,6 @@ function setupPeerConnection(sessionId) {
isConnected.value = true
//
connectionStatus.value = 'connected'
//
errorMessage.value = ''
//
ElMessage.success('实时语音连接已建立')
// failed disconnected
@ -281,9 +256,8 @@ async function flushPendingIceCandidates(pc) {
/**
* 处理信令服务器发来的消息
* - offer设置远端描述创建并返回 answer
* - answer完成发起端的远端描述设置
* - ice-candidate立即添加候选或等待远端 SDP 就绪后再添加
* - candidate立即添加候选或等待远端 SDP 就绪后再添加
* @param {MessageEvent} event WebSocket 消息事件
* @param {number} sessionId 本次通话的会话编号
* @returns {Promise<void>}
@ -304,42 +278,40 @@ async function handleSignalingMessage(event, sessionId) {
return
}
// ID
if (message.sessionId && signalingSessionId && message.sessionId !== signalingSessionId) return
// WebRTC SDP ICE
const pc = peerConnection.value
//
if (!pc) return
try {
// Offer
if (message.type === 'offer') {
// Offer 使
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: message.sdp }))
// ICE
await flushPendingIceCandidates(pc)
// Offer SDP
const answer = await pc.createAnswer()
// Answer使 WebRTC
await pc.setLocalDescription(answer)
// Answer
sendSignalingMessage({ type: 'answer', sdp: answer.sdp })
// Answer Offer
} else if (message.type === 'answer') {
// Offer Answer
if (message.type === 'answer') {
// Answer SDP Offer/Answer
await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: message.sdp }))
// SDP Answer ICE
await flushPendingIceCandidates(pc)
// ICE
} else if (message.type === 'ice-candidate') {
} else if (message.type === 'candidate') {
// 使 RTCIceCandidate
const candidate = new RTCIceCandidate({
candidate: message.candidate,
sdpMLineIndex: message.sdpMLineIndex,
sdpMid: message.sdpMid
candidate: message.candidate?.candidate,
sdpMLineIndex: message.candidate?.sdpMLineIndex,
sdpMid: message.candidate?.sdpMid
})
// WebRTC
if (pc.remoteDescription) await pc.addIceCandidate(candidate)
// SDP
else pendingIceCandidates.push(candidate)
//
} else if (message.type === 'error') {
showError(message.message || '语音会话发生异常')
//
} else if (message.type === 'stopped' || message.type === 'stop') {
cleanupResources(false)
connectionStatus.value = 'idle'
}
} catch (error) {
// SDP Answer
@ -354,24 +326,75 @@ async function handleSignalingMessage(event, sessionId) {
* @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例
*/
function connectSignalingServer(sessionId) {
// Promise startCall WebSocket Offer
// Promise joined Offer sessionId
return new Promise((resolve, reject) => {
// 使 WebSocket
const socket = new WebSocket(getSignalingUrl())
//
//
const socket = new WebSocket(SIGNALING_SERVER_URL)
//
webSocket.value = socket
// joined 线
let hasJoined = false
// Promise SDP
socket.onopen = () => resolve(socket)
// type
socket.onmessage = (event) => handleSignalingMessage(event, sessionId)
// Promise startCall
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
//
// WebSocket join gRPC
socket.onopen = () => {
sendSignalingMessage({
type: 'join',
terminalId: TERMINAL_ID,
deviceId: DEVICE_ID,
operatorId: OPERATOR_ID
})
}
// joined WebRTC
socket.onmessage = (event) => {
let message
try {
// joined
message = JSON.parse(event.data)
} catch (error) {
//
handleSignalingMessage(event, sessionId)
return
}
// sessionId
if (message.type === 'joined') {
// sessionId
if (!message.sessionId) {
reject(new Error('后端 joined 响应缺少 sessionId'))
return
}
// joined
if (sessionId !== callSessionId) return
// ID使
signalingSessionId = message.sessionId
//
hasJoined = true
// joined startCall Offer
resolve(message)
return
}
// join startCall
if (message.type === 'error' && !hasJoined) {
reject(new Error(message.message || '后端创建语音会话失败'))
return
}
// joined AnswerCandidate WebRTC
handleSignalingMessage(event, sessionId)
}
// WebSocket
socket.onerror = () => {
if (!hasJoined) reject(new Error('WebSocket 连接失败'))
}
// 线
socket.onclose = () => {
//
if (sessionId === callSessionId && isCalling.value) {
//
if (!hasJoined) {
reject(new Error('信令服务器在会话建立前已断开'))
} else if (sessionId === callSessionId && isCalling.value) {
showError('信令服务器连接已断开')
}
}
@ -429,10 +452,13 @@ async function startCall() {
isStarting.value = true
// UI
connectionStatus.value = 'connecting'
//
errorMessage.value = ''
try {
// join joined sessionId
await connectSignalingServer(sessionId)
// joined WebRTC
const pc = setupPeerConnection(sessionId)
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
@ -460,17 +486,17 @@ async function startCall() {
if (localAudio.value) localAudio.value.srcObject = stream
//
startVolumeMeter(stream)
// WebRTC
const pc = setupPeerConnection(sessionId)
// Offer
await connectSignalingServer(sessionId)
// WebRTC WebRTC
stream.getAudioTracks().forEach((track) => {
pc.addTrack(track, stream)
})
// SDP Offer
const offer = await pc.createOffer()
const offer = await pc.createOffer({ offerToReceiveAudio: true })
// ICE
await pc.setLocalDescription(offer)
// Offer
sendSignalingMessage({ type: 'offer', sdp: offer.sdp })
sendSignalingMessage({ type: 'offer', sessionId: signalingSessionId, sdp: offer.sdp })
// connectionstatechange
isCalling.value = true
} catch (error) {
@ -517,7 +543,17 @@ function toggleMute() {
* 包括停止音量分析关闭 AudioContext/WebRTC/WebSocket停止本地和远端轨道
* 清空 audio 元素并重置通话相关状态该方法可重复调用供异常挂断和卸载共同使用
*/
function cleanupResources() {
function cleanupResources(notifyBackend = true) {
// gRPC
if (notifyBackend && signalingSessionId && webSocket.value?.readyState === WebSocket.OPEN) {
try {
// 使
sendSignalingMessage({ type: 'stop', sessionId: signalingSessionId })
} catch (error) {
//
console.warn('停止会话消息发送失败', error)
}
}
// 使
callSessionId += 1
// ICE
@ -587,6 +623,8 @@ function cleanupResources() {
isConnected.value = false
//
isMuted.value = false
// ID sessionId
signalingSessionId = ''
}
/**
@ -599,7 +637,6 @@ function endCall() {
//
connectionStatus.value = 'idle'
//
errorMessage.value = ''
//
ElMessage.success('通话已结束')
}

View File

@ -99,7 +99,9 @@
</div>
<div class="right-container">
<div class="container-title">车载监控</div>
<div class="monitor-container"></div>
<div class="monitor-container">
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
<div class="voice-container">
@ -118,6 +120,7 @@ import { getRobotList } from '@/api/inspection/robot'
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi } from '@/api/inspection/cockpit'
import { Remove, CirclePlus } from '@element-plus/icons-vue'
import VoiceConversation from "./VoiceConversation.vue";
import IPlayer from "@/components/IPlayer/index.vue";
const robotList = ref([])
@ -259,6 +262,8 @@ const stop = async () => {
})
}
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
onMounted(() => {
fetchRobotList()
})

View File

@ -20,7 +20,16 @@
</div>
</div>
</div>
<div class="center-container">监控</div>
<div class="center-container">
<div class="video-container" v-if="activeBot == 0">
<div class="ttt" v-for="value in 4">
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
</div>
<div class="single-video-container" v-else>
<IPlayer :isLive="true" :videoUrl="videoUrl" />
</div>
</div>
<div v-if="activeBot != 0" class="right-container">
<div class="container-title">OCR识别</div>
<div class="ocr-container"></div>
@ -31,6 +40,9 @@
</template>
<script setup>
import SvgIcon from "@/components/SvgIcon";
import IPlayer from "@/components/IPlayer/index.vue";
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff']
@ -95,8 +107,38 @@ const changeBot = (index) => {
.center-container {
flex: 1;
border: 1px solid #1E3A5F;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #1e3a5f;
border-top: 0;
.video-container {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 16px;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
.ttt {
width: auto;
height: auto;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
}
}
.single-video-container {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
}
.right-container {