Compare commits

4 Commits
main ... dev

Author SHA1 Message Date
08ae7414d0 chore: refact webRTC relative logic and update npm module 2026-03-17 18:10:30 +08:00
merlin
6f205d2408 fix: update dependences 2026-03-02 15:51:33 +08:00
merlin
b40691a4a3 feat: dplayer 2025-12-29 15:40:42 +08:00
merlin
27c4a247d3 reafactor: group basic function refactor complete 2025-12-17 18:03:16 +08:00
33 changed files with 7336 additions and 7520 deletions

4
.env_template Normal file
View File

@@ -0,0 +1,4 @@
STUN_URL=''
TURN_URL=''
TURN_USERNAME=''
TURN_CREDENTIAL=''

4
.gitignore vendored
View File

@@ -28,3 +28,7 @@ coverage
*.sw?
*.tsbuildinfo
# dev environment variables
.env

2800
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"proxy": "node proxy.js"
},
"dependencies": {
"axios": "^1.12.0",
"axios": "^1.13.6",
"crypto-js": "^4.2.0",
"dplayer": "^1.27.0",
"dotenv": "^17.3.1",
"dplayer": "^1.27.1",
"element-plus": "^2.9.4",
"express": "^4.21.2",
"hls.js": "^1.5.20",
@@ -29,5 +31,8 @@
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.11",
"vite-plugin-vue-devtools": "^7.7.1"
},
"overrides": {
"axios": "^1.13.6"
}
}

View File

@@ -8,6 +8,8 @@ const port = 3000;
// 允许跨域请求
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Range');
next();
});
@@ -18,22 +20,35 @@ app.get('/proxy', async (req, res) => {
const targetUrl = req.query.url;
console.log('Fetching data from:', targetUrl);
if (!targetUrl) {
return res.status(400).send('URL parameter is required');
return res.status(400).json({ error: 'URL parameter is required' });
}
try {
// 设置请求头
const headers = {};
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
};
if (req.headers.range) {
headers['Range'] = req.headers.range; // 转发 Range 请求头
headers['Range'] = req.headers.range; // 转发 Range 请求头,支持视频分段加载
}
// 向目标 URL 发起请求
const response = await fetch(targetUrl, { headers });
// 检查响应状态
if (!response.ok) {
return res.status(response.status).json({
error: `Failed to fetch: ${response.statusText}`,
status: response.status
});
}
// 设置响应头
response.headers.forEach((value, key) => {
if (key !== 'content-encoding') { // 避免某些头信息导致问题
// 跳过一些可能导致问题的头信息
if (key.toLowerCase() !== 'content-encoding' &&
key.toLowerCase() !== 'transfer-encoding' &&
key.toLowerCase() !== 'connection') {
res.setHeader(key, value);
}
});
@@ -44,19 +59,55 @@ app.get('/proxy', async (req, res) => {
}
// 将响应体直接流式传输给客户端
if (response.body) {
response.body.pipe(res, { end: true });
// 错误处理
response.body.on('error', (err) => {
console.error('Error during data transfer:', err);
res.status(500).send('Error during data transfer');
if (!res.headersSent) {
res.status(500).json({ error: 'Error during data transfer' });
}
});
} else {
res.status(500).json({ error: 'No response body' });
}
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).send('Error fetching data');
// 根据错误类型返回更具体的错误信息
let errorMessage = 'Error fetching data';
let statusCode = 500;
let hostname = '';
try {
hostname = new URL(targetUrl).hostname;
} catch (e) {
hostname = 'unknown';
}
if (error.code === 'ENOTFOUND') {
errorMessage = `DNS解析失败无法解析域名 "${hostname}"。请检查URL是否正确或网络连接是否正常。`;
statusCode = 502;
} else if (error.code === 'ECONNREFUSED') {
errorMessage = `连接被拒绝:无法连接到目标服务器 "${hostname}"。`;
statusCode = 502;
} else if (error.code === 'ETIMEDOUT') {
errorMessage = `请求超时:目标服务器 "${hostname}" 响应时间过长。`;
statusCode = 504;
}
if (!res.headersSent) {
res.status(statusCode).json({
error: errorMessage,
code: error.code,
targetUrl: targetUrl
});
}
}
});
app.listen(port, () => {
console.log(`Proxy server running at http://localhost:${port}`);
});

93
src/api/group.ts Normal file
View File

@@ -0,0 +1,93 @@
import axios from "axios";
import { userInfoStore } from "@/store/user";
const userinfo = userInfoStore();
export const getGroups = async ()=> {
const response = await axios.get('/api/group/get',{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}
});
if(response.data.code === "200"){
return response.data.data;
} else {
throw new Error(response.data.message);
}
}
export const createGroup = async (group_name: string) => {
const response = await axios.post('/api/group/create',{
g_name: group_name
}
,{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}
});
if(response.data.code === "200"){
return true;
} else {
throw new Error(response.data.message);
}
}
export const searchGroups = async (group_name: string) => {
const response = await axios.post('/api/group/search',{
g_name: group_name
},{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}
})
if(response.data.code === "200"){
return response.data.data.records;
}else{
throw new Error(response.data.message);
}
}
export const joinGroup = async (group_id: number) => {
const response = await axios.get('/api/group/join/'+group_id,{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}})
if(response.data.code === "200"){
return true;
}else{
throw new Error(response.data.message);
}
}
export const leaveGroup = async (group_id: number) => {
const response = await axios.get('/api/group/leave/'+group_id,{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}})
if(response.data.code === "200"){
return true;
}else{
throw new Error(response.data.message);
}
}
export const getGroupMembers = async (group_id: number) => {
const response = await axios.get('/api/group/member/'+group_id,{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
},
params:{
currentPage: 1,
pageSize: 50
}
})
if(response.data.code === "200"){
return response.data.data;
}else{
throw new Error(response.data.message);
}
}

67
src/api/playroom.ts Normal file
View File

@@ -0,0 +1,67 @@
import { userInfoStore } from "@/store/user";
import axios from "axios";
const userinfo = userInfoStore();
export const searchPlayRoom = async (inputValue: string) => {
const response = await axios.post('/api/playroom/search',{
r_name: inputValue
},{
headers:{
'Authorization': "Bearer " + userinfo.token
}
})
if(response.data.code === "200"){
return response.data.data.records
} else {
console.error(response.data.msg)
return []
}
}
export const createPlayroom = async (room_name: string) => {
const response = await axios.post('/api/playroom/create',{
r_name: room_name
},{
headers:{
'Authorization': "Bearer " + userinfo.token
}
})
if(response.data.code === "200"){
return true;
} else {
throw new Error(response.data.message);
}
}
export const getPlayrooms = async ()=> {
const response = await axios.get('/api/playroom/get',{
headers:{
'Authorization' : `Bearer ${userinfo.token}`
}
})
if(response.data.code === "200"){
return response.data.data;
} else {
throw new Error(response.data.message);
}
}
export const joinPlayroom = async (r_id: number) => {
const response = await axios.post('/api/inviting/playroom',{
inviter: userinfo.user.id,
target: userinfo.user.id,
status: 0,
room: r_id,
},{
headers:{
'Authorization': "Bearer " + userinfo.token
}
})
if(response.data.code === "200"){
return true;
} else {
throw new Error(response.data.message);
}
}

