diff --git a/src/components/IPlayer/config.ts b/src/components/IPlayer/config.ts
index 3048428..116e133 100644
--- a/src/components/IPlayer/config.ts
+++ b/src/components/IPlayer/config.ts
@@ -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', // 跳转后继续播放
diff --git a/src/components/IPlayer/index.vue b/src/components/IPlayer/index.vue
index 55be899..1f6d5a2 100644
--- a/src/components/IPlayer/index.vue
+++ b/src/components/IPlayer/index.vue
@@ -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;
}
}
diff --git a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue
index b597862..5299251 100644
--- a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue
+++ b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue
@@ -57,17 +57,7 @@
当前状态:{{ statusMeta.label }}
{{ statusMeta.description }}
-
-
-
+
高清语音
低延迟
@@ -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,后续 Offer、Candidate、心跳和停止消息都必须携带。
+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}
@@ -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 实例
*/
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 之外的 Answer、Candidate、错误等消息按 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('通话已结束')
}
diff --git a/src/views/inspection/cockpit/components/ManualTakeover/index.vue b/src/views/inspection/cockpit/components/ManualTakeover/index.vue
index 777983e..e5df2b6 100644
--- a/src/views/inspection/cockpit/components/ManualTakeover/index.vue
+++ b/src/views/inspection/cockpit/components/ManualTakeover/index.vue
@@ -99,7 +99,9 @@
车载监控
-
+
+
+
语音对话
@@ -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()
})
diff --git a/src/views/inspection/cockpit/components/Monitor.vue b/src/views/inspection/cockpit/components/Monitor.vue
index d404e90..b158262 100644
--- a/src/views/inspection/cockpit/components/Monitor.vue
+++ b/src/views/inspection/cockpit/components/Monitor.vue
@@ -20,7 +20,16 @@
- 监控
+