chore: refact webRTC relative logic and update npm module
This commit is contained in:
4
.env_template
Normal file
4
.env_template
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
STUN_URL=''
|
||||||
|
TURN_URL=''
|
||||||
|
TURN_USERNAME=''
|
||||||
|
TURN_CREDENTIAL=''
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,3 +28,7 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|
||||||
|
# dev environment variables
|
||||||
|
.env
|
||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"dplayer": "^1.27.1",
|
"dplayer": "^1.27.1",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -2331,6 +2332,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
|
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dplayer": {
|
"node_modules/dplayer": {
|
||||||
"version": "1.27.1",
|
"version": "1.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/dplayer/-/dplayer-1.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/dplayer/-/dplayer-1.27.1.tgz",
|
||||||
@@ -2342,17 +2355,6 @@
|
|||||||
"promise-polyfill": "8.3.0"
|
"promise-polyfill": "8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dplayer/node_modules/axios": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"follow-redirects": "^1.15.0",
|
|
||||||
"form-data": "^4.0.0",
|
|
||||||
"proxy-from-env": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"dplayer": "^1.27.1",
|
"dplayer": "^1.27.1",
|
||||||
"element-plus": "^2.9.4",
|
"element-plus": "^2.9.4",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -30,5 +31,8 @@
|
|||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vite-plugin-vue-devtools": "^7.7.1"
|
"vite-plugin-vue-devtools": "^7.7.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"axios": "^1.13.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, initCustomFormatter } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import logo from '../assets/logo.png';
|
import logo from '../assets/logo.png';
|
||||||
import { onlineSocketStore } from '@/store/Online';
|
import { onlineSocketStore } from '@/store/Online';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { voiceStore } from '@/store/Voice';
|
|
||||||
import { groupMessageStore } from '@/store/message.ts';
|
import { groupMessageStore } from '@/store/message.ts';
|
||||||
import { userInfoStore } from '@/store/user';
|
import { userInfoStore } from '@/store/user';
|
||||||
|
|
||||||
const userInfo = userInfoStore()
|
const userInfo = userInfoStore()
|
||||||
const voice = voiceStore()
|
|
||||||
const socket = onlineSocketStore()
|
const socket = onlineSocketStore()
|
||||||
const groupMessage = groupMessageStore()
|
const groupMessage = groupMessageStore()
|
||||||
|
|
||||||
@@ -73,7 +71,6 @@ const logout = () => {
|
|||||||
userInfo.clearUserInfo();
|
userInfo.clearUserInfo();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
// 断开websocket链接
|
// 断开websocket链接
|
||||||
voice.disconnect();
|
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
// 跳转到登录页面
|
// 跳转到登录页面
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
|
|||||||
@@ -18,24 +18,26 @@
|
|||||||
<button @click="answer" v-if="!onCall.from">
|
<button @click="answer" v-if="!onCall.from">
|
||||||
接听
|
接听
|
||||||
</button>
|
</button>
|
||||||
<button @click="hangup">
|
<button @click="denyCall(userinfo.user.id, onCall.target.u_id)" v-if="!onCall.from">
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
<button @click="hangupCall(userinfo.user.id, onCall.target.u_id)" v-if="onCall.from">
|
||||||
挂断
|
挂断
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="playRemoteAudio">没有声音?试试我</el-button>
|
<el-button @click="playRemoteAudio">没有声音?试试我</el-button>
|
||||||
</div>
|
</div>
|
||||||
<audio ref="remoteAudio" autoplay></audio>
|
<audio ref="remoteAudio" id="remoteAudio" autoplay playsinline></audio>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { userInfoStore } from '@/store/store';
|
import { userInfoStore } from '@/store/user';
|
||||||
import { voiceStore } from '@/store/Voice';
|
|
||||||
import { onCallStore } from '@/store/VoiceTarget';
|
import { onCallStore } from '@/store/VoiceTarget';
|
||||||
import { Mic } from '@element-plus/icons-vue';
|
import { Mic } from '@element-plus/icons-vue';
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { denyCall, hangupCall,sendOffer } from '@/store/Voice.ts';
|
||||||
|
|
||||||
const voice = voiceStore();
|
|
||||||
const onCall = onCallStore();
|
const onCall = onCallStore();
|
||||||
const userinfo = userInfoStore()
|
const userinfo = userInfoStore()
|
||||||
|
|
||||||
@@ -48,36 +50,33 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// 清理音频流和元素
|
// 清理音频流和元素
|
||||||
if (localAudio.value.srcObject) {
|
if (localAudio.value && localAudio.value.srcObject) {
|
||||||
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||||
}
|
}
|
||||||
if (remoteAudio.value.srcObject) {
|
if (remoteAudio.value && remoteAudio.value.srcObject) {
|
||||||
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const answer = () => {
|
const answer = async () => {
|
||||||
console.log("接听");
|
console.log("接听");
|
||||||
onCall.callingOff();
|
onCall.callingOff();
|
||||||
onCall.fromOn();
|
onCall.fromOn();
|
||||||
voice.pickup(userinfo.user.u_id, onCall.target.u_id)
|
// voice.pickup(userinfo.user.id, onCall.target.u_id)
|
||||||
|
await sendOffer(userinfo.user.id, onCall.target.u_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hangup = () => {
|
|
||||||
voice.hangup();
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTelegramPanel = () => {
|
const showTelegramPanel = () => {
|
||||||
onCall.panel = !onCall.panel;
|
onCall.panel = !onCall.panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playRemoteAudio = () => {
|
const playRemoteAudio = () => {
|
||||||
const audio = document.getElementById("remoteAudio");
|
const audio = remoteAudio.value;
|
||||||
if (audio) {
|
if (!audio) return;
|
||||||
audio.play().catch(err => {
|
audio.play().catch(err => {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { userInfoStore } from '@/store/store';
|
import { userInfoStore } from '@/store/user.ts';
|
||||||
|
|
||||||
const userinfo = userInfoStore();
|
const userinfo = userInfoStore();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const onlineSocketStore = defineStore('onlineSocket', {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
connect(id) {
|
connect(id: number) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
if (this.isConnected === true) return
|
if (this.isConnected === true) return
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
@@ -30,8 +30,13 @@ export const onlineSocketStore = defineStore('onlineSocket', {
|
|||||||
message.saveMessagesHistory(this.id)
|
message.saveMessagesHistory(this.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
send(message) {
|
send(message: any) {
|
||||||
sendMessage(JSON.stringify(message));
|
try {
|
||||||
|
sendMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stringify message:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { connectVoicesocket, disconnectVoicesocket, sendMessage, hangup } from "@/websocket/voiceSocket";
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
|
|
||||||
export const voiceStore = defineStore("voice", {
|
|
||||||
state: () => ({
|
|
||||||
isConnected: false
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
connect() {
|
|
||||||
connectVoicesocket();
|
|
||||||
this.isConnected = true;
|
|
||||||
},
|
|
||||||
disconnect() {
|
|
||||||
disconnectVoicesocket();
|
|
||||||
this.isConnected = false;
|
|
||||||
},
|
|
||||||
startCall(from, from_name, from_avatar, to) {
|
|
||||||
if (this.isConnected) {
|
|
||||||
const message = {
|
|
||||||
type: "incomingcall",
|
|
||||||
from: from,
|
|
||||||
from_name: from_name,
|
|
||||||
from_avatar: from_avatar,
|
|
||||||
to: to
|
|
||||||
}
|
|
||||||
sendMessage(JSON.stringify(message))
|
|
||||||
} else {
|
|
||||||
console.log("voice socket is not connected")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pickup(from,to){
|
|
||||||
if (this.isConnected) {
|
|
||||||
const message ={
|
|
||||||
type: "pickup",
|
|
||||||
from: from,
|
|
||||||
to: to
|
|
||||||
}
|
|
||||||
sendMessage(JSON.stringify(message))
|
|
||||||
} else {
|
|
||||||
console.log("voice socket is not connected")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hangup(){
|
|
||||||
hangup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
235
src/store/Voice.ts
Normal file
235
src/store/Voice.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { sendMessage } from "@/websocket/onlineSocket";
|
||||||
|
import { onCallStore } from "@/store/VoiceTarget";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const iceserver = {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: process.env.STUN_URL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urls: process.env.TURN_URL,
|
||||||
|
username: process.env.TURN_USERNAME,
|
||||||
|
credential: process.env.TURN_CREDENTIAL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
interface message {
|
||||||
|
cmd: "VOICE_ICE_CANDIDATE" | "VOICE_SDP_OFFER" | "VOICE_SDP_ANSWER",
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
content: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
const oncall = onCallStore();
|
||||||
|
|
||||||
|
const RTCpeerConnection = ref(null);
|
||||||
|
|
||||||
|
const localstream = ref(null);
|
||||||
|
const remotestream = ref<MediaStream | null>(null);
|
||||||
|
|
||||||
|
// 本地候选:为了避免“绑定 onicecandidate 太晚”导致丢失,统一先缓存,等 remoteDescription 就绪后再发送
|
||||||
|
const pendingLocalCandidates = ref<any[]>([]);
|
||||||
|
// 远端候选:避免远端 candidate 早到但 remoteDescription 未 set 导致 addIceCandidate 失败
|
||||||
|
const pendingRemoteCandidates = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 当前会话对端信息(用于发送 ICE 时带上 from/to)
|
||||||
|
const currentFrom = ref<number | null>(null);
|
||||||
|
const currentTo = ref<number | null>(null);
|
||||||
|
|
||||||
|
const canSendIceNow = () => {
|
||||||
|
const pc: any = RTCpeerConnection.value;
|
||||||
|
return (
|
||||||
|
!!pc &&
|
||||||
|
!!pc.localDescription &&
|
||||||
|
!!pc.remoteDescription &&
|
||||||
|
currentFrom.value !== null &&
|
||||||
|
currentTo.value !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushLocalCandidatesIfReady = () => {
|
||||||
|
if (!canSendIceNow()) return;
|
||||||
|
if (pendingLocalCandidates.value.length === 0) return;
|
||||||
|
|
||||||
|
pendingLocalCandidates.value.forEach((candidate) => {
|
||||||
|
const message: message = {
|
||||||
|
cmd: "VOICE_ICE_CANDIDATE",
|
||||||
|
from: currentFrom.value as number,
|
||||||
|
to: currentTo.value as number,
|
||||||
|
content: candidate,
|
||||||
|
};
|
||||||
|
sendMessage(message);
|
||||||
|
});
|
||||||
|
pendingLocalCandidates.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushRemoteCandidatesIfReady = async () => {
|
||||||
|
const pc: any = RTCpeerConnection.value;
|
||||||
|
if (!pc || !pc.remoteDescription) return;
|
||||||
|
if (pendingRemoteCandidates.value.length === 0) return;
|
||||||
|
|
||||||
|
for (const c of pendingRemoteCandidates.value) {
|
||||||
|
await pc.addIceCandidate(new RTCIceCandidate(c));
|
||||||
|
}
|
||||||
|
pendingRemoteCandidates.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getlocalStream = async () => {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
console.log("获取本地音频流成功");
|
||||||
|
return stream;
|
||||||
|
// // 获取音频和视频轨道
|
||||||
|
// const audioTrack = stream.getAudioTracks()[0];
|
||||||
|
// // 将轨道添加到 RTCPeerConnection
|
||||||
|
// peerConnection.addTrack(audioTrack, stream);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLocalStream = (stream: MediaStream) => {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initRTCconnection = async () => {
|
||||||
|
RTCpeerConnection.value = new RTCPeerConnection(iceserver);
|
||||||
|
localstream.value = await getlocalStream();
|
||||||
|
RTCpeerConnection.value.addTrack(localstream.value.getAudioTracks()[0], localstream.value);
|
||||||
|
|
||||||
|
// 必须尽早绑定,否则可能在 setLocalDescription 后就把 candidate 产完了
|
||||||
|
RTCpeerConnection.value.onicecandidate = (event: { candidate: any }) => {
|
||||||
|
if (!event.candidate) return;
|
||||||
|
pendingLocalCandidates.value.push(event.candidate);
|
||||||
|
flushLocalCandidatesIfReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 远端音频:收到 track 后把 MediaStream 绑定到 UI 的 <audio>
|
||||||
|
RTCpeerConnection.value.ontrack = (event: RTCTrackEvent) => {
|
||||||
|
// 优先用浏览器提供的 streams[0];否则手动聚合 track
|
||||||
|
let stream = event.streams && event.streams[0] ? event.streams[0] : null;
|
||||||
|
if (!stream) {
|
||||||
|
if (!remotestream.value) remotestream.value = new MediaStream();
|
||||||
|
if (event.track) remotestream.value.addTrack(event.track);
|
||||||
|
stream = remotestream.value;
|
||||||
|
}
|
||||||
|
if (stream) {
|
||||||
|
oncall.setRemoteStream(stream);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendOffer = async (from: number,to: number) => {
|
||||||
|
if (!RTCpeerConnection.value) {
|
||||||
|
await initRTCconnection();
|
||||||
|
};
|
||||||
|
currentFrom.value = from;
|
||||||
|
currentTo.value = to;
|
||||||
|
const offer = await RTCpeerConnection.value.createOffer();
|
||||||
|
await RTCpeerConnection.value.setLocalDescription(offer);
|
||||||
|
const message: message = {
|
||||||
|
cmd: "VOICE_SDP_OFFER",
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
content: offer
|
||||||
|
}
|
||||||
|
sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleOffer = async (offer: any, from: number, to: number) => {
|
||||||
|
if (!RTCpeerConnection.value) {
|
||||||
|
await initRTCconnection();
|
||||||
|
}
|
||||||
|
// 这里是“我收到对方 offer”,因此我这端的 from/to 应该是 (to -> from)
|
||||||
|
currentFrom.value = to;
|
||||||
|
currentTo.value = from;
|
||||||
|
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
|
await flushRemoteCandidatesIfReady();
|
||||||
|
const answer = await RTCpeerConnection.value.createAnswer();
|
||||||
|
await RTCpeerConnection.value.setLocalDescription(answer);
|
||||||
|
const message: message = {
|
||||||
|
cmd: "VOICE_SDP_ANSWER",
|
||||||
|
from: to,
|
||||||
|
to: from,
|
||||||
|
content: answer
|
||||||
|
}
|
||||||
|
sendMessage(message);
|
||||||
|
// remoteDescription + localDescription 都已具备,允许发送本地候选
|
||||||
|
flushLocalCandidatesIfReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAnswer = async (answer: any, from: number, to: number) => {
|
||||||
|
if (!RTCpeerConnection.value)
|
||||||
|
{
|
||||||
|
console.error("connection lost");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 这里是“我收到对方 answer”,因此我这端的 from/to 应该是 (to -> from)
|
||||||
|
currentFrom.value = to;
|
||||||
|
currentTo.value = from;
|
||||||
|
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(answer));
|
||||||
|
await flushRemoteCandidatesIfReady();
|
||||||
|
flushLocalCandidatesIfReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleCandidate = async (candidate: any) => {
|
||||||
|
if (!RTCpeerConnection.value) return;
|
||||||
|
// candidate 可能早到:remoteDescription 未 set 时直接 add 会失败,先缓存
|
||||||
|
if (!RTCpeerConnection.value.remoteDescription) {
|
||||||
|
pendingRemoteCandidates.value.push(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await RTCpeerConnection.value.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hangupCall = (from: number, to: number) => {
|
||||||
|
if(from === null || to === null) return;
|
||||||
|
if (RTCpeerConnection.value) {
|
||||||
|
RTCpeerConnection.value.close();
|
||||||
|
RTCpeerConnection.value = null;
|
||||||
|
const msg = {
|
||||||
|
cmd: "VOICE_CALL_END",
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
time: new Date().toLocaleString()
|
||||||
|
}
|
||||||
|
sendMessage(msg);
|
||||||
|
oncall.clear();
|
||||||
|
closeLocalStream(localstream.value);
|
||||||
|
remotestream.value = null;
|
||||||
|
pendingLocalCandidates.value = [];
|
||||||
|
pendingRemoteCandidates.value = [];
|
||||||
|
currentFrom.value = null;
|
||||||
|
currentTo.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const denyCall = (from: number, to: number) => {
|
||||||
|
if(from === null || to === null) return;
|
||||||
|
const msg = {
|
||||||
|
cmd: "VOICE_CALL_DENY",
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
time: new Date().toLocaleString()
|
||||||
|
}
|
||||||
|
sendMessage(msg);
|
||||||
|
oncall.clear();
|
||||||
|
remotestream.value = null;
|
||||||
|
pendingLocalCandidates.value = [];
|
||||||
|
pendingRemoteCandidates.value = [];
|
||||||
|
currentFrom.value = null;
|
||||||
|
currentTo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const closeConnection = () => {
|
||||||
|
if (RTCpeerConnection.value) {
|
||||||
|
RTCpeerConnection.value.close();
|
||||||
|
RTCpeerConnection.value = null;
|
||||||
|
}
|
||||||
|
closeLocalStream(localstream.value);
|
||||||
|
remotestream.value = null;
|
||||||
|
pendingLocalCandidates.value = [];
|
||||||
|
pendingRemoteCandidates.value = [];
|
||||||
|
currentFrom.value = null;
|
||||||
|
currentTo.value = null;
|
||||||
|
}
|
||||||
@@ -21,7 +21,10 @@ export const onCallStore = defineStore("onCall", {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
setTarget(u_id, u_name, u_avatar) {
|
getTarget() {
|
||||||
|
return this.target;
|
||||||
|
},
|
||||||
|
setTarget(u_id:number, u_name:string, u_avatar:string) {
|
||||||
this.target = {
|
this.target = {
|
||||||
u_id: u_id,
|
u_id: u_id,
|
||||||
name: u_name,
|
name: u_name,
|
||||||
@@ -52,6 +55,25 @@ export const onCallStore = defineStore("onCall", {
|
|||||||
statusOff() {
|
statusOff() {
|
||||||
this.status = false
|
this.status = false
|
||||||
},
|
},
|
||||||
|
clear(){
|
||||||
|
this.target = {
|
||||||
|
u_id: "",
|
||||||
|
name: "",
|
||||||
|
avatar: ""
|
||||||
|
}
|
||||||
|
this.panel = false;
|
||||||
|
this.calling = false;
|
||||||
|
this.from = false;
|
||||||
|
this.status = false;
|
||||||
|
this.localstream = {
|
||||||
|
audioStream: null,
|
||||||
|
audioElement: null
|
||||||
|
}
|
||||||
|
this.remotestream = {
|
||||||
|
audioStream: null,
|
||||||
|
audioElement: null
|
||||||
|
}
|
||||||
|
},
|
||||||
setLocalStream(stream) {
|
setLocalStream(stream) {
|
||||||
this.localstream.audioStream = stream;
|
this.localstream.audioStream = stream;
|
||||||
if (this.localstream.audioStream) {
|
if (this.localstream.audioStream) {
|
||||||
@@ -91,17 +91,15 @@
|
|||||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||||
import { userInfoStore } from '@/store/user';
|
import { userInfoStore } from '@/store/user';
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { onlineSocketStore } from '@/store/Online';
|
import { sendMessage } from '@/websocket/onlineSocket';
|
||||||
import { messageStore } from '@/store/message.ts';
|
import { messageStore } from '@/store/message.ts';
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { voiceStore } from '@/store/Voice';
|
import { onCallStore } from '@/store/VoiceTarget.ts';
|
||||||
import { onCallStore } from '@/store/VoiceTarget';
|
|
||||||
import { getFriends, deleteFriend } from '@/api/friend';
|
import { getFriends, deleteFriend } from '@/api/friend';
|
||||||
|
|
||||||
const socket = onlineSocketStore()
|
|
||||||
const userinfo = userInfoStore()
|
const userinfo = userInfoStore()
|
||||||
const message = messageStore()
|
const message = messageStore()
|
||||||
const voice = voiceStore()
|
|
||||||
const oncall = onCallStore()
|
const oncall = onCallStore()
|
||||||
|
|
||||||
const messagebox = ref([])
|
const messagebox = ref([])
|
||||||
@@ -125,7 +123,8 @@ const selectedFriendId = ref('')
|
|||||||
const tempsearchResult = computed(() => {
|
const tempsearchResult = computed(() => {
|
||||||
if (!tempsearch.value)
|
if (!tempsearch.value)
|
||||||
return friends.value
|
return friends.value
|
||||||
friends.value.filter(item => item.u_name.includes(tempsearch.value))
|
else
|
||||||
|
return friends.value.filter(item => item.u_name.includes(tempsearch.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
const friendSearchResult = computed(() => {
|
const friendSearchResult = computed(() => {
|
||||||
@@ -170,7 +169,7 @@ const handleEnter = () => {
|
|||||||
time: new Date().toLocaleString()
|
time: new Date().toLocaleString()
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.send(msg)
|
sendMessage(msg);
|
||||||
message.addMessage(msg)
|
message.addMessage(msg)
|
||||||
inputValue.value = ''
|
inputValue.value = ''
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -234,7 +233,16 @@ const handleConfirmCall = () => {
|
|||||||
oncall.panelOn()
|
oncall.panelOn()
|
||||||
oncall.callingOn()
|
oncall.callingOn()
|
||||||
oncall.fromOn()
|
oncall.fromOn()
|
||||||
voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value)
|
// voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value)
|
||||||
|
const msg = {
|
||||||
|
cmd: "VOICE_CALL_REQUEST",
|
||||||
|
from: userinfo.user.id,
|
||||||
|
from_name: userinfo.user.u_name,
|
||||||
|
from_avatar: userinfo.user.u_avatar,
|
||||||
|
to: oppositeId.value,
|
||||||
|
time: new Date().toLocaleString()
|
||||||
|
}
|
||||||
|
sendMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,13 @@ import { onMounted, watchEffect } from 'vue';
|
|||||||
import Navbar from '../../components/navBar.vue';
|
import Navbar from '../../components/navBar.vue';
|
||||||
import UserProfile from '../../components/userProfile.vue';
|
import UserProfile from '../../components/userProfile.vue';
|
||||||
import { userInfoStore } from '@/store/user';
|
import { userInfoStore } from '@/store/user';
|
||||||
import { onlineSocketStore } from '@/store/Online';
|
import { onlineSocketStore } from '@/store/Online.ts';
|
||||||
import { getUserInfo } from '@/api/user';
|
import { getUserInfo } from '@/api/user';
|
||||||
import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
|
import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
|
||||||
import { messageStore } from '@/store/message.ts';
|
import { messageStore } from '@/store/message.ts';
|
||||||
import phonePanel from '@/components/phonePanel.vue';
|
import phonePanel from '@/components/phonePanel.vue';
|
||||||
import { voiceStore } from '@/store/Voice';
|
|
||||||
import { initDB } from '@/functions/historyMessages';
|
import { initDB } from '@/functions/historyMessages';
|
||||||
|
|
||||||
const voice = voiceStore();
|
|
||||||
|
|
||||||
const userinfo = userInfoStore();
|
const userinfo = userInfoStore();
|
||||||
const socket = onlineSocketStore();
|
const socket = onlineSocketStore();
|
||||||
const message = messageStore();
|
const message = messageStore();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { messageStore } from "@/store/message";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { messageSignStore } from "@/store/message_sign";
|
import { messageSignStore } from "@/store/message_sign";
|
||||||
import { groupMessageStore } from "@/store/group_message";
|
import { groupMessageStore } from "@/store/group_message";
|
||||||
|
import { onCallStore } from "@/store/VoiceTarget";
|
||||||
|
import { handleOffer, handleAnswer, closeConnection, handleCandidate } from "@/store/Voice.ts";
|
||||||
|
|
||||||
// userinfo 实例
|
// userinfo 实例
|
||||||
const userinfo = userInfoStore();
|
const userinfo = userInfoStore();
|
||||||
@@ -11,6 +13,8 @@ const userinfo = userInfoStore();
|
|||||||
const message = messageStore();
|
const message = messageStore();
|
||||||
// groupMessage 实例
|
// groupMessage 实例
|
||||||
const groupMessage = groupMessageStore();
|
const groupMessage = groupMessageStore();
|
||||||
|
// oncall 实例
|
||||||
|
const oncall = onCallStore();
|
||||||
// messageSignStoregn 实例
|
// messageSignStoregn 实例
|
||||||
// const messageSign = messageSignStore();
|
// const messageSign = messageSignStore();
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ const reconnectScheduled = ref(false);
|
|||||||
|
|
||||||
const retryCount = ref(0);
|
const retryCount = ref(0);
|
||||||
|
|
||||||
|
|
||||||
export const getRetryCount = () => {
|
export const getRetryCount = () => {
|
||||||
return retryCount.value;
|
return retryCount.value;
|
||||||
};
|
};
|
||||||
@@ -78,7 +83,7 @@ export const connectWebSocket = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
//处理消息逻辑
|
//处理消息逻辑
|
||||||
socket.value.onmessage = (event) => {
|
socket.value.onmessage = async (event) => {
|
||||||
console.log("从服务器收到消息:", event.data);
|
console.log("从服务器收到消息:", event.data);
|
||||||
try{
|
try{
|
||||||
const MessageData = JSON.parse(event.data);
|
const MessageData = JSON.parse(event.data);
|
||||||
@@ -106,24 +111,39 @@ export const connectWebSocket = () => {
|
|||||||
// 语音通话相关:
|
// 语音通话相关:
|
||||||
case "VOICE_CALL_REQUEST":
|
case "VOICE_CALL_REQUEST":
|
||||||
ElMessage.info("您有一个新的语音通话请求");
|
ElMessage.info("您有一个新的语音通话请求");
|
||||||
|
oncall.setTarget(MessageData.from, MessageData.from_name, MessageData.from_avatar);
|
||||||
|
oncall.panelOn();
|
||||||
|
oncall.callingOn();
|
||||||
|
oncall.fromOff();
|
||||||
|
oncall.statusOff();
|
||||||
break;
|
break;
|
||||||
case "VOICE_CALL_RESPONSE":
|
case "VOICE_CALL_RESPONSE":
|
||||||
console.log("收到语音通话响应消息");
|
console.log("收到语音通话响应消息");
|
||||||
|
//直接跳转到icecandidate阶段,不再接收额外的响应消息
|
||||||
break;
|
break;
|
||||||
case "VOICE_CALL_DENY":
|
case "VOICE_CALL_DENY":
|
||||||
ElMessage.info("对方拒绝了您的语音通话请求");
|
ElMessage.info("对方拒绝了您的语音通话请求");
|
||||||
|
oncall.clear();
|
||||||
break;
|
break;
|
||||||
case "VOICE_CALL_END":
|
case "VOICE_CALL_END":
|
||||||
ElMessage.info("语音通话已结束");
|
ElMessage.info("语音通话已结束");
|
||||||
|
closeConnection();
|
||||||
|
oncall.clear();
|
||||||
break;
|
break;
|
||||||
case "VOICE_ICE_CANDIDATE":
|
case "VOICE_ICE_CANDIDATE":
|
||||||
console.log("收到新的ICE候选", MessageData.candidate);
|
// stage3 : 收到ICE候选,添加到RTC连接中
|
||||||
|
console.log("收到新的ICE候选");
|
||||||
|
await handleCandidate(MessageData.content);
|
||||||
break;
|
break;
|
||||||
case "VOICE_SDP_OFFER":
|
case "VOICE_SDP_OFFER":
|
||||||
console.log("收到SDP Offer", MessageData.sdp);
|
// stage1 :收到offer,发送answer
|
||||||
|
console.log("收到SDP Offer");
|
||||||
|
await handleOffer(MessageData.content, MessageData.from, MessageData.to);
|
||||||
break;
|
break;
|
||||||
case "VOICE_SDP_ANSWER":
|
case "VOICE_SDP_ANSWER":
|
||||||
console.log("收到SDP Answer", MessageData.sdp);
|
// stage2 : 收到answer,设置远端SDP
|
||||||
|
console.log("收到SDP Answer");
|
||||||
|
await handleAnswer(MessageData.content, MessageData.from, MessageData.to);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -170,11 +190,18 @@ export const reConnectWebSocket = () => {
|
|||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
export const sendMessage = (message) => {
|
export const sendMessage = (message) => {
|
||||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
try {
|
||||||
socket.value.send(message);
|
const jsonmessage = JSON.stringify(message);
|
||||||
} else {
|
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||||
console.warn("WebSocket未连接,无法发送消息");
|
socket.value.send(jsonmessage);
|
||||||
|
} else {
|
||||||
|
console.warn("WebSocket未连接,无法发送消息");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to stringify message:", error);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
//没有错误的重连,只是浏览器在后台断开了连接
|
//没有错误的重连,只是浏览器在后台断开了连接
|
||||||
|
|||||||
@@ -39,13 +39,12 @@ const getIsManualClose = () => {
|
|||||||
const iceserver = {
|
const iceserver = {
|
||||||
iceServers: [
|
iceServers: [
|
||||||
{
|
{
|
||||||
// urls: "stun:stun.l.google.com:19302"
|
urls: process.env.STUN_URL,
|
||||||
urls: "stun:8.134.92.199:3478",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urls: "turn:8.134.92.199:3478",
|
urls: process.env.TURN_URL,
|
||||||
username: "test",
|
username: process.env.TURN_USERNAME,
|
||||||
credential: "123456",
|
credential: process.env.TURN_CREDENTIAL,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -251,9 +250,9 @@ document.addEventListener("visibilitychange", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
//WebRTC连接相关代码
|
//WebRTC连接相关代码
|
||||||
const RTCpeerConnection = ref(null);
|
export const RTCpeerConnection = ref(null);
|
||||||
|
|
||||||
export const getlocalStream = async () => {
|
const getlocalStream = async () => {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
console.log("获取本地音频流成功", stream);
|
console.log("获取本地音频流成功", stream);
|
||||||
return stream;
|
return stream;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
@@ -58,4 +61,10 @@ export default defineConfig({
|
|||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
});
|
define: {
|
||||||
|
'process.env.STUN_URL': JSON.stringify(process.env.STUN_URL),
|
||||||
|
'process.env.TURN_URL': JSON.stringify(process.env.TURN_URL),
|
||||||
|
'process.env.TURN_USERNAME': JSON.stringify(process.env.TURN_USERNAME),
|
||||||
|
'process.env.TURN_CREDENTIAL': JSON.stringify(process.env.TURN_CREDENTIAL),
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user