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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,16 @@
</div> </div>
</div> </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 v-if="activeBot != 0" class="right-container">
<div class="container-title">OCR识别</div> <div class="container-title">OCR识别</div>
<div class="ocr-container"></div> <div class="ocr-container"></div>
@ -31,6 +40,9 @@
</template> </template>
<script setup> <script setup>
import SvgIcon from "@/components/SvgIcon"; 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'] const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff']
@ -95,8 +107,38 @@ const changeBot = (index) => {
.center-container { .center-container {
flex: 1; flex: 1;
border: 1px solid #1E3A5F; min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #1e3a5f;
border-top: 0; 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 { .right-container {