| | |
| | | |
| | | <audio ref="remoteAudio" autoplay class="hidden-audio"></audio> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, computed, onBeforeUnmount, onMounted } from 'vue' |
| | | |
| | | /** ✅ 你的 WS 地址前缀(后面拼 userId) */ |
| | | const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId=' |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useRoute } from 'vue-router' |
| | | |
| | | const route = useRoute() |
| | | /** ✅ 你的 WS 地址前缀(后面拼 userId) */ |
| | | const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId=' |
| | | |
| | | // 解析URL参数 |
| | | const parseUrlParams = () => { |
| | |
| | | const uid = ref('3') |
| | | const peerUid = ref('2') |
| | | |
| | | const connected = ref(false) |
| | | const state = ref('idle') // idle | calling | ringing | in-call |
| | | const logText = ref('') |
| | | const remoteAudio = ref(null) |
| | | |
| | | /** |
| | | * 根据状态返回对应颜色 |
| | | */ |
| | | const getStateColor = (state) => { |
| | | const colors = { |
| | | 'idle': '#666', |
| | | 'calling': '#2196F3', |
| | | 'ringing': '#FF9800', |
| | | 'in-call': '#4CAF50' |
| | | } |
| | | return colors[state] || '#666' |
| | | const connected = ref(false) |
| | | const state = ref('idle') // idle | calling | ringing | in-call |
| | | const logText = ref('') |
| | | const remoteAudio = ref(null) |
| | | |
| | | /** |
| | | * 根据状态返回对应颜色 |
| | | */ |
| | | const getStateColor = (state) => { |
| | | const colors = { |
| | | 'idle': '#666', |
| | | 'calling': '#2196F3', |
| | | 'ringing': '#FF9800', |
| | | 'in-call': '#4CAF50' |
| | | } |
| | | |
| | | 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(() => { |
| | | return colors[state] || '#666' |
| | | } |
| | | |
| | | 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 createPC() { |
| | | pc = new RTCPeerConnection({ |
| | | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], |
| | | 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 }) |
| | | } |
| | | 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(() => {}) |
| | | } |
| | | log('✅ 收到远端音频流') |
| | | const stream = e.streams[0] |
| | | if (remoteAudio.value) { |
| | | remoteAudio.value.srcObject = stream |
| | | remoteAudio.value.play().catch(() => { |
| | | }) |
| | | } |
| | | } |
| | | |
| | | |
| | | pc.onconnectionstatechange = () => { |
| | | log('pc.connectionState =', pc.connectionState) |
| | | log('pc.connectionState =', pc.connectionState) |
| | | } |
| | | } |
| | | |
| | | async function getLocalAudio() { |
| | | } |
| | | |
| | | async function getLocalAudio() { |
| | | if (localStream) return localStream |
| | | |
| | | |
| | | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
| | | log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面') |
| | | throw new Error('getUserMedia not available') |
| | | log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面') |
| | | throw new Error('getUserMedia not available') |
| | | } |
| | | |
| | | |
| | | try { |
| | | localStream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
| | | log('✅ 已获取本地麦克风') |
| | | return localStream |
| | | localStream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
| | | log('✅ 已获取本地麦克风') |
| | | return localStream |
| | | } catch (e) { |
| | | log('❌ 获取麦克风失败:', String(e)) |
| | | throw e |
| | | log('❌ 获取麦克风失败:', String(e)) |
| | | throw e |
| | | } |
| | | } |
| | | |
| | | /** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */ |
| | | async function ensureLocalTracksAdded() { |
| | | } |
| | | |
| | | /** ✅ 关键:只 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) |
| | | 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) |
| | | } |
| | | 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() { |
| | | } |
| | | |
| | | 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)) |
| | | } |
| | | const c = pendingCandidates.shift() |
| | | try { |
| | | await pc.addIceCandidate(new RTCIceCandidate(c)) |
| | | } catch (e) { |
| | | log('addIceCandidate failed:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | /* ---------------- 信令处理 ---------------- */ |
| | | |
| | | async function onSignal(msg) { |
| | | } |
| | | |
| | | /* ---------------- 信令处理 ---------------- */ |
| | | |
| | | async function onSignal(msg) { |
| | | if (!msg) return |
| | | const t = (msg.type || '').toString() |
| | | |
| | | |
| | | // ✅ 忽略后端回显给自己的消息(你的后端会回显 sender) |
| | | if (msg.from && String(msg.from) === String(uid.value)) { |
| | | return |
| | | return |
| | | } |
| | | |
| | | |
| | | // system 消息:login/pong/busy 仅记录 |
| | | if (msg.from === 'system') { |
| | | log('system msg:', msg) |
| | | return |
| | | 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 |
| | | // 收到来电:弹接听按钮 |
| | | 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 |
| | | // 主叫收到 accept:开始 offer |
| | | log('✅ 对方接听 accept,开始建立连接(offer)') |
| | | await startOffer() |
| | | return |
| | | } |
| | | |
| | | |
| | | if (t === 'busy') { |
| | | log('❌ 对方忙线/拒绝') |
| | | cleanup(false) |
| | | return |
| | | log('❌ 对方忙线/拒绝') |
| | | cleanup(false) |
| | | return |
| | | } |
| | | |
| | | if (t === 'offer') { |
| | |
| | | lastOfferSdp = null |
| | | |
| | | state.value = 'idle' |
| | | } |
| | | |
| | | onMounted(() => { |
| | | // 解析URL参数 |
| | | parseUrlParams() |
| | | }) |
| | | } |
| | | |
| | | onMounted(() => { |
| | | const receiveParameters = route.query.contact |
| | | console.log('接收参数', receiveParameters) |
| | | // 解析URL参数 |
| | | parseUrlParams() |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | cleanup(false) |