【m】开始尝试接入后端

This commit is contained in:
zhangzheng
2026-03-04 17:55:55 +08:00
parent fd00100808
commit 93b56da25e
11 changed files with 681 additions and 217 deletions

View File

@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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 = `<strong>Sending resolution:</strong> ${localVideo.videoWidth} x ${localVideo.videoHeight} px`;
}
// 显示远程视频分辨率
if (remoteVideo.videoWidth) {
remoteVideoStatsDiv.innerHTML = `<strong>Receiving resolution:</strong> ${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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -257,7 +257,7 @@
<div class="flex items-center gap-3 p-2 rounded-lg bg-white/5" data-user-id="remote">
<div class="relative">
<!-- [DATA_FIELD: remoteUser.avatar] -->
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
<img src=""
class="w-10 h-10 rounded-full object-cover"
data-field="remoteUser.avatar">
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
@@ -278,7 +278,7 @@
<!-- 本地用户项 -->
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5" data-user-id="local">
<!-- [DATA_FIELD: localUser.avatar] -->
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
<img src=""
class="w-10 h-10 rounded-full object-cover"
data-field="localUser.avatar">
<div class="flex-1">
@@ -328,7 +328,6 @@
</div>
<!-- [DATA_FIELD: message.content] -->
<div class="glass px-3 py-2 rounded-2xl rounded-tl-none text-sm text-gray-200" data-field="message.content">
嗨,能听到我说话吗?
</div>
</div>
</div>
@@ -348,7 +347,6 @@
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
</div>
<div class="bg-indigo-600 px-3 py-2 rounded-2xl rounded-tr-none text-sm text-white" data-field="message.content">
很清楚!你的画面也很清晰 👍
</div>
</div>
</div>
@@ -506,6 +504,36 @@
</div>
</div>
<!-- 通话请求弹窗 -->
<div id="callRequestDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
<div class="glass rounded-2xl p-6 w-80 max-w-md">
<div class="text-center mb-6">
<div class="w-16 h-16 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-video text-indigo-500 text-2xl"></i>
</div>
<h3 class="text-xl font-bold mb-2" id="callRequestName">Sarah Chen</h3>
<p class="text-gray-400 text-sm" id="callRequestText">正在请求与您进行视频通话</p>
<div class="mt-4 flex items-center justify-center gap-4">
<img id="callRequestAvatar" src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop" class="w-16 h-16 rounded-full object-cover border-4 border-indigo-500">
</div>
</div>
<div class="flex gap-3">
<button id="rejectCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
<div class="flex items-center justify-center gap-2">
<i class="fas fa-phone-slash"></i>
<span>拒绝</span>
</div>
</button>
<button id="acceptCall" class="flex-1 py-2 rounded-lg bg-green-500 hover:bg-green-600 transition-colors">
<div class="flex items-center justify-center gap-2">
<i class="fas fa-phone"></i>
<span>接受</span>
</div>
</button>
</div>
</div>
</div>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="main.js"></script>

View File

@@ -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);
}
}
/**

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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<void>}
*/
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<void>}
*/
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;

View File

@@ -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 - 事件名称

View File

@@ -60,9 +60,10 @@ function reset(mode: string): void {
*/
function add(ws: WebSocket): void {
// 为新连接创建空的连接ID集合
clients.set(ws, new Set<string>());
var id = new Set<string>();
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 };

View File

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