| | |
| | | <template> |
| | | <div class="call-container" :class="{'incoming-call': state === 'ringing'} "> |
| | | <div class="content-wrapper"> |
| | | <!-- <div class="input-group">--> |
| | | <!-- <div class="input-item">--> |
| | | <!-- <span class="input-label">我的 userId:</span>--> |
| | | <!-- <input v-model="uid" class="input-field" />--> |
| | | <!-- </div>--> |
| | | <!-- --> |
| | | <!-- <div class="input-item">--> |
| | | <!-- <span class="input-label">对方 userId:</span>--> |
| | | <!-- <input v-model="peerUid" class="input-field" />--> |
| | | <!-- </div>--> |
| | | <!-- </div>--> |
| | | |
| | | <div class="button-group" v-if="false"> |
| | | <button @click="connectWS" :disabled="connected" class="btn btn-connect">连接WS</button> |
| | | </div> |
| | | <!-- 头像--> |
| | | <div class="contactAvatar"> |
| | | <img :src="defaultAvatar" alt=""></img> |
| | | </div> |
| | | <div class="status-group"> |
| | | <strong v-if="state==='ringing'" class="status-text incoming-status">来电中...</strong> |
| | | <strong v-if="state==='calling'" class="status-text calling-status">呼叫中...</strong> |
| | | <!-- <strong class="status-text">状态:<span :style="{color: getStateColor(state)}">{{ state }}</span></strong>--> |
| | | <strong v-if="state==='in-call'" class="status-text">通话时长:{{ durationText }}</strong> |
| | | </div> |
| | | <div class="button-group"> |
| | | <button v-if="false" @click="requestCall" :disabled="!connected || state!=='idle'" class="btn btn-call">呼叫</button> |
| | | |
| | | <button v-if="state==='ringing'" @click="acceptCall" class="btn btn-accept incoming-accept">接听</button> |
| | | <button v-if="state==='ringing'" @click="rejectCall" class="btn btn-reject incoming-reject">拒绝</button> |
| | | |
| | | <button v-if="state==='calling' || state==='in-call'" @click="hangup" class="btn btn-hangup">挂断</button> |
| | | </div> |
| | | |
| | | <div class="content-wrapper"> |
| | | <!-- <div class="input-group"> |
| | | <div class="input-item"> |
| | | <span class="input-label">我的 userId:</span> |
| | | <input v-model="uid" class="input-field" /> |
| | | </div> |
| | | |
| | | </div> |
| | | |
| | | <!-- <div class="tip-message">--> |
| | | <!-- <small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>--> |
| | | <!-- </div>--> |
| | | |
| | | <!-- <div class="log-container">--> |
| | | <!-- <div class="log-box">--> |
| | | <!-- <div class="log-title">日志信息:</div>--> |
| | | <!-- <pre class="log-content">{{ logText }}</pre>--> |
| | | <!-- </div>--> |
| | | <!-- </div>--> |
| | | |
| | | <audio ref="remoteAudio" autoplay class="hidden-audio"></audio> |
| | | <div class="input-item"> |
| | | <span class="input-label">对方 userId:</span> |
| | | <input v-model="peerUid" class="input-field" /> |
| | | </div> |
| | | </div>--> |
| | | |
| | | <div class="button-group" v-if="false"> |
| | | <button @click="connectWS" :disabled="connected" class="btn btn-connect">连接WS</button> |
| | | </div> |
| | | <!-- 头像--> |
| | | <div class="contactAvatar"> |
| | | <img :src="defaultAvatar" alt=""></img> |
| | | </div> |
| | | <div class="status-group"> |
| | | <strong v-if="state==='ringing'" class="status-text incoming-status">来电中...</strong> |
| | | <strong v-if="state==='calling'" class="status-text calling-status">呼叫中...</strong> |
| | | <!-- <strong class="status-text">状态:<span :style="{color: getStateColor(state)}">{{ state }}</span></strong>--> |
| | | <strong v-if="state==='in-call'" class="status-text">通话时长:{{ durationText }}</strong> |
| | | </div> |
| | | <div class="button-group"> |
| | | <button @click="requestCall" :disabled="!connected || state!=='idle'" class="btn btn-call">呼叫</button> |
| | | |
| | | <button v-if="state==='ringing'" @click="acceptCall" class="btn btn-accept incoming-accept">接听</button> |
| | | <button v-if="state==='ringing'" @click="rejectCall" class="btn btn-reject incoming-reject">拒绝</button> |
| | | |
| | | <button v-if="state==='calling' || state==='in-call'" @click="hangup" class="btn btn-hangup">挂断</button> |
| | | </div> |
| | | |
| | | |
| | | </div> |
| | | |
| | | <!-- <div class="tip-message"> |
| | | <small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small> |
| | | </div>--> |
| | | |
| | | <!-- <div class="log-container"> |
| | | <div class="log-box"> |
| | | <div class="log-title">日志信息:</div> |
| | | <pre class="log-content">{{ logText }}</pre> |
| | | </div> |
| | | </div>--> |
| | | |
| | | <audio ref="remoteAudio" autoplay class="hidden-audio"></audio> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | /** ✅ 你的 WS 地址前缀(后面拼 userId) */ |
| | | const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId=' |
| | | |
| | | // 解析URL参数 |
| | | const parseUrlParams = () => { |
| | | const url = new URL(window.location.href) |
| | | const paramsStr = url.searchParams.get('params') |
| | | if (paramsStr) { |
| | | try { |
| | | const params = JSON.parse(decodeURIComponent(paramsStr)) |
| | | if (params.peerUid) { |
| | | peerUid.value = String(params.peerUid) |
| | | log('🔗 从URL参数获取peerUid:', params.peerUid) |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析URL参数失败:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | // 解析URL参数 |
| | | const parseUrlParams = () => { |
| | | const url = new URL(window.location.href) |
| | | const paramsStr = url.searchParams.get('params') |
| | | if (paramsStr) { |
| | | try { |
| | | const params = JSON.parse(decodeURIComponent(paramsStr)) |
| | | if (params.peerUid) { |
| | | peerUid.value = String(params.peerUid) |
| | | log('🔗 从URL参数获取peerUid:', params.peerUid) |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析URL参数失败:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | const uid = ref('3') |
| | | const peerUid = ref('2') |
| | | // 从URL参数或本地存储获取用户ID |
| | | const getUserIdFromStorage = () => { |
| | | const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId') |
| | | if (userId) { |
| | | log('🔗 从本地存储获取userId:', userId) |
| | | return userId |
| | | } |
| | | return '3' // 默认值 |
| | | } |
| | | |
| | | const uid = ref('3') |
| | | const peerUid = ref('2') |
| | | |
| | | const connected = ref(false) |
| | | const state = ref('idle') // idle | calling | ringing | in-call |
| | |
| | | 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() { |
| | | }) |
| | | function startTimer() { |
| | | stopTimer() |
| | | seconds.value = 0 |
| | | timer = setInterval(() => seconds.value++, 1000) |
| | | } |
| | | function stopTimer() { |
| | | } |
| | | function stopTimer() { |
| | | if (timer) clearInterval(timer) |
| | | timer = null |
| | | } |
| | | |
| | | // 心跳 |
| | | let pingTimer = null |
| | | function startPing() { |
| | | } |
| | | |
| | | // 心跳 |
| | | let pingTimer = null |
| | | function startPing() { |
| | | stopPing() |
| | | // 每 25 秒发一次 ping(你后端有 PING/PONG) |
| | | pingTimer = setInterval(() => { |
| | | if (connected.value) send('ping', 'system', null) |
| | | if (connected.value) send('ping', 'system', null) |
| | | }, 25000) |
| | | } |
| | | } |
| | | |
| | | // 来电提示相关 |
| | | let ringtoneAudio = null |
| | | let vibrationTimer = null |
| | | // 来电提示相关 |
| | | let ringtoneAudio = null |
| | | let vibrationTimer = null |
| | | |
| | | // 播放来电铃声 |
| | | function playRingtone() { |
| | | // 播放来电铃声 |
| | | function playRingtone() { |
| | | // 创建音频对象(使用默认的系统提示音) |
| | | ringtoneAudio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA=='); |
| | | ringtoneAudio.loop = true; |
| | | ringtoneAudio.volume = 0.5; |
| | | |
| | | |
| | | try { |
| | | ringtoneAudio.play(); |
| | | log('🔊 开始播放来电铃声') |
| | | ringtoneAudio.play(); |
| | | log('🔊 开始播放来电铃声') |
| | | } catch (e) { |
| | | log('❌ 播放来电铃声失败:', String(e)) |
| | | log('❌ 播放来电铃声失败:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 停止来电铃声 |
| | | function stopRingtone() { |
| | | // 停止来电铃声 |
| | | function stopRingtone() { |
| | | if (ringtoneAudio) { |
| | | try { |
| | | ringtoneAudio.pause(); |
| | | ringtoneAudio.currentTime = 0; |
| | | ringtoneAudio = null; |
| | | log('🔇 停止播放来电铃声') |
| | | } catch (e) { |
| | | log('❌ 停止来电铃声失败:', String(e)) |
| | | } |
| | | try { |
| | | ringtoneAudio.pause(); |
| | | ringtoneAudio.currentTime = 0; |
| | | ringtoneAudio = null; |
| | | log('🔇 停止播放来电铃声') |
| | | } catch (e) { |
| | | log('❌ 停止来电铃声失败:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 开始震动 |
| | | function startVibration() { |
| | | // 开始震动 |
| | | function startVibration() { |
| | | // 检查浏览器是否支持震动 API |
| | | if ('vibrate' in navigator) { |
| | | // 震动模式:震动1秒,暂停0.5秒,重复 |
| | | const vibrationPattern = [1000, 500]; |
| | | |
| | | try { |
| | | navigator.vibrate(vibrationPattern); |
| | | vibrationTimer = setInterval(() => { |
| | | navigator.vibrate(vibrationPattern); |
| | | }, 1500); // 每1.5秒重复一次震动模式 |
| | | log('📳 开始震动提示') |
| | | } catch (e) { |
| | | log('❌ 震动失败:', String(e)) |
| | | } |
| | | } else { |
| | | log('ℹ️ 当前浏览器不支持震动功能') |
| | | } |
| | | } |
| | | // 震动模式:震动1秒,暂停0.5秒,重复 |
| | | const vibrationPattern = [1000, 500]; |
| | | |
| | | // 停止震动 |
| | | function stopVibration() { |
| | | try { |
| | | navigator.vibrate(vibrationPattern); |
| | | vibrationTimer = setInterval(() => { |
| | | navigator.vibrate(vibrationPattern); |
| | | }, 1500); // 每1.5秒重复一次震动模式 |
| | | log('📳 开始震动提示') |
| | | } catch (e) { |
| | | log('❌ 震动失败:', String(e)) |
| | | } |
| | | } else { |
| | | log('ℹ️ 当前浏览器不支持震动功能') |
| | | } |
| | | } |
| | | |
| | | // 停止震动 |
| | | function stopVibration() { |
| | | if (vibrationTimer) { |
| | | clearInterval(vibrationTimer); |
| | | vibrationTimer = null; |
| | | clearInterval(vibrationTimer); |
| | | vibrationTimer = null; |
| | | } |
| | | |
| | | |
| | | if ('vibrate' in navigator) { |
| | | try { |
| | | navigator.vibrate(0); // 停止震动 |
| | | log('📳 停止震动提示') |
| | | } catch (e) { |
| | | log('❌ 停止震动失败:', String(e)) |
| | | } |
| | | try { |
| | | navigator.vibrate(0); // 停止震动 |
| | | log('📳 停止震动提示') |
| | | } catch (e) { |
| | | log('❌ 停止震动失败:', String(e)) |
| | | } |
| | | } |
| | | } |
| | | function stopPing() { |
| | | } |
| | | function stopPing() { |
| | | if (pingTimer) clearInterval(pingTimer) |
| | | pingTimer = null |
| | | } |
| | | |
| | | function log(...args) { |
| | | } |
| | | |
| | | 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) { |
| | | } |
| | | |
| | | /** |
| | | * ✅ 严格按后端 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(), |
| | | 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() { |
| | | } |
| | | |
| | | /* ---------------- WebRTC ---------------- */ |
| | | |
| | | function createPC() { |
| | | pc = new RTCPeerConnection({ |
| | | iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], |
| | | }) |
| | |
| | | /* ---------------- 信令处理 ---------------- */ |
| | | |
| | | async function onSignal(msg) { |
| | | console.log('1cesss',msg); |
| | | |
| | | if (!msg) return |
| | | const t = (msg.type || '').toString() |
| | | |
| | |
| | | |
| | | state.value = 'ringing' |
| | | log('📞 收到来电 call,来自', incomingFrom) |
| | | |
| | | |
| | | // 开始来电提示(铃声和震动) |
| | | playRingtone() |
| | | startVibration() |
| | | |
| | | |
| | | return |
| | | } |
| | | |
| | |
| | | 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 |
| | | // 被叫收到 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 |
| | | } |
| | | |
| | | // 已接听:直接 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 |
| | | 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 |
| | | 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 |
| | | log('📴 对方挂断') |
| | | cleanup(false) |
| | | return |
| | | } |
| | | |
| | | |
| | | if (t === 'pong') { |
| | | // 心跳响应 |
| | | // log('pong') |
| | | return |
| | | // 心跳响应 |
| | | // log('pong') |
| | | return |
| | | } |
| | | } |
| | | |
| | | // 暂存 offer(用于“先点接听再 answer”) |
| | | let lastOfferSdp = null |
| | | |
| | | /* ---------------- 业务操作 ---------------- */ |
| | | |
| | | function connectWS() { |
| | | } |
| | | |
| | | // 暂存 offer(用于“先点接听再 answer”) |
| | | let lastOfferSdp = null |
| | | |
| | | /* ---------------- 业务操作 ---------------- */ |
| | | |
| | | function connectWS() { |
| | | // 确保获取最新的用户ID |
| | | uid.value = getUserIdFromStorage() |
| | | |
| | | if (ws) ws.close() |
| | | |
| | | |
| | | ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value)) |
| | | |
| | | |
| | | ws.onopen = () => { |
| | | connected.value = true |
| | | log('WS 已连接 userId=', uid.value) |
| | | startPing() |
| | | connected.value = true |
| | | log('WS 已连接 userId=', uid.value) |
| | | startPing() |
| | | } |
| | | |
| | | |
| | | ws.onclose = () => { |
| | | connected.value = false |
| | | log('WS 已关闭') |
| | | stopPing() |
| | | 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) |
| | | const msg = JSON.parse(e.data) |
| | | log('收到信令', msg) |
| | | onSignal(msg) |
| | | } |
| | | } |
| | | |
| | | function requestCall() { |
| | | } |
| | | |
| | | function requestCall() { |
| | | state.value = 'calling' |
| | | incomingFrom = null |
| | | acceptedByMe = false |
| | | offeredByPeer = false |
| | | lastOfferSdp = null |
| | | |
| | | |
| | | send('call', peerUid.value, null) |
| | | log('➡️ 发起呼叫 call 给', peerUid.value) |
| | | } |
| | | |
| | | function acceptCall() { |
| | | } |
| | | |
| | | function acceptCall() { |
| | | if (!incomingFrom) return |
| | | acceptedByMe = true |
| | | |
| | |
| | | |
| | | // 如果 offer 已经提前到达(我们暂存了),立刻 answer |
| | | if (lastOfferSdp) { |
| | | answerOffer(lastOfferSdp, incomingFrom) |
| | | lastOfferSdp = null |
| | | answerOffer(lastOfferSdp, incomingFrom) |
| | | lastOfferSdp = null |
| | | } |
| | | } |
| | | |
| | | function rejectCall() { |
| | | } |
| | | |
| | | 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' |
| | | |
| | | |
| | | // 返回上一页(uni-app的语音通话列表页面) |
| | | if (window.uni) { |
| | | window.uni.navigateBack() |
| | | window.uni.navigateBack() |
| | | } else { |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | } |
| | | } |
| | | |
| | | async function startOffer() { |
| | | } |
| | | |
| | | 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) { |
| | | } |
| | | |
| | | async function answerOffer(offerSdp, from) { |
| | | if (!offerSdp) { |
| | | log('❌ offer sdp 为空,无法接听') |
| | | return |
| | | 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() { |
| | | } |
| | | |
| | | function hangup() { |
| | | cleanup(true) |
| | | } |
| | | |
| | | /* ---------------- 清理 ---------------- */ |
| | | |
| | | function cleanup(sendToPeer) { |
| | | } |
| | | |
| | | /* ---------------- 清理 ---------------- */ |
| | | |
| | | function cleanup(sendToPeer) { |
| | | try { |
| | | if (sendToPeer) send('hangup', peerUid.value, null) |
| | | if (sendToPeer) send('hangup', peerUid.value, null) |
| | | } catch {} |
| | | |
| | | |
| | | stopTimer() |
| | | // 停止来电提示 |
| | | stopRingtone() |
| | | stopVibration() |
| | | |
| | | if (pc) { |
| | | pc.getSenders().forEach(s => s.track && s.track.stop()) |
| | | pc.close() |
| | | pc.getSenders().forEach(s => s.track && s.track.stop()) |
| | | pc.close() |
| | | } |
| | | if (localStream) { |
| | | localStream.getTracks().forEach(t => t.stop()) |
| | | localStream.getTracks().forEach(t => t.stop()) |
| | | } |
| | | |
| | | |
| | | pc = null |
| | | localStream = null |
| | | pendingCandidates.length = 0 |
| | | |
| | | |
| | | incomingFrom = null |
| | | acceptedByMe = false |
| | | offeredByPeer = false |
| | | lastOfferSdp = null |
| | | |
| | | |
| | | state.value = 'idle' |
| | | |
| | | |
| | | // 返回上一页(uni-app的语音通话列表页面) |
| | | if (window.uni) { |
| | | window.uni.navigateBack() |
| | | window.uni.navigateBack() |
| | | } else { |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | log('❌ uni对象不存在,无法返回上一页') |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | const receiveParameters = route.query.contact |
| | | console.log('接收参数', receiveParameters) |
| | | |
| | | // 解析并使用contact参数 |
| | | if (receiveParameters) { |
| | | try { |
| | | const contact = JSON.parse(decodeURIComponent(receiveParameters)) |
| | | if (contact.friendId) { |
| | | peerUid.value = contact.friendId |
| | | log('🔗 从contact参数获取peerUid:', contact.friendId) |
| | | log('👤 联系人名称:', contact.friendNickName || '未知联系人') |
| | | } |
| | | } catch (e) { |
| | | log('❌ 解析contact参数失败:', String(e)) |
| | | } |
| | | } |
| | | |
| | | // 解析URL参数 |
| | | parseUrlParams() |
| | | // 自动连接WebSocket |
| | |
| | | }) |
| | | |
| | | // 监听连接状态,连接成功后自动呼叫 |
| | | watch(() => connected.value, (newVal) => { |
| | | if (newVal && state.value === 'idle') { |
| | | requestCall() |
| | | } |
| | | // watch(() => connected.value, (newVal) => { |
| | | // if (newVal && state.value === 'idle') { |
| | | // requestCall() |
| | | // } |
| | | // }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | cleanup(false) |
| | | stopPing() |
| | | if (ws) ws.close() |
| | | }) |
| | | </script> |
| | | <style scoped lang="scss"> |
| | | .call-container { |
| | | padding: 20px; |
| | | min-height: 100vh; |
| | | font-family: Arial, sans-serif; |
| | | background-color: #f5f5f5; |
| | | transition: all 0.3s ease; |
| | | } |
| | | |
| | | onBeforeUnmount(() => { |
| | | cleanup(false) |
| | | stopPing() |
| | | if (ws) ws.close() |
| | | }) |
| | | </script> |
| | | <style scoped lang="scss"> |
| | | .call-container { |
| | | padding: 20px; |
| | | min-height: 100vh; |
| | | font-family: Arial, sans-serif; |
| | | background-color: #f5f5f5; |
| | | transition: all 0.3s ease; |
| | | /* 来电时的全屏样式 */ |
| | | .call-container.incoming-call { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 99999; |
| | | background-color: #ffffff; |
| | | padding: 0; |
| | | } |
| | | |
| | | .content-wrapper { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | max-width: 600px; |
| | | margin: 0 auto; |
| | | height: 100vh; |
| | | justify-content: center; |
| | | align-items: center; |
| | | } |
| | | |
| | | .input-group { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .input-item { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | } |
| | | |
| | | .input-label { |
| | | font-weight: bold; |
| | | color: #333; |
| | | } |
| | | |
| | | .input-field { |
| | | width: 120px; |
| | | padding: 8px; |
| | | border: 1px solid #ddd; |
| | | border-radius: 4px; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .button-group { |
| | | display: flex; |
| | | gap: 50px; |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | margin-top: 80px; |
| | | } |
| | | |
| | | .btn { |
| | | padding: 15px 30px; |
| | | color: white; |
| | | border: none; |
| | | border-radius: 8px; |
| | | cursor: pointer; |
| | | font-size: 16px; |
| | | font-weight: bold; |
| | | transition: all 0.3s ease; |
| | | min-width: 120px; |
| | | |
| | | &:disabled { |
| | | opacity: 0.6; |
| | | cursor: not-allowed; |
| | | } |
| | | } |
| | | |
| | | /* 来电时的全屏样式 */ |
| | | .call-container.incoming-call { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 99999; |
| | | background-color: #ffffff; |
| | | padding: 0; |
| | | /* 来电时的接听和拒绝按钮样式 */ |
| | | .btn.incoming-accept, .btn.incoming-reject { |
| | | width: 80px; |
| | | height: 80px; |
| | | border-radius: 50%; |
| | | padding: 0; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); |
| | | } |
| | | |
| | | .btn.incoming-accept { |
| | | background-color: #4CAF50; |
| | | transform: scale(1.1); |
| | | } |
| | | |
| | | .btn.incoming-reject { |
| | | background-color: #f44336; |
| | | } |
| | | |
| | | .btn.incoming-accept:active { |
| | | transform: scale(0.95); |
| | | } |
| | | |
| | | .btn.incoming-reject:active { |
| | | transform: scale(0.95); |
| | | } |
| | | |
| | | .btn-connect { |
| | | background-color: #4CAF50; |
| | | } |
| | | |
| | | .btn-call { |
| | | background-color: #2196F3; |
| | | } |
| | | |
| | | .btn-accept { |
| | | background-color: #4CAF50; |
| | | } |
| | | |
| | | .btn-reject { |
| | | background-color: #f44336; |
| | | } |
| | | |
| | | .btn-hangup { |
| | | background-color: #9E9E9E; |
| | | padding: 15px 40px; |
| | | min-width: 150px; |
| | | } |
| | | |
| | | .status-group { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .status-text { |
| | | font-size: 18px; |
| | | color: #333; |
| | | text-align: center; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | /* 来电状态样式 */ |
| | | .status-text.incoming-status { |
| | | font-size: 24px; |
| | | color: #333; |
| | | font-weight: bold; |
| | | animation: pulse 1.5s infinite; |
| | | } |
| | | |
| | | .status-text.calling-status { |
| | | font-size: 20px; |
| | | color: #666; |
| | | } |
| | | |
| | | /* 呼吸灯动画 */ |
| | | @keyframes pulse { |
| | | 0% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | |
| | | .content-wrapper { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 20px; |
| | | max-width: 600px; |
| | | margin: 0 auto; |
| | | height: 100vh; |
| | | justify-content: center; |
| | | align-items: center; |
| | | 50% { |
| | | opacity: 0.7; |
| | | transform: scale(1.05); |
| | | } |
| | | |
| | | .input-group { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | 100% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | .input-item { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | .tip-message { |
| | | margin-top: 15px; |
| | | color: #666; |
| | | text-align: center; |
| | | } |
| | | |
| | | .log-container { |
| | | margin-top: 20px; |
| | | max-width: 600px; |
| | | margin-left: auto; |
| | | margin-right: auto; |
| | | } |
| | | |
| | | .log-box { |
| | | background-color: white; |
| | | border: 1px solid #ddd; |
| | | border-radius: 8px; |
| | | padding: 10px; |
| | | } |
| | | |
| | | .log-title { |
| | | font-weight: bold; |
| | | color: #333; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .log-content { |
| | | max-height: 200px; |
| | | overflow: auto; |
| | | background-color: #fafafa; |
| | | padding: 10px; |
| | | border-radius: 4px; |
| | | font-size: 12px; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .hidden-audio { |
| | | display: none; |
| | | } |
| | | |
| | | /* 头像样式 */ |
| | | .contactAvatar { |
| | | width: 180px; |
| | | height: 180px; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | margin-bottom: 40px; |
| | | border-radius: 50%; |
| | | background-color: #f0f0f0; |
| | | overflow: hidden; |
| | | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
| | | |
| | | img { |
| | | width: 100%; |
| | | height: 100%; |
| | | object-fit: cover; |
| | | } |
| | | } |
| | | |
| | | .input-label { |
| | | font-weight: bold; |
| | | color: #333; |
| | | } |
| | | |
| | | .input-field { |
| | | width: 120px; |
| | | padding: 8px; |
| | | border: 1px solid #ddd; |
| | | border-radius: 4px; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .button-group { |
| | | display: flex; |
| | | gap: 50px; |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | margin-top: 80px; |
| | | } |
| | | |
| | | .btn { |
| | | padding: 15px 30px; |
| | | color: white; |
| | | border: none; |
| | | border-radius: 8px; |
| | | cursor: pointer; |
| | | font-size: 16px; |
| | | font-weight: bold; |
| | | transition: all 0.3s ease; |
| | | min-width: 120px; |
| | | |
| | | &:disabled { |
| | | opacity: 0.6; |
| | | cursor: not-allowed; |
| | | } |
| | | } |
| | | |
| | | /* 来电时的接听和拒绝按钮样式 */ |
| | | .btn.incoming-accept, .btn.incoming-reject { |
| | | width: 80px; |
| | | height: 80px; |
| | | border-radius: 50%; |
| | | padding: 0; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); |
| | | } |
| | | |
| | | .btn.incoming-accept { |
| | | background-color: #4CAF50; |
| | | transform: scale(1.1); |
| | | } |
| | | |
| | | .btn.incoming-reject { |
| | | background-color: #f44336; |
| | | } |
| | | |
| | | .btn.incoming-accept:active { |
| | | transform: scale(0.95); |
| | | } |
| | | |
| | | .btn.incoming-reject:active { |
| | | transform: scale(0.95); |
| | | } |
| | | |
| | | .btn-connect { |
| | | background-color: #4CAF50; |
| | | } |
| | | |
| | | .btn-call { |
| | | background-color: #2196F3; |
| | | } |
| | | |
| | | .btn-accept { |
| | | background-color: #4CAF50; |
| | | } |
| | | |
| | | .btn-reject { |
| | | background-color: #f44336; |
| | | } |
| | | |
| | | .btn-hangup { |
| | | background-color: #9E9E9E; |
| | | padding: 15px 40px; |
| | | min-width: 150px; |
| | | } |
| | | |
| | | .status-group { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .status-text { |
| | | font-size: 18px; |
| | | color: #333; |
| | | text-align: center; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | /* 来电状态样式 */ |
| | | .status-text.incoming-status { |
| | | font-size: 24px; |
| | | color: #333; |
| | | font-weight: bold; |
| | | animation: pulse 1.5s infinite; |
| | | } |
| | | |
| | | .status-text.calling-status { |
| | | font-size: 20px; |
| | | color: #666; |
| | | } |
| | | |
| | | /* 呼吸灯动画 */ |
| | | @keyframes pulse { |
| | | 0% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | 50% { |
| | | opacity: 0.7; |
| | | transform: scale(1.05); |
| | | } |
| | | 100% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | .tip-message { |
| | | margin-top: 15px; |
| | | color: #666; |
| | | text-align: center; |
| | | } |
| | | |
| | | .log-container { |
| | | margin-top: 20px; |
| | | max-width: 600px; |
| | | margin-left: auto; |
| | | margin-right: auto; |
| | | } |
| | | |
| | | .log-box { |
| | | background-color: white; |
| | | border: 1px solid #ddd; |
| | | border-radius: 8px; |
| | | padding: 10px; |
| | | } |
| | | |
| | | .log-title { |
| | | font-weight: bold; |
| | | color: #333; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .log-content { |
| | | max-height: 200px; |
| | | overflow: auto; |
| | | background-color: #fafafa; |
| | | padding: 10px; |
| | | border-radius: 4px; |
| | | font-size: 12px; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .hidden-audio { |
| | | display: none; |
| | | } |
| | | |
| | | /* 头像样式 */ |
| | | .contactAvatar { |
| | | width: 180px; |
| | | height: 180px; |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | margin-bottom: 40px; |
| | | border-radius: 50%; |
| | | background-color: #f0f0f0; |
| | | overflow: hidden; |
| | | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
| | | |
| | | img { |
| | | width: 100%; |
| | | height: 100%; |
| | | object-fit: cover; |
| | | } |
| | | } |
| | | |
| | | /* 来电时的头像放大效果 */ |
| | | .call-container.incoming-call .contactAvatar { |
| | | width: 220px; |
| | | height: 220px; |
| | | margin-bottom: 60px; |
| | | } |
| | | </style> |
| | | /* 来电时的头像放大效果 */ |
| | | .call-container.incoming-call .contactAvatar { |
| | | width: 220px; |
| | | height: 220px; |
| | | margin-bottom: 60px; |
| | | } |
| | | </style> |