From ed18423fd694c46f3b6b30185bef04f295f7af2a Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Tue, 13 Jan 2026 14:05:51 +0800
Subject: [PATCH] feat:pc语音通话demo

---
 applications/drone-command/src/views/audioCall.vue |  439 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 439 insertions(+), 0 deletions(-)

diff --git a/applications/drone-command/src/views/audioCall.vue b/applications/drone-command/src/views/audioCall.vue
new file mode 100644
index 0000000..55de074
--- /dev/null
+++ b/applications/drone-command/src/views/audioCall.vue
@@ -0,0 +1,439 @@
+<template>
+	<div style="padding:16px">
+	  <h2>1v1 语音通话 WebRTC Demo(对齐后端 ChatMessage,JS)</h2>
+  
+	  <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
+		<span>我的 userId:</span>
+		<input v-model="uid" style="width:120px" />
+  
+		<span>对方 userId:</span>
+		<input v-model="peerUid" style="width:120px" />
+  
+		<button @click="connectWS" :disabled="connected">连接WS</button>
+  
+		<button @click="requestCall" :disabled="!connected || state!=='idle'">呼叫</button>
+  
+		<button v-if="state==='ringing'" @click="acceptCall">接听</button>
+		<button v-if="state==='ringing'" @click="rejectCall">拒绝</button>
+  
+		<button @click="hangup" :disabled="!connected || (state!=='calling' && state!=='in-call')">挂断</button>
+  
+		<strong>状态:{{ state }}</strong>
+		<strong v-if="state==='in-call'">通话时长:{{ durationText }}</strong>
+	  </div>
+  
+	  <div style="margin-top:8px;color:#666">
+		<small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
+	  </div>
+  
+	  <pre style="margin-top:12px;max-height:280px;overflow:auto;">{{ logText }}</pre>
+  
+	  <audio ref="remoteAudio" autoplay></audio>
+	</div>
+  </template>
+  
+  <script setup>
+  import { ref, computed, onBeforeUnmount } from 'vue'
+  
+  /** ✅ 你的 WS 地址前缀(后面拼 userId) */
+  const WS_BASE = 'wss://域名/ws/chat?userId='
+  
+  const uid = ref('2')
+  const peerUid = ref('3')
+  
+  const connected = ref(false)
+  const state = ref('idle') // idle | calling | ringing | in-call
+  const logText = ref('')
+  const remoteAudio = ref(null)
+  
+  let ws = null
+  let pc = null
+  let localStream = null
+  const pendingCandidates = []
+  
+  // 来电暂存
+  let incomingFrom = null
+  let acceptedByMe = false // 我是否已经点了接听
+  let offeredByPeer = false // 是否已收到对方 offer(用于流程判断)
+  
+  // 计时
+  let timer = null
+  const seconds = ref(0)
+  const durationText = computed(() => {
+	const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
+	const s = String(seconds.value % 60).padStart(2, '0')
+	return `${m}:${s}`
+  })
+  function startTimer() {
+	stopTimer()
+	seconds.value = 0
+	timer = setInterval(() => seconds.value++, 1000)
+  }
+  function stopTimer() {
+	if (timer) clearInterval(timer)
+	timer = null
+  }
+  
+  // 心跳
+  let pingTimer = null
+  function startPing() {
+	stopPing()
+	// 每 25 秒发一次 ping(你后端有 PING/PONG)
+	pingTimer = setInterval(() => {
+	  if (connected.value) send('ping', 'system', null)
+	}, 25000)
+  }
+  function stopPing() {
+	if (pingTimer) clearInterval(pingTimer)
+	pingTimer = null
+  }
+  
+  function log(...args) {
+	const line = args.map(v => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
+	logText.value += `[${new Date().toLocaleTimeString()}] ${line}\n`
+	console.log(...args)
+  }
+  
+  /**
+   * ✅ 严格按后端 ChatMessage 字段发:
+   * { type, from, to, payload, timestamp }
+   * 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
+   */
+  function send(type, to, payload) {
+	if (!type) return
+	if (!ws || ws.readyState !== WebSocket.OPEN) return
+  
+	const msg = {
+	  type,                 // "call/accept/busy/offer/answer/candidate/hangup/ping"
+	  from: String(uid.value),
+	  to: String(to),
+	  payload: payload ?? null,
+	  timestamp: Date.now(),
+	}
+	ws.send(JSON.stringify(msg))
+  }
+  
+  /* ---------------- WebRTC ---------------- */
+  
+  function createPC() {
+	pc = new RTCPeerConnection({
+	  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
+	})
+  
+	pc.onicecandidate = (e) => {
+	  if (e.candidate) {
+		send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
+	  }
+	}
+  
+	pc.ontrack = (e) => {
+	  log('✅ 收到远端音频流')
+	  const stream = e.streams[0]
+	  if (remoteAudio.value) {
+		remoteAudio.value.srcObject = stream
+		remoteAudio.value.play().catch(() => {})
+	  }
+	}
+  
+	pc.onconnectionstatechange = () => {
+	  log('pc.connectionState =', pc.connectionState)
+	}
+  }
+  
+  async function getLocalAudio() {
+	if (localStream) return localStream
+  
+	if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+	  log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面')
+	  throw new Error('getUserMedia not available')
+	}
+  
+	try {
+	  localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+	  log('✅ 已获取本地麦克风')
+	  return localStream
+	} catch (e) {
+	  log('❌ 获取麦克风失败:', String(e))
+	  throw e
+	}
+  }
+  
+  /** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
+  async function ensureLocalTracksAdded() {
+	if (!pc) createPC()
+	const stream = await getLocalAudio()
+  
+	const existingTrackIds = new Set(
+	  pc.getSenders().map(s => (s.track ? s.track.id : null)).filter(Boolean)
+	)
+  
+	stream.getTracks().forEach(track => {
+	  if (!existingTrackIds.has(track.id)) {
+		pc.addTrack(track, stream)
+		log('addTrack ok:', track.id)
+	  } else {
+		// log('skip addTrack:', track.id)
+	  }
+	})
+  
+	return stream
+  }
+  
+  async function flushCandidates() {
+	if (!pc || !pc.remoteDescription) return
+	while (pendingCandidates.length) {
+	  const c = pendingCandidates.shift()
+	  try {
+		await pc.addIceCandidate(new RTCIceCandidate(c))
+	  } catch (e) {
+		log('addIceCandidate failed:', String(e))
+	  }
+	}
+  }
+  
+  /* ---------------- 信令处理 ---------------- */
+  
+  async function onSignal(msg) {
+	if (!msg) return
+	const t = (msg.type || '').toString()
+  
+	// ✅ 忽略后端回显给自己的消息(你的后端会回显 sender)
+	if (msg.from && String(msg.from) === String(uid.value)) {
+	  return
+	}
+  
+	// system 消息:login/pong/busy 仅记录
+	if (msg.from === 'system') {
+	  log('system msg:', msg)
+	  return
+	}
+  
+	if (!t) return
+  
+	if (t === 'call') {
+	  // 收到来电:弹接听按钮
+	  incomingFrom = String(msg.from)
+	  peerUid.value = incomingFrom
+	  acceptedByMe = false
+	  offeredByPeer = false
+  
+	  state.value = 'ringing'
+	  log('📞 收到来电 call,来自', incomingFrom)
+	  return
+	}
+  
+	if (t === 'accept') {
+	  // 主叫收到 accept:开始 offer
+	  log('✅ 对方接听 accept,开始建立连接(offer)')
+	  await startOffer()
+	  return
+	}
+  
+	if (t === 'busy') {
+	  log('❌ 对方忙线/拒绝')
+	  cleanup(false)
+	  return
+	}
+  
+	if (t === 'offer') {
+	  // 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
+	  offeredByPeer = true
+	  incomingFrom = String(msg.from)
+	  peerUid.value = incomingFrom
+  
+	  if (!acceptedByMe) {
+		state.value = 'ringing'
+		log('📩 收到 offer(但未接听),已等待用户点击接听')
+		// 暂存 offer 到一个变量
+		lastOfferSdp = msg.payload?.sdp
+		return
+	  }
+  
+	  // 已接听:直接 answer
+	  await answerOffer(msg.payload?.sdp, incomingFrom)
+	  return
+	}
+  
+	if (t === 'answer') {
+	  log('✅ 收到 answer,通话建立')
+	  if (!pc) createPC()
+	  await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
+	  await flushCandidates()
+  
+	  state.value = 'in-call'
+	  startTimer()
+	  return
+	}
+  
+	if (t === 'candidate') {
+	  const c = msg.payload?.candidate
+	  if (!c) return
+	  if (pc && pc.remoteDescription) {
+		await pc.addIceCandidate(new RTCIceCandidate(c))
+	  } else {
+		pendingCandidates.push(c)
+	  }
+	  return
+	}
+  
+	if (t === 'hangup') {
+	  log('📴 对方挂断')
+	  cleanup(false)
+	  return
+	}
+  
+	if (t === 'pong') {
+	  // 心跳响应
+	  // log('pong')
+	  return
+	}
+  }
+  
+  // 暂存 offer(用于“先点接听再 answer”)
+  let lastOfferSdp = null
+  
+  /* ---------------- 业务操作 ---------------- */
+  
+  function connectWS() {
+	if (ws) ws.close()
+  
+	ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
+  
+	ws.onopen = () => {
+	  connected.value = true
+	  log('WS 已连接 userId=', uid.value)
+	  startPing()
+	}
+  
+	ws.onclose = () => {
+	  connected.value = false
+	  log('WS 已关闭')
+	  stopPing()
+	}
+  
+	ws.onerror = (e) => log('WS error', e)
+  
+	ws.onmessage = (e) => {
+	  const msg = JSON.parse(e.data)
+	  log('收到信令', msg)
+	  onSignal(msg)
+	}
+  }
+  
+  function requestCall() {
+	state.value = 'calling'
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+  
+	send('call', peerUid.value, null)
+	log('➡️ 发起呼叫 call 给', peerUid.value)
+  }
+  
+  function acceptCall() {
+	if (!incomingFrom) return
+	acceptedByMe = true
+  
+	// 先告诉对方我接听了(后端会把双方置忙)
+	send('accept', incomingFrom, null)
+	log('✅ 已点击接听,发送 accept 给', incomingFrom)
+  
+	state.value = 'calling'
+  
+	// 如果 offer 已经提前到达(我们暂存了),立刻 answer
+	if (lastOfferSdp) {
+	  answerOffer(lastOfferSdp, incomingFrom)
+	  lastOfferSdp = null
+	}
+  }
+  
+  function rejectCall() {
+	if (!incomingFrom) return
+  
+	// 你后端没有 reject,用 busy 表示拒绝/不可接听
+	send('busy', incomingFrom, null)
+	log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
+  
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+	state.value = 'idle'
+  }
+  
+  async function startOffer() {
+	if (!pc) createPC()
+  
+	// ✅ 不会重复 addTrack
+	await ensureLocalTracksAdded()
+  
+	const offer = await pc.createOffer()
+	await pc.setLocalDescription(offer)
+  
+	send('offer', peerUid.value, { sdp: pc.localDescription })
+	log('➡️ 已发送 offer 给', peerUid.value)
+  }
+  
+  async function answerOffer(offerSdp, from) {
+	if (!offerSdp) {
+	  log('❌ offer sdp 为空,无法接听')
+	  return
+	}
+	if (!pc) createPC()
+  
+	await pc.setRemoteDescription(new RTCSessionDescription(offerSdp))
+	await flushCandidates()
+  
+	await ensureLocalTracksAdded()
+  
+	const answer = await pc.createAnswer()
+	await pc.setLocalDescription(answer)
+  
+	send('answer', from, { sdp: pc.localDescription })
+	log('⬅️ 已发送 answer 给', from)
+  
+	state.value = 'in-call'
+	startTimer()
+  }
+  
+  function hangup() {
+	cleanup(true)
+  }
+  
+  /* ---------------- 清理 ---------------- */
+  
+  function cleanup(sendToPeer) {
+	try {
+	  if (sendToPeer) send('hangup', peerUid.value, null)
+	} catch {}
+  
+	stopTimer()
+  
+	if (pc) {
+	  pc.getSenders().forEach(s => s.track && s.track.stop())
+	  pc.close()
+	}
+	if (localStream) {
+	  localStream.getTracks().forEach(t => t.stop())
+	}
+  
+	pc = null
+	localStream = null
+	pendingCandidates.length = 0
+  
+	incomingFrom = null
+	acceptedByMe = false
+	offeredByPeer = false
+	lastOfferSdp = null
+  
+	state.value = 'idle'
+  }
+  
+  onBeforeUnmount(() => {
+	cleanup(false)
+	stopPing()
+	if (ws) ws.close()
+  })
+  </script>
+  
\ No newline at end of file

--
Gitblit v1.9.3