diff --git a/WebApp/client/public/bidirectional/js/main.js b/WebApp/client/public/bidirectional/js/main.js index 57d0638..2e4df80 100644 --- a/WebApp/client/public/bidirectional/js/main.js +++ b/WebApp/client/public/bidirectional/js/main.js @@ -1,76 +1,101 @@ -import { SendVideo } from "./sendvideo.js"; -import { getServerConfig, getRTCConfiguration } from "../../js/config.js"; -import { createDisplayStringArray } from "../../js/stats.js"; -import { RenderStreaming } from "../../module/renderstreaming.js"; -import { Signaling, WebSocketSignaling } from "../../module/signaling.js"; +/** + * 双向视频通话应用主文件 + * 负责初始化视频设备、建立WebRTC连接、处理信令和显示视频流 + */ +// 导入必要的模块 +import { SendVideo } from "./sendvideo.js"; // 视频发送和接收处理 +import { getServerConfig, getRTCConfiguration } from "../../js/config.js"; // 服务器配置和RTC配置 +import { createDisplayStringArray } from "../../js/stats.js"; // 统计信息处理 +import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连接管理 +import { Signaling, WebSocketSignaling } from "../../module/signaling.js"; // 信令管理 + +// 默认视频流尺寸 const defaultStreamWidth = 1280; const defaultStreamHeight = 720; + +// 预定义的视频分辨率列表 const streamSizeList = [ - { width: 640, height: 360 }, - { width: 1280, height: 720 }, - { width: 1920, height: 1080 }, - { width: 2560, height: 1440 }, - { width: 3840, height: 2160 }, - { width: 360, height: 640 }, - { width: 720, height: 1280 }, - { width: 1080, height: 1920 }, - { width: 1440, height: 2560 }, - { width: 2160, height: 3840 }, + { width: 640, height: 360 }, // 标清 + { width: 1280, height: 720 }, // 高清 + { width: 1920, height: 1080 }, // 全高清 + { width: 2560, height: 1440 }, // 2K + { width: 3840, height: 2160 }, // 4K + { width: 360, height: 640 }, // 竖屏标清 + { width: 720, height: 1280 }, // 竖屏高清 + { width: 1080, height: 1920 }, // 竖屏全高清 + { width: 1440, height: 2560 }, // 竖屏2K + { width: 2160, height: 3840 }, // 竖屏4K ]; -const localVideo = document.getElementById('localVideo'); -const remoteVideo = document.getElementById('remoteVideo'); -const localVideoStatsDiv = document.getElementById('localVideoStats'); -const remoteVideoStatsDiv = document.getElementById('remoteVideoStats'); -const textForConnectionId = document.getElementById('textForConnectionId'); -textForConnectionId.value = getRandom(); -const videoSelect = document.querySelector('select#videoSource'); -const audioSelect = document.querySelector('select#audioSource'); -const videoResolutionSelect = document.querySelector('select#videoResolution'); -const cameraWidthInput = document.querySelector('input#cameraWidth'); -const cameraHeightInput = document.querySelector('input#cameraHeight'); +// DOM元素引用 +const localVideo = document.getElementById('localVideo'); // 本地视频元素 +const remoteVideo = document.getElementById('remoteVideo'); // 远程视频元素 +const localVideoStatsDiv = document.getElementById('localVideoStats'); // 本地视频统计信息 +const remoteVideoStatsDiv = document.getElementById('remoteVideoStats'); // 远程视频统计信息 +const textForConnectionId = document.getElementById('textForConnectionId'); // 连接ID输入框 +textForConnectionId.value = getRandom(); // 生成随机连接ID +const videoSelect = document.querySelector('select#videoSource'); // 视频设备选择 +const audioSelect = document.querySelector('select#audioSource'); // 音频设备选择 +const videoResolutionSelect = document.querySelector('select#videoResolution'); // 视频分辨率选择 +const cameraWidthInput = document.querySelector('input#cameraWidth'); // 自定义宽度输入 +const cameraHeightInput = document.querySelector('input#cameraHeight'); // 自定义高度输入 +// 编解码器偏好设置 const codecPreferences = document.getElementById('codecPreferences'); +// 检查浏览器是否支持设置编解码器偏好 const supportsSetCodecPreferences = window.RTCRtpTransceiver && 'setCodecPreferences' in window.RTCRtpTransceiver.prototype; -const messageDiv = document.getElementById('message'); -messageDiv.style.display = 'none'; +const messageDiv = document.getElementById('message'); // 消息显示区域 +messageDiv.style.display = 'none'; // 初始隐藏消息区域 -let useCustomResolution = false; +let useCustomResolution = false; // 是否使用自定义分辨率 +// 初始化输入选择和编解码器选择 setUpInputSelect(); showCodecSelect(); /** @type {SendVideo} */ -let sendVideo = new SendVideo(localVideo, remoteVideo); +let sendVideo = new SendVideo(localVideo, remoteVideo); // 视频处理实例 /** @type {RenderStreaming} */ -let renderstreaming; -let useWebSocket; -let connectionId; +let renderstreaming; // WebRTC连接管理实例 +let useWebSocket; // 是否使用WebSocket信令 +let connectionId; // 连接ID +// 按钮事件绑定 const startButton = document.getElementById('startVideoButton'); -startButton.addEventListener('click', startVideo); +startButton.addEventListener('click', startVideo); // 启动视频按钮 const setupButton = document.getElementById('setUpButton'); -setupButton.addEventListener('click', setUp); +setupButton.addEventListener('click', setUp); // 设置连接按钮 const hangUpButton = document.getElementById('hangUpButton'); -hangUpButton.addEventListener('click', hangUp); +hangUpButton.addEventListener('click', hangUp); // 挂断按钮 +// 页面卸载前清理 window.addEventListener('beforeunload', async () => { if(!renderstreaming) return; - await renderstreaming.stop(); + await renderstreaming.stop(); // 停止WebRTC连接 }, true); +// 初始化配置 setupConfig(); +/** + * 初始化服务器配置 + * @async + * @returns {Promise} + */ async function setupConfig() { - const res = await getServerConfig(); - useWebSocket = res.useWebSocket; - showWarningIfNeeded(res.startupMode); + const res = await getServerConfig(); // 获取服务器配置 + useWebSocket = res.useWebSocket; // 设置是否使用WebSocket + showWarningIfNeeded(res.startupMode); // 显示启动模式警告 } +/** + * 根据启动模式显示警告信息 + * @param {string} startupMode - 启动模式,可能的值包括"public"和"private" + */ function showWarningIfNeeded(startupMode) { const warningDiv = document.getElementById("warning"); if (startupMode == "public") { @@ -79,7 +104,13 @@ function showWarningIfNeeded(startupMode) { } } +/** + * 启动本地视频 + * @async + * @returns {Promise} + */ async function startVideo() { + // 禁用相关输入控件 videoSelect.disabled = true; audioSelect.disabled = true; videoResolutionSelect.disabled = true; @@ -89,6 +120,8 @@ async function startVideo() { let width = 0; let height = 0; + + // 根据选择的分辨率设置视频尺寸 if (useCustomResolution) { width = cameraWidthInput.value ? cameraWidthInput.value : defaultStreamWidth; height = cameraHeightInput.value ? cameraHeightInput.value : defaultStreamHeight; @@ -98,48 +131,65 @@ async function startVideo() { height = size.height; } + // 启动本地视频 await sendVideo.startLocalVideo(videoSelect.value, audioSelect.value, width, height); - // enable setup button after initializing local video. + // 启用设置按钮 setupButton.disabled = false; } +/** + * 设置WebRTC连接 + * @async + * @returns {Promise} + */ async function setUp() { - setupButton.disabled = true; - hangUpButton.disabled = false; - connectionId = textForConnectionId.value; - codecPreferences.disabled = true; + setupButton.disabled = true; // 禁用设置按钮 + hangUpButton.disabled = false; // 启用挂断按钮 + connectionId = textForConnectionId.value; // 获取连接ID + codecPreferences.disabled = true; // 禁用编解码器选择 + // 创建信令实例 const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling(); - const config = getRTCConfiguration(); - renderstreaming = new RenderStreaming(signaling, config); + const config = getRTCConfiguration(); // 获取RTC配置 + renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例 + + // 连接建立回调 renderstreaming.onConnect = () => { - const tracks = sendVideo.getLocalTracks(); + const tracks = sendVideo.getLocalTracks(); // 获取本地媒体轨道 for (const track of tracks) { - renderstreaming.addTransceiver(track, { direction: 'sendonly' }); + renderstreaming.addTransceiver(track, { direction: 'sendonly' }); // 添加发送轨道 } - setCodecPreferences(); - showStatsMessage(); + setCodecPreferences(); // 设置编解码器偏好 + showStatsMessage(); // 显示统计信息 }; + + // 连接断开回调 renderstreaming.onDisconnect = () => { - hangUp(); + hangUp(); // 挂断连接 }; + + // 轨道事件回调 renderstreaming.onTrackEvent = (data) => { const direction = data.transceiver.direction; if (direction == "sendrecv" || direction == "recvonly") { - sendVideo.addRemoteTrack(data.track); + sendVideo.addRemoteTrack(data.track); // 添加远程轨道 } }; + // 启动WebRTC连接 await renderstreaming.start(); await renderstreaming.createConnection(connectionId); } -// 获取浏览器麦克风并发送到 Unity +/** + * 设置编解码器偏好 + */ function setCodecPreferences() { /** @type {RTCRtpCodecCapability[] | null} */ let selectedCodecs = null; + if (supportsSetCodecPreferences) { const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex]; if (preferredCodec.value !== '') { @@ -154,41 +204,63 @@ function setCodecPreferences() { if (selectedCodecs == null) { return; } + + // 获取视频收发器并设置编解码器偏好 const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video"); if (transceivers && transceivers.length > 0) { transceivers.forEach(t => t.setCodecPreferences(selectedCodecs)); } } +/** + * 挂断WebRTC连接 + * @async + * @returns {Promise} + */ async function hangUp() { - clearStatsMessage(); + clearStatsMessage(); // 清除统计信息 messageDiv.style.display = 'block'; messageDiv.innerText = `Disconnect peer on ${connectionId}.`; - hangUpButton.disabled = true; - setupButton.disabled = false; + hangUpButton.disabled = true; // 禁用挂断按钮 + setupButton.disabled = false; // 启用设置按钮 + + // 删除连接并停止WebRTC await renderstreaming.deleteConnection(); await renderstreaming.stop(); renderstreaming = null; - remoteVideo.srcObject = null; + remoteVideo.srcObject = null; // 清除远程视频源 - textForConnectionId.value = getRandom(); + textForConnectionId.value = getRandom(); // 生成新的随机连接ID connectionId = null; + + // 启用编解码器选择 if (supportsSetCodecPreferences) { codecPreferences.disabled = false; } } +/** + * 生成随机连接ID + * @returns {string} 5位随机数字字符串 + */ function getRandom() { const max = 99999; const length = String(max).length; const number = Math.floor(Math.random() * max); - return (Array(length).join('0') + number).slice(-length); + return (Array(length).join('0') + number).slice(-length); // 补零确保5位 } +/** + * 设置输入选择控件 + * @async + * @returns {Promise} + */ async function setUpInputSelect() { + // 获取媒体设备列表 const deviceInfos = await navigator.mediaDevices.enumerateDevices(); + // 填充视频设备选择 for (let i = 0; i !== deviceInfos.length; ++i) { const deviceInfo = deviceInfos[i]; if (deviceInfo.kind === 'videoinput') { @@ -197,6 +269,7 @@ async function setUpInputSelect() { option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`; videoSelect.appendChild(option); } else if (deviceInfo.kind === 'audioinput') { + // 填充音频设备选择 const option = document.createElement('option'); option.value = deviceInfo.deviceId; option.text = deviceInfo.label || `mic ${audioSelect.length + 1}`; @@ -204,6 +277,7 @@ async function setUpInputSelect() { } } + // 填充视频分辨率选择 for (let i = 0; i < streamSizeList.length; i++) { const streamSize = streamSizeList[i]; const option = document.createElement('option'); @@ -212,12 +286,14 @@ async function setUpInputSelect() { videoResolutionSelect.appendChild(option); } + // 添加自定义分辨率选项 const option = document.createElement('option'); option.value = streamSizeList.length; option.text = 'Custom'; videoResolutionSelect.appendChild(option); - videoResolutionSelect.value = 1; // default select index (1280 x 720) + videoResolutionSelect.value = 1; // 默认选择1280 x 720 + // 分辨率选择变化事件 videoResolutionSelect.addEventListener('change', (event) => { const isCustom = event.target.value >= streamSizeList.length; cameraWidthInput.disabled = !isCustom; @@ -226,6 +302,9 @@ async function setUpInputSelect() { }); } +/** + * 显示编解码器选择 + */ function showCodecSelect() { if (!supportsSetCodecPreferences) { messageDiv.style.display = 'block'; @@ -233,8 +312,10 @@ function showCodecSelect() { return; } + // 获取视频编解码器能力 const codecs = RTCRtpSender.getCapabilities('video').codecs; codecs.forEach(codec => { + // 跳过冗余和FEC编解码器 if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) { return; } @@ -246,14 +327,21 @@ function showCodecSelect() { codecPreferences.disabled = false; } -let lastStats; -let intervalId; +// 统计信息相关变量 +let lastStats; // 上次统计信息 +let intervalId; // 统计信息更新间隔ID +/** + * 显示统计信息 + */ function showStatsMessage() { + // 每秒更新一次统计信息 intervalId = setInterval(async () => { + // 显示本地视频分辨率 if (localVideo.videoWidth) { localVideoStatsDiv.innerHTML = `Sending resolution: ${localVideo.videoWidth} x ${localVideo.videoHeight} px`; } + // 显示远程视频分辨率 if (remoteVideo.videoWidth) { remoteVideoStatsDiv.innerHTML = `Receiving resolution: ${remoteVideo.videoWidth} x ${remoteVideo.videoHeight} px`; } @@ -262,11 +350,13 @@ function showStatsMessage() { return; } + // 获取WebRTC统计信息 const stats = await renderstreaming.getStats(); if (stats == null) { return; } + // 创建统计信息显示数组 const array = createDisplayStringArray(stats, lastStats); if (array.length) { messageDiv.style.display = 'block'; @@ -276,9 +366,12 @@ function showStatsMessage() { }, 1000); } +/** + * 清除统计信息 + */ function clearStatsMessage() { if (intervalId) { - clearInterval(intervalId); + clearInterval(intervalId); // 清除定时器 } lastStats = null; intervalId = null; diff --git a/WebApp/client/public/images/p1.png b/WebApp/client/public/images/p1.png new file mode 100644 index 0000000..bf2c348 Binary files /dev/null and b/WebApp/client/public/images/p1.png differ diff --git a/WebApp/client/public/images/p2.png b/WebApp/client/public/images/p2.png new file mode 100644 index 0000000..dac4c6a Binary files /dev/null and b/WebApp/client/public/images/p2.png differ diff --git a/WebApp/client/public/onebyone/index.html b/WebApp/client/public/onebyone/index.html index 8e1549e..dbc0fef 100644 --- a/WebApp/client/public/onebyone/index.html +++ b/WebApp/client/public/onebyone/index.html @@ -257,7 +257,7 @@
- @@ -278,7 +278,7 @@
-
@@ -328,7 +328,6 @@
- 嗨,能听到我说话吗?
@@ -348,7 +347,6 @@ 14:32
- 很清楚!你的画面也很清晰 👍
@@ -506,6 +504,36 @@ + + + diff --git a/WebApp/client/public/onebyone/main.js b/WebApp/client/public/onebyone/main.js index a96f52d..a15373c 100644 --- a/WebApp/client/public/onebyone/main.js +++ b/WebApp/client/public/onebyone/main.js @@ -6,11 +6,12 @@ import store from './store.js'; import UIRenderer from './renderer.js'; import apiClient from './api.js'; import wsManager from './websocket.js'; +import { mockCallSession } from './models.js'; import { showNotification, generateId } from './utils.js'; // 全局变量 let renderer = null; - +let connectionId = ""; /** * 初始化应用 */ @@ -30,6 +31,7 @@ function initApp() { // 初始化WebRTC (如果需要) // initWebRTC(); + console.log('App initialized'); } @@ -85,6 +87,19 @@ function bindWebSocketEvents() { store.endCall(); showNotification('通话已结束', 3000); }); + + wsManager.on('call-request', (data) => { + console.log('Call request received:', data); + // 显示通话请求弹窗 + if (window.showCallRequest) { + const caller = { + name: mockCallSession.remoteUser.name, + avatar:mockCallSession.remoteUser.avatar + }; + window.showCallRequest(caller); + connectionId =data.connectionId; + } + }); } /** @@ -92,31 +107,31 @@ function bindWebSocketEvents() { */ function bindDomEvents() { // 切换侧边栏 - window.toggleSidebar = function() { + window.toggleSidebar = function () { store.toggleSidebar(); }; // 切换麦克风 - window.toggleMute = function(button) { + window.toggleMute = function (button) { const state = store.getState(); const currentState = state.session.localUser.mediaState.audio; store.updateLocalMedia('audio', !currentState); }; // 切换视频 - window.toggleVideo = function(button) { + window.toggleVideo = function (button) { const state = store.getState(); const currentState = state.session.localUser.mediaState.video; store.updateLocalMedia('video', !currentState); }; // 切换本地视频(用于悬停控制) - window.toggleLocalVideo = function() { + window.toggleLocalVideo = function () { window.toggleVideo(); }; // 切换录屏 - window.toggleRecording = function(button) { + window.toggleRecording = function (button) { const state = store.getState(); const currentState = state.session.localUser.mediaState.recording || false; store.updateLocalMedia('recording', !currentState); @@ -130,25 +145,64 @@ function bindDomEvents() { }; // 结束通话 - window.endCall = function() { + window.endCall = function () { // 显示确认对话框 document.getElementById('endCallDialog').classList.remove('hidden'); }; // 取消结束通话 - window.cancelEndCall = function() { + window.cancelEndCall = function () { document.getElementById('endCallDialog').classList.add('hidden'); }; // 确认结束通话 - window.confirmEndCall = function() { + window.confirmEndCall = function () { document.getElementById('endCallDialog').classList.add('hidden'); store.endCall(); showNotification('通话已结束'); }; + // 显示通话请求弹窗 + window.showCallRequest = function (caller) { + const dialog = document.getElementById('callRequestDialog'); + if (dialog) { + // 设置通话请求信息 + if (document.getElementById('callRequestName')) { + document.getElementById('callRequestName').textContent = caller.name; + } + if (document.getElementById('callRequestAvatar')) { + document.getElementById('callRequestAvatar').src = caller.avatar; + } + // 显示弹窗 + dialog.classList.remove('hidden'); + } + }; + + // 拒绝通话 + window.rejectCall = function () { + const dialog = document.getElementById('callRequestDialog'); + if (dialog) { + dialog.classList.add('hidden'); + } + showNotification('已拒绝通话请求'); + // 可以在这里添加发送拒绝通话请求到服务器的逻辑 + }; + + // 接受通话 + window.acceptCall = function () { + const dialog = document.getElementById('callRequestDialog'); + if (dialog) { + dialog.classList.add('hidden'); + } + showNotification('已接受通话请求'); + // 可以在这里添加发送接受通话请求到服务器的逻辑 + // 然后初始化通话 + store.initCall(); + store.setUp(connectionId); + }; + // 发送消息 - window.sendMessage = function() { + window.sendMessage = function () { const chatInput = document.getElementById('chatInput'); const content = chatInput.value.trim(); @@ -174,19 +228,19 @@ function bindDomEvents() { }; // 处理聊天输入回车 - window.handleChatSubmit = function(event) { + window.handleChatSubmit = function (event) { if (event.key === 'Enter') { window.sendMessage(); } }; // 打开图片选择器 - window.openImagePicker = function() { + window.openImagePicker = function () { document.getElementById('imageInput').click(); }; // 处理图片上传 - window.handleImageUpload = function(event) { + window.handleImageUpload = function (event) { const file = event.target.files[0]; if (file) { // 检查文件类型 @@ -203,7 +257,7 @@ function bindDomEvents() { // 读取图片文件 const reader = new FileReader(); - reader.onload = function(e) { + reader.onload = function (e) { const imageUrl = e.target.result; sendImageMessage(imageUrl, file.name); }; @@ -253,6 +307,14 @@ function bindDomEvents() { // 绑定对话框事件 document.getElementById('cancelEndCall').addEventListener('click', window.cancelEndCall); document.getElementById('confirmEndCall').addEventListener('click', window.confirmEndCall); + + // 绑定通话请求对话框事件 + if (document.getElementById('rejectCall')) { + document.getElementById('rejectCall').addEventListener('click', window.rejectCall); + } + if (document.getElementById('acceptCall')) { + document.getElementById('acceptCall').addEventListener('click', window.acceptCall); + } } /** diff --git a/WebApp/client/public/onebyone/models.js b/WebApp/client/public/onebyone/models.js index 969bc51..62a2dfd 100644 --- a/WebApp/client/public/onebyone/models.js +++ b/WebApp/client/public/onebyone/models.js @@ -67,7 +67,7 @@ const mockCallSession = { localUser: { id: "user-local-001", name: "我", - avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop", + avatar: "/images/p1.png", isHost: true, mediaState: { audio: true, @@ -81,8 +81,8 @@ const mockCallSession = { // 远端用户信息 remoteUser: { id: "user-remote-002", - name: "Sarah Chen", - avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop", + name: "Unity", + avatar: "/images/p2.png", status: "online", // online | offline | connecting networkQuality: "excellent", // excellent | good | fair | poor mediaState: { @@ -110,8 +110,8 @@ const mockMessages = [ { id: "msg-002", senderId: "user-remote-002", - senderName: "Sarah Chen", - senderAvatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop", + senderName: mockCallSession.remoteUser.name, + senderAvatar: mockCallSession.remoteUser.avatar, content: "嗨,能听到我说话吗?", type: "text", timestamp: "2024-01-15T14:32:15.000Z", @@ -120,8 +120,8 @@ const mockMessages = [ { id: "msg-003", senderId: "user-local-001", - senderName: "我", - senderAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop", + senderName: mockCallSession.localUser.name, + senderAvatar: mockCallSession.localUser.avatar, content: "很清楚!你的画面也很清晰 👍", type: "text", timestamp: "2024-01-15T14:32:45.000Z", diff --git a/WebApp/client/public/onebyone/renderer.js b/WebApp/client/public/onebyone/renderer.js index 2b3820d..9454aaa 100644 --- a/WebApp/client/public/onebyone/renderer.js +++ b/WebApp/client/public/onebyone/renderer.js @@ -3,7 +3,7 @@ * 负责将状态映射到DOM,与状态管理解耦 */ import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js'; - +import {mockCallSession } from './models.js'; class UIRenderer { constructor(stateManager) { this.stateManager = stateManager; @@ -40,14 +40,14 @@ class UIRenderer { userList: document.getElementById('userList'), localMediaStatus: document.getElementById('localMediaStatus'), localMuteIcon: document.querySelector('[data-field="localUser.muteIcon"]'), - // 控制按钮 micBtn: document.getElementById('micBtn'), videoBtn: document.getElementById('videoBtn'), recordBtn: document.getElementById('recordBtn'), connectionQuality: document.getElementById('connectionQuality') }; - + // 订阅状态变化 + this.unsubscribe = stateManager.subscribe(this.render.bind(this)); // 初始化渲染 this.render(this.stateManager.getState(), { type: 'INIT' }); } @@ -58,23 +58,27 @@ class UIRenderer { case 'INIT': this.renderHeader(state.session); this.renderRemoteVideo(state.session.remoteUser); - this.renderLocalVideo(state.session.localUser); + this.renderLocalVideo(state.session.localUser, state.localStream); this.renderControlButtons(state.session.localUser.mediaState); this.renderChatMessages(state.messages); + this.renderUserList(state.session.localUser, state.session.remoteUser); break; case 'DURATION_UPDATE': this.renderCallDuration(changes.duration); break; case 'LOCAL_MEDIA_CHANGE': this.renderControlButtons(state.session.localUser.mediaState); - this.renderLocalVideo(state.session.localUser); + this.renderLocalVideo(state.session.localUser, state.localStream); this.renderLocalUserStatus(state.session.localUser); + this.renderUserList(state.session.localUser, state.session.remoteUser); break; case 'LOCAL_STREAM_OBTAINED': this.renderLocalStream(state.localStream); + this.renderLocalVideo(state.session.localUser, state.localStream); break; case 'REMOTE_MEDIA_CHANGE': this.renderRemoteVideo(state.session.remoteUser); + this.renderUserList(state.session.localUser, state.session.remoteUser); break; case 'NEW_MESSAGE': this.renderChatMessages(state.messages); @@ -140,9 +144,11 @@ class UIRenderer { } // 渲染本地视频 - renderLocalVideo(localUser) { + renderLocalVideo(localUser, localStream) { if (this.elements.localVideoPlaceholder) { - toggleElement(this.elements.localVideoPlaceholder, !localUser.mediaState.video); + // 当没有视频流或视频关闭时显示占位符 + const shouldShowPlaceholder = !localStream || !localUser.mediaState.video; + toggleElement(this.elements.localVideoPlaceholder, shouldShowPlaceholder); } if (this.elements.localAudioWave) { @@ -192,6 +198,41 @@ class UIRenderer { } } + // 渲染侧边栏用户列表 + renderUserList(localUser, remoteUser) { + if (!this.elements.userList) return; + + // 渲染本地用户 + const localUserElement = this.elements.userList.querySelector('[data-user-id="local"]'); + if (localUserElement) { + // 渲染本地用户头像 + const localAvatar = localUserElement.querySelector('img[data-field="localUser.avatar"]'); + if (localAvatar) { + localAvatar.src = localUser.avatar; + } + // 渲染本地用户名字 + const localName = localUserElement.querySelector('[data-field="localUser.name"]'); + if (localName) { + localName.textContent = localUser.name; + } + } + + // 渲染远程用户 + const remoteUserElement = this.elements.userList.querySelector('[data-user-id="remote"]'); + if (remoteUserElement) { + // 渲染远程用户头像 + const remoteAvatar = remoteUserElement.querySelector('img[data-field="remoteUser.avatar"]'); + if (remoteAvatar) { + remoteAvatar.src = remoteUser.avatar; + } + // 渲染远程用户名字 + const remoteName = remoteUserElement.querySelector('[data-field="remoteUser.name"]'); + if (remoteName) { + remoteName.textContent = remoteUser.name; + } + } + } + // 渲染控制按钮 renderControlButtons(mediaState) { if (this.elements.micBtn) { diff --git a/WebApp/client/public/onebyone/store.js b/WebApp/client/public/onebyone/store.js index 8f71c18..b4cac57 100644 --- a/WebApp/client/public/onebyone/store.js +++ b/WebApp/client/public/onebyone/store.js @@ -3,9 +3,20 @@ * 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia */ import { mockCallSession, mockMessages } from './models.js'; +import { Signaling, WebSocketSignaling } from "../../module/signaling.js";// 信令管理 +import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连接管理 +import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置 +// 默认视频流尺寸 +const defaultStreamWidth = 1280; +const defaultStreamHeight = 720; + + class CallStateManager { constructor() { + let renderstreaming; // WebRTC连接管理实例 + let useWebSocket; // 是否使用WebSocket信令 + let connectionId; // 连接ID // 核心状态 this.state = { session: { ...mockCallSession }, @@ -20,7 +31,7 @@ class CallStateManager { this.listeners = []; // 初始化 - this.init(); + //this.init(); } // 订阅状态变化 @@ -43,17 +54,22 @@ class CallStateManager { this.state.session.duration++; this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); }, 1000); - + // 初始化配置 + this.setupConfig(); // 获取本地摄像头视频流 this.getLocalStream(); + // 模拟远端音频活动 (实际应由 WebRTC VAD 检测触发) this.simulateRemoteActivity(); // 模拟网络质量变化 this.simulateNetworkChange(); } - + async setupConfig() { + const res = await getServerConfig(); + this.useWebSocket = res.useWebSocket; + } // 获取本地摄像头视频流 async getLocalStream() { try { @@ -103,6 +119,30 @@ class CallStateManager { // 更新本地媒体状态 async updateLocalMedia(mediaType, value) { + + // 如果是开启视频,重新获取摄像头资源 + if (mediaType === 'video' && value) { + if (this.state.localStream) { + this.state.localStream = null; + } + //if(this.state.localStream.getVideoTracks().length==0){ + // 请求摄像头权限并获取媒体流 + this.state.localStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true + }); + // } + await this.getLocalStream(); + } else { + // 直接更新媒体状态 + this.state.session.localUser.mediaState[mediaType] = value; + this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); + + // 发送媒体状态到服务器 + this.emitMediaStateChange(); + } + + // 如果是关闭视频,释放摄像头资源 if (mediaType === 'video' && !value && this.state.localStream) { this.state.localStream.getTracks().forEach(track => { @@ -122,28 +162,107 @@ class CallStateManager { }); } - // 如果是开启视频,重新获取摄像头资源 - if (mediaType === 'video' && value ) { - if(this.state.localStream){ - this.state.localStream=null; - } - //if(this.state.localStream.getVideoTracks().length==0){ - // 请求摄像头权限并获取媒体流 - this.state.localStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true - }); - // } - await this.getLocalStream(); - } else { - // 直接更新媒体状态 - this.state.session.localUser.mediaState[mediaType] = value; - this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); - // 发送媒体状态到服务器 - this.emitMediaStateChange(); + } + /** + * 设置WebRTC连接 + * @async + * @returns {Promise} + */ + async setUp(connectionId) { + //TODO + this.connectionId = connectionId; // 获取连接ID + codecPreferences.disabled = true; // 禁用编解码器选择 + + // 创建信令实例 + const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling(); + const config = getRTCConfiguration(); // 获取RTC配置 + this.renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例 + + // 连接建立回调 + this.renderstreaming.onConnect = () => { + const tracks = this.state.localStream.getTracks(); // 获取本地媒体轨道 + for (const track of tracks) { + this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }); // 添加发送轨道 + } + setCodecPreferences(); // 设置编解码器偏好 + showStatsMessage(); // 显示统计信息 + }; + + // 连接断开回调 + this.renderstreaming.onDisconnect = () => { + hangUp(); // 挂断连接 + }; + + // 轨道事件回调 + this.renderstreaming.onTrackEvent = (data) => { + const direction = data.transceiver.direction; + if (direction == "sendrecv" || direction == "recvonly") { + if (this.state.remoteStream == null) { + this.state.remoteStream = new MediaStream(); + } + this.state.remoteStream.addTrack(data.track); + } + }; + + // 启动WebRTC连接 + await this.renderstreaming.start(); + await this.renderstreaming.createConnection(connectionId); + + } + + /** + * 挂断WebRTC连接 + * @async + * @returns {Promise} + */ + async hangUp() { + clearStatsMessage(); // 清除统计信息 + messageDiv.style.display = 'block'; + messageDiv.innerText = `Disconnect peer on ${connectionId}.`; + + // 删除连接并停止WebRTC + await renderstreaming.deleteConnection(); + await renderstreaming.stop(); + renderstreaming = null; + remoteVideo.srcObject = null; // 清除远程视频源 + + connectionId = null; + + // 启用编解码器选择 + if (supportsSetCodecPreferences) { + codecPreferences.disabled = false; } } + /** + * 设置编解码器偏好 + */ + setCodecPreferences() { + /** @type {RTCRtpCodecCapability[] | null} */ + let selectedCodecs = null; + + if (supportsSetCodecPreferences) { + const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex]; + if (preferredCodec.value !== '') { + const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' '); + const { codecs } = RTCRtpSender.getCapabilities('video'); + const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine); + const selectCodec = codecs[selectedCodecIndex]; + selectedCodecs = [selectCodec]; + } + } + + if (selectedCodecs == null) { + return; + } + + // 获取视频收发器并设置编解码器偏好 + const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video"); + if (transceivers && transceivers.length > 0) { + transceivers.forEach(t => t.setCodecPreferences(selectedCodecs)); + } + } + // 更新远端媒体状态 (由 WebSocket 消息触发) updateRemoteMedia(mediaState) { @@ -228,4 +347,11 @@ class CallStateManager { // 创建单例实例 const store = new CallStateManager(); +// 页面卸载前清理 +window.addEventListener('beforeunload', async () => { + if (!store.renderstreaming) + return; + await store.renderstreaming.stop(); // 停止WebRTC连接 +}, true); export default store; + diff --git a/WebApp/client/public/onebyone/websocket.js b/WebApp/client/public/onebyone/websocket.js index 3c8ad4a..cdf51fe 100644 --- a/WebApp/client/public/onebyone/websocket.js +++ b/WebApp/client/public/onebyone/websocket.js @@ -12,6 +12,8 @@ class WebSocketManager { this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; + this.connectionId = null; + this.heartbeatInterval = null; } /** @@ -34,12 +36,23 @@ class WebSocketManager { console.log('WebSocket connected'); this.isConnected = true; this.reconnectAttempts = 0; + + // 生成连接ID + this.connectionId = this.generateConnectionId(); + + // 发送连接消息 + this.sendConnectMessage(); + + // 启动心跳 + this.startHeartbeat(); + this.emit('connect'); }; this.socket.onclose = () => { console.log('WebSocket disconnected'); this.isConnected = false; + this.stopHeartbeat(); this.emit('disconnect'); this.attemptReconnect(); }; @@ -76,13 +89,28 @@ class WebSocketManager { /** * 发送消息 - * @param {string} event - 事件名称 + * @param {string} type - 消息类型 * @param {Object} data - 消息数据 */ - send(event, data) { + send(type, data) { if (this.isConnected && this.socket) { try { - const message = JSON.stringify({ event, data }); + let message; + + // 根据消息类型构建不同的消息格式 + if (type === 'connect' || type === 'disconnect') { + message = JSON.stringify({ type, connectionId: this.connectionId }); + } else if (type === 'offer' || type === 'answer' || type === 'candidate') { + message = JSON.stringify({ type, data }); + } else if (type === 'broadcast') { + message = JSON.stringify({ type, message: data.message, targetConnectionId: data.targetConnectionId }); + } else if (type === 'ping' || type === 'pong') { + message = JSON.stringify({ type }); + } else { + // 兼容旧格式,用于自定义事件 + message = JSON.stringify({ event: type, data }); + } + this.socket.send(message); } catch (error) { console.error('Error sending WebSocket message:', error); @@ -97,36 +125,60 @@ class WebSocketManager { * @param {Object} message - 消息对象 */ handleMessage(message) { - switch (message.type) { - case 'user-joined': - this.emit('user-joined', message.data); - break; - case 'user-left': - this.emit('user-left', message.data); - break; - case 'media-state-changed': - this.emit('media-state-changed', message.data); - break; - case 'message-received': - this.emit('message-received', message.data); - break; - case 'network-quality': - this.emit('network-quality', message.data); - break; - case 'call-ended': - this.emit('call-ended', message.data); - break; - case 'ping': - // 处理心跳请求,回复pong - this.send('pong', {}); - break; - case 'pong': - // 处理心跳响应 - this.emit('pong'); - break; - default: - this.emit('message', message); - break; + if (message.type) { + switch (message.type) { + case 'user-joined': + this.emit('user-joined', message.data); + break; + case 'user-left': + this.emit('user-left', message.data); + break; + case 'media-state-changed': + this.emit('media-state-changed', message.data); + break; + case 'message-received': + this.emit('message-received', message.data); + break; + case 'network-quality': + this.emit('network-quality', message.data); + break; + case 'call-ended': + this.emit('call-ended', message.data); + break; + case 'call-request': + this.emit('call-request', message.data); + break; + case 'ping': + // 处理心跳请求,回复pong + this.send('pong'); + break; + case 'pong': + // 处理心跳响应 + this.emit('pong'); + break; + case 'offer': + this.emit('offer', message.data); + break; + case 'answer': + this.emit('answer', message.data); + break; + case 'candidate': + this.emit('candidate', message.data); + break; + default: + // 处理旧格式消息 + if (message.event) { + this.emit(message.event, message.data); + } else { + this.emit('message', message); + } + break; + } + } else if (message.event) { + // 处理旧格式消息 + this.emit(message.event, message.data); + } else { + this.emit('message', message); } } @@ -150,6 +202,57 @@ class WebSocketManager { } } + /** + * 生成连接ID + * @returns {string} 连接ID + */ + generateConnectionId() { + return 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + /** + * 发送连接消息 + */ + sendConnectMessage() { + this.send('connect'); + } + + /** + * 发送断开连接消息 + */ + sendDisconnectMessage() { + this.send('disconnect'); + } + + /** + * 启动心跳 + */ + startHeartbeat() { + this.heartbeatInterval = setInterval(() => { + if (this.isConnected) { + this.send('ping'); + } + }, 30000); // 每30秒发送一次心跳 + } + + /** + * 停止心跳 + */ + stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + /** + * 获取连接ID + * @returns {string} 连接ID + */ + getConnectionId() { + return this.connectionId; + } + /** * 订阅事件 * @param {string} event - 事件名称 diff --git a/WebApp/src/class/websockethandler.ts b/WebApp/src/class/websockethandler.ts index 8d5bdb5..fb55ff4 100644 --- a/WebApp/src/class/websockethandler.ts +++ b/WebApp/src/class/websockethandler.ts @@ -60,9 +60,10 @@ function reset(mode: string): void { */ function add(ws: WebSocket): void { // 为新连接创建空的连接ID集合 - clients.set(ws, new Set()); + var id = new Set(); + clients.set(ws, id); // 记录添加WebSocket连接的日志 - console.log(`Add WebSocket: ${ws}`); + console.log(`Add WebSocket: ${id}`); } /** @@ -157,8 +158,8 @@ function onDisconnect(ws: WebSocket, connectionId: string): void { // 向当前连接发送断开连接消息 ws.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); //RemoveHeartbeat(ws); - // 记录断开连接的日志 - console.log(`Disconnect connectionId: ${connectionId}`); + // 记录断开连接的日志 + console.log(`Disconnect connectionId: ${connectionId}`); } /** @@ -257,65 +258,71 @@ function onCandidate(ws: WebSocket, message: any): void { } return; } - - // 公共模式:向所有其他客户端广播candidate - clients.forEach((_v, k) => { - if (k === ws) { - return; - } - k.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate })); - }); } - - - -/** - * 处理广播消息请求 - * @param ws WebSocket连接实例 - * @param message 消息数据 - */ -/** - * 处理广播消息请求 - * @param ws WebSocket连接实例 - * @param message 消息数据 - */ -function onBroadcast(ws: WebSocket, message: any): void { - const broadcastMessage = message.message; - const targetConnectionId = message.targetConnectionId; - - if (targetConnectionId) { - // 向指定连接广播 - if (connectionPair.has(targetConnectionId)) { - const pair = connectionPair.get(targetConnectionId); - // 向连接对中的两个WebSocket实例发送消息 - if (pair[0]) { - pair[0].send(JSON.stringify({ - type: "broadcast", - message: broadcastMessage, - from: "server" - })); - } - if (pair[1]) { - pair[1].send(JSON.stringify({ - type: "broadcast", - message: broadcastMessage, - from: "server" - })); - } - } - } else { - // 全局广播:向所有客户端发送消息 + function onCallConnectionId(ws: WebSocket, message: any): void { + // 获取连接ID + const connectionId = message.connectionId; + const clientId = message.clientId; clients.forEach((_v, k) => { - k.send(JSON.stringify({ - type: "broadcast", - message: broadcastMessage, - from: "server" - })); + if (k === ws) { + return; + } + if (_v == clientId) { + k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId })); + } + }); + } -} -function AddHeartbeat(ws: WebSocket, connectionId: string){ - // 初始化心跳检测 + + + /** + * 处理广播消息请求 + * @param ws WebSocket连接实例 + * @param message 消息数据 + */ + /** + * 处理广播消息请求 + * @param ws WebSocket连接实例 + * @param message 消息数据 + */ + function onBroadcast(ws: WebSocket, message: any): void { + const broadcastMessage = message.message; + const targetConnectionId = message.targetConnectionId; + + if (targetConnectionId) { + // 向指定连接广播 + if (connectionPair.has(targetConnectionId)) { + const pair = connectionPair.get(targetConnectionId); + // 向连接对中的两个WebSocket实例发送消息 + if (pair[0]) { + pair[0].send(JSON.stringify({ + type: "broadcast", + message: broadcastMessage, + from: "server" + })); + } + if (pair[1]) { + pair[1].send(JSON.stringify({ + type: "broadcast", + message: broadcastMessage, + from: "server" + })); + } + } + } else { + // 全局广播:向所有客户端发送消息 + clients.forEach((_v, k) => { + k.send(JSON.stringify({ + type: "broadcast", + message: broadcastMessage, + from: "server" + })); + }); + } + } + function AddHeartbeat(ws: WebSocket, connectionId: string) { + // 初始化心跳检测 (ws as any).lastActivity = Date.now(); // 设置心跳检测定时器,每30秒发送一次ping @@ -333,14 +340,14 @@ function AddHeartbeat(ws: WebSocket, connectionId: string){ console.log('WebSocket connection heartbeat, lastActivity: ', (ws as any).lastActivity); } }, 3000); -} -function RemoveHeartbeat(ws: WebSocket){ + } + function RemoveHeartbeat(ws: WebSocket) { // 清除心跳检测定时器 if ((ws as any).heartbeatTimer) { clearInterval((ws as any).heartbeatTimer); } -} -/** - * 导出WebSocket处理器函数 - */ -export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onBroadcast, AddHeartbeat, RemoveHeartbeat }; + } + /** + * 导出WebSocket处理器函数 + */ + export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate,onCallConnectionId, onBroadcast, AddHeartbeat, RemoveHeartbeat }; diff --git a/WebApp/src/websocket.ts b/WebApp/src/websocket.ts index e66d823..89e2172 100644 --- a/WebApp/src/websocket.ts +++ b/WebApp/src/websocket.ts @@ -104,6 +104,10 @@ export default class WSSignaling { case "broadcast": handler.onBroadcast(ws, msg.data); break; + case 'call-request'://接受连接ConnectionId + // 处理callConnectionId信令 + handler.onCallConnectionId(ws, msg.data); + break; default: // 忽略未知消息类型 break;