View File

@@ -1,21 +0,0 @@
import { userInfoStore } from "@/store/user";
import axios from "axios";
const userinfo = userInfoStore();
export const searchPlayRoom = async (inputValue: string) => {
const response = await axios.post('/api/playroom/search',{
r_name: inputValue
},{
headers:{
'Authorization': "Bearer " + userinfo.token
}
})
if(response.data.code === "200"){
return response.data.data
} else {
console.error(response.data.msg)
return []
}
}

View File

@@ -24,16 +24,14 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, initCustomFormatter } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
import logo from '../assets/logo.png';
import { onlineSocketStore } from '@/store/Online';
import router from '@/router';
import { voiceStore } from '@/store/Voice';
import { groupMessageStore } from '@/store/message.ts';
import { userInfoStore } from '@/store/user';
const userInfo = userInfoStore()
const voice = voiceStore()
const socket = onlineSocketStore()
const groupMessage = groupMessageStore()
@@ -73,7 +71,6 @@ const logout = () => {
userInfo.clearUserInfo();
localStorage.clear();
// 断开websocket链接
voice.disconnect();
socket.disconnect();
// 跳转到登录页面
window.location.href = '/';

View File

@@ -18,24 +18,26 @@
<button @click="answer" v-if="!onCall.from">
接听
</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>
</div>
</div>
<el-button @click="playRemoteAudio">没有声音试试我</el-button>
</div>
<audio ref="remoteAudio" autoplay></audio>
<audio ref="remoteAudio" id="remoteAudio" autoplay playsinline></audio>
</template>
<script setup>
import { userInfoStore } from '@/store/store';
import { voiceStore } from '@/store/Voice';
import { userInfoStore } from '@/store/user';
import { onCallStore } from '@/store/VoiceTarget';
import { Mic } from '@element-plus/icons-vue';
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { denyCall, hangupCall,sendOffer } from '@/store/Voice.ts';
const voice = voiceStore();
const onCall = onCallStore();
const userinfo = userInfoStore()
@@ -48,37 +50,34 @@ onMounted(() => {
onBeforeUnmount(() => {
// 清理音频流和元素
if (localAudio.value.srcObject) {
if (localAudio.value && localAudio.value.srcObject) {
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());
}
})
const answer = () => {
const answer = async () => {
console.log("接听");
onCall.callingOff();
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 = () => {
onCall.panel = !onCall.panel;
}
const playRemoteAudio = () => {
const audio = document.getElementById("remoteAudio");
if (audio) {
const audio = remoteAudio.value;
if (!audio) return;
audio.play().catch(err => {
console.log(err);
});
}
}

View File

@@ -10,7 +10,7 @@
<script setup>
import { ref, computed } from 'vue';
import { userInfoStore } from '@/store/store';
import { userInfoStore } from '@/store/user.ts';
const userinfo = userInfoStore();

View File

@@ -8,6 +8,8 @@ import DPlayer from 'dplayer';
import Hls from 'hls.js';
const videoRef = ref(null);
const player = ref(null);
const hlsInstance = ref(null);
const props = defineProps({
autoplay: { type: Boolean, default: false },
@@ -15,75 +17,208 @@ const props = defineProps({
danmaku: { type: Object, default: () => ({}) }
});
onMounted(() => {
const playerOptions = {
container: videoRef.value,
autoplay: props.autoplay,
video: {
url: `/proxy?url=${encodeURIComponent(props.videoUrl)}`,
type: 'customHls',
customType: {
customHls: function (video, player) {
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(video.src);
hls.attachMedia(video);
hls.config.maxBufferLength = 60; // 设置最大缓冲时间为60秒
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
video.play();
});
}
}
}
},
danmaku: props.danmaku
// 判断是否为 HLS 格式
const isHlsUrl = (url) => {
return url.includes('.m3u8') || url.includes('hls') || url.includes('application/x-mpegURL');
};
console.log(`/proxy?url=${encodeURIComponent(props.videoUrl)}`)
const player = new DPlayer(playerOptions);
// 清理 HLS 实例
const cleanupHls = () => {
if (hlsInstance.value) {
try {
hlsInstance.value.destroy();
} catch (e) {
console.warn('清理 HLS 实例时出错:', e);
}
hlsInstance.value = null;
}
};
player.on('play', () => {
console.log("播放...")
})
player.on('pause', () => {
console.log("暂停...")
})
player.on('ended', () => {
console.log("结束...")
})
player.on('error', () => {
console.log("出错...")
})
// 创建播放器配置
const createPlayerOptions = (url) => {
const proxyUrl = `/proxy?url=${encodeURIComponent(url)}`;
const isHls = isHlsUrl(url);
watch(() => props.videoUrl, (newUrl) => {
if (player) {
console.log("切换视频...")
player.switchVideo({
url: `/proxy?url=${encodeURIComponent(newUrl)}`,
console.log('视频 URL:', url);
console.log('是否为 HLS 格式:', isHls);
console.log('代理 URL:', proxyUrl);
const options = {
container: videoRef.value,
autoplay: props.autoplay,
video: {},
danmaku: props.danmaku
};
if (isHls && Hls.isSupported()) {
// HLS 格式使用 customHls
options.video = {
url: proxyUrl,
type: 'customHls',
customType: {
customHls: function (video, player) {
if (Hls.isSupported()) {
const hls = new Hls();
cleanupHls(); // 清理旧的实例
const hls = new Hls({
maxBufferLength: 20,
maxMaxBufferLength: 60,
enableWorker: true,
lowLatencyMode: false
});
hlsInstance.value = hls;
hls.loadSource(video.src);
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
video.play();
});
}
}
}
})
}
}
)
// 销毁时清理播放器
onBeforeUnmount(() => {
player.destroy();
// 错误处理
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS 错误:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('网络错误,尝试恢复...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('媒体错误,尝试恢复...');
hls.recoverMediaError();
break;
default:
console.error('致命错误,无法恢复');
hls.destroy();
break;
}
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS 清单解析完成');
if (props.autoplay) {
video.play().catch(err => {
console.warn('自动播放失败:', err);
});
}
});
}
}
};
} else {
// 普通视频格式MP4等
options.video = {
url: proxyUrl,
type: 'auto' // 让 DPlayer 自动检测类型
};
}
return options;
};
onMounted(() => {
try {
const playerOptions = createPlayerOptions(props.videoUrl);
player.value = new DPlayer(playerOptions);
player.value.on('play', () => {
console.log('播放:', props.videoUrl);
});
player.value.on('pause', () => {
console.log('暂停');
});
player.value.on('ended', () => {
console.log('播放结束');
});
player.value.on('error', (error) => {
console.error('播放器错误:', error);
});
player.value.on('loadstart', () => {
console.log('开始加载视频');
});
player.value.on('canplay', () => {
console.log('视频可以播放');
});
} catch (error) {
console.error('初始化播放器失败:', error);
}
});
watch(() => props.videoUrl, (newUrl) => {
if (player.value && newUrl) {
console.log('切换视频到:', newUrl);
try {
cleanupHls(); // 清理旧的 HLS 实例
const isHls = isHlsUrl(newUrl);
const proxyUrl = `/proxy?url=${encodeURIComponent(newUrl)}`;
if (isHls && Hls.isSupported()) {
// HLS 格式切换
player.value.switchVideo({
url: proxyUrl,
type: 'customHls',
customType: {
customHls: function (video, player) {
cleanupHls();
const hls = new Hls({
maxBufferLength: 20,
maxMaxBufferLength: 60,
enableWorker: true
});
hlsInstance.value = hls;
hls.loadSource(video.src);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS 错误:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
}
}
});
} else {
// 普通视频格式切换
player.value.switchVideo({
url: proxyUrl,
type: 'auto'
});
}
} catch (error) {
console.error('切换视频失败:', error);
}
}
});
// 销毁时清理播放器和 HLS 实例
onBeforeUnmount(() => {
cleanupHls();
if (player.value) {
try {
player.value.destroy();
} catch (e) {
console.warn('销毁播放器时出错:', e);
}
player.value = null;
}
});
</script>

View File

@@ -5,7 +5,7 @@ const STORE_NAME = 'groupHistoryMessages';
// 打开数据库
const getDB = async () => {
return openDB(DB_NAME, 3, {
return openDB(DB_NAME, 4, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME); // 使用 userId 作为 key

View File

@@ -79,7 +79,7 @@ const router = createRouter({
{
path: '/room',
name: 'room',
component: () => import('../views/room/room.vue'),
component: () => import('../views/room/index.vue'),
}

View File

@@ -12,7 +12,7 @@ export const onlineSocketStore = defineStore('onlineSocket', {
}),
actions: {
connect(id) {
connect(id: number) {
this.id = id;
if (this.isConnected === true) return
connectWebSocket();
@@ -30,8 +30,13 @@ export const onlineSocketStore = defineStore('onlineSocket', {
message.saveMessagesHistory(this.id)
}
},
send(message) {
sendMessage(JSON.stringify(message));
send(message: any) {
try {
sendMessage(message);
} catch (error) {
console.error('Failed to stringify message:', error);
return;
}
}
}
})

View File

@@ -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
View 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;
}

View File

@@ -21,7 +21,10 @@ export const onCallStore = defineStore("onCall", {
}
}),
actions: {
setTarget(u_id, u_name, u_avatar) {
getTarget() {
return this.target;
},
setTarget(u_id:number, u_name:string, u_avatar:string) {
this.target = {
u_id: u_id,
name: u_name,
@@ -52,6 +55,25 @@ export const onCallStore = defineStore("onCall", {
statusOff() {
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) {
this.localstream.audioStream = stream;
if (this.localstream.audioStream) {

View File

@@ -0,0 +1,76 @@
import { defineStore } from "pinia";
import {
saveGroupMessages,
loadGroupMessages,
deleteGroupMessages,
} from "@/functions/groupHistoryMessage";
interface Group_Message{
cmd: string;
from: number;
to: number;
group: number;
content: string;
time: string;
}
export const groupMessageStore = defineStore("groupMessageStore", {
state: () => ({
g_id: "",
historymessages: [],
messages: [],
corresponding: [],
}),
actions: {
async recieveMessage(u_id: number, message: Group_Message) {
const key = `${u_id}-${message.group}`
if(this.g_id !== '' && this.g_id === message.group) this.messages.push(message);
await saveGroupMessages(key, [message]);
},
reset(){
this.g_id = "";
this.historymessages = [];
this.messages = [];
this.corresponding = [];
},
addMessage(message: Group_Message) {
this.messages.push(message);
},
clearMessages() {
this.messages = [];
},
async initMessages() {
this.historymessages = [...this.historymessages, ...this.messages];
this.messages = [];
},
async getHistoryMessages(u_id:number, g_id: number) {
this.g_id = g_id;
const key = `${u_id}-${g_id}`;
try {
this.historymessages = await loadGroupMessages(key);
// 确保历史消息是数组类型
if (!Array.isArray(this.historymessages)) {
console.error("历史消息数据无效:", this.historymessages);
this.historymessages = []; // 如果数据无效,设置为空数组
}
} catch (error) {
console.log("加载历史消息时出错" + error);
}
},
async saveMessagesHistory(u_id: number, g_id: number) {
// const key = `${u_id}-${g_id}`;
// const messages = toRaw(this.messages);
this.messages = [];
this.historymessages = [];
// await saveGroupMessages(key, messages);
},
async deleteMessagesHistory(u_id: number, g_id: number) {
const key = `${u_id}-${g_id}`;
this.historymessages = [];
this.messages = [];
await deleteGroupMessages(key, []);
},
},
});

View File

@@ -1,186 +0,0 @@
import { defineStore } from "pinia";
import {
saveMessages,
loadMessages,
deleteMessages,
} from "@/functions/historyMessages";
import {
saveGroupMessages,
loadGroupMessages,
deleteGroupMessages,
} from "@/functions/groupHistoryMessage";
import { toRaw } from "vue";
export const messageStore = defineStore("messageStore", {
// 定义一个响应式数组来存储聊天消息
state: () => ({
historymessages: [],
messages: [],
sender: "", //选择的聊天用户id
target: "", //用户自己的id
corresponding: [],
}),
// 定义操作消息的函数
actions: {
// 添加消息到数组
addMessage(message) {
this.messages.push(message);
},
// 清空所有消息
clearMessages() {
this.messages = [];
},
setCorresponding() {
// 过滤出当前聊天中的消息
this.corresponding = this.messages.filter(
(msg) =>
(msg.sender === this.sender && msg.target === this.target) ||
(msg.sender === this.target && msg.target === this.sender)
);
const historymessages = this.historymessages.filter(
(msg) =>
(msg.sender === this.sender && msg.target === this.target) ||
(msg.sender === this.target && msg.target === this.sender)
);
this.corresponding = [...historymessages, ...this.corresponding];
},
// 清楚当前登录的聊天数据,保存到本地
async saveMessagesHistory(id) {
const messages = toRaw(this.messages);
await saveMessages(id, messages);
},
// 加载本地聊天数据
async loadMessagesHistory(u_id) {
try {
this.historymessages = await loadMessages(u_id);
// 确保历史消息是数组类型
if (!Array.isArray(this.historymessages)) {
console.error("历史消息数据无效:", this.historymessages);
this.historymessages = []; // 如果数据无效,设置为空数组
}
} catch (error) {
console.log("加载历史消息时出错" + error);
}
},
async deleteMessagesHistory(u_id, f_id) {
this.historymessages = this.historymessages.filter(
(msg) =>
!(
(msg.sender === f_id && msg.target === u_id) ||
(msg.sender === u_id && msg.target === f_id)
)
);
this.messages = this.messages.filter(
(msg) =>
!(
(msg.sender === f_id && msg.target === u_id) ||
(msg.sender === u_id && msg.target === f_id)
)
);
if (f_id === this.sender) {
console.log("清除对应聊天数据展示栈");
this.corresponding = [];
}
const messages = JSON.parse(JSON.stringify(toRaw(this.historymessages)));
try {
await deleteMessages(u_id, messages);
} catch (error) {
console.log("删除历史消息时出错" + error);
}
},
},
// 定义计算属性(可选)
getters: {
// 获取消息数量
messageCount: (state) => state.messages.length,
},
});
export const messageSignStore = defineStore("messageSignStore", {
state: () => ({
sign: [],
}),
actions: {
addSign(sign) {
// 检查是否已经存在相同的值
const exists = this.sign.some(
(item) =>
item.sender === sign.sender && item.sender_name === sign.sender_name
);
if (!exists) {
this.sign.push(sign);
} else {
console.warn(`Sign "${sign}" already exists and will not be added.`);
}
},
clearSign() {
this.sign = [];
},
removeSign(sign) {
// 找到相同值的索引
const index = this.sign.indexOf(sign);
if (index !== -1) {
// 如果存在,删除该值
this.sign.splice(index, 1);
} else {
console.warn(`Sign "${sign}" not found.`);
}
},
},
});
export const groupMessageStore = defineStore("groupMessageStore", {
state: () => ({
g_id: "",
historymessages: [],
messages: [],
corresponding: [],
}),
actions: {
addMessage(message) {
this.messages.push(message);
},
clearMessages() {
this.messages = [];
},
async initMessages() {
this.historymessages = [...this.historymessages, ...this.messages];
this.messages = [];
},
async getHistoryMessages(u_id, g_id) {
this.g_id = g_id;
const key = `${u_id}-${g_id}`;
try {
this.historymessages = await loadGroupMessages(key);
console.log("缓存到历史消息:");
console.log(this.historymessages);
// 确保历史消息是数组类型
if (!Array.isArray(this.historymessages)) {
console.error("历史消息数据无效:", this.historymessages);
this.historymessages = []; // 如果数据无效,设置为空数组
}
} catch (error) {
console.log("加载历史消息时出错" + error);
}
},
async saveMessagesHistory(u_id, g_id) {
const key = `${u_id}-${g_id}`;
console.log(key);
const messages = toRaw(this.messages);
this.messages = [];
this.historymessages = [];
await saveGroupMessages(key, messages);
},
async deleteMessagesHistory(u_id, g_id) {
const key = `${u_id}-${g_id}`;
this.historymessages = [];
this.messages = [];
await deleteGroupMessages(key, []);
},
},
});

73
src/store/playroom.ts Normal file
View File

@@ -0,0 +1,73 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/roomSocket'
interface PlayroomState {
id: number
r_id: number;
r_name: string;
r_introduction: string;
r_avatar: string;
role: number;
}
export const PlayroomStore = defineStore("PlayroomStore",
() =>{
const currentPlayroom = ref<PlayroomState>();
const currentUrl = ref<string>("");
const setCurrentPlayroom = (playroom: PlayroomState) => {
currentPlayroom.value = playroom;
}
const getCurrentPlayroom = () =>{
return currentPlayroom.value;
}
const getCurrentId = () =>{
return currentPlayroom.value?.r_id;
}
const setCurrentUrl = (url: string) => {
currentUrl.value = url;
}
const clearPlayroom = () => {
currentPlayroom.value = undefined;
currentUrl.value = "";
}
return {
getCurrentPlayroom,
getCurrentId,
setCurrentPlayroom,
setCurrentUrl,
clearPlayroom,
}
})
export const videoSocketStore = defineStore("videoSocketStore",{
state: () => ({
isConnected: false,
hasGotMessage: false,
id: 0
}),
actions: {
connect(id: number) {
this.id = id;
if (this.isConnected === true) return
connectWebSocket();
this.isConnected = true;
},
disconnect() {
setIsManualClose(true);
disconnectWebSocket();
this.isConnected = false;
},
send(message: string) {
sendMessage(JSON.stringify(message));
}
}
})

View File

@@ -1,15 +0,0 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export const roomStore = defineStore("room",{
state: () => ({
r_id: '',
r_name: '',
r_avatar: '',
inroomTag: '',
currentURL: '',
}),
actions:{
}
})

View File

@@ -1,11 +1,12 @@
<template>
<div class="home-default">
<h1>欢迎使用Myplayer</h1>
<h2>当前版本v0.2.0测试版</h2>
<h2>当前版本v0.3.0测试版</h2>
<p>v0.1.0 websocket实时聊天实装</p>
<p>v0.1.1 面板更新websocket重连机制增加</p>
<p>v0.1.2 修复bug群聊功能性实现webRTC点对点语言聊天实装</p>
<p>v0.2.0 修复若干bug完善了部分ui和逻辑</p>
<p>v0.3.0 重构了后端以及前端逻辑</p>
<p>预期开发计划1播放器相关开发 2ui重绘 3群聊功能完善......</p>
<p>总之还有好多事慢慢写吧</p>
</div>

View File

@@ -91,17 +91,15 @@
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { userInfoStore } from '@/store/user';
import { ElMessage } from 'element-plus'
import { onlineSocketStore } from '@/store/Online';
import { sendMessage } from '@/websocket/onlineSocket';
import { messageStore } from '@/store/message.ts';
import { Search } from '@element-plus/icons-vue'
import { voiceStore } from '@/store/Voice';
import { onCallStore } from '@/store/VoiceTarget';
import { onCallStore } from '@/store/VoiceTarget.ts';
import { getFriends, deleteFriend } from '@/api/friend';
const socket = onlineSocketStore()
const userinfo = userInfoStore()
const message = messageStore()
const voice = voiceStore()
const oncall = onCallStore()
const messagebox = ref([])
@@ -125,7 +123,8 @@ const selectedFriendId = ref('')
const tempsearchResult = computed(() => {
if (!tempsearch.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(() => {
@@ -170,7 +169,7 @@ const handleEnter = () => {
time: new Date().toLocaleString()
};
socket.send(msg)
sendMessage(msg);
message.addMessage(msg)
inputValue.value = ''
scrollToBottom()
@@ -234,7 +233,16 @@ const handleConfirmCall = () => {
oncall.panelOn()
oncall.callingOn()
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);
}

View File

@@ -37,7 +37,7 @@
<el-button class="groupInfoBtn" style="right: 200px;" v-if="selected"
@click="groupManagementVisible = true">群管理</el-button>
<el-button class="groupInfoBtn" style="right: 110px;" v-if="selected"
@click="leaveGroup">离开群聊</el-button>
@click="leavegroup">离开群聊</el-button>
</div>
</el-col>
</el-row>
@@ -47,14 +47,14 @@
<el-scrollbar ref="scrollbarRef" class="messagebox" style="height: 450px;">
<div v-for="(item) in messagebox" class="message-item">
<div
:class="{ 'message-item-profile': true, 'left': item.sender !== userinfo.user.u_id, 'right': item.sender === userinfo.user.u_id }">
<img :src="getAvatar(item.sender)" alt="User Avatar" />
:class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
<img :src="getAvatar(item.from)" alt="User Avatar" />
</div>
<div
:class="{ 'message-item-content': true, 'left': item.sender !== userinfo.user.u_id, 'right': item.sender === userinfo.user.u_id }">
:class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
{{ item.content }}</div>
<div
:class="{ 'message-item-time': true, 'left': item.sender === userinfo.user.u_id, 'right': item.sender !== userinfo.user.u_id }">
:class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }">
{{ item.time }}</div>
</div>
</el-scrollbar>
@@ -75,7 +75,7 @@
<el-dialog v-model="createDialogVisible" title="创建群聊">
<el-input v-model="groupName" placeholder="请输入群聊名称" clearable style="width: 80%;"></el-input>
<el-button type="primary" @click="createGroup"
<el-button type="primary" @click="creategroup"
style="position: absolute;bottom: 10px;right: 10px;">创建</el-button>
</el-dialog>
@@ -112,19 +112,19 @@
<el-table-column prop="m_avatar" label="" width="100">
<template #default="scope">
<!-- 使用 el-avatar 组件显示头像 -->
<el-avatar :src="memberSearchResult[scope.$index].m_avatar" size="large" />
<el-avatar :src="memberSearchResult[scope.$index].u_avatar" size="large" />
</template>
</el-table-column>
<el-table-column prop="m_name" label="姓名" width="125"></el-table-column>
<el-table-column prop="m_id" label="id" width="125"></el-table-column>
<el-table-column prop="u_name" label="姓名" width="125"></el-table-column>
<el-table-column prop="u_id" label="u_id" width="125"></el-table-column>
<el-table-column>
<template #default="scope">
<el-button @click="addFriend(scope.row.m_id)">添加好友</el-button>
<el-button @click="addFriend(scope.row.id)">添加好友</el-button>
</template>
</el-table-column>
<el-table-column>
<template #default="scope">
<el-button @click="kick(scope.row.m_id)">踢出</el-button>
<el-button @click="kick(scope.row.id)">踢出</el-button>
</template>
</el-table-column>
@@ -137,7 +137,7 @@
<el-row>
<el-col :span="24">
<el-input v-model="searchGroup" placeholder="请输入群聊名称" clearable style="width: 80%;"
:prefix-icon="Search" @keyup.enter="searchGroups"></el-input>
:prefix-icon="Search" @keyup.enter="searchgroups"></el-input>
</el-col>
</el-row>
<el-row>
@@ -153,7 +153,7 @@
<el-table-column prop="identify" label="是否需要验证" width="125"></el-table-column>
<el-table-column>
<template #default="scope">
<el-button @click="joinGroup(scope.row.g_id)">加入</el-button>
<el-button @click="joingroup(scope.row.g_id)">加入</el-button>
</template>
</el-table-column>
</el-table>
@@ -162,14 +162,13 @@
</el-dialog>
</template>
<script setup>
import { groupMessageStore } from '@/store/message'
import { groupMessageStore } from '@/store/group_message'
import { onMounted, ref, computed, nextTick, watch } from 'vue'
import { userInfoStore } from '@/store/store'
import { userInfoStore } from '@/store/user'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onlineSocketStore } from '@/store/Online'
import axios from 'axios'
import { getGroups,createGroup, searchGroups, joinGroup, leaveGroup, getGroupMembers } from '@/api/group'
const socket = onlineSocketStore();
const userinfo = userInfoStore();
@@ -201,26 +200,8 @@ const memberSearchResult = computed(() => {
})
const groupsMember = ref([
]);
const groups = ref([
// {
// g_id: '1',
// g_name: '我的群',
// g_avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif'
// },
// {
// g_id: '2',
// g_name: '我的好友群',
// g_avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif'
// },
// {
// g_id: '3',
// g_name: '我的家人群',
// g_avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif'
// },
])
const groupsMember = ref([]);
const groups = ref([])
const messagebox = ref([])
@@ -249,35 +230,25 @@ const scrollToBottom = () => {
});
};
const getGroupMembers = async (g_id) => {
axios({
url: '/api/group/getmember/' + g_id,
headers: {
'Authorization': userinfo.token
const getgroupmembers = async (g_id) => {
try{
groupsMember.value = await getGroupMembers(g_id)
}catch(error){
ElMessage.error("获取群成员失败:",error)
}
}).then((response) => {
if (response.data.code === 200) {
groupsMember.value = response.data.data
currentRole.value = response.data.data.find(item => item.m_id === userinfo.user.u_id).role
}
else {
ElMessage.error(response.data.msg)
}
})
}
const switchGroup = async (g_id, g_name, g_avatar, g_note) => {
groupMessage.reset()
groupMessage.g_id = g_id
inputDisabled.value = false
getGroupMembers(g_id)
await getgroupmembers(g_id)
if (selectedGroupId.value) {
groupMessage.saveMessagesHistory(userinfo.user.u_id, selectedGroupId.value)
// groupMessage.saveMessagesHistory(userinfo.user.u_id, selectedGroupId.value)
messagebox.value = []
console.log(messagebox.value)
}
await groupMessage.getHistoryMessages(userinfo.user.u_id, g_id)
await groupMessage.getHistoryMessages(userinfo.user.id, g_id)
await groupMessage.initMessages()
messagebox.value = groupMessage.historymessages
selectedGroupId.value = g_id
@@ -293,29 +264,24 @@ const sendMessage = () => {
ElMessage.error('消息不能为空')
return
}
// 发送四类消息给目标用户
const msg = {
group: true,
message: true,
system: false,
sender: userinfo.user.u_id,
sender_name: userinfo.user.u_name,
target: selectedGroupId.value,
target_name: selectedGroupName.value,
cmd: 'GROUP_MESSAGE',
group: groupMessage.g_id,
from: userinfo.user.id,
to: 0,
content: message.value,
time: new Date().toLocaleString()
};
socket.send(msg)
groupMessage.addMessage(msg)
groupMessage.recieveMessage(userinfo.user.id, msg)
message.value = ''
scrollToBottom()
}
const getAvatar = (u_id) => {
// 假设 groupsMember 是一个响应式变量,包含多个对象
const member = groupsMember.value.find(member => member.u_id === u_id);
const member = groupsMember.value.find(member => member.id === u_id);
return member ? member.u_avatar : null; // 如果找到匹配项,返回 u_avatar否则返回 null
};
// 监听消息
@@ -325,107 +291,76 @@ watch(() => groupMessage.messages.length, (newLength) => {
scrollToBottom()
})
const getGroups = async () => {
const response = await axios.get('/api/group/getgroups', {
headers: {
'Authorization': userinfo.token
}
})
if (response.data.code === 200) {
groups.value = response.data.data
}
else {
ElMessage.error(response.data.msg)
}
}
const createGroup = async () => {
const creategroup = async () => {
try{
if (groupName.value === '') {
ElMessage.error('群聊名称不能为空')
return
}
const response = await axios.post('/api/group/create', {
g_name: groupName.value
}, {
headers: {
'Authorization': userinfo.token
}
})
if (response.data.code === 200) {
if(await createGroup(groupName.value)){
ElMessage.success('创建成功')
createDialogVisible.value = false
getGroups()
groups.value = await getGroups()
}
}catch(error){
ElMessage.error('创建失败:',error)
}
}
const searchGroups = () => {
axios({
url: '/api/group/search',
method: 'get',
headers: {
'Authorization': userinfo.token
},
params: {
g_name: searchGroup.value
const searchgroups = async () => {
try{
groupSearchResult.value = await searchGroups(searchGroup.value)
}catch(error){
ElMessage.error("搜索群聊失败:",error)
}
}).then((response) => {
if (response.data.code === 200) {
groupSearchResult.value = response.data.data
} else {
ElMessage.error(response.data.msg)
}
})
}
const joinGroup = (g_id) => {
axios({
url: '/api/group/joingroup',
method: 'post',
data: {
g_id: g_id,
u_id: userinfo.user.u_id,
identify: 0
},
headers: {
'Authorization': userinfo.token
const joingroup = async (g_id) => {
try{
if(!g_id){
ElMessage.error('群聊id不能为空')
return
}
}).then((response) => {
if (response.data.code === 200) {
if(await joinGroup(g_id)){
ElMessage.success('加入成功')
joinDialogVisible.value = false
} else {
ElMessage.error(response.data.msg)
groups.value = await getGroups()
}
})
getGroups()
}catch(error){
ElMessage.error('加入群聊失败:',error)
}
const leaveGroup = async () => {
const response = await axios.get('/api/group/leave', {
params: {
g_id: selectedGroupId.value
}
}, {
headers: {
'Authorization': userinfo.token
const leavegroup = async () => {
try{
if(selectedGroupId.value===''){
ElMessage.error('未选择群聊')
return
}
})
if (response.data.code === 200) {
if(await leaveGroup(selectedGroupId.value)){
ElMessage.success('离开成功')
selected.value = false
selectedGroupId.value = ''
selectedGroupName.valuemessa = '请选择群聊'
selectedGroupName.value = '请选择群聊'
messagebox.value = []
groupMessage.clearMessages()
groupMessage.reset()
groups.value = await getGroups()
}
}catch(error){
ElMessage.error('离开群聊失败:',error)
}
}
const setNote = () => {
}
onMounted(() => {
getGroups()
onMounted(async() => {
// getGroups()
groups.value = await getGroups()
})
</script>

View File

@@ -27,16 +27,13 @@ import { onMounted, watchEffect } from 'vue';
import Navbar from '../../components/navBar.vue';
import UserProfile from '../../components/userProfile.vue';
import { userInfoStore } from '@/store/user';
import { onlineSocketStore } from '@/store/Online';
import { onlineSocketStore } from '@/store/Online.ts';
import { getUserInfo } from '@/api/user';
import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
import { messageStore } from '@/store/message.ts';
import phonePanel from '@/components/phonePanel.vue';
import { voiceStore } from '@/store/Voice';
import { initDB } from '@/functions/historyMessages';
const voice = voiceStore();
const userinfo = userInfoStore();
const socket = onlineSocketStore();
const message = messageStore();

View File

@@ -16,7 +16,7 @@
<el-table-column label="简介" prop="r_introduction"></el-table-column>
<el-table-column label="">
<template #default="scope">
<el-button @click="goToRoom(scope.row.r_id)">进入房间</el-button>
<el-button @click="goToRoom(scope.row)">进入房间</el-button>
</template>
</el-table-column>
</el-table>
@@ -24,7 +24,7 @@
<el-dialog v-model="createWindow" title="创建房间" style="width: 400px;height: 300px;">
<el-form ref="form" :model="formData" label-width="80px" @submit.prevent="createRoom">
<el-form ref="form" :model="formData" label-width="80px" @submit.prevent="createroom">
<el-form-item label="房间名" prop="roomName">
<el-input v-model="formData.roomName" placeholder="请输入房间名" clearable></el-input>
</el-form-item>
@@ -39,14 +39,11 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { roomStore } from '@/store/room';
import axios from 'axios';
import { userInfoStore } from '@/store/store';
import { PlayroomStore } from '@/store/playroom';
import { ElMessage } from 'element-plus';
import { getPlayrooms, createPlayroom } from '@/api/playroom';
const userinfo = userInfoStore()
const roominfo = roomStore()
const roominfo = PlayroomStore()
const createWindow = ref(false)
const formData = reactive({
roomName: ''
@@ -54,65 +51,45 @@ const formData = reactive({
const rooms = ref([])
const goToRoom = (r_id) => {
console.log(r_id)
roominfo.r_id = r_id
const goToRoom = (r) => {
roominfo.setCurrentPlayroom(r);
// 跳转到房间页面
const baseUrl = window.location.origin;
const targetUrl = `${baseUrl}/room`; // 替换为你的目标路由
window.open(targetUrl, "room");
};
const createRoom = async () => {
const createroom = async () => {
if (!formData.roomName || formData.roomName.trim() === '') {
ElMessage.error('房间名不能为空')
return
}
try {
const response = await axios.post('/api/room/create',{
r_name: formData.roomName
},{
headers: {
'Content-Type': 'application/json',
'Authorization': userinfo.token
},
})
if (response.data.code === 200) {
console.log(formData.roomName + '创建成功')
if(await createPlayroom(formData.roomName)){
ElMessage.success('创建成功')
}else{
console.log(response.data.msg)
ElMessage.error('创建失败')
}
formData.roomName = ''
createWindow.value = false
getRooms()
rooms.value = await getPlayrooms()
}
catch (error) {
console.log(error)
}
}
const getRooms = async () => {
const getrooms = async () => {
try {
const response = await axios.get('/api/room/getrooms',{
headers: {
'Content-Type': 'application/json',
'Authorization': userinfo.token
},
})
if (response.data.code === 200) {
console.log(response.data.data)
rooms.value = response.data.data
} else {
console.log(response.data.msg)
}
rooms.value = await getPlayrooms()
}
catch (error) {
console.log(error)
ElMessage.error('获取房间列表失败' + error)
}
}
onMounted(() => {
getRooms()
getrooms()
})
</script>

View File

@@ -50,7 +50,7 @@
<el-table-column prop="r_introduction" label="个性签名" width="200"></el-table-column>
<el-table-column>
<template #default="scope">
<el-button @click="joinRoom(scope.row.r_id)">加入房间</el-button>
<el-button @click="joinroom(scope.row.r_id)">加入房间</el-button>
</template>
</el-table-column>
</el-table>
@@ -69,7 +69,7 @@ import { userInfoStore } from '@/store/user'
import { onlineSocketStore } from '@/store/Online'
import { ElMessage } from 'element-plus'
import { searchFriend,addFriend } from '@/api/friend'
import { searchPlayRoom } from '@/api/room'
import { searchPlayRoom, joinPlayroom } from '@/api/playroom'
const socket = onlineSocketStore()
const inputValue = ref('')
@@ -123,26 +123,16 @@ const addfriend = async (f_id) => {
}
//加入房间申请逻辑
const joinRoom = (r_id) => {
axios({
headers: {
'Authorization': userinfo.token,
},
url: '/api/inviting/sendinviting',
method: 'POST',
data: {
inviter: userinfo.user.u_id,
target: userinfo.user.u_id,
room: r_id
}
}).then((response) => {
if (response.data.code === 200) {
const joinroom = (r_id) => {
try{
if(joinPlayroom(r_id)){
ElMessage.success("请求发送成功,请耐心等待审核")
return
}
else if (response.data.code === 500) {
ElMessage.error("请勿重复发送!")
}catch(error){
ElMessage.error("加入房间失败:",error)
}
})
}
</script>

View File

@@ -37,7 +37,7 @@
</el-row>
</el-col>
<el-col :span="16">
<video-player :autoplay="false" :videoUrl="videoUrl" />
<video-player :autoplay="false" :videoUrl="currentURL" />
</el-col>
<el-col :span="4">
<el-row style="height: 50%">
@@ -140,7 +140,7 @@
<el-dialog title="设置/替换视频流" v-model="dialogVisibleVideo">
<el-row>
<el-col :span="20">
<el-input v-model="curruentRoomInfo.currentURL" placeholder="请输入视频流地址"></el-input>
<el-input v-model="changingVideoUrl" placeholder="请输入视频流地址"></el-input>
</el-col>
<el-col :span="4">
<el-button>测试</el-button>
@@ -159,12 +159,13 @@
<script setup>
import { onMounted, ref, watchEffect } from 'vue';
import { ElMessage } from 'element-plus';
import { userInfoStore } from '@/store/store';
import { userInfoStore } from '@/store/user';
import videoPlayer from '@/components/videoPlayer.vue';
import { roomStore } from '@/store/room';
import { PlayroomStore } from '@/store/playroom';
//import audioPlayer from '@/components/audioPlayer.vue'; //
const curruentRoomInfo = roomStore();
const curruentRoomInfo = PlayroomStore();
const userinfo = userInfoStore();
const dialogVisibleCode = ref(false)
@@ -175,6 +176,8 @@ const drawer = ref(false);
const role = ref(null);
const invitingCode = ref('666666')
const changingVideoUrl = ref('')
const currentURL = ref('https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4')
const avatar = ref(null)
const avatarPreview = ref('')
@@ -333,7 +336,7 @@ const getInvitingCode = () => {
}
const replaceURL = () => {
currentURL.value = curruentRoomInfo.currentURL;
currentURL.value = changingVideoUrl.value;
dialogVisibleVideo.value = false;
}
@@ -365,7 +368,7 @@ onMounted(() => {
//
curruentRoomInfo.r_id = '123456';
curruentRoomInfo.r_name = '弹幕聊天室';
curruentRoomInfo.currentURL = 'https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4';
curruentRoomInfo.setCurrentUrl('https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4')
curruentRoomInfo.r_avatar = 'https://merlin.xin/avatars/avatar';
role.value = 0;
avatarPreview.value = curruentRoomInfo.r_avatar;

View File

@@ -1,15 +1,20 @@
import { ref } from "vue";
import { userInfoStore } from "@/store/store";
import { userInfoStore } from "@/store/user";
import { messageStore } from "@/store/message";
import { ElMessage } from "element-plus";
import { messageSignStore } from "@/store/message_sign";
import { groupMessageStore } from "@/store/group_message";
import { onCallStore } from "@/store/VoiceTarget";
import { handleOffer, handleAnswer, closeConnection, handleCandidate } from "@/store/Voice.ts";
// userinfo 实例
const userinfo = userInfoStore();
// message 实例
const message = messageStore();
// groupMessage 实例
// const groupMessage = groupMessageStore();
const groupMessage = groupMessageStore();
// oncall 实例
const oncall = onCallStore();
// messageSignStoregn 实例
// const messageSign = messageSignStore();
@@ -23,6 +28,7 @@ const reconnectScheduled = ref(false);
const retryCount = ref(0);
export const getRetryCount = () => {
return retryCount.value;
};
@@ -77,7 +83,7 @@ export const connectWebSocket = () => {
};
//处理消息逻辑
socket.value.onmessage = (event) => {
socket.value.onmessage = async (event) => {
console.log("从服务器收到消息:", event.data);
try{
const MessageData = JSON.parse(event.data);
@@ -85,6 +91,7 @@ export const connectWebSocket = () => {
switch(cmd){
case "MESSAGE":
message.addMessage(MessageData);
// TODO需要使用u_name或者u_id进行消息标记
// messageSign.setMessageSign(true);
// ElMessage.info("您有一条新的消息");
break;
@@ -92,58 +99,58 @@ export const connectWebSocket = () => {
messageSign.setMessageSign(true);
ElMessage.info("您有一条新的邀请消息");
break;
case "GROUP_MESSAGE":
groupMessage.recieveMessage(userinfo.user.id, MessageData);
// TODO: 需要使用groupId进行消息标记
break;
case "VIDEO_SYNC":
console.log("视频同步消息", MessageData);
break;
// 语音通话相关:
case "VOICE_CALL_REQUEST":
ElMessage.info("您有一个新的语音通话请求");
oncall.setTarget(MessageData.from, MessageData.from_name, MessageData.from_avatar);
oncall.panelOn();
oncall.callingOn();
oncall.fromOff();
oncall.statusOff();
break;
case "VOICE_CALL_RESPONSE":
console.log("收到语音通话响应消息");
//直接跳转到icecandidate阶段不再接收额外的响应消息
break;
case "VOICE_CALL_DENY":
ElMessage.info("对方拒绝了您的语音通话请求");
oncall.clear();
break;
case "VOICE_CALL_END":
ElMessage.info("语音通话已结束");
closeConnection();
oncall.clear();
break;
case "VOICE_ICE_CANDIDATE":
// stage3 : 收到ICE候选添加到RTC连接中
console.log("收到新的ICE候选");
await handleCandidate(MessageData.content);
break;
case "VOICE_SDP_OFFER":
// stage1 收到offer发送answer
console.log("收到SDP Offer");
await handleOffer(MessageData.content, MessageData.from, MessageData.to);
break;
case "VOICE_SDP_ANSWER":
// stage2 : 收到answer设置远端SDP
console.log("收到SDP Answer");
await handleAnswer(MessageData.content, MessageData.from, MessageData.to);
break;
}
}catch(error){
console.error("解析 JSON 失败:", error);
}
// try {
// const MessageData = JSON.parse(event.data);
// //是否为上下线消息(三类消息)
// if (!MessageData.system) {
// //是否为新消息
// if (MessageData.message) {
// messagePoint.hasNewMessage = true;
// if (MessageData.group) {
// //四类消息,群聊消息
// console.log("有新群消息");
// groupMessage.addMessage(MessageData);
// console.log(groupMessage.messages);
// messageSign.addSign({
// sender_name: MessageData.sender_name,
// g_id: MessageData.g_id,
// g_name: MessageData.g_name,
// });
// } else {
// //一类消息,私聊消息
// console.log("有新消息");
// message.addMessage(MessageData);
// console.log(message.messages);
// messageSign.addSign({
// sender: MessageData.sender,
// sender_name: MessageData.sender_name,
// });
// }
// }
// } else {
// if (MessageData.engaged) {
// // 五类消息,账号重复登录逻辑
// setIsManualClose(true);
// disconnectWebSocket();
// ElMessage("账号重复登录,请注意密码泄露");
// console.log("账号重复登陆");
// window.location.replace("/");
// } else {
// //三类消息,指示用户上下线
// if (MessageData.status === "online")
// ElMessage("用户:" + MessageData.u_name + "上线");
// else {
// ElMessage("用户:" + MessageData.u_name + "下线");
// }
// }
// }
// } catch (error) {
// console.error("解析 JSON 失败:", error);
// }
};
socket.value.onerror = (error) => {
@@ -183,11 +190,18 @@ export const reConnectWebSocket = () => {
// 发送消息
export const sendMessage = (message) => {
try {
const jsonmessage = JSON.stringify(message);
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(message);
socket.value.send(jsonmessage);
} else {
console.warn("WebSocket未连接无法发送消息");
}
}
catch (error) {
console.error("Failed to stringify message:", error);
}
};
//没有错误的重连,只是浏览器在后台断开了连接

View File

@@ -0,0 +1,149 @@
import { ref } from "vue";
import { userInfoStore } from "@/store/user";
import { ElMessage } from "element-plus";
import { PlayroomStore } from "@/store/playroom";
// userinfo 实例
const userinfo = userInfoStore();
// 延迟获取 playroom 实例,避免循环依赖
const getPlayroom = () => {
return PlayroomStore();
};
// WebSocket 实例
const socket = ref(null);
const isManualClose = ref(false);
const reconnectScheduled = ref(false);
const retryCount = ref(0);
export const getRetryCount = () => {
return retryCount.value;
};
export const addRetryCount = () => {
retryCount.value = retryCount.value + 1;
};
export const resetRetryCount = () => {
retryCount.value = 0;
};
export const setReconnectScheduled = (value) => {
reconnectScheduled.value = value;
};
export const getReconnectScheduled = () => {
return reconnectScheduled.value;
};
export const setIsManualClose = (value) => {
isManualClose.value = value;
};
export const getIsManualClose = () => {
return isManualClose.value;
};
// 连接WebSocket
export const connectWebSocket = () => {
const playroom = getPlayroom();
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const host = window.location.host;
const socketUrl = `${protocol}${host}/ws/video?r_id=${playroom.getCurrentId()}`;
// const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
console.log("还在重连中...");
return;
}
const retrytime = getRetryCount();
if (retrytime >= 10) {
console.log("重连失败,请稍后再试");
return;
}
console.log(retrytime);
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
socket.value.onopen = (event) => {
console.log("WebSocket for video 连接已建立", event);
setReconnectScheduled(false);
setIsManualClose(false);
resetRetryCount();
};
//处理消息逻辑
socket.value.onmessage = (event) => {
console.log("从服务器收到消息:", event.data);
try{
const MessageData = JSON.parse(event.data);
const cmd = MessageData.cmd;
switch(cmd){
case "VIDEO_SYNC":
console.log("视频同步消息", MessageData);
break;
}
}catch(error){
console.error("解析 JSON 失败:", error);
}
};
socket.value.onerror = (error) => {
console.error("WebSocket for video 发生错误:", error);
// console.log(error);
setReconnectScheduled(true);
socket.value.close();
};
socket.value.onclose = (event) => {
if (!getIsManualClose()) {
if (getReconnectScheduled()) {
socket.value = null;
addRetryCount();
setTimeout(reConnectWebSocket, 5000);
setReconnectScheduled(false);
} else {
// console.log("websocket因为浏览器省电设置断开");
console.log("WebSocket for video 连接已关闭", event);
}
}
};
};
// 断开WebSocket连接
export const disconnectWebSocket = () => {
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.close();
}
};
// 重连机制
export const reConnectWebSocket = () => {
connectWebSocket();
};
// 发送消息
export const sendMessage = (message) => {
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(message);
} else {
console.warn("WebSocket for video 未连接,无法发送消息");
}
};
//没有错误的重连,只是浏览器在后台断开了连接
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
} else {
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
if (getReconnectScheduled()) {
return;
}
reConnectWebSocket();
}
}
});

View File

@@ -1,5 +1,5 @@
import { ref } from "vue";
import { userInfoStore } from "@/store/store";
import { userInfoStore } from "@/store/user";
import { onCallStore } from "@/store/VoiceTarget";
import { ElMessage } from "element-plus";
@@ -39,13 +39,12 @@ const getIsManualClose = () => {
const iceserver = {
iceServers: [
{
// urls: "stun:stun.l.google.com:19302"
urls: "stun:8.134.92.199:3478",
urls: process.env.STUN_URL,
},
{
urls: "turn:8.134.92.199:3478",
username: "test",
credential: "123456",
urls: process.env.TURN_URL,
username: process.env.TURN_USERNAME,
credential: process.env.TURN_CREDENTIAL,
},
],
};
@@ -251,9 +250,9 @@ document.addEventListener("visibilitychange", () => {
});
//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 });
console.log("获取本地音频流成功", stream);
return stream;

View File

@@ -1,5 +1,8 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
plugins: [vue()],
@@ -32,6 +35,8 @@ export default defineConfig({
// ws: true,
// }
},
host: '0.0.0.0',
port: 5173,
},
// server: {
// https:{
@@ -56,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),
},
